paybridge 0.1.3 → 0.2.2

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