paybridge 0.1.2 → 0.2.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/README.md +87 -7
- package/dist/circuit-breaker-store.d.ts +27 -0
- package/dist/circuit-breaker-store.js +25 -0
- package/dist/circuit-breaker.d.ts +30 -0
- package/dist/circuit-breaker.js +86 -0
- package/dist/crypto/base.d.ts +15 -0
- package/dist/crypto/base.js +24 -0
- package/dist/crypto/index.d.ts +35 -0
- package/dist/crypto/index.js +95 -0
- package/dist/crypto/mock.d.ts +15 -0
- package/dist/crypto/mock.js +112 -0
- package/dist/crypto/moonpay.d.ts +33 -0
- package/dist/crypto/moonpay.js +251 -0
- package/dist/crypto/router.d.ts +36 -0
- package/dist/crypto/router.js +287 -0
- package/dist/crypto/types.d.ts +89 -0
- package/dist/crypto/types.js +5 -0
- package/dist/crypto/yellowcard.d.ts +56 -0
- package/dist/crypto/yellowcard.js +310 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +59 -3
- package/dist/providers/base.d.ts +5 -0
- package/dist/providers/flutterwave.d.ts +36 -0
- package/dist/providers/flutterwave.js +338 -0
- package/dist/providers/ozow.d.ts +20 -2
- package/dist/providers/ozow.js +161 -114
- package/dist/providers/payfast.d.ts +40 -0
- package/dist/providers/payfast.js +355 -0
- package/dist/providers/paystack.d.ts +37 -0
- package/dist/providers/paystack.js +335 -0
- package/dist/providers/peach.d.ts +50 -0
- package/dist/providers/peach.js +305 -0
- package/dist/providers/softycomp.d.ts +106 -0
- package/dist/providers/softycomp.js +234 -10
- package/dist/providers/stripe.d.ts +38 -0
- package/dist/providers/stripe.js +370 -0
- package/dist/providers/yoco.d.ts +12 -0
- package/dist/providers/yoco.js +159 -61
- package/dist/router.d.ts +33 -0
- package/dist/router.js +247 -0
- package/dist/routing-types.d.ts +39 -0
- package/dist/routing-types.js +14 -0
- package/dist/stores/redis.d.ts +30 -0
- package/dist/stores/redis.js +42 -0
- package/dist/strategies.d.ts +18 -0
- package/dist/strategies.js +44 -0
- package/dist/types.d.ts +4 -2
- package/package.json +7 -4
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Stripe payment provider
|
|
4
|
+
* Global payment processor supporting 135+ currencies
|
|
5
|
+
* @see https://stripe.com/docs/api
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.StripeProvider = void 0;
|
|
42
|
+
const crypto = __importStar(require("crypto"));
|
|
43
|
+
const base_1 = require("./base");
|
|
44
|
+
const currency_1 = require("../utils/currency");
|
|
45
|
+
class StripeProvider extends base_1.PaymentProvider {
|
|
46
|
+
constructor(config) {
|
|
47
|
+
super();
|
|
48
|
+
this.name = 'stripe';
|
|
49
|
+
this.supportedCurrencies = ['USD', 'EUR', 'GBP', 'ZAR', 'NGN'];
|
|
50
|
+
this.baseUrl = 'https://api.stripe.com/v1';
|
|
51
|
+
this.apiKey = config.apiKey;
|
|
52
|
+
this.webhookSecret = config.webhookSecret;
|
|
53
|
+
this.sandbox = config.sandbox ?? this.apiKey.startsWith('sk_test_');
|
|
54
|
+
}
|
|
55
|
+
buildFormData(data, prefix = '') {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const [key, value] of Object.entries(data)) {
|
|
58
|
+
if (value === undefined || value === null)
|
|
59
|
+
continue;
|
|
60
|
+
const fieldName = prefix ? `${prefix}[${key}]` : key;
|
|
61
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
62
|
+
parts.push(this.buildFormData(value, fieldName));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
parts.push(`${encodeURIComponent(fieldName)}=${encodeURIComponent(String(value))}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parts.join('&');
|
|
69
|
+
}
|
|
70
|
+
async apiRequest(method, path, data) {
|
|
71
|
+
const url = `${this.baseUrl}${path}`;
|
|
72
|
+
const body = data ? this.buildFormData(data) : undefined;
|
|
73
|
+
const response = await fetch(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
77
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
78
|
+
},
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const errorText = await response.text();
|
|
83
|
+
throw new Error(`Stripe API error (${method} ${path}): ${response.status} - ${errorText}`);
|
|
84
|
+
}
|
|
85
|
+
return (await response.json());
|
|
86
|
+
}
|
|
87
|
+
async createPayment(params) {
|
|
88
|
+
this.validateCurrency(params.currency);
|
|
89
|
+
const amountInMinorUnits = (0, currency_1.toMinorUnit)(params.amount, params.currency);
|
|
90
|
+
const currencyLower = params.currency.toLowerCase();
|
|
91
|
+
const metadata = {
|
|
92
|
+
reference: params.reference,
|
|
93
|
+
};
|
|
94
|
+
if (params.metadata) {
|
|
95
|
+
for (const [key, value] of Object.entries(params.metadata)) {
|
|
96
|
+
metadata[key] = String(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const sessionData = {
|
|
100
|
+
mode: 'payment',
|
|
101
|
+
'line_items[0][price_data][currency]': currencyLower,
|
|
102
|
+
'line_items[0][price_data][unit_amount]': amountInMinorUnits,
|
|
103
|
+
'line_items[0][price_data][product_data][name]': params.description || params.reference,
|
|
104
|
+
'line_items[0][quantity]': 1,
|
|
105
|
+
success_url: params.urls.success,
|
|
106
|
+
cancel_url: params.urls.cancel,
|
|
107
|
+
client_reference_id: params.reference,
|
|
108
|
+
customer_email: params.customer.email,
|
|
109
|
+
};
|
|
110
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
111
|
+
sessionData[`metadata[${key}]`] = value;
|
|
112
|
+
}
|
|
113
|
+
const response = await this.apiRequest('POST', '/checkout/sessions', sessionData);
|
|
114
|
+
return {
|
|
115
|
+
id: response.id,
|
|
116
|
+
checkoutUrl: response.url,
|
|
117
|
+
status: 'pending',
|
|
118
|
+
amount: (0, currency_1.toMajorUnit)(response.amount_total || amountInMinorUnits, params.currency),
|
|
119
|
+
currency: (response.currency || currencyLower).toUpperCase(),
|
|
120
|
+
reference: response.client_reference_id || params.reference,
|
|
121
|
+
provider: 'stripe',
|
|
122
|
+
createdAt: new Date(response.created * 1000).toISOString(),
|
|
123
|
+
expiresAt: response.expires_at ? new Date(response.expires_at * 1000).toISOString() : undefined,
|
|
124
|
+
raw: response,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async createSubscription(params) {
|
|
128
|
+
this.validateCurrency(params.currency);
|
|
129
|
+
const amountInMinorUnits = (0, currency_1.toMinorUnit)(params.amount, params.currency);
|
|
130
|
+
const currencyLower = params.currency.toLowerCase();
|
|
131
|
+
const intervalMap = {
|
|
132
|
+
weekly: 'week',
|
|
133
|
+
monthly: 'month',
|
|
134
|
+
yearly: 'year',
|
|
135
|
+
};
|
|
136
|
+
const stripeInterval = intervalMap[params.interval];
|
|
137
|
+
const metadata = {
|
|
138
|
+
reference: params.reference,
|
|
139
|
+
};
|
|
140
|
+
if (params.metadata) {
|
|
141
|
+
for (const [key, value] of Object.entries(params.metadata)) {
|
|
142
|
+
metadata[key] = String(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const sessionData = {
|
|
146
|
+
mode: 'subscription',
|
|
147
|
+
'line_items[0][price_data][currency]': currencyLower,
|
|
148
|
+
'line_items[0][price_data][unit_amount]': amountInMinorUnits,
|
|
149
|
+
'line_items[0][price_data][recurring][interval]': stripeInterval,
|
|
150
|
+
'line_items[0][price_data][product_data][name]': params.description || params.reference,
|
|
151
|
+
'line_items[0][quantity]': 1,
|
|
152
|
+
success_url: params.urls.success,
|
|
153
|
+
cancel_url: params.urls.cancel,
|
|
154
|
+
client_reference_id: params.reference,
|
|
155
|
+
customer_email: params.customer.email,
|
|
156
|
+
};
|
|
157
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
158
|
+
sessionData[`metadata[${key}]`] = value;
|
|
159
|
+
}
|
|
160
|
+
const response = await this.apiRequest('POST', '/checkout/sessions', sessionData);
|
|
161
|
+
return {
|
|
162
|
+
id: response.id,
|
|
163
|
+
checkoutUrl: response.url,
|
|
164
|
+
status: 'pending',
|
|
165
|
+
amount: (0, currency_1.toMajorUnit)(response.amount_total || amountInMinorUnits, params.currency),
|
|
166
|
+
currency: (response.currency || currencyLower).toUpperCase(),
|
|
167
|
+
interval: params.interval,
|
|
168
|
+
reference: response.client_reference_id || params.reference,
|
|
169
|
+
provider: 'stripe',
|
|
170
|
+
startsAt: params.startDate,
|
|
171
|
+
createdAt: new Date(response.created * 1000).toISOString(),
|
|
172
|
+
raw: response,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async getPayment(id) {
|
|
176
|
+
const session = await this.apiRequest('GET', `/checkout/sessions/${id}`);
|
|
177
|
+
let status = 'pending';
|
|
178
|
+
if (session.payment_status === 'paid') {
|
|
179
|
+
status = 'completed';
|
|
180
|
+
}
|
|
181
|
+
else if (session.payment_status === 'unpaid' && session.status === 'open') {
|
|
182
|
+
status = 'pending';
|
|
183
|
+
}
|
|
184
|
+
else if (session.status === 'expired') {
|
|
185
|
+
status = 'failed';
|
|
186
|
+
}
|
|
187
|
+
const currency = (session.currency || 'USD').toUpperCase();
|
|
188
|
+
return {
|
|
189
|
+
id: session.id,
|
|
190
|
+
checkoutUrl: session.url || '',
|
|
191
|
+
status,
|
|
192
|
+
amount: (0, currency_1.toMajorUnit)(session.amount_total || 0, currency),
|
|
193
|
+
currency,
|
|
194
|
+
reference: session.client_reference_id || session.id,
|
|
195
|
+
provider: 'stripe',
|
|
196
|
+
createdAt: new Date(session.created * 1000).toISOString(),
|
|
197
|
+
expiresAt: session.expires_at ? new Date(session.expires_at * 1000).toISOString() : undefined,
|
|
198
|
+
raw: session,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async refund(params) {
|
|
202
|
+
let paymentIntentId;
|
|
203
|
+
if (params.paymentId.startsWith('cs_')) {
|
|
204
|
+
const session = await this.apiRequest('GET', `/checkout/sessions/${params.paymentId}`);
|
|
205
|
+
paymentIntentId = session.payment_intent;
|
|
206
|
+
if (!paymentIntentId) {
|
|
207
|
+
throw new Error('Session has no payment_intent - payment may not be completed yet');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
paymentIntentId = params.paymentId;
|
|
212
|
+
}
|
|
213
|
+
const refundData = {
|
|
214
|
+
payment_intent: paymentIntentId,
|
|
215
|
+
};
|
|
216
|
+
if (params.amount !== undefined) {
|
|
217
|
+
refundData.amount = (0, currency_1.toMinorUnit)(params.amount, 'USD');
|
|
218
|
+
}
|
|
219
|
+
if (params.reason) {
|
|
220
|
+
refundData.reason = 'requested_by_customer';
|
|
221
|
+
refundData['metadata[reason]'] = params.reason;
|
|
222
|
+
}
|
|
223
|
+
const response = await this.apiRequest('POST', '/refunds', refundData);
|
|
224
|
+
const currency = (response.currency || 'USD').toUpperCase();
|
|
225
|
+
return {
|
|
226
|
+
id: response.id,
|
|
227
|
+
status: response.status === 'succeeded' ? 'completed' : 'pending',
|
|
228
|
+
amount: (0, currency_1.toMajorUnit)(response.amount || 0, currency),
|
|
229
|
+
currency,
|
|
230
|
+
paymentId: params.paymentId,
|
|
231
|
+
createdAt: new Date(response.created * 1000).toISOString(),
|
|
232
|
+
raw: response,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
parseWebhook(body, _headers) {
|
|
236
|
+
const event = typeof body === 'string' ? JSON.parse(body) : body;
|
|
237
|
+
const typeMap = {
|
|
238
|
+
'checkout.session.completed': 'payment.completed',
|
|
239
|
+
'checkout.session.expired': 'payment.cancelled',
|
|
240
|
+
'checkout.session.async_payment_failed': 'payment.failed',
|
|
241
|
+
'payment_intent.succeeded': 'payment.completed',
|
|
242
|
+
'payment_intent.payment_failed': 'payment.failed',
|
|
243
|
+
'charge.refunded': 'refund.completed',
|
|
244
|
+
'customer.subscription.created': 'subscription.created',
|
|
245
|
+
'customer.subscription.deleted': 'subscription.cancelled',
|
|
246
|
+
};
|
|
247
|
+
const eventType = typeMap[event.type] || 'payment.pending';
|
|
248
|
+
const data = event.data?.object || {};
|
|
249
|
+
let payment;
|
|
250
|
+
let subscription;
|
|
251
|
+
let refund;
|
|
252
|
+
if (event.type.startsWith('checkout.session') || event.type.startsWith('payment_intent')) {
|
|
253
|
+
const currency = (data.currency || 'USD').toUpperCase();
|
|
254
|
+
let status = 'pending';
|
|
255
|
+
if (data.payment_status === 'paid' || data.status === 'succeeded') {
|
|
256
|
+
status = 'completed';
|
|
257
|
+
}
|
|
258
|
+
else if (data.status === 'expired') {
|
|
259
|
+
status = 'failed';
|
|
260
|
+
}
|
|
261
|
+
else if (event.type.includes('failed')) {
|
|
262
|
+
status = 'failed';
|
|
263
|
+
}
|
|
264
|
+
payment = {
|
|
265
|
+
id: data.id,
|
|
266
|
+
checkoutUrl: data.url || '',
|
|
267
|
+
status,
|
|
268
|
+
amount: (0, currency_1.toMajorUnit)(data.amount_total || data.amount || 0, currency),
|
|
269
|
+
currency,
|
|
270
|
+
reference: data.client_reference_id || data.id,
|
|
271
|
+
provider: 'stripe',
|
|
272
|
+
createdAt: new Date((data.created || Date.now() / 1000) * 1000).toISOString(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
else if (event.type.startsWith('customer.subscription')) {
|
|
276
|
+
const currency = (data.currency || 'USD').toUpperCase();
|
|
277
|
+
subscription = {
|
|
278
|
+
id: data.id,
|
|
279
|
+
checkoutUrl: '',
|
|
280
|
+
status: event.type.includes('deleted') ? 'cancelled' : 'active',
|
|
281
|
+
amount: (0, currency_1.toMajorUnit)(data.plan?.amount || 0, currency),
|
|
282
|
+
currency,
|
|
283
|
+
interval: 'monthly',
|
|
284
|
+
reference: data.metadata?.reference || data.id,
|
|
285
|
+
provider: 'stripe',
|
|
286
|
+
createdAt: new Date((data.created || Date.now() / 1000) * 1000).toISOString(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
else if (event.type === 'charge.refunded') {
|
|
290
|
+
const currency = (data.currency || 'USD').toUpperCase();
|
|
291
|
+
refund = {
|
|
292
|
+
id: data.refunds?.data?.[0]?.id || data.id,
|
|
293
|
+
status: 'completed',
|
|
294
|
+
amount: (0, currency_1.toMajorUnit)(data.amount_refunded || 0, currency),
|
|
295
|
+
currency,
|
|
296
|
+
paymentId: data.payment_intent || data.id,
|
|
297
|
+
createdAt: new Date((data.created || Date.now() / 1000) * 1000).toISOString(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
type: eventType,
|
|
302
|
+
payment,
|
|
303
|
+
subscription,
|
|
304
|
+
refund,
|
|
305
|
+
raw: event,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Verify webhook signature using Stripe's scheme.
|
|
310
|
+
*
|
|
311
|
+
* CRITICAL: body must be the raw string or Buffer from the webhook request.
|
|
312
|
+
* Passing a parsed JSON object will cause signature verification to fail.
|
|
313
|
+
*/
|
|
314
|
+
verifyWebhook(body, headers) {
|
|
315
|
+
if (!this.webhookSecret) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const signature = headers?.['stripe-signature'] || headers?.['Stripe-Signature'];
|
|
319
|
+
if (!signature) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
const parts = signature.split(',').reduce((acc, part) => {
|
|
323
|
+
const [key, value] = part.split('=');
|
|
324
|
+
if (key && value) {
|
|
325
|
+
acc[key] = value;
|
|
326
|
+
}
|
|
327
|
+
return acc;
|
|
328
|
+
}, {});
|
|
329
|
+
const timestamp = parts.t;
|
|
330
|
+
const expectedSig = parts.v1;
|
|
331
|
+
if (!timestamp || !expectedSig) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const timestampNum = parseInt(timestamp, 10);
|
|
335
|
+
const now = Math.floor(Date.now() / 1000);
|
|
336
|
+
if (now - timestampNum > 300) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const rawBody = typeof body === 'string' ? body : body.toString('utf8');
|
|
340
|
+
const signedPayload = `${timestamp}.${rawBody}`;
|
|
341
|
+
const computedSig = crypto
|
|
342
|
+
.createHmac('sha256', this.webhookSecret)
|
|
343
|
+
.update(signedPayload)
|
|
344
|
+
.digest('hex');
|
|
345
|
+
try {
|
|
346
|
+
const computedBuffer = Buffer.from(computedSig, 'hex');
|
|
347
|
+
const expectedBuffer = Buffer.from(expectedSig, 'hex');
|
|
348
|
+
if (computedBuffer.length !== expectedBuffer.length) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
return crypto.timingSafeEqual(computedBuffer, expectedBuffer);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
getCapabilities() {
|
|
358
|
+
return {
|
|
359
|
+
fees: {
|
|
360
|
+
fixed: 0.30,
|
|
361
|
+
percent: 2.9,
|
|
362
|
+
currency: 'USD',
|
|
363
|
+
},
|
|
364
|
+
currencies: this.supportedCurrencies,
|
|
365
|
+
country: 'GLOBAL',
|
|
366
|
+
avgLatencyMs: 400,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
exports.StripeProvider = StripeProvider;
|
package/dist/providers/yoco.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { PaymentProvider } from './base';
|
|
7
7
|
import { CreatePaymentParams, PaymentResult, CreateSubscriptionParams, SubscriptionResult, RefundParams, RefundResult, WebhookEvent } from '../types';
|
|
8
|
+
import { ProviderCapabilities } from '../routing-types';
|
|
8
9
|
interface YocoConfig {
|
|
9
10
|
apiKey: string;
|
|
10
11
|
sandbox: boolean;
|
|
@@ -19,11 +20,22 @@ export declare class YocoProvider extends PaymentProvider {
|
|
|
19
20
|
private webhookSecret?;
|
|
20
21
|
constructor(config: YocoConfig);
|
|
21
22
|
createPayment(params: CreatePaymentParams): Promise<PaymentResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Yoco does not support subscriptions in the standard Online Payments API.
|
|
25
|
+
* Use the Yoco Recurring Billing API directly or choose another provider.
|
|
26
|
+
*/
|
|
22
27
|
createSubscription(params: CreateSubscriptionParams): Promise<SubscriptionResult>;
|
|
23
28
|
getPayment(id: string): Promise<PaymentResult>;
|
|
24
29
|
refund(params: RefundParams): Promise<RefundResult>;
|
|
25
30
|
parseWebhook(body: any, _headers?: any): WebhookEvent;
|
|
31
|
+
/**
|
|
32
|
+
* Verify webhook signature using Yoco's Svix-based signing scheme.
|
|
33
|
+
*
|
|
34
|
+
* CRITICAL: body must be the raw string or Buffer from the webhook request.
|
|
35
|
+
* Passing a parsed JSON object will cause signature verification to fail.
|
|
36
|
+
*/
|
|
26
37
|
verifyWebhook(body: string | Buffer, headers?: any): boolean;
|
|
38
|
+
getCapabilities(): ProviderCapabilities;
|
|
27
39
|
private mapYocoStatus;
|
|
28
40
|
private mapYocoEventType;
|
|
29
41
|
}
|
package/dist/providers/yoco.js
CHANGED
|
@@ -26,18 +26,6 @@ class YocoProvider extends base_1.PaymentProvider {
|
|
|
26
26
|
// ==================== Payment Methods ====================
|
|
27
27
|
async createPayment(params) {
|
|
28
28
|
this.validateCurrency(params.currency);
|
|
29
|
-
// TODO: Implement Yoco checkout creation
|
|
30
|
-
// POST /v1/checkouts
|
|
31
|
-
// Amount must be in CENTS (minor unit)
|
|
32
|
-
// Auth: Bearer {secret_key}
|
|
33
|
-
// Body: {
|
|
34
|
-
// amount: 29900, // cents
|
|
35
|
-
// currency: "ZAR",
|
|
36
|
-
// cancelUrl: "...",
|
|
37
|
-
// successUrl: "...",
|
|
38
|
-
// failureUrl: "...",
|
|
39
|
-
// metadata: { ... }
|
|
40
|
-
// }
|
|
41
29
|
const amountInCents = (0, currency_1.toMinorUnit)(params.amount, params.currency);
|
|
42
30
|
const requestBody = {
|
|
43
31
|
amount: amountInCents,
|
|
@@ -47,59 +35,111 @@ class YocoProvider extends base_1.PaymentProvider {
|
|
|
47
35
|
failureUrl: params.urls.cancel,
|
|
48
36
|
metadata: {
|
|
49
37
|
reference: params.reference,
|
|
50
|
-
description: params.description,
|
|
38
|
+
description: params.description || '',
|
|
51
39
|
customerName: params.customer.name,
|
|
52
40
|
customerEmail: params.customer.email,
|
|
53
41
|
...params.metadata,
|
|
54
42
|
},
|
|
55
43
|
};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
44
|
+
const response = await fetch(`${this.baseUrl}/checkouts`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
'Idempotency-Key': crypto_1.default.randomUUID(),
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(requestBody),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const errorText = await response.text();
|
|
55
|
+
throw new Error(`Yoco API error (POST /checkouts): ${response.status} - ${errorText}`);
|
|
56
|
+
}
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
return {
|
|
59
|
+
id: data.id,
|
|
60
|
+
checkoutUrl: data.redirectUrl,
|
|
61
|
+
status: this.mapYocoStatus(data.status),
|
|
62
|
+
amount: (0, currency_1.toMajorUnit)(data.amount, params.currency),
|
|
63
|
+
currency: data.currency,
|
|
64
|
+
reference: data.metadata?.reference || params.reference,
|
|
65
|
+
provider: 'yoco',
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
raw: data,
|
|
68
|
+
};
|
|
59
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Yoco does not support subscriptions in the standard Online Payments API.
|
|
72
|
+
* Use the Yoco Recurring Billing API directly or choose another provider.
|
|
73
|
+
*/
|
|
60
74
|
async createSubscription(params) {
|
|
61
75
|
this.validateCurrency(params.currency);
|
|
62
|
-
|
|
63
|
-
// Yoco may not support subscriptions directly - need to check their API
|
|
64
|
-
// May need to implement via recurring charges or use Yoco recurring billing
|
|
65
|
-
console.warn('[PayBridge:Yoco] createSubscription not yet implemented');
|
|
66
|
-
throw new Error('Yoco subscriptions not yet implemented. Coming soon!');
|
|
76
|
+
throw new Error('Yoco does not support subscriptions. Use the Yoco Recurring Billing API directly or choose another provider.');
|
|
67
77
|
}
|
|
68
78
|
async getPayment(id) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
const response = await fetch(`${this.baseUrl}/checkouts/${id}`, {
|
|
80
|
+
method: 'GET',
|
|
81
|
+
headers: {
|
|
82
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const errorText = await response.text();
|
|
88
|
+
throw new Error(`Yoco API error (GET /checkouts/${id}): ${response.status} - ${errorText}`);
|
|
89
|
+
}
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
return {
|
|
92
|
+
id: data.id,
|
|
93
|
+
checkoutUrl: data.redirectUrl || '',
|
|
94
|
+
status: this.mapYocoStatus(data.status),
|
|
95
|
+
amount: (0, currency_1.toMajorUnit)(data.amount, 'ZAR'),
|
|
96
|
+
currency: data.currency,
|
|
97
|
+
reference: data.metadata?.reference || id,
|
|
98
|
+
provider: 'yoco',
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
raw: data,
|
|
101
|
+
};
|
|
74
102
|
}
|
|
75
103
|
async refund(params) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
const requestBody = {
|
|
105
|
+
checkoutId: params.paymentId,
|
|
106
|
+
};
|
|
107
|
+
if (params.amount !== undefined) {
|
|
108
|
+
requestBody.amount = (0, currency_1.toMinorUnit)(params.amount, 'ZAR');
|
|
109
|
+
}
|
|
110
|
+
if (params.reason) {
|
|
111
|
+
requestBody.metadata = { reason: params.reason };
|
|
112
|
+
}
|
|
113
|
+
const response = await fetch(`${this.baseUrl}/refunds`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: {
|
|
116
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
'Idempotency-Key': crypto_1.default.randomUUID(),
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(requestBody),
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const errorText = await response.text();
|
|
124
|
+
throw new Error(`Yoco API error (POST /refunds): ${response.status} - ${errorText}`);
|
|
125
|
+
}
|
|
126
|
+
const data = await response.json();
|
|
127
|
+
const status = this.mapYocoStatus(data.status);
|
|
128
|
+
const refundStatus = status === 'completed' ? 'completed' :
|
|
129
|
+
status === 'failed' ? 'failed' : 'pending';
|
|
130
|
+
return {
|
|
131
|
+
id: data.id,
|
|
132
|
+
status: refundStatus,
|
|
133
|
+
amount: (0, currency_1.toMajorUnit)(data.amount, 'ZAR'),
|
|
134
|
+
currency: data.currency,
|
|
135
|
+
paymentId: params.paymentId,
|
|
136
|
+
createdAt: new Date().toISOString(),
|
|
137
|
+
raw: data,
|
|
138
|
+
};
|
|
87
139
|
}
|
|
88
140
|
// ==================== Webhooks ====================
|
|
89
141
|
parseWebhook(body, _headers) {
|
|
90
142
|
const event = typeof body === 'string' ? JSON.parse(body) : body;
|
|
91
|
-
// TODO: Map Yoco webhook structure to PayBridge WebhookEvent
|
|
92
|
-
// Yoco webhook payload structure:
|
|
93
|
-
// {
|
|
94
|
-
// type: "payment.succeeded" | "payment.failed" | "payment.refunded",
|
|
95
|
-
// payload: {
|
|
96
|
-
// id: "...",
|
|
97
|
-
// amount: 29900, // cents
|
|
98
|
-
// currency: "ZAR",
|
|
99
|
-
// status: "succeeded" | "failed" | "refunded",
|
|
100
|
-
// metadata: { ... }
|
|
101
|
-
// }
|
|
102
|
-
// }
|
|
103
143
|
const yocoStatus = event.payload?.status || event.status;
|
|
104
144
|
const status = this.mapYocoStatus(yocoStatus);
|
|
105
145
|
const eventType = this.mapYocoEventType(event.type);
|
|
@@ -118,29 +158,85 @@ class YocoProvider extends base_1.PaymentProvider {
|
|
|
118
158
|
raw: event,
|
|
119
159
|
};
|
|
120
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Verify webhook signature using Yoco's Svix-based signing scheme.
|
|
163
|
+
*
|
|
164
|
+
* CRITICAL: body must be the raw string or Buffer from the webhook request.
|
|
165
|
+
* Passing a parsed JSON object will cause signature verification to fail.
|
|
166
|
+
*/
|
|
121
167
|
verifyWebhook(body, headers) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
168
|
+
if (!this.webhookSecret) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const webhookId = headers?.['webhook-id'];
|
|
172
|
+
const webhookTimestamp = headers?.['webhook-timestamp'];
|
|
173
|
+
const webhookSignature = headers?.['webhook-signature'];
|
|
174
|
+
if (!webhookId || !webhookTimestamp || !webhookSignature) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const timestamp = parseInt(webhookTimestamp, 10);
|
|
178
|
+
const now = Math.floor(Date.now() / 1000);
|
|
179
|
+
if (now - timestamp > 300) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const rawBody = typeof body === 'string' ? body : body.toString('utf8');
|
|
183
|
+
const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`;
|
|
184
|
+
let secretBytes;
|
|
185
|
+
if (this.webhookSecret.startsWith('whsec_')) {
|
|
186
|
+
secretBytes = Buffer.from(this.webhookSecret.slice(6), 'base64');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
secretBytes = Buffer.from(this.webhookSecret, 'utf8');
|
|
190
|
+
}
|
|
191
|
+
const computedSig = crypto_1.default
|
|
192
|
+
.createHmac('sha256', secretBytes)
|
|
193
|
+
.update(signedPayload, 'utf8')
|
|
194
|
+
.digest('base64');
|
|
195
|
+
const signatures = webhookSignature.split(' ');
|
|
196
|
+
for (const sig of signatures) {
|
|
197
|
+
const [version, expectedSig] = sig.split(',');
|
|
198
|
+
if (version === 'v1') {
|
|
199
|
+
try {
|
|
200
|
+
const computedBuffer = Buffer.from(computedSig, 'base64');
|
|
201
|
+
const expectedBuffer = Buffer.from(expectedSig, 'base64');
|
|
202
|
+
if (computedBuffer.length === expectedBuffer.length) {
|
|
203
|
+
if (crypto_1.default.timingSafeEqual(computedBuffer, expectedBuffer)) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
126
212
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
// ==================== Capabilities ====================
|
|
216
|
+
getCapabilities() {
|
|
217
|
+
return {
|
|
218
|
+
fees: {
|
|
219
|
+
fixed: 0,
|
|
220
|
+
percent: 2.95,
|
|
221
|
+
currency: 'ZAR',
|
|
222
|
+
},
|
|
223
|
+
currencies: this.supportedCurrencies,
|
|
224
|
+
country: 'ZA',
|
|
225
|
+
avgLatencyMs: 500,
|
|
226
|
+
};
|
|
134
227
|
}
|
|
135
228
|
// ==================== Helpers ====================
|
|
136
229
|
mapYocoStatus(yocoStatus) {
|
|
137
230
|
const statusMap = {
|
|
231
|
+
created: 'pending',
|
|
232
|
+
pending: 'pending',
|
|
233
|
+
processing: 'pending',
|
|
234
|
+
completed: 'completed',
|
|
138
235
|
succeeded: 'completed',
|
|
139
236
|
successful: 'completed',
|
|
140
237
|
failed: 'failed',
|
|
141
238
|
cancelled: 'cancelled',
|
|
142
239
|
refunded: 'refunded',
|
|
143
|
-
pending: 'pending',
|
|
144
240
|
};
|
|
145
241
|
return statusMap[yocoStatus?.toLowerCase()] || 'pending';
|
|
146
242
|
}
|
|
@@ -151,6 +247,8 @@ class YocoProvider extends base_1.PaymentProvider {
|
|
|
151
247
|
'payment.failed': 'payment.failed',
|
|
152
248
|
'payment.cancelled': 'payment.cancelled',
|
|
153
249
|
'payment.refunded': 'refund.completed',
|
|
250
|
+
'refund.succeeded': 'refund.completed',
|
|
251
|
+
'refund.failed': 'payment.failed',
|
|
154
252
|
};
|
|
155
253
|
return typeMap[yocoType] || 'payment.pending';
|
|
156
254
|
}
|