@miniduckco/stash 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/src/errors.d.ts +4 -0
- package/dist/src/errors.js +7 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.js +160 -0
- package/dist/src/internal/encoding.d.ts +2 -0
- package/dist/src/internal/encoding.js +10 -0
- package/dist/src/internal/form.d.ts +5 -0
- package/dist/src/internal/form.js +40 -0
- package/dist/src/internal/guards.d.ts +3 -0
- package/dist/src/internal/guards.js +19 -0
- package/dist/src/internal/hash.d.ts +2 -0
- package/dist/src/internal/hash.js +7 -0
- package/dist/src/providers/adapters.d.ts +6 -0
- package/dist/src/providers/adapters.js +198 -0
- package/dist/src/providers/ozow.d.ts +4 -0
- package/dist/src/providers/ozow.js +258 -0
- package/dist/src/providers/payfast.d.ts +4 -0
- package/dist/src/providers/payfast.js +213 -0
- package/dist/src/providers/paystack.d.ts +4 -0
- package/dist/src/providers/paystack.js +116 -0
- package/dist/src/providers/provider-adapter.d.ts +22 -0
- package/dist/src/providers/provider-adapter.js +1 -0
- package/dist/src/types.d.ts +159 -0
- package/dist/src/types.js +1 -0
- package/dist/test/ozow.test.d.ts +1 -0
- package/dist/test/ozow.test.js +112 -0
- package/dist/test/payfast.test.d.ts +1 -0
- package/dist/test/payfast.test.js +80 -0
- package/dist/test/stash.test.d.ts +1 -0
- package/dist/test/stash.test.js +209 -0
- package/package.json +32 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { sha512Hex } from "../internal/hash.js";
|
|
2
|
+
import { formatAmount, requireValue, toStringValue } from "../internal/guards.js";
|
|
3
|
+
import { parseFormEncoded, pairsToRecord } from "../internal/form.js";
|
|
4
|
+
const OZOW_ORDER = [
|
|
5
|
+
"SiteCode",
|
|
6
|
+
"CountryCode",
|
|
7
|
+
"CurrencyCode",
|
|
8
|
+
"Amount",
|
|
9
|
+
"TransactionReference",
|
|
10
|
+
"BankReference",
|
|
11
|
+
"Optional1",
|
|
12
|
+
"Optional2",
|
|
13
|
+
"Optional3",
|
|
14
|
+
"Optional4",
|
|
15
|
+
"Optional5",
|
|
16
|
+
"Customer",
|
|
17
|
+
"CancelUrl",
|
|
18
|
+
"ErrorUrl",
|
|
19
|
+
"SuccessUrl",
|
|
20
|
+
"NotifyUrl",
|
|
21
|
+
"IsTest",
|
|
22
|
+
"SelectedBankId",
|
|
23
|
+
"BankAccountNumber",
|
|
24
|
+
"BankAccountBranchCode",
|
|
25
|
+
"BankAccountName",
|
|
26
|
+
"BankName",
|
|
27
|
+
"ExpiryDateUtc",
|
|
28
|
+
"AllowVariableAmount",
|
|
29
|
+
"VariableAmountMin",
|
|
30
|
+
"VariableAmountMax",
|
|
31
|
+
"CustomerIdentityNumber",
|
|
32
|
+
"CustomerCellphoneNumber",
|
|
33
|
+
"HashCheck",
|
|
34
|
+
"Token",
|
|
35
|
+
"GenerateShortUrl",
|
|
36
|
+
];
|
|
37
|
+
const OZOW_RESPONSE_ORDER = [
|
|
38
|
+
"SiteCode",
|
|
39
|
+
"TransactionId",
|
|
40
|
+
"TransactionReference",
|
|
41
|
+
"Amount",
|
|
42
|
+
"Status",
|
|
43
|
+
"Optional1",
|
|
44
|
+
"Optional2",
|
|
45
|
+
"Optional3",
|
|
46
|
+
"Optional4",
|
|
47
|
+
"Optional5",
|
|
48
|
+
"CurrencyCode",
|
|
49
|
+
"IsTest",
|
|
50
|
+
"StatusMessage",
|
|
51
|
+
];
|
|
52
|
+
const OZOW_ALLOWED_FIELDS = new Set(OZOW_ORDER);
|
|
53
|
+
const OZOW_PROVIDER_OPTION_FIELDS = {
|
|
54
|
+
selectedBankId: "SelectedBankId",
|
|
55
|
+
customerIdentityNumber: "CustomerIdentityNumber",
|
|
56
|
+
allowVariableAmount: "AllowVariableAmount",
|
|
57
|
+
variableAmountMin: "VariableAmountMin",
|
|
58
|
+
variableAmountMax: "VariableAmountMax",
|
|
59
|
+
};
|
|
60
|
+
const OZOW_ENDPOINTS = {
|
|
61
|
+
live: "https://api.ozow.com/PostPaymentRequest",
|
|
62
|
+
sandbox: "https://stagingapi.ozow.com/PostPaymentRequest",
|
|
63
|
+
};
|
|
64
|
+
function buildOzowPayload(input) {
|
|
65
|
+
const siteCode = requireValue(input.secrets.siteCode, "secrets.siteCode");
|
|
66
|
+
const currency = (input.currency ?? "ZAR").toUpperCase();
|
|
67
|
+
const country = "ZA";
|
|
68
|
+
if (currency !== "ZAR") {
|
|
69
|
+
throw new Error("Ozow only supports ZAR amounts");
|
|
70
|
+
}
|
|
71
|
+
const payload = {
|
|
72
|
+
SiteCode: siteCode,
|
|
73
|
+
CountryCode: country,
|
|
74
|
+
CurrencyCode: currency,
|
|
75
|
+
Amount: formatAmount(input.amount),
|
|
76
|
+
TransactionReference: input.reference,
|
|
77
|
+
};
|
|
78
|
+
const bankReference = input.providerData?.BankReference ?? input.description ?? input.reference;
|
|
79
|
+
payload.BankReference = toStringValue(bankReference);
|
|
80
|
+
const providerOptions = input.providerOptions;
|
|
81
|
+
applyOzowProviderOptions(payload, providerOptions, input.providerData);
|
|
82
|
+
if (input.metadata) {
|
|
83
|
+
const entries = Object.entries(input.metadata).slice(0, 5);
|
|
84
|
+
entries.forEach(([, value], index) => {
|
|
85
|
+
payload[`Optional${index + 1}`] = value;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (input.customer) {
|
|
89
|
+
const fullName = [input.customer.firstName, input.customer.lastName]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(" ");
|
|
92
|
+
if (fullName) {
|
|
93
|
+
payload.Customer = fullName;
|
|
94
|
+
}
|
|
95
|
+
if (input.customer.phone) {
|
|
96
|
+
payload.CustomerCellphoneNumber = input.customer.phone;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (input.urls?.cancelUrl)
|
|
100
|
+
payload.CancelUrl = input.urls.cancelUrl;
|
|
101
|
+
if (input.urls?.errorUrl)
|
|
102
|
+
payload.ErrorUrl = input.urls.errorUrl;
|
|
103
|
+
if (input.urls?.returnUrl)
|
|
104
|
+
payload.SuccessUrl = input.urls.returnUrl;
|
|
105
|
+
if (input.urls?.notifyUrl)
|
|
106
|
+
payload.NotifyUrl = input.urls.notifyUrl;
|
|
107
|
+
payload.IsTest = input.testMode ? "true" : "false";
|
|
108
|
+
if (input.providerData) {
|
|
109
|
+
for (const [key, value] of Object.entries(input.providerData)) {
|
|
110
|
+
if (!OZOW_ALLOWED_FIELDS.has(key)) {
|
|
111
|
+
throw new Error(`Unsupported Ozow field: ${key}`);
|
|
112
|
+
}
|
|
113
|
+
if (value === undefined || value === null)
|
|
114
|
+
continue;
|
|
115
|
+
if (key === "HashCheck")
|
|
116
|
+
continue;
|
|
117
|
+
if (providerOptions && isOzowProviderOptionField(key)) {
|
|
118
|
+
throw new Error(`providerData overlaps providerOptions: ${key}`);
|
|
119
|
+
}
|
|
120
|
+
payload[key] = toStringValue(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return payload;
|
|
124
|
+
}
|
|
125
|
+
function applyOzowProviderOptions(payload, options, providerData) {
|
|
126
|
+
if (!options)
|
|
127
|
+
return;
|
|
128
|
+
for (const fieldKey of Object.values(OZOW_PROVIDER_OPTION_FIELDS)) {
|
|
129
|
+
if (providerData && fieldKey in providerData) {
|
|
130
|
+
throw new Error(`providerData overlaps providerOptions: ${fieldKey}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (options.selectedBankId) {
|
|
134
|
+
payload.SelectedBankId = options.selectedBankId;
|
|
135
|
+
}
|
|
136
|
+
if (options.customerIdentityNumber) {
|
|
137
|
+
payload.CustomerIdentityNumber = options.customerIdentityNumber;
|
|
138
|
+
}
|
|
139
|
+
if (options.allowVariableAmount !== undefined) {
|
|
140
|
+
payload.AllowVariableAmount = options.allowVariableAmount ? "true" : "false";
|
|
141
|
+
}
|
|
142
|
+
if (options.allowVariableAmount) {
|
|
143
|
+
if (options.variableAmountMin === undefined) {
|
|
144
|
+
throw new Error("variableAmountMin is required when allowVariableAmount is true");
|
|
145
|
+
}
|
|
146
|
+
if (options.variableAmountMax === undefined) {
|
|
147
|
+
throw new Error("variableAmountMax is required when allowVariableAmount is true");
|
|
148
|
+
}
|
|
149
|
+
payload.VariableAmountMin = String(options.variableAmountMin);
|
|
150
|
+
payload.VariableAmountMax = String(options.variableAmountMax);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function isOzowProviderOptionField(fieldKey) {
|
|
154
|
+
const optionFields = Object.values(OZOW_PROVIDER_OPTION_FIELDS);
|
|
155
|
+
return optionFields.includes(fieldKey);
|
|
156
|
+
}
|
|
157
|
+
export function buildOzowHashCheck(payload, privateKey) {
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const key of OZOW_ORDER) {
|
|
160
|
+
if (key === "HashCheck" || key === "Token")
|
|
161
|
+
continue;
|
|
162
|
+
if (key === "CustomerCellphoneNumber")
|
|
163
|
+
continue;
|
|
164
|
+
if (key === "GenerateShortUrl")
|
|
165
|
+
continue;
|
|
166
|
+
const value = payload[key];
|
|
167
|
+
if (value === undefined || value === null || value === "")
|
|
168
|
+
continue;
|
|
169
|
+
if (key === "AllowVariableAmount" &&
|
|
170
|
+
typeof value === "string" &&
|
|
171
|
+
value.toLowerCase() === "false") {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
parts.push(toStringValue(value));
|
|
175
|
+
}
|
|
176
|
+
const concatenated = `${parts.join("")}${privateKey}`.toLowerCase();
|
|
177
|
+
return sha512Hex(concatenated);
|
|
178
|
+
}
|
|
179
|
+
export async function makeOzowPayment(input) {
|
|
180
|
+
const apiKey = requireValue(input.secrets.apiKey, "secrets.apiKey");
|
|
181
|
+
const privateKey = requireValue(input.secrets.privateKey, "secrets.privateKey");
|
|
182
|
+
const payload = buildOzowPayload(input);
|
|
183
|
+
payload.HashCheck = buildOzowHashCheck(payload, privateKey);
|
|
184
|
+
const endpoint = input.testMode ? OZOW_ENDPOINTS.sandbox : OZOW_ENDPOINTS.live;
|
|
185
|
+
const response = await fetch(endpoint, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
ApiKey: apiKey,
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
Accept: "application/json",
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify(payload),
|
|
193
|
+
});
|
|
194
|
+
const body = await response.json().catch(() => null);
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const errorMessage = body?.ErrorMessage || body?.errorMessage || response.statusText;
|
|
197
|
+
throw new Error(`Ozow payment request failed: ${errorMessage}`);
|
|
198
|
+
}
|
|
199
|
+
const paymentUrl = body?.PaymentUrl ?? body?.paymentUrl;
|
|
200
|
+
if (!paymentUrl) {
|
|
201
|
+
throw new Error("Ozow payment response missing PaymentUrl");
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
provider: "ozow",
|
|
205
|
+
redirectUrl: paymentUrl,
|
|
206
|
+
method: "GET",
|
|
207
|
+
paymentRequestId: body?.PaymentRequestId ?? body?.paymentRequestId,
|
|
208
|
+
raw: body,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export function verifyOzowWebhook(input) {
|
|
212
|
+
const privateKey = input.secrets.privateKey;
|
|
213
|
+
if (!privateKey) {
|
|
214
|
+
return {
|
|
215
|
+
provider: "ozow",
|
|
216
|
+
isValid: false,
|
|
217
|
+
reason: "missingPrivateKey",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const raw = input.rawBody
|
|
221
|
+
? Buffer.isBuffer(input.rawBody)
|
|
222
|
+
? input.rawBody.toString("utf8")
|
|
223
|
+
: input.rawBody
|
|
224
|
+
: null;
|
|
225
|
+
const payload = raw
|
|
226
|
+
? pairsToRecord(parseFormEncoded(raw))
|
|
227
|
+
: input.payload
|
|
228
|
+
? Object.entries(input.payload).reduce((acc, [key, value]) => {
|
|
229
|
+
if (value === undefined || value === null)
|
|
230
|
+
return acc;
|
|
231
|
+
acc[key] = toStringValue(value);
|
|
232
|
+
return acc;
|
|
233
|
+
}, {})
|
|
234
|
+
: null;
|
|
235
|
+
if (!payload) {
|
|
236
|
+
return {
|
|
237
|
+
provider: "ozow",
|
|
238
|
+
isValid: false,
|
|
239
|
+
reason: "missingPayload",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const received = payload.HashCheck || payload.hashCheck;
|
|
243
|
+
if (!received) {
|
|
244
|
+
return {
|
|
245
|
+
provider: "ozow",
|
|
246
|
+
isValid: false,
|
|
247
|
+
reason: "missingHashCheck",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const parts = OZOW_RESPONSE_ORDER.map((key) => payload[key] ?? "");
|
|
251
|
+
const concatenated = `${parts.join("")}${privateKey}`.toLowerCase();
|
|
252
|
+
const computed = sha512Hex(concatenated);
|
|
253
|
+
const normalize = (value) => value.replace(/^0+/, "").toLowerCase();
|
|
254
|
+
return {
|
|
255
|
+
provider: "ozow",
|
|
256
|
+
isValid: normalize(received) === normalize(computed),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PaymentRequest, PaymentResponse, WebhookVerifyInput, WebhookVerifyResult } from "../types.js";
|
|
2
|
+
export declare function buildPayfastSignature(fields: Record<string, string>, passphrase?: string): string;
|
|
3
|
+
export declare function makePayfastPayment(input: PaymentRequest): PaymentResponse;
|
|
4
|
+
export declare function verifyPayfastWebhook(input: WebhookVerifyInput): WebhookVerifyResult;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { encodePayfastValue } from "../internal/encoding.js";
|
|
2
|
+
import { md5Hex } from "../internal/hash.js";
|
|
3
|
+
import { formatAmount, requireValue, toStringValue } from "../internal/guards.js";
|
|
4
|
+
import { parseFormEncoded, pairsToRecord } from "../internal/form.js";
|
|
5
|
+
const PAYFAST_ORDER = [
|
|
6
|
+
"merchant_id",
|
|
7
|
+
"merchant_key",
|
|
8
|
+
"return_url",
|
|
9
|
+
"cancel_url",
|
|
10
|
+
"notify_url",
|
|
11
|
+
"fica_id_number",
|
|
12
|
+
"name_first",
|
|
13
|
+
"name_last",
|
|
14
|
+
"email_address",
|
|
15
|
+
"cell_number",
|
|
16
|
+
"m_payment_id",
|
|
17
|
+
"amount",
|
|
18
|
+
"item_name",
|
|
19
|
+
"item_description",
|
|
20
|
+
"custom_int1",
|
|
21
|
+
"custom_int2",
|
|
22
|
+
"custom_int3",
|
|
23
|
+
"custom_int4",
|
|
24
|
+
"custom_int5",
|
|
25
|
+
"custom_str1",
|
|
26
|
+
"custom_str2",
|
|
27
|
+
"custom_str3",
|
|
28
|
+
"custom_str4",
|
|
29
|
+
"custom_str5",
|
|
30
|
+
"email_confirmation",
|
|
31
|
+
"confirmation_address",
|
|
32
|
+
"payment_method",
|
|
33
|
+
"subscription_type",
|
|
34
|
+
"billing_date",
|
|
35
|
+
"recurring_amount",
|
|
36
|
+
"frequency",
|
|
37
|
+
"cycles",
|
|
38
|
+
"subscription_notify_email",
|
|
39
|
+
"subscription_notify_webhook",
|
|
40
|
+
"subscription_notify_buyer",
|
|
41
|
+
"setup",
|
|
42
|
+
"token",
|
|
43
|
+
"return",
|
|
44
|
+
];
|
|
45
|
+
const PAYFAST_ALLOWED_FIELDS = new Set([...PAYFAST_ORDER, "signature"]);
|
|
46
|
+
const PAYFAST_SIGNATURE_EXCLUSIONS = new Set(["signature", "setup"]);
|
|
47
|
+
const PAYFAST_PROVIDER_OPTION_FIELDS = {
|
|
48
|
+
paymentMethod: "payment_method",
|
|
49
|
+
emailConfirmation: "email_confirmation",
|
|
50
|
+
confirmationAddress: "confirmation_address",
|
|
51
|
+
mPaymentId: "m_payment_id",
|
|
52
|
+
itemName: "item_name",
|
|
53
|
+
itemDescription: "item_description",
|
|
54
|
+
};
|
|
55
|
+
const PAYFAST_ENDPOINTS = {
|
|
56
|
+
live: "https://www.payfast.co.za/eng/process",
|
|
57
|
+
sandbox: "https://sandbox.payfast.co.za/eng/process",
|
|
58
|
+
};
|
|
59
|
+
function normalizePayfastFields(input) {
|
|
60
|
+
const merchantId = requireValue(input.secrets.merchantId, "secrets.merchantId");
|
|
61
|
+
const merchantKey = requireValue(input.secrets.merchantKey, "secrets.merchantKey");
|
|
62
|
+
const currency = (input.currency ?? "ZAR").toUpperCase();
|
|
63
|
+
if (currency !== "ZAR") {
|
|
64
|
+
throw new Error("Payfast only supports ZAR amounts");
|
|
65
|
+
}
|
|
66
|
+
const fields = {
|
|
67
|
+
merchant_id: merchantId,
|
|
68
|
+
merchant_key: merchantKey,
|
|
69
|
+
};
|
|
70
|
+
if (input.urls?.returnUrl)
|
|
71
|
+
fields.return_url = input.urls.returnUrl;
|
|
72
|
+
if (input.urls?.cancelUrl)
|
|
73
|
+
fields.cancel_url = input.urls.cancelUrl;
|
|
74
|
+
if (input.urls?.notifyUrl)
|
|
75
|
+
fields.notify_url = input.urls.notifyUrl;
|
|
76
|
+
if (input.customer?.firstName)
|
|
77
|
+
fields.name_first = input.customer.firstName;
|
|
78
|
+
if (input.customer?.lastName)
|
|
79
|
+
fields.name_last = input.customer.lastName;
|
|
80
|
+
if (input.customer?.email)
|
|
81
|
+
fields.email_address = input.customer.email;
|
|
82
|
+
if (input.customer?.phone)
|
|
83
|
+
fields.cell_number = input.customer.phone;
|
|
84
|
+
fields.amount = formatAmount(input.amount);
|
|
85
|
+
const providerOptions = input.providerOptions;
|
|
86
|
+
applyPayfastProviderOptions(fields, providerOptions, input.providerData);
|
|
87
|
+
if (!fields.m_payment_id) {
|
|
88
|
+
fields.m_payment_id = input.reference;
|
|
89
|
+
}
|
|
90
|
+
if (!fields.item_name) {
|
|
91
|
+
fields.item_name = input.description ?? input.reference;
|
|
92
|
+
}
|
|
93
|
+
if (input.metadata) {
|
|
94
|
+
const entries = Object.entries(input.metadata).slice(0, 5);
|
|
95
|
+
entries.forEach(([, value], index) => {
|
|
96
|
+
fields[`custom_str${index + 1}`] = value;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (input.providerData) {
|
|
100
|
+
for (const [key, value] of Object.entries(input.providerData)) {
|
|
101
|
+
if (!PAYFAST_ALLOWED_FIELDS.has(key)) {
|
|
102
|
+
throw new Error(`Unsupported Payfast field: ${key}`);
|
|
103
|
+
}
|
|
104
|
+
if (value === undefined || value === null)
|
|
105
|
+
continue;
|
|
106
|
+
if (key === "signature")
|
|
107
|
+
continue;
|
|
108
|
+
if (providerOptions && isPayfastProviderOptionField(key)) {
|
|
109
|
+
throw new Error(`providerData overlaps providerOptions: ${key}`);
|
|
110
|
+
}
|
|
111
|
+
fields[key] = toStringValue(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return fields;
|
|
115
|
+
}
|
|
116
|
+
function applyPayfastProviderOptions(fields, options, providerData) {
|
|
117
|
+
if (!options)
|
|
118
|
+
return;
|
|
119
|
+
for (const fieldKey of Object.values(PAYFAST_PROVIDER_OPTION_FIELDS)) {
|
|
120
|
+
if (providerData && fieldKey in providerData) {
|
|
121
|
+
throw new Error(`providerData overlaps providerOptions: ${fieldKey}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (options.paymentMethod) {
|
|
125
|
+
fields.payment_method = options.paymentMethod;
|
|
126
|
+
}
|
|
127
|
+
if (options.emailConfirmation !== undefined) {
|
|
128
|
+
fields.email_confirmation = options.emailConfirmation ? "1" : "0";
|
|
129
|
+
}
|
|
130
|
+
if (options.confirmationAddress) {
|
|
131
|
+
fields.confirmation_address = options.confirmationAddress;
|
|
132
|
+
}
|
|
133
|
+
if (options.mPaymentId) {
|
|
134
|
+
fields.m_payment_id = options.mPaymentId;
|
|
135
|
+
}
|
|
136
|
+
if (options.itemName) {
|
|
137
|
+
fields.item_name = options.itemName;
|
|
138
|
+
}
|
|
139
|
+
if (options.itemDescription) {
|
|
140
|
+
fields.item_description = options.itemDescription;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isPayfastProviderOptionField(fieldKey) {
|
|
144
|
+
const optionFields = Object.values(PAYFAST_PROVIDER_OPTION_FIELDS);
|
|
145
|
+
return optionFields.includes(fieldKey);
|
|
146
|
+
}
|
|
147
|
+
export function buildPayfastSignature(fields, passphrase) {
|
|
148
|
+
const pairs = [];
|
|
149
|
+
for (const key of PAYFAST_ORDER) {
|
|
150
|
+
if (PAYFAST_SIGNATURE_EXCLUSIONS.has(key))
|
|
151
|
+
continue;
|
|
152
|
+
const value = fields[key];
|
|
153
|
+
if (value === undefined || value === null || value === "")
|
|
154
|
+
continue;
|
|
155
|
+
pairs.push(`${key}=${encodePayfastValue(String(value).trim())}`);
|
|
156
|
+
}
|
|
157
|
+
let paramString = pairs.join("&");
|
|
158
|
+
if (passphrase) {
|
|
159
|
+
paramString += `&passphrase=${encodePayfastValue(passphrase.trim())}`;
|
|
160
|
+
}
|
|
161
|
+
return md5Hex(paramString);
|
|
162
|
+
}
|
|
163
|
+
export function makePayfastPayment(input) {
|
|
164
|
+
const fields = normalizePayfastFields(input);
|
|
165
|
+
const signature = buildPayfastSignature(fields, input.secrets.passphrase);
|
|
166
|
+
fields.signature = signature;
|
|
167
|
+
return {
|
|
168
|
+
provider: "payfast",
|
|
169
|
+
redirectUrl: input.testMode ? PAYFAST_ENDPOINTS.sandbox : PAYFAST_ENDPOINTS.live,
|
|
170
|
+
method: "POST",
|
|
171
|
+
formFields: fields,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function verifyPayfastWebhook(input) {
|
|
175
|
+
const raw = input.rawBody
|
|
176
|
+
? Buffer.isBuffer(input.rawBody)
|
|
177
|
+
? input.rawBody.toString("utf8")
|
|
178
|
+
: input.rawBody
|
|
179
|
+
: null;
|
|
180
|
+
if (!raw) {
|
|
181
|
+
return {
|
|
182
|
+
provider: "payfast",
|
|
183
|
+
isValid: false,
|
|
184
|
+
reason: "rawBodyRequired",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const pairs = parseFormEncoded(raw);
|
|
188
|
+
const payload = pairsToRecord(pairs);
|
|
189
|
+
const signature = payload.signature;
|
|
190
|
+
if (!signature) {
|
|
191
|
+
return {
|
|
192
|
+
provider: "payfast",
|
|
193
|
+
isValid: false,
|
|
194
|
+
reason: "missingSignature",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const params = [];
|
|
198
|
+
for (const [key, value] of pairs) {
|
|
199
|
+
if (key === "signature") {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
params.push(`${key}=${encodePayfastValue(value)}`);
|
|
203
|
+
}
|
|
204
|
+
let paramString = params.join("&");
|
|
205
|
+
if (input.secrets.passphrase) {
|
|
206
|
+
paramString += `&passphrase=${encodePayfastValue(input.secrets.passphrase)}`;
|
|
207
|
+
}
|
|
208
|
+
const computed = md5Hex(paramString);
|
|
209
|
+
return {
|
|
210
|
+
provider: "payfast",
|
|
211
|
+
isValid: signature.toLowerCase() === computed.toLowerCase(),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PaymentRequest, PaymentResponse, VerificationResult } from "../types.js";
|
|
2
|
+
export declare function makePaystackPayment(input: PaymentRequest): Promise<PaymentResponse>;
|
|
3
|
+
export declare function verifyPaystackWebhook(rawBody: string | Buffer, signatureHeader: string | undefined, secretKey: string): boolean;
|
|
4
|
+
export declare function verifyPaystackPayment(reference: string, secretKey: string): Promise<VerificationResult>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { requireValue } from "../internal/guards.js";
|
|
3
|
+
const PAYSTACK_BASE_URL = "https://api.paystack.co";
|
|
4
|
+
function parseMinorAmount(amount) {
|
|
5
|
+
const raw = typeof amount === "string" ? amount : String(amount);
|
|
6
|
+
if (raw.includes(".")) {
|
|
7
|
+
throw new Error("Paystack amount must be in minor units (integer)");
|
|
8
|
+
}
|
|
9
|
+
const value = Number(raw);
|
|
10
|
+
if (!Number.isInteger(value)) {
|
|
11
|
+
throw new Error("Paystack amount must be an integer in minor units");
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function resolvePaystackOptions(input) {
|
|
16
|
+
return input.providerOptions;
|
|
17
|
+
}
|
|
18
|
+
export async function makePaystackPayment(input) {
|
|
19
|
+
const secretKey = requireValue(input.secrets.paystackSecretKey, "secrets.paystackSecretKey");
|
|
20
|
+
const email = input.customer?.email;
|
|
21
|
+
if (!email) {
|
|
22
|
+
throw new Error("Paystack requires customer email");
|
|
23
|
+
}
|
|
24
|
+
const amount = parseMinorAmount(input.amount);
|
|
25
|
+
const currency = input.currency ?? "ZAR";
|
|
26
|
+
const options = resolvePaystackOptions(input);
|
|
27
|
+
const payload = {
|
|
28
|
+
email,
|
|
29
|
+
amount,
|
|
30
|
+
currency,
|
|
31
|
+
reference: input.reference,
|
|
32
|
+
};
|
|
33
|
+
if (input.urls?.returnUrl) {
|
|
34
|
+
payload.callback_url = input.urls.returnUrl;
|
|
35
|
+
}
|
|
36
|
+
if (options?.channels) {
|
|
37
|
+
payload.channels = options.channels;
|
|
38
|
+
}
|
|
39
|
+
if (input.metadata) {
|
|
40
|
+
payload.metadata = input.metadata;
|
|
41
|
+
}
|
|
42
|
+
if (input.providerData) {
|
|
43
|
+
if (options?.channels && "channels" in input.providerData) {
|
|
44
|
+
throw new Error("providerData overlaps providerOptions: channels");
|
|
45
|
+
}
|
|
46
|
+
for (const [key, value] of Object.entries(input.providerData)) {
|
|
47
|
+
if (value === undefined || value === null)
|
|
48
|
+
continue;
|
|
49
|
+
if (key in payload) {
|
|
50
|
+
throw new Error(`providerData overlaps core fields: ${key}`);
|
|
51
|
+
}
|
|
52
|
+
payload[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const response = await fetch(`${PAYSTACK_BASE_URL}/transaction/initialize`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${secretKey}`,
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
Accept: "application/json",
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(payload),
|
|
63
|
+
});
|
|
64
|
+
const body = await response.json().catch(() => null);
|
|
65
|
+
if (!response.ok || !body?.status) {
|
|
66
|
+
const message = body?.message || response.statusText;
|
|
67
|
+
throw new Error(`Paystack initialize failed: ${message}`);
|
|
68
|
+
}
|
|
69
|
+
const authUrl = body?.data?.authorization_url;
|
|
70
|
+
const reference = body?.data?.reference;
|
|
71
|
+
if (!authUrl || !reference) {
|
|
72
|
+
throw new Error("Paystack response missing authorization_url or reference");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
provider: "paystack",
|
|
76
|
+
redirectUrl: authUrl,
|
|
77
|
+
method: "GET",
|
|
78
|
+
paymentRequestId: reference,
|
|
79
|
+
raw: body,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function verifyPaystackWebhook(rawBody, signatureHeader, secretKey) {
|
|
83
|
+
if (!signatureHeader) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
|
|
87
|
+
const hash = createHmac("sha512", secretKey).update(payload).digest("hex");
|
|
88
|
+
return hash === signatureHeader;
|
|
89
|
+
}
|
|
90
|
+
export async function verifyPaystackPayment(reference, secretKey) {
|
|
91
|
+
const response = await fetch(`${PAYSTACK_BASE_URL}/transaction/verify/${encodeURIComponent(reference)}`, {
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${secretKey}`,
|
|
94
|
+
Accept: "application/json",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const body = await response.json().catch(() => null);
|
|
98
|
+
if (!response.ok || !body?.status) {
|
|
99
|
+
const message = body?.message || response.statusText;
|
|
100
|
+
throw new Error(`Paystack verify failed: ${message}`);
|
|
101
|
+
}
|
|
102
|
+
const status = String(body?.data?.status ?? "").toLowerCase();
|
|
103
|
+
const normalized = status === "success"
|
|
104
|
+
? "paid"
|
|
105
|
+
: status === "failed"
|
|
106
|
+
? "failed"
|
|
107
|
+
: status === "abandoned"
|
|
108
|
+
? "pending"
|
|
109
|
+
: "unknown";
|
|
110
|
+
return {
|
|
111
|
+
provider: "paystack",
|
|
112
|
+
status: normalized,
|
|
113
|
+
providerRef: body?.data?.id ? String(body.data.id) : undefined,
|
|
114
|
+
raw: body,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PaymentRequest, PaymentResponse, VerificationResult, WebhookEvent } from "../types.js";
|
|
2
|
+
export type ProviderWebhookInput = {
|
|
3
|
+
rawBody: string | Buffer;
|
|
4
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
5
|
+
secrets: PaymentRequest["secrets"];
|
|
6
|
+
};
|
|
7
|
+
export type ProviderWebhookResult = {
|
|
8
|
+
isValid: boolean;
|
|
9
|
+
event: WebhookEvent;
|
|
10
|
+
raw: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
export type ProviderVerifyInput = {
|
|
13
|
+
reference: string;
|
|
14
|
+
secrets: PaymentRequest["secrets"];
|
|
15
|
+
testMode?: boolean;
|
|
16
|
+
};
|
|
17
|
+
export interface ProviderAdapter {
|
|
18
|
+
id: PaymentRequest["provider"];
|
|
19
|
+
createPayment(input: PaymentRequest): Promise<PaymentResponse>;
|
|
20
|
+
parseWebhook(input: ProviderWebhookInput): ProviderWebhookResult;
|
|
21
|
+
verifyPayment?: (input: ProviderVerifyInput) => Promise<VerificationResult>;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|