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>
135 lines
5.2 KiB
JavaScript
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,
|
|
};
|