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.
Files changed (48) hide show
  1. package/README.md +87 -7
  2. package/dist/circuit-breaker-store.d.ts +27 -0
  3. package/dist/circuit-breaker-store.js +25 -0
  4. package/dist/circuit-breaker.d.ts +30 -0
  5. package/dist/circuit-breaker.js +86 -0
  6. package/dist/crypto/base.d.ts +15 -0
  7. package/dist/crypto/base.js +24 -0
  8. package/dist/crypto/index.d.ts +35 -0
  9. package/dist/crypto/index.js +95 -0
  10. package/dist/crypto/mock.d.ts +15 -0
  11. package/dist/crypto/mock.js +112 -0
  12. package/dist/crypto/moonpay.d.ts +33 -0
  13. package/dist/crypto/moonpay.js +251 -0
  14. package/dist/crypto/router.d.ts +36 -0
  15. package/dist/crypto/router.js +287 -0
  16. package/dist/crypto/types.d.ts +89 -0
  17. package/dist/crypto/types.js +5 -0
  18. package/dist/crypto/yellowcard.d.ts +56 -0
  19. package/dist/crypto/yellowcard.js +310 -0
  20. package/dist/index.d.ts +9 -1
  21. package/dist/index.js +59 -3
  22. package/dist/providers/base.d.ts +5 -0
  23. package/dist/providers/flutterwave.d.ts +36 -0
  24. package/dist/providers/flutterwave.js +338 -0
  25. package/dist/providers/ozow.d.ts +20 -2
  26. package/dist/providers/ozow.js +161 -114
  27. package/dist/providers/payfast.d.ts +40 -0
  28. package/dist/providers/payfast.js +355 -0
  29. package/dist/providers/paystack.d.ts +37 -0
  30. package/dist/providers/paystack.js +335 -0
  31. package/dist/providers/peach.d.ts +50 -0
  32. package/dist/providers/peach.js +305 -0
  33. package/dist/providers/softycomp.d.ts +106 -0
  34. package/dist/providers/softycomp.js +234 -10
  35. package/dist/providers/stripe.d.ts +38 -0
  36. package/dist/providers/stripe.js +370 -0
  37. package/dist/providers/yoco.d.ts +12 -0
  38. package/dist/providers/yoco.js +159 -61
  39. package/dist/router.d.ts +33 -0
  40. package/dist/router.js +247 -0
  41. package/dist/routing-types.d.ts +39 -0
  42. package/dist/routing-types.js +14 -0
  43. package/dist/stores/redis.d.ts +30 -0
  44. package/dist/stores/redis.js +42 -0
  45. package/dist/strategies.d.ts +18 -0
  46. package/dist/strategies.js +44 -0
  47. package/dist/types.d.ts +4 -2
  48. 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;
@@ -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
  }
@@ -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
- // TODO: Make actual API request
57
- console.warn('[PayBridge:Yoco] createPayment not yet implemented:', requestBody);
58
- throw new Error('Yoco provider not yet fully implemented. Coming soon!');
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
- // TODO: Implement Yoco subscription creation
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
- // TODO: Implement Yoco payment status check
70
- // GET /v1/checkouts/{id}
71
- // Auth: Bearer {secret_key}
72
- console.warn('[PayBridge:Yoco] getPayment not yet implemented:', id);
73
- throw new Error('Yoco getPayment not yet implemented. Coming soon!');
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
- // TODO: Implement Yoco refund
77
- // POST /v1/refunds
78
- // Amount in CENTS
79
- // Auth: Bearer {secret_key}
80
- // Body: {
81
- // paymentId: "...",
82
- // amount: 29900 // cents, optional for partial refund
83
- // }
84
- const amountInCents = params.amount ? (0, currency_1.toMinorUnit)(params.amount, 'ZAR') : undefined;
85
- console.warn('[PayBridge:Yoco] refund not yet implemented:', { ...params, amountInCents });
86
- throw new Error('Yoco refunds not yet implemented. Coming soon!');
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
- const signature = headers?.['x-yoco-signature'] || headers?.signature;
123
- if (!signature || !this.webhookSecret) {
124
- // No signature validation configured
125
- return true;
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
- // TODO: Implement Yoco signature validation
128
- // Yoco uses HMAC-SHA256 with X-Yoco-Signature header
129
- const expectedSignature = crypto_1.default
130
- .createHmac('sha256', this.webhookSecret)
131
- .update(body)
132
- .digest('hex');
133
- return signature === expectedSignature;
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
  }