Files
hope-events/backend/src/utils/yoco.js
T
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00

135 lines
5.2 KiB
JavaScript

/**
* Yoco configuration and API helpers.
*
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
* database has no value, so older deployments keep working until an admin saves them.
*/
const axios = require('axios');
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const YOCO_API = 'https://payments.yoco.com/api';
const KEY_SECRET = 'yoco_secret_key';
const KEY_WEBHOOK = 'yoco_webhook_secret';
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
async function getYocoConfigSource() {
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
return {
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
};
}
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
/**
* Mask a secret like a card number: keep the prefix and the last 3 characters.
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
*/
function maskSecret(value) {
if (!value) return '';
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
const rest = value.slice(prefix.length);
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
}
function yocoMode(key) {
if (!key) return 'unknown';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'unknown';
}
function notConfiguredError() {
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
err.statusCode = 503;
return err;
}
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
async function yocoAuthHeader() {
const key = await getYocoSecretKey();
if (!key) throw notConfiguredError();
return `Bearer ${key}`;
}
/** Turn a Yoco API error into a message an admin can act on. */
function friendlyYocoError(e) {
const status = e.response?.status;
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
let message;
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
else message = e.message || 'Could not reach Yoco.';
const err = new Error(message);
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
timeout: 15000,
});
return res.data;
} catch (e) {
throw friendlyYocoError(e);
}
}
async function listWebhooks(key) {
const data = await yocoRequest('get', '/webhooks', undefined, key);
return data?.subscriptions || [];
}
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
/**
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
* current request (correct behind a proxy when TRUST_PROXY is set).
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
function webhookUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'The webhook URL must be a public address, not localhost.';
}
return null;
}
module.exports = {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, yocoAuthHeader,
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
};