Files
hope-events/backend/src/payments/providers/yoco.js
T
joshuaandClaude Opus 5.5 ede1ddbe00 Payment provider interface: Yoco behind it, Site settings → Payments
- 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>
2026-10-23 12:17:29 +02:00

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,
};