- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
4.7 KiB
JavaScript
108 lines
4.7 KiB
JavaScript
/**
|
|
* Yoco as a payment provider (see ../index.js for the interface).
|
|
*
|
|
* Checkouts: POST /checkouts on the Yoco Checkout API; Yoco redirects the payer back and
|
|
* posts a `payment.succeeded` webhook to /api/webhooks/yoco. Webhooks are signed with the
|
|
* webhook secret (Standard Webhooks: HMAC-SHA256 over "id.timestamp.body").
|
|
* Keys and webhooks are managed in Site settings → Payments → Yoco (utils/yoco.js).
|
|
* Refunds and transaction lookups aren't done through Yoco's API here: refunds are made in
|
|
* the Yoco portal and recorded with the manual refund flow.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const { getYocoSecretKey, getYocoWebhookSecret, yocoRequest } = require('../../utils/yoco');
|
|
|
|
// Yoco recommends rejecting webhooks more than 3 minutes old (replays).
|
|
const WEBHOOK_TOLERANCE_MS = 3 * 60 * 1000;
|
|
|
|
const capabilities = { refund: false, getTransaction: false, listTransactions: false };
|
|
|
|
async function isConfigured() {
|
|
return !!(await getYocoSecretKey());
|
|
}
|
|
|
|
/** Our line items → Yoco's (amounts in cents; a negative price is a deduction line). */
|
|
function toYocoLineItems(lineItems) {
|
|
return (lineItems || []).map(l => ({ displayName: l.name, quantity: l.quantity, pricingDetails: { price: l.unitCents } }));
|
|
}
|
|
|
|
async function createCheckout({ amountCents, currency, metadata, lineItems, successUrl, cancelUrl, failureUrl, idempotencyKey }) {
|
|
const data = await yocoRequest('post', '/checkouts', {
|
|
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
|
|
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
|
|
return { checkoutId: data.id, redirectUrl: data.redirectUrl };
|
|
}
|
|
|
|
/**
|
|
* The signature Yoco sends for a webhook. Pure.
|
|
* @param {string} secret - whsec_<base64>
|
|
*/
|
|
function webhookSignature(secret, id, timestamp, rawBody) {
|
|
const secretBytes = Buffer.from(String(secret).split('_')[1] || '', 'base64');
|
|
return crypto.createHmac('sha256', secretBytes).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
|
|
}
|
|
|
|
/** Checks the webhook headers, age and signature of an incoming request (req.rawBody). */
|
|
async function verifyWebhook(req, now = Date.now()) {
|
|
const headers = req.headers || {};
|
|
const id = headers['webhook-id'];
|
|
const timestamp = headers['webhook-timestamp'];
|
|
const rawSigHeader = headers['webhook-signature'];
|
|
if (!id || !timestamp || !rawSigHeader) {
|
|
return {
|
|
ok: false, status: 400, message: 'Missing webhook headers',
|
|
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) },
|
|
};
|
|
}
|
|
|
|
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
|
|
if (isNaN(webhookTimeMs) || Math.abs(now - webhookTimeMs) > WEBHOOK_TOLERANCE_MS) {
|
|
return { ok: false, status: 400, message: 'Webhook timestamp outside acceptable window' };
|
|
}
|
|
|
|
const secret = await getYocoWebhookSecret();
|
|
if (!secret) {
|
|
console.error('Yoco webhook secret is not configured (Site Settings → Payments → Yoco, or YOCO_WEBHOOK_SECRET)');
|
|
return { ok: false, status: 500, message: 'Server configuration error', details: { error: 'Missing webhook secret configuration' } };
|
|
}
|
|
|
|
const expected = webhookSignature(secret, id, timestamp, req.rawBody);
|
|
// Accept if any of the space-separated signatures match (supports key rotation)
|
|
const signatures = String(rawSigHeader).split(' ').map(s => s.split(',')[1]).filter(Boolean);
|
|
const valid = signatures.some(sig => {
|
|
try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); } catch { return false; }
|
|
});
|
|
if (!valid) {
|
|
console.error('Invalid webhook signature');
|
|
return { ok: false, status: 403, message: 'Invalid signature', details: { error: 'Webhook signature verification failed' } };
|
|
}
|
|
return { ok: true, webhookId: id, timestamp };
|
|
}
|
|
|
|
/** A Yoco webhook body → { type, transaction }. Pure. */
|
|
function parseWebhook(body) {
|
|
const payload = body?.payload || {};
|
|
const metadata = payload.metadata || {};
|
|
const createdDate = payload.createdDate || body?.createdDate;
|
|
return {
|
|
type: body?.type,
|
|
transaction: {
|
|
externalId: payload.id || body?.id || null,
|
|
amountCents: typeof payload.amount === 'number' ? payload.amount : null,
|
|
currency: payload.currency || null,
|
|
createdDate: createdDate ? new Date(createdDate) : null,
|
|
checkoutId: payload.checkoutId || metadata.checkoutId || metadata.checkout_id || null,
|
|
methodType: payload.paymentMethodDetails?.type || null,
|
|
metadata,
|
|
status: payload.status || null,
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
id: 'yoco', label: 'Yoco', capabilities,
|
|
isConfigured, createCheckout, verifyWebhook, parseWebhook,
|
|
// for tests
|
|
webhookSignature, toYocoLineItems,
|
|
};
|