mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-11 06:52:18 +00:00
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV, le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de l’application mobile. Lecteur, sources et Live TV - Refonte du lecteur HLS avec sélection et mémorisation de la qualité, détection des résolutions, scan des liens, choix des pistes audio et fallback contrôlé de 1080p vers 720p. - Ajoute une garde contre les boucles de changement de source, notamment lors des erreurs vidéo et des réponses 429. - Revoit SeekStreaming de bout en bout : validation des embeds, prise en charge de plusieurs candidats, regroupement par serveur, règles de headers persistantes et cache d’extraction raccourci. - Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache stale-while-revalidate, déduplication des requêtes et limite partagée côté API. - Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill switch, développe les wrappers 1jour1film et actualise le domaine Wiflix. - Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même chaîne et permet de choisir le serveur ou la qualité. - Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi que de nouvelles métriques de visionnage. Cast et application mobile - Unifie le contrôle Cast entre le Web Cast SDK et le pont Android : état distant, lecture, pause, seek, arrêt et reprise après reconnexion. - Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground. - Prépare les sources avant diffusion et prend en charge les playlists HLS TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les segments MPEG-TS encapsulés dans des PNG. - Renforce le proxy média local : support Range, validation des redirections, liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid. - Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités propres à chaque document et invalidation lors des navigations. - Ajoute le Picture-in-Picture Android avec lecture/pause et seek de 10 secondes depuis la fenêtre flottante. - Maintient désormais l’écran allumé uniquement pendant une lecture active. - Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses métadonnées d’intégrité. API, base de données et paiements - Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL centralisé, additif et capable de détecter les divergences de structure. - Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL, confirmations pour les tables Wrapped et couverture de tests dédiée. - Rend configurables les limites du pool MySQL et fiabilise les advisory locks. - Revoit entièrement PayGate : références de callback séparées, nonces, validation des wallets et transactions, états monotones, déduplication des paiements et protection contre les courses ou réutilisations de txid. - Renforce l’annulation, la réconciliation et la livraison idempotente des factures VIP, avec réponses no-store sur les routes sensibles. - Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer correctement 401 ou 503 sans provoquer de déconnexions inutiles. - Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en solution de secours. - Améliore la gestion HLS et Range dans proxiesembed. Frontend, profils et infrastructure - Protège la sélection et l’hydratation des profils contre les réponses concurrentes, les intentions obsolètes et l’effacement accidentel du stockage local. - Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données structurées, le service worker, robots.txt et sitemap.xml. - Optimise les probes réseau du service worker avec cache court et single-flight. - Charge dynamiquement les traductions anglaises, plusieurs routes secondaires et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires. - Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit les animations, calculs et rendus hors écran. - Ajoute la page publique des frais, les coins carrés persistants, une nouvelle intro Movix, les avertissements de publicités adultes et de nombreux textes Cast, qualité, Live TV et sources. - Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast. - Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur. - Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et actualise la documentation d’installation et d’initialisation MySQL. Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
1745 lines
58 KiB
JavaScript
1745 lines
58 KiB
JavaScript
'use strict';
|
|
|
|
const { test, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
PAYGATE_FIXTURE,
|
|
buildWalletPayload,
|
|
buildCallbackPayload
|
|
} = require('./fixtures/paygateFixtures');
|
|
const axios = require('axios');
|
|
const originalConsoleLog = console.log.bind(console);
|
|
const originalConsoleError = console.error.bind(console);
|
|
|
|
mock.method(console, 'log', (...args) => {
|
|
const message = String(args[0] || '');
|
|
if (message.startsWith('[dotenv@') || message.startsWith('[WIFLIX FREE PROXY]')) {
|
|
return;
|
|
}
|
|
originalConsoleLog(...args);
|
|
});
|
|
|
|
mock.method(console, 'error', (...args) => {
|
|
if (args[0] === 'VIP PayGate operation failed') {
|
|
return;
|
|
}
|
|
originalConsoleError(...args);
|
|
});
|
|
|
|
mock.method(axios, 'get', async (url, config = {}) => {
|
|
if (url.startsWith('https://api.proxyscrape.com/')) {
|
|
return { data: '' };
|
|
}
|
|
if (url === 'https://api.paygate.to/control/convert.php') {
|
|
return { data: { value_coin: '7.6' } };
|
|
}
|
|
if (url === 'https://api.paygate.to/control/wallet.php') {
|
|
return {
|
|
data: buildWalletPayload({ callback_url: config.params?.callback })
|
|
};
|
|
}
|
|
throw new Error(`Unexpected HTTP request in PayGate creation test: ${url}`);
|
|
});
|
|
|
|
const vipPaygate = require('../vipPaygate');
|
|
const {
|
|
createVipInvoice,
|
|
handlePaygateCallback,
|
|
refreshInvoiceStatus,
|
|
forceValidateInvoice,
|
|
cancelInvoice,
|
|
serializePublicInvoice,
|
|
serializeAdminInvoice,
|
|
ensureVipDonationsTables
|
|
} = require('../vipDonations');
|
|
|
|
function setEnv(t, values) {
|
|
const previous = new Map();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
previous.set(key, process.env[key]);
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
t.after(() => {
|
|
for (const [key, value] of previous) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
function makeCreatedInvoice(overrides = {}) {
|
|
return {
|
|
id: 41,
|
|
public_id: PAYGATE_FIXTURE.legacyPublicId,
|
|
payment_method: 'paygate_hosted',
|
|
status: 'awaiting_payment',
|
|
pack_eur: 7,
|
|
amount_eur: 7,
|
|
amount_usd: 7.6,
|
|
vip_years: 1.5,
|
|
recipient_mode: 'self',
|
|
paygate_checkout_url: 'https://checkout.saved.example/pay.php?frozen=1',
|
|
paygate_tracking_address: PAYGATE_FIXTURE.addressInRaw,
|
|
paygate_payer_email: 'payer@example.test',
|
|
created_at: new Date('2026-07-28T12:00:00Z'),
|
|
expires_at: new Date('2026-07-28T15:30:00Z'),
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function makeCreationPool({ eventError = null } = {}) {
|
|
const calls = [];
|
|
const state = {
|
|
began: 0,
|
|
committed: 0,
|
|
rolledBack: 0,
|
|
released: 0,
|
|
insertParams: null,
|
|
eventParams: null,
|
|
events: 0
|
|
};
|
|
const connection = {
|
|
async beginTransaction() {
|
|
state.began += 1;
|
|
},
|
|
async execute(sql, params = []) {
|
|
calls.push({ scope: 'connection', sql, params });
|
|
if (/INSERT INTO vip_invoices/i.test(sql)) {
|
|
state.insertParams = params;
|
|
return [{ insertId: 41, affectedRows: 1 }, []];
|
|
}
|
|
if (/INSERT INTO vip_invoice_events/i.test(sql)) {
|
|
if (eventError) throw eventError;
|
|
state.eventParams = params;
|
|
state.events += 1;
|
|
return [{ insertId: 91, affectedRows: 1 }, []];
|
|
}
|
|
return [{ affectedRows: 1 }, []];
|
|
},
|
|
async commit() {
|
|
state.committed += 1;
|
|
},
|
|
async rollback() {
|
|
state.rolledBack += 1;
|
|
},
|
|
release() {
|
|
state.released += 1;
|
|
}
|
|
};
|
|
const pool = {
|
|
async getConnection() {
|
|
return connection;
|
|
},
|
|
async execute(sql, params = []) {
|
|
calls.push({ scope: 'pool', sql, params });
|
|
if (/SELECT \* FROM vip_invoices WHERE id = \?/i.test(sql)) {
|
|
return [[makeCreatedInvoice()], []];
|
|
}
|
|
return [[], []];
|
|
}
|
|
};
|
|
return { pool, state, calls };
|
|
}
|
|
|
|
function makePaygateClient() {
|
|
const calls = {
|
|
convert: [],
|
|
wallet: []
|
|
};
|
|
return {
|
|
calls,
|
|
async convertEurToUsd(amountEur) {
|
|
calls.convert.push(amountEur);
|
|
return 7.6;
|
|
},
|
|
async createWallet(input) {
|
|
calls.wallet.push(input);
|
|
return vipPaygate.parsePaygateWalletResponse(
|
|
buildWalletPayload({ callback_url: input.callbackUrl }),
|
|
input.callbackUrl
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
test('PayGate remains unavailable for 5 EUR and available from 7 EUR', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'development',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_MIN_PACK_EUR: '7',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool } = makeCreationPool();
|
|
const paygateClient = makePaygateClient();
|
|
|
|
await assert.rejects(
|
|
createVipInvoice(pool, {
|
|
packEur: 5,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, {
|
|
callbackBaseUrl: 'http://localhost:25565',
|
|
paygateClient
|
|
}),
|
|
/pack 5 EUR/
|
|
);
|
|
|
|
await assert.doesNotReject(createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, {
|
|
callbackBaseUrl: 'http://localhost:25565',
|
|
paygateClient
|
|
}));
|
|
});
|
|
|
|
test('production requires a configured HTTPS callback origin and ignores request host fallback', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: undefined,
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool, state } = makeCreationPool();
|
|
|
|
await assert.rejects(
|
|
createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, {
|
|
callbackBaseUrl: 'https://attacker.example',
|
|
paygateClient: makePaygateClient()
|
|
}),
|
|
(error) => error.statusCode >= 500 && !error.message.includes('attacker.example')
|
|
);
|
|
assert.equal(state.began, 0);
|
|
});
|
|
|
|
test('malformed wallet response creates neither invoice nor event', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: 'https://api.movix.example',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool, state } = makeCreationPool();
|
|
const paygateClient = makePaygateClient();
|
|
paygateClient.createWallet = async () => {
|
|
throw new vipPaygate.PaygateProtocolError(
|
|
'wallet response contains secret details',
|
|
'PAYGATE_WALLET_RESPONSE_INVALID',
|
|
502
|
|
);
|
|
};
|
|
|
|
await assert.rejects(
|
|
createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, { paygateClient }),
|
|
(error) => error.statusCode === 502
|
|
&& error.message === 'Service PayGate temporairement indisponible'
|
|
);
|
|
assert.equal(state.began, 0);
|
|
assert.equal(state.events, 0);
|
|
});
|
|
|
|
test('tiny injected EUR conversion cannot create a zero-value stored invoice', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: 'https://api.movix.example',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool, state } = makeCreationPool();
|
|
const createWallet = mock.fn(async () => {
|
|
throw new Error('wallet creation must not run');
|
|
});
|
|
|
|
await assert.rejects(
|
|
createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, {
|
|
paygateClient: {
|
|
async convertEurToUsd() {
|
|
return 0.004;
|
|
},
|
|
createWallet
|
|
}
|
|
}),
|
|
(error) => (
|
|
error.message === 'Service PayGate temporairement indisponible'
|
|
&& error.code === 'PAYGATE_CONVERT_INVALID'
|
|
&& error.statusCode === 502
|
|
&& !error.message.includes('0.004')
|
|
)
|
|
);
|
|
|
|
assert.equal(createWallet.mock.callCount(), 0);
|
|
assert.equal(state.began, 0);
|
|
assert.equal(state.events, 0);
|
|
});
|
|
|
|
test('new PayGate creation separates publicId from callback reference and commits invoice plus event atomically', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: 'https://api.movix.example',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet,
|
|
VIP_PAYGATE_DOMAIN: 'checkout.paygate.to',
|
|
VIP_PAYGATE_MIN_AMOUNT_EUR: '6.25'
|
|
});
|
|
const { pool, state } = makeCreationPool();
|
|
const paygateClient = makePaygateClient();
|
|
|
|
await createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer+vip@example.test'
|
|
}, { paygateClient });
|
|
|
|
assert.equal(paygateClient.calls.convert[0], 7);
|
|
assert.equal(paygateClient.calls.wallet.length, 1);
|
|
const callback = new URL(paygateClient.calls.wallet[0].callbackUrl);
|
|
assert.deepEqual([...callback.searchParams.keys()].sort(), ['nonce', 'reference']);
|
|
assert.match(callback.searchParams.get('reference'), /^[a-f0-9]{48}$/);
|
|
assert.match(callback.searchParams.get('nonce'), /^[a-f0-9]{48}$/);
|
|
assert.equal(callback.searchParams.has('publicId'), false);
|
|
assert.equal(callback.toString().includes(PAYGATE_FIXTURE.legacyPublicId), false);
|
|
|
|
assert.equal(state.began, 1);
|
|
assert.equal(state.committed, 1);
|
|
assert.equal(state.rolledBack, 0);
|
|
assert.equal(state.events, 1);
|
|
assert.ok(state.insertParams.includes(callback.searchParams.get('reference')));
|
|
assert.ok(state.insertParams.includes(callback.searchParams.get('nonce')));
|
|
});
|
|
|
|
test('event failure rolls back the invoice transaction', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: 'https://api.movix.example',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool, state } = makeCreationPool({ eventError: new Error('event failed') });
|
|
|
|
await assert.rejects(createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'self',
|
|
payerEmail: 'payer@example.test'
|
|
}, { paygateClient: makePaygateClient() }));
|
|
|
|
assert.equal(state.committed, 0);
|
|
assert.equal(state.rolledBack, 1);
|
|
});
|
|
|
|
test('public serializer returns only the frozen stored checkout URL', (t) => {
|
|
setEnv(t, {
|
|
VIP_PAYGATE_DOMAIN: 'changed.example',
|
|
VIP_PAYGATE_LOGO_URL: 'https://changed.example/new-logo.png',
|
|
VIP_PAYGATE_MIN_AMOUNT_EUR: '999'
|
|
});
|
|
const invoice = makeCreatedInvoice();
|
|
assert.equal(
|
|
serializePublicInvoice(invoice).checkoutUrl,
|
|
invoice.paygate_checkout_url
|
|
);
|
|
assert.equal(
|
|
serializePublicInvoice({ ...invoice, paygate_checkout_url: null }).checkoutUrl,
|
|
null
|
|
);
|
|
});
|
|
|
|
test('admin serializer preserves nullable callback contract without authentication material', () => {
|
|
const invoice = makeCreatedInvoice({
|
|
paygate_callback_reference: PAYGATE_FIXTURE.callbackReference,
|
|
paygate_callback_nonce: PAYGATE_FIXTURE.callbackNonce,
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded,
|
|
paygate_callback_url: `https://api.movix.example/api/vip/paygate/callback?reference=${PAYGATE_FIXTURE.callbackReference}&nonce=${PAYGATE_FIXTURE.callbackNonce}`
|
|
});
|
|
|
|
const response = serializeAdminInvoice(invoice);
|
|
const serialized = JSON.stringify(response);
|
|
|
|
assert.equal(Object.hasOwn(response, 'callbackUrl'), true);
|
|
assert.equal(response.callbackUrl, null);
|
|
assert.equal(serialized.includes(invoice.paygate_callback_url), false);
|
|
assert.equal(serialized.includes(PAYGATE_FIXTURE.callbackReference), false);
|
|
assert.equal(serialized.includes(PAYGATE_FIXTURE.callbackNonce), false);
|
|
assert.equal(serialized.includes(PAYGATE_FIXTURE.ipnTokenEncoded), false);
|
|
});
|
|
|
|
test('PayGate creation event exposes only its explicit non-secret allowlist', async (t) => {
|
|
setEnv(t, {
|
|
NODE_ENV: 'production',
|
|
VIP_PAYGATE_ENABLED: 'true',
|
|
VIP_PAYGATE_CALLBACK_BASE_URL: 'https://api.movix.example',
|
|
VIP_PAYGATE_SETTLEMENT_WALLET: PAYGATE_FIXTURE.settlementWallet
|
|
});
|
|
const { pool, state } = makeCreationPool();
|
|
const paygateClient = makePaygateClient();
|
|
|
|
await createVipInvoice(pool, {
|
|
packEur: 7,
|
|
paymentMethod: 'paygate_hosted',
|
|
recipientMode: 'gift',
|
|
payerEmail: 'payer+event-secret@example.test'
|
|
}, { paygateClient });
|
|
|
|
const callbackUrl = new URL(paygateClient.calls.wallet[0].callbackUrl);
|
|
const callbackReference = callbackUrl.searchParams.get('reference');
|
|
const callbackNonce = callbackUrl.searchParams.get('nonce');
|
|
const event = JSON.parse(String(state.eventParams[5]));
|
|
const eventPayload = String(state.eventParams[5]);
|
|
const giftToken = state.insertParams.find((value) => (
|
|
typeof value === 'string' && value.startsWith('gift_')
|
|
));
|
|
const checkoutUrl = state.insertParams.find((value) => (
|
|
typeof value === 'string' && value.startsWith('https://checkout.paygate.to/pay.php')
|
|
));
|
|
|
|
assert.match(giftToken, /^gift_[a-f0-9]{32}$/);
|
|
assert.equal(typeof checkoutUrl, 'string');
|
|
assert.equal(
|
|
paygateClient.calls.wallet[0].settlementWallet,
|
|
PAYGATE_FIXTURE.settlementWallet
|
|
);
|
|
assert.ok(state.insertParams.includes(PAYGATE_FIXTURE.ipnTokenRaw));
|
|
assert.ok(state.insertParams.includes(PAYGATE_FIXTURE.temporaryWallet));
|
|
assert.ok(state.insertParams.includes(PAYGATE_FIXTURE.addressInRaw));
|
|
assert.deepEqual(Object.keys(event).sort(), [
|
|
'amountUsd',
|
|
'checkoutAmountEur',
|
|
'packEur',
|
|
'paymentMethod',
|
|
'recipientMode',
|
|
'vipYears'
|
|
]);
|
|
for (const secretValue of [
|
|
'payer+event-secret@example.test',
|
|
callbackReference,
|
|
callbackNonce,
|
|
PAYGATE_FIXTURE.ipnTokenRaw,
|
|
paygateClient.calls.wallet[0].callbackUrl,
|
|
checkoutUrl,
|
|
PAYGATE_FIXTURE.settlementWallet,
|
|
PAYGATE_FIXTURE.temporaryWallet,
|
|
PAYGATE_FIXTURE.addressInRaw,
|
|
giftToken,
|
|
'VIP-KEY-SENTINEL'
|
|
]) {
|
|
assert.equal(eventPayload.includes(secretValue), false);
|
|
}
|
|
for (const secretKey of [
|
|
'payerEmail',
|
|
'callbackReference',
|
|
'callbackNonce',
|
|
'callbackUrl',
|
|
'checkoutUrl',
|
|
'ipnToken',
|
|
'settlementWallet',
|
|
'temporaryWalletAddress',
|
|
'encryptedAddress',
|
|
'giftToken',
|
|
'giftUrl',
|
|
'vipKey'
|
|
]) {
|
|
assert.equal(Object.hasOwn(event, secretKey), false);
|
|
}
|
|
});
|
|
|
|
test('schema adds nullable unique callback reference and inbound transaction fields', async () => {
|
|
const sqlCalls = [];
|
|
const missingColumns = new Set([
|
|
'paygate_callback_reference',
|
|
'paygate_ipn_token',
|
|
'paygate_paid_txid_in'
|
|
]);
|
|
const missingIndexes = new Set([
|
|
'uniq_vip_invoices_paygate_callback_reference',
|
|
'uniq_vip_invoices_paygate_paid_txid_in'
|
|
]);
|
|
const pool = {
|
|
async execute(sql, params = []) {
|
|
sqlCalls.push({ sql, params });
|
|
if (/INFORMATION_SCHEMA\.COLUMNS/i.test(sql)) {
|
|
return [missingColumns.has(params[1]) ? [] : [{ COLUMN_NAME: params[1] }], []];
|
|
}
|
|
if (/INFORMATION_SCHEMA\.STATISTICS/i.test(sql)) {
|
|
return [missingIndexes.has(params[1]) ? [] : [{ INDEX_NAME: params[1] }], []];
|
|
}
|
|
return [[], []];
|
|
}
|
|
};
|
|
|
|
await ensureVipDonationsTables(pool);
|
|
const allSql = sqlCalls.map((item) => item.sql).join('\n');
|
|
assert.match(allSql, /paygate_callback_reference VARCHAR\(64\) DEFAULT NULL/);
|
|
assert.match(allSql, /paygate_paid_txid_in VARCHAR\(255\) DEFAULT NULL/);
|
|
assert.match(allSql, /UNIQUE INDEX `uniq_vip_invoices_paygate_callback_reference`/);
|
|
assert.match(allSql, /UNIQUE INDEX `uniq_vip_invoices_paygate_paid_txid_in`/);
|
|
|
|
const historicalPaygatePredicates = sqlCalls
|
|
.map((item) => item.sql)
|
|
.filter((sql) => (
|
|
/WHERE payment_method IS NULL/i.test(sql)
|
|
&& /paygate_tracking_address/i.test(sql)
|
|
));
|
|
assert.equal(historicalPaygatePredicates.length, 2);
|
|
for (const sql of historicalPaygatePredicates) {
|
|
assert.match(sql, /NULLIF\(paygate_callback_reference, ''\) IS NOT NULL/);
|
|
assert.match(sql, /NULLIF\(paygate_ipn_token, ''\) IS NOT NULL/);
|
|
assert.match(sql, /NULLIF\(paygate_paid_txid_in, ''\) IS NOT NULL/);
|
|
}
|
|
|
|
const callbackNormalizationIndex = sqlCalls.findIndex((item) => (
|
|
/SET paygate_callback_reference = NULL/i.test(item.sql)
|
|
));
|
|
const txidNormalizationIndex = sqlCalls.findIndex((item) => (
|
|
/SET paygate_paid_txid_in = NULL/i.test(item.sql)
|
|
));
|
|
const callbackIndexCheckIndex = sqlCalls.findIndex((item) => (
|
|
/INFORMATION_SCHEMA\.STATISTICS/i.test(item.sql)
|
|
&& item.params[1] === 'uniq_vip_invoices_paygate_callback_reference'
|
|
));
|
|
const txidIndexCheckIndex = sqlCalls.findIndex((item) => (
|
|
/INFORMATION_SCHEMA\.STATISTICS/i.test(item.sql)
|
|
&& item.params[1] === 'uniq_vip_invoices_paygate_paid_txid_in'
|
|
));
|
|
assert.ok(callbackNormalizationIndex >= 0);
|
|
assert.ok(txidNormalizationIndex >= 0);
|
|
assert.ok(callbackNormalizationIndex < callbackIndexCheckIndex);
|
|
assert.ok(txidNormalizationIndex < txidIndexCheckIndex);
|
|
assert.match(
|
|
sqlCalls[callbackNormalizationIndex].sql,
|
|
/TRIM\(paygate_callback_reference\) = ''/
|
|
);
|
|
assert.match(
|
|
sqlCalls[txidNormalizationIndex].sql,
|
|
/TRIM\(paygate_paid_txid_in\) = ''/
|
|
);
|
|
|
|
const migrationUpdates = sqlCalls
|
|
.map((item) => item.sql)
|
|
.filter((sql) => /^\s*UPDATE vip_invoices/i.test(sql));
|
|
assert.equal(
|
|
migrationUpdates.filter((sql) => /SET paygate_callback_reference\s*=/i.test(sql)).length,
|
|
1
|
|
);
|
|
assert.equal(
|
|
migrationUpdates.filter((sql) => /SET paygate_paid_txid_in\s*=/i.test(sql)).length,
|
|
1
|
|
);
|
|
});
|
|
|
|
test('startup rejects a named PayGate security index that is not unique', async () => {
|
|
const pool = {
|
|
async execute(sql, params = []) {
|
|
if (/INFORMATION_SCHEMA\.COLUMNS/i.test(sql)) {
|
|
return [[{ COLUMN_NAME: params[1] }], []];
|
|
}
|
|
if (/INFORMATION_SCHEMA\.STATISTICS/i.test(sql)) {
|
|
if (params[1] === 'uniq_vip_invoices_paygate_callback_reference') {
|
|
return [[{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 1,
|
|
COLUMN_NAME: 'paygate_callback_reference',
|
|
SEQ_IN_INDEX: 1
|
|
}], []];
|
|
}
|
|
return [[{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 0,
|
|
COLUMN_NAME: params[1] === 'uniq_vip_invoices_paygate_paid_txid_in'
|
|
? 'paygate_paid_txid_in'
|
|
: 'unrelated',
|
|
SEQ_IN_INDEX: 1
|
|
}], []];
|
|
}
|
|
return [[], []];
|
|
}
|
|
};
|
|
|
|
await assert.rejects(
|
|
ensureVipDonationsTables(pool),
|
|
/Index de sécurité invalide/
|
|
);
|
|
});
|
|
|
|
test('startup rejects a named PayGate security index with the wrong column or sequence', async () => {
|
|
for (const invalidShape of [
|
|
{ COLUMN_NAME: 'paygate_paid_txid', SEQ_IN_INDEX: 1 },
|
|
{ COLUMN_NAME: 'paygate_paid_txid_in', SEQ_IN_INDEX: 2 }
|
|
]) {
|
|
const pool = {
|
|
async execute(sql, params = []) {
|
|
if (/INFORMATION_SCHEMA\.COLUMNS/i.test(sql)) {
|
|
return [[{ COLUMN_NAME: params[1] }], []];
|
|
}
|
|
if (/INFORMATION_SCHEMA\.STATISTICS/i.test(sql)) {
|
|
const columnName = params[1] === 'uniq_vip_invoices_paygate_callback_reference'
|
|
? 'paygate_callback_reference'
|
|
: 'paygate_paid_txid_in';
|
|
return [[{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 0,
|
|
COLUMN_NAME: columnName,
|
|
SEQ_IN_INDEX: 1,
|
|
...(params[1] === 'uniq_vip_invoices_paygate_paid_txid_in'
|
|
? invalidShape
|
|
: {})
|
|
}], []];
|
|
}
|
|
return [[], []];
|
|
}
|
|
};
|
|
|
|
await assert.rejects(
|
|
ensureVipDonationsTables(pool),
|
|
/Index de sécurité invalide/
|
|
);
|
|
}
|
|
});
|
|
|
|
test('startup rejects a two-row composite shape for the named inbound-tx security index', async () => {
|
|
const pool = {
|
|
async execute(sql, params = []) {
|
|
if (/INFORMATION_SCHEMA\.COLUMNS/i.test(sql)) {
|
|
return [[{ COLUMN_NAME: params[1] }], []];
|
|
}
|
|
if (/INFORMATION_SCHEMA\.STATISTICS/i.test(sql)) {
|
|
if (params[1] === 'uniq_vip_invoices_paygate_paid_txid_in') {
|
|
return [[
|
|
{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 0,
|
|
COLUMN_NAME: 'paygate_paid_txid_in',
|
|
SEQ_IN_INDEX: 1
|
|
},
|
|
{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 0,
|
|
COLUMN_NAME: 'paygate_paid_txid',
|
|
SEQ_IN_INDEX: 2
|
|
}
|
|
], []];
|
|
}
|
|
return [[{
|
|
INDEX_NAME: params[1],
|
|
NON_UNIQUE: 0,
|
|
COLUMN_NAME: 'paygate_callback_reference',
|
|
SEQ_IN_INDEX: 1
|
|
}], []];
|
|
}
|
|
return [[], []];
|
|
}
|
|
};
|
|
|
|
await assert.rejects(
|
|
ensureVipDonationsTables(pool),
|
|
/Index de s.curit. invalide/
|
|
);
|
|
});
|
|
|
|
const { createFakeVipInvoicePool } = require('./helpers/fakeVipInvoicePool');
|
|
|
|
function makePaygateInvoice(overrides = {}) {
|
|
return {
|
|
...makeCreatedInvoice(),
|
|
id: 51,
|
|
public_id: PAYGATE_FIXTURE.legacyPublicId,
|
|
status: 'awaiting_payment',
|
|
amount_usd: 7.6,
|
|
paygate_callback_reference: PAYGATE_FIXTURE.callbackReference,
|
|
paygate_callback_nonce: PAYGATE_FIXTURE.callbackNonce,
|
|
paygate_temporary_wallet_address: PAYGATE_FIXTURE.temporaryWallet,
|
|
amount_crypto_received: null,
|
|
paygate_paid_coin: null,
|
|
paygate_paid_value: null,
|
|
paygate_paid_txid_in: null,
|
|
paygate_paid_txid: null,
|
|
paid_at: null,
|
|
vip_key_value: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
const INVALID_STORED_PAYGATE_AMOUNTS = [
|
|
{ name: 'zero', value: 0 },
|
|
{ name: 'negative', value: -1 },
|
|
{ name: 'null', value: null },
|
|
{ name: 'blank', value: '' },
|
|
{ name: 'malformed', value: 'not-a-number' },
|
|
{ name: 'sub-cent', value: 0.004 }
|
|
];
|
|
|
|
function isStoredPaygateAmountError(error) {
|
|
return error.message === 'Callback PayGate invalide'
|
|
&& error.code === 'PAYGATE_INVOICE_AMOUNT_INVALID'
|
|
&& error.statusCode === 409;
|
|
}
|
|
|
|
function makeCallbackDeps(fake, overrides = {}) {
|
|
let deliveryTail = Promise.resolve();
|
|
let deliveries = 0;
|
|
const deliverInvoice = async (_pool, invoiceId) => {
|
|
let deliveredInvoice;
|
|
deliveryTail = deliveryTail.then(async () => {
|
|
const invoice = fake.getInvoice(invoiceId);
|
|
if (invoice.status === 'paid' && !invoice.vip_key_value) {
|
|
deliveries += 1;
|
|
fake.mutateInvoice(invoiceId, {
|
|
status: 'delivered',
|
|
vip_key_value: 'VIP-TEST'
|
|
});
|
|
}
|
|
deliveredInvoice = fake.getInvoice(invoiceId);
|
|
});
|
|
await deliveryTail;
|
|
return deliveredInvoice;
|
|
};
|
|
return {
|
|
paygateClient: {
|
|
async convertPaymentToUsd(payment) {
|
|
return payment.valueCoin;
|
|
},
|
|
...overrides.paygateClient
|
|
},
|
|
deliverInvoice,
|
|
getDeliveryCount: () => deliveries
|
|
};
|
|
}
|
|
|
|
test('callback rejects every invalid stored PayGate USD amount before pricing or state change', async (t) => {
|
|
for (const invalidAmount of INVALID_STORED_PAYGATE_AMOUNTS) {
|
|
await t.test(invalidAmount.name, async () => {
|
|
const invoice = makePaygateInvoice({ amount_usd: invalidAmount.value });
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const before = fake.getInvoice(invoice.id);
|
|
const convertPaymentToUsd = mock.fn(async () => 5);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: { convertPaymentToUsd }
|
|
});
|
|
|
|
await assert.rejects(
|
|
handlePaygateCallback(fake.pool, buildCallbackPayload(), deps),
|
|
isStoredPaygateAmountError
|
|
);
|
|
|
|
assert.deepEqual(fake.getInvoice(invoice.id), before);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('callback rejects wrong nonce, address, coin, or transaction without state change', async () => {
|
|
const invalidPayloads = [
|
|
buildCallbackPayload({ nonce: '0'.repeat(48) }),
|
|
buildCallbackPayload({ address_in: '0x1111111111111111111111111111111111111111' }),
|
|
buildCallbackPayload({ coin: 'doge' }),
|
|
buildCallbackPayload({ txid_in: '0x1234' })
|
|
];
|
|
|
|
for (const payload of invalidPayloads) {
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake);
|
|
await assert.rejects(handlePaygateCallback(fake.pool, payload, deps));
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
}
|
|
});
|
|
|
|
test('native-price failure makes no state change and remains retryable', async () => {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn
|
|
})
|
|
]);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: {
|
|
async convertPaymentToUsd() {
|
|
const error = new Error('temporary price failure');
|
|
error.code = 'PAYGATE_NATIVE_PRICE_UNAVAILABLE';
|
|
error.statusCode = 503;
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ coin: 'polygon_pol', value_coin: '23' }),
|
|
deps
|
|
),
|
|
(error) => error.statusCode === 503
|
|
);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
});
|
|
|
|
test('paid native callback retries delivery without requesting a fresh price', async () => {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'paid',
|
|
amount_crypto_received: 5,
|
|
paygate_paid_coin: 'polygon_pol',
|
|
paygate_paid_value: 5,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut,
|
|
paid_at: new Date('2026-07-28T12:30:00Z')
|
|
})
|
|
]);
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
throw new Error('native price is unavailable');
|
|
});
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: { convertPaymentToUsd }
|
|
});
|
|
|
|
const result = await handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ coin: 'polygon_pol', value_coin: '5.00' }),
|
|
deps
|
|
);
|
|
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(result.status, 'delivered');
|
|
assert.equal(fake.getInvoice(51).paygate_paid_value, 5);
|
|
assert.equal(fake.getInvoice(51).amount_crypto_received, 5);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
});
|
|
|
|
test('identical native callback replay remains a no-op during a price outage', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
let priceAvailable = true;
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
if (!priceAvailable) {
|
|
const error = new Error('native price is unavailable');
|
|
error.code = 'PAYGATE_NATIVE_PRICE_UNAVAILABLE';
|
|
error.statusCode = 503;
|
|
throw error;
|
|
}
|
|
return 3;
|
|
});
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: {
|
|
convertPaymentToUsd
|
|
}
|
|
});
|
|
const payload = buildCallbackPayload({
|
|
coin: 'polygon_pol',
|
|
value_coin: '2.00'
|
|
});
|
|
|
|
await handlePaygateCallback(fake.pool, payload, deps);
|
|
const firstObservation = fake.getInvoice(51);
|
|
const updatesAfterFirst = fake.stats.updates;
|
|
const eventsAfterFirst = fake.events.length;
|
|
|
|
priceAvailable = false;
|
|
await handlePaygateCallback(fake.pool, payload, deps);
|
|
const replayedObservation = fake.getInvoice(51);
|
|
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 1);
|
|
assert.equal(firstObservation.status, 'partial_payment');
|
|
assert.equal(firstObservation.paygate_paid_value, 3);
|
|
assert.equal(updatesAfterFirst, 1);
|
|
assert.equal(eventsAfterFirst, 1);
|
|
assert.equal(replayedObservation.status, firstObservation.status);
|
|
assert.equal(firstObservation.amount_crypto_received, 2);
|
|
assert.equal(
|
|
replayedObservation.amount_crypto_received,
|
|
firstObservation.amount_crypto_received
|
|
);
|
|
assert.equal(replayedObservation.paygate_paid_value, firstObservation.paygate_paid_value);
|
|
assert.equal(fake.stats.updates, updatesAfterFirst);
|
|
assert.equal(fake.events.length, eventsAfterFirst);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
});
|
|
|
|
test('pricing failure rechecks a concurrent paid or same-raw observation before failing', async () => {
|
|
for (const race of [
|
|
{
|
|
name: 'paid',
|
|
changes: {
|
|
status: 'paid',
|
|
amount_crypto_received: 23,
|
|
paygate_paid_coin: 'polygon_pol',
|
|
paygate_paid_value: 5,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut,
|
|
paid_at: new Date('2026-07-28T12:30:00Z')
|
|
},
|
|
expectedDeliveryCount: 1
|
|
},
|
|
{
|
|
name: 'same raw',
|
|
changes: {
|
|
status: 'partial_payment',
|
|
amount_crypto_received: 23,
|
|
paygate_paid_coin: 'polygon_pol',
|
|
paygate_paid_value: 3,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut
|
|
},
|
|
expectedDeliveryCount: 0
|
|
}
|
|
]) {
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: {
|
|
async convertPaymentToUsd() {
|
|
fake.mutateInvoice(51, race.changes);
|
|
const error = new Error('native price is unavailable');
|
|
error.code = 'PAYGATE_NATIVE_PRICE_UNAVAILABLE';
|
|
error.statusCode = 503;
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
|
|
await assert.doesNotReject(
|
|
handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ coin: 'polygon_pol', value_coin: '23' }),
|
|
deps
|
|
),
|
|
race.name
|
|
);
|
|
assert.equal(fake.stats.updates, 0, race.name);
|
|
assert.equal(deps.getDeliveryCount(), race.expectedDeliveryCount, race.name);
|
|
}
|
|
});
|
|
|
|
test('pricing failure remains authoritative when the recovery recheck cannot classify state', async () => {
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const originalGetConnection = fake.pool.getConnection.bind(fake.pool);
|
|
let lockedReads = 0;
|
|
fake.pool.getConnection = async () => {
|
|
const connection = await originalGetConnection();
|
|
const originalExecute = connection.execute.bind(connection);
|
|
connection.execute = async (sql, params) => {
|
|
if (/SELECT \* FROM vip_invoices WHERE id = \? FOR UPDATE/i.test(sql)) {
|
|
lockedReads += 1;
|
|
if (lockedReads === 2) {
|
|
throw new Error('recovery database failure');
|
|
}
|
|
}
|
|
return originalExecute(sql, params);
|
|
};
|
|
return connection;
|
|
};
|
|
const pricingError = new Error('native price is unavailable');
|
|
pricingError.code = 'PAYGATE_NATIVE_PRICE_UNAVAILABLE';
|
|
pricingError.statusCode = 503;
|
|
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ coin: 'polygon_pol', value_coin: '23' }),
|
|
{
|
|
paygateClient: {
|
|
async convertPaymentToUsd() {
|
|
throw pricingError;
|
|
}
|
|
}
|
|
}
|
|
),
|
|
(error) => error === pricingError
|
|
);
|
|
assert.equal(lockedReads, 2);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
});
|
|
|
|
test('callback accepts the inclusive 0.60 boundary and records both transaction hashes', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake);
|
|
|
|
await handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ value_coin: '4.56', value_forwarded_coin: '4.49' }),
|
|
deps
|
|
);
|
|
|
|
const invoice = fake.getInvoice(51);
|
|
assert.equal(invoice.status, 'delivered');
|
|
assert.equal(invoice.paygate_paid_value, 4.56);
|
|
assert.equal(invoice.paygate_paid_txid_in, PAYGATE_FIXTURE.txidIn);
|
|
assert.equal(invoice.paygate_paid_txid, PAYGATE_FIXTURE.txidOut);
|
|
assert.equal(fake.events.length, 1);
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
});
|
|
|
|
test('expired invoices move only to partial or paid, while delivered and cancelled stay final', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
|
|
const expiredPartial = createFakeVipInvoicePool([
|
|
makePaygateInvoice({ status: 'expired' })
|
|
]);
|
|
await handlePaygateCallback(
|
|
expiredPartial.pool,
|
|
buildCallbackPayload({ value_coin: '3.00', value_forwarded_coin: '2.95' }),
|
|
makeCallbackDeps(expiredPartial)
|
|
);
|
|
assert.equal(expiredPartial.getInvoice(51).status, 'partial_payment');
|
|
|
|
const expiredPaid = createFakeVipInvoicePool([
|
|
makePaygateInvoice({ status: 'expired' })
|
|
]);
|
|
const paidDeps = makeCallbackDeps(expiredPaid);
|
|
await handlePaygateCallback(
|
|
expiredPaid.pool,
|
|
buildCallbackPayload({ value_coin: '5.00', value_forwarded_coin: '4.90' }),
|
|
paidDeps
|
|
);
|
|
assert.equal(expiredPaid.getInvoice(51).status, 'delivered');
|
|
assert.equal(paidDeps.getDeliveryCount(), 1);
|
|
|
|
for (const finalStatus of ['delivered', 'cancelled']) {
|
|
const finalFake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({ status: finalStatus })
|
|
]);
|
|
const finalDeps = makeCallbackDeps(finalFake);
|
|
await handlePaygateCallback(finalFake.pool, buildCallbackPayload(), finalDeps);
|
|
assert.equal(finalFake.getInvoice(51).status, finalStatus);
|
|
assert.equal(finalFake.stats.updates, 0);
|
|
assert.equal(finalFake.events.length, 0);
|
|
assert.equal(finalDeps.getDeliveryCount(), 0);
|
|
}
|
|
});
|
|
|
|
test('event failure rolls back payment facts but preserves the authoritative txid claim', async () => {
|
|
const fake = createFakeVipInvoicePool(
|
|
[makePaygateInvoice()],
|
|
{ failEvent: new Error('event insert failed') }
|
|
);
|
|
const deps = makeCallbackDeps(fake);
|
|
await assert.rejects(handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload(),
|
|
deps
|
|
));
|
|
assert.equal(fake.getInvoice(51).status, 'awaiting_payment');
|
|
assert.equal(fake.getInvoice(51).paygate_paid_txid_in, PAYGATE_FIXTURE.txidIn);
|
|
assert.equal(fake.stats.identityUpdates, 1);
|
|
assert.equal(fake.stats.rollbacks, 1);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
});
|
|
|
|
test('underpayment becomes partial and exact replay creates no update or event', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake);
|
|
const payload = buildCallbackPayload({
|
|
value_coin: '4.55',
|
|
value_forwarded_coin: '4.48'
|
|
});
|
|
|
|
await handlePaygateCallback(fake.pool, payload, deps);
|
|
const updatesAfterFirst = fake.stats.updates;
|
|
const eventsAfterFirst = fake.events.length;
|
|
await handlePaygateCallback(fake.pool, payload, deps);
|
|
|
|
assert.equal(fake.getInvoice(51).status, 'partial_payment');
|
|
assert.equal(fake.stats.updates, updatesAfterFirst);
|
|
assert.equal(fake.events.length, eventsAfterFirst);
|
|
});
|
|
|
|
test('same inbound transaction can increase partial to paid but can never regress paid', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake);
|
|
|
|
await handlePaygateCallback(fake.pool, buildCallbackPayload({
|
|
value_coin: '3.00',
|
|
value_forwarded_coin: '2.95'
|
|
}), deps);
|
|
await handlePaygateCallback(fake.pool, buildCallbackPayload({
|
|
value_coin: '5.00',
|
|
value_forwarded_coin: '4.92'
|
|
}), deps);
|
|
await handlePaygateCallback(fake.pool, buildCallbackPayload({
|
|
value_coin: '2.00',
|
|
value_forwarded_coin: '1.95'
|
|
}), deps);
|
|
|
|
assert.equal(fake.getInvoice(51).status, 'delivered');
|
|
assert.equal(fake.getInvoice(51).paygate_paid_value, 5);
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
});
|
|
|
|
test('different inbound transaction on the same or another invoice conflicts', async () => {
|
|
const otherTxid = `0x${'b'.repeat(64)}`;
|
|
const sameInvoiceFake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'partial_payment',
|
|
paygate_paid_value: 3,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut
|
|
})
|
|
]);
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
sameInvoiceFake.pool,
|
|
buildCallbackPayload({ txid_in: otherTxid }),
|
|
makeCallbackDeps(sameInvoiceFake)
|
|
),
|
|
(error) => error.statusCode === 409
|
|
);
|
|
|
|
const otherInvoiceFake = createFakeVipInvoicePool([
|
|
makePaygateInvoice(),
|
|
makePaygateInvoice({
|
|
id: 52,
|
|
public_id: 'inv_aaaaaaaaaaaaaaaaaaaaaaaa',
|
|
paygate_callback_reference: 'b'.repeat(48),
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn
|
|
})
|
|
]);
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
otherInvoiceFake.pool,
|
|
buildCallbackPayload(),
|
|
makeCallbackDeps(otherInvoiceFake)
|
|
),
|
|
(error) => error.statusCode === 409
|
|
);
|
|
});
|
|
|
|
test('legacy partial callback without a raw baseline fails closed', async () => {
|
|
const legacyPayload = buildCallbackPayload({
|
|
reference: undefined,
|
|
publicId: PAYGATE_FIXTURE.legacyPublicId,
|
|
coin: 'polygon_pol',
|
|
value_coin: '2.00'
|
|
});
|
|
|
|
for (const rawBaseline of [null, 0]) {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'partial_payment',
|
|
paygate_callback_reference: null,
|
|
amount_crypto_received: rawBaseline,
|
|
paygate_paid_coin: 'polygon_pol',
|
|
paygate_paid_value: 3,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut
|
|
})
|
|
]);
|
|
const before = fake.getInvoice(51);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: {
|
|
async convertPaymentToUsd() {
|
|
return 5;
|
|
}
|
|
}
|
|
});
|
|
|
|
await assert.rejects(
|
|
handlePaygateCallback(fake.pool, legacyPayload, deps),
|
|
(error) => (
|
|
error.message === 'Callback PayGate invalide'
|
|
&& error.code === 'PAYGATE_PAYMENT_BASELINE_MISSING'
|
|
&& error.statusCode === 409
|
|
)
|
|
);
|
|
|
|
assert.deepEqual(fake.getInvoice(51), before);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
}
|
|
});
|
|
|
|
test('legacy paid callback without a raw baseline retries delivery', async () => {
|
|
const legacyPayload = buildCallbackPayload({
|
|
reference: undefined,
|
|
publicId: PAYGATE_FIXTURE.legacyPublicId
|
|
});
|
|
|
|
for (const rawBaseline of [null, 0]) {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'paid',
|
|
paygate_callback_reference: null,
|
|
amount_crypto_received: rawBaseline,
|
|
paygate_paid_coin: 'polygon_usdc',
|
|
paygate_paid_value: 5,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut
|
|
})
|
|
]);
|
|
const deps = makeCallbackDeps(fake);
|
|
|
|
const result = await handlePaygateCallback(fake.pool, legacyPayload, deps);
|
|
|
|
assert.equal(result.status, 'delivered');
|
|
assert.equal(fake.getInvoice(51).status, 'delivered');
|
|
assert.equal(fake.getInvoice(51).amount_crypto_received, rawBaseline);
|
|
assert.equal(fake.getInvoice(51).paygate_paid_value, 5);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
}
|
|
});
|
|
|
|
test('legacy publicId callback is accepted only when callback reference is NULL', async () => {
|
|
const legacyPayload = buildCallbackPayload({
|
|
reference: undefined,
|
|
publicId: PAYGATE_FIXTURE.legacyPublicId
|
|
});
|
|
const legacyFake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({ paygate_callback_reference: null })
|
|
]);
|
|
await assert.doesNotReject(handlePaygateCallback(
|
|
legacyFake.pool,
|
|
legacyPayload,
|
|
makeCallbackDeps(legacyFake)
|
|
));
|
|
|
|
const newFake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
await assert.rejects(
|
|
handlePaygateCallback(newFake.pool, legacyPayload, makeCallbackDeps(newFake)),
|
|
(error) => error.statusCode === 403
|
|
);
|
|
});
|
|
|
|
test('concurrent sufficient and partial callbacks end delivered exactly once', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const fake = createFakeVipInvoicePool([makePaygateInvoice()]);
|
|
const deps = makeCallbackDeps(fake);
|
|
await Promise.all([
|
|
handlePaygateCallback(fake.pool, buildCallbackPayload({
|
|
value_coin: '5.00',
|
|
value_forwarded_coin: '4.90'
|
|
}), deps),
|
|
handlePaygateCallback(fake.pool, buildCallbackPayload({
|
|
value_coin: '3.00',
|
|
value_forwarded_coin: '2.90'
|
|
}), deps)
|
|
]);
|
|
|
|
assert.equal(fake.getInvoice(51).status, 'delivered');
|
|
assert.equal(fake.getInvoice(51).paygate_paid_value, 5);
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
});
|
|
|
|
test('admin cancellation cannot race a paid invoice back to cancelled', async () => {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({ status: 'paid' })
|
|
]);
|
|
await assert.rejects(
|
|
cancelInvoice(fake.pool, 51, { userId: 'admin-1' }),
|
|
/déjà payée ou livrée/
|
|
);
|
|
assert.equal(fake.getInvoice(51).status, 'paid');
|
|
});
|
|
|
|
test('public PayGate refresh never calls payment-status.php', async () => {
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded,
|
|
expires_at: new Date(Date.now() + 60_000)
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const fetchPaymentStatus = mock.fn(async () => ({ status: 'unpaid' }));
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
actorType: 'system',
|
|
actorId: invoice.public_id,
|
|
paygateClient: { fetchPaymentStatus }
|
|
});
|
|
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 0);
|
|
assert.equal(fake.getInvoice(invoice.id).status, 'awaiting_payment');
|
|
});
|
|
|
|
test('admin reconciliation without IPN token or on final invoice makes no external call', async () => {
|
|
const fetchPaymentStatus = mock.fn(async () => ({ status: 'unpaid' }));
|
|
for (const invoice of [
|
|
makePaygateInvoice({ paygate_ipn_token: null }),
|
|
makePaygateInvoice({ status: 'delivered', paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenRaw }),
|
|
makePaygateInvoice({ status: 'cancelled', paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenRaw })
|
|
]) {
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: { fetchPaymentStatus }
|
|
});
|
|
}
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 0);
|
|
});
|
|
|
|
test('admin unpaid reconciliation calls PayGate once, changes no status, and is audited', async () => {
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded,
|
|
expires_at: new Date(Date.now() + 60_000)
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const fetchPaymentStatus = mock.fn(async () => ({ status: 'unpaid' }));
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: { fetchPaymentStatus }
|
|
});
|
|
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 1);
|
|
assert.equal(fetchPaymentStatus.mock.calls[0].arguments[0], PAYGATE_FIXTURE.ipnTokenEncoded);
|
|
assert.equal(fake.getInvoice(invoice.id).status, 'awaiting_payment');
|
|
assert.equal(fake.events.at(-1).eventType, 'invoice_paygate_admin_reconciliation_unpaid');
|
|
assert.equal(fake.events.at(-1).actorType, 'admin');
|
|
});
|
|
|
|
test('stale unpaid reconciliation does not audit an invoice delivered during the PayGate request', async () => {
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const fetchPaymentStatus = mock.fn(async () => {
|
|
fake.mutateInvoice(invoice.id, {
|
|
status: 'delivered',
|
|
vip_key_value: 'VIP-CONCURRENT'
|
|
});
|
|
return { status: 'unpaid' };
|
|
});
|
|
|
|
const result = await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: { fetchPaymentStatus }
|
|
});
|
|
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 1);
|
|
assert.equal(result.status, 'delivered');
|
|
assert.equal(result.vip_key_value, 'VIP-CONCURRENT');
|
|
assert.equal(fake.events.length, 0);
|
|
});
|
|
|
|
function makePaidReconciliationClient(valueCoin) {
|
|
return {
|
|
async fetchPaymentStatus() {
|
|
return {
|
|
status: 'paid',
|
|
coin: 'polygon_usdc',
|
|
valueCoin,
|
|
txidOut: PAYGATE_FIXTURE.txidOut
|
|
};
|
|
},
|
|
async convertPaymentToUsd(payment) {
|
|
return payment.valueCoin;
|
|
}
|
|
};
|
|
}
|
|
|
|
test('explicit admin reconciliation rejects every invalid stored PayGate USD amount without mutation', async (t) => {
|
|
for (const invalidAmount of INVALID_STORED_PAYGATE_AMOUNTS) {
|
|
for (const reportedStatus of ['paid', 'unpaid']) {
|
|
await t.test(`${invalidAmount.name}/${reportedStatus}`, async () => {
|
|
const invoice = makePaygateInvoice({
|
|
amount_usd: invalidAmount.value,
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const before = fake.getInvoice(invoice.id);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: reportedStatus === 'paid'
|
|
? makePaidReconciliationClient(5)
|
|
: {
|
|
async fetchPaymentStatus() {
|
|
return { status: 'unpaid' };
|
|
}
|
|
}
|
|
});
|
|
|
|
await assert.rejects(
|
|
refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: deps.paygateClient,
|
|
deliverInvoice: deps.deliverInvoice
|
|
}),
|
|
isStoredPaygateAmountError
|
|
);
|
|
|
|
assert.deepEqual(fake.getInvoice(invoice.id), before);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(deps.getDeliveryCount(), 0);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test('admin paid reconciliation reuses the monotonic transition without inventing txid_in', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const deps = makeCallbackDeps(fake, {
|
|
paygateClient: {
|
|
async fetchPaymentStatus() {
|
|
return {
|
|
status: 'paid',
|
|
coin: 'polygon_usdc',
|
|
valueCoin: 4.56,
|
|
txidOut: PAYGATE_FIXTURE.txidOut
|
|
};
|
|
},
|
|
async convertPaymentToUsd(payment) {
|
|
return payment.valueCoin;
|
|
}
|
|
}
|
|
});
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: deps.paygateClient,
|
|
deliverInvoice: deps.deliverInvoice
|
|
});
|
|
|
|
const refreshed = fake.getInvoice(invoice.id);
|
|
assert.equal(refreshed.status, 'delivered');
|
|
assert.equal(refreshed.paygate_paid_txid_in, null);
|
|
assert.equal(refreshed.paygate_paid_txid, PAYGATE_FIXTURE.txidOut);
|
|
assert.equal(fake.events[0].eventType, 'invoice_paygate_admin_reconciliation_paid');
|
|
assert.equal(deps.getDeliveryCount(), 1);
|
|
});
|
|
|
|
test('callback enriches an admin-reconciled partial payment exactly once without repricing', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: makePaidReconciliationClient(3)
|
|
});
|
|
const reconciled = fake.getInvoice(invoice.id);
|
|
const eventsAfterReconciliation = fake.events.length;
|
|
const updatesAfterReconciliation = fake.stats.updates;
|
|
assert.equal(reconciled.status, 'partial_payment');
|
|
assert.equal(reconciled.paygate_paid_txid_in, null);
|
|
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
throw new Error('exact replay must not request pricing');
|
|
});
|
|
const callbackDeps = makeCallbackDeps(fake, {
|
|
paygateClient: { convertPaymentToUsd }
|
|
});
|
|
const payload = buildCallbackPayload({ value_coin: '3.00' });
|
|
await handlePaygateCallback(fake.pool, payload, callbackDeps);
|
|
await handlePaygateCallback(fake.pool, payload, callbackDeps);
|
|
|
|
const enriched = fake.getInvoice(invoice.id);
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(enriched.paygate_paid_txid_in, PAYGATE_FIXTURE.txidIn);
|
|
assert.equal(enriched.status, reconciled.status);
|
|
assert.equal(enriched.paygate_paid_value, reconciled.paygate_paid_value);
|
|
assert.equal(enriched.amount_crypto_received, reconciled.amount_crypto_received);
|
|
assert.equal(enriched.paid_at, reconciled.paid_at);
|
|
assert.equal(fake.stats.identityUpdates, 1);
|
|
assert.equal(fake.stats.updates, updatesAfterReconciliation);
|
|
assert.equal(fake.events.length, eventsAfterReconciliation);
|
|
assert.equal(callbackDeps.getDeliveryCount(), 0);
|
|
});
|
|
|
|
test('callback enriches an admin-reconciled paid invoice and retries delivery without repricing', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const keepPaid = mock.fn(async () => fake.getInvoice(invoice.id));
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: makePaidReconciliationClient(4.56),
|
|
deliverInvoice: keepPaid
|
|
});
|
|
const reconciled = fake.getInvoice(invoice.id);
|
|
const eventsAfterReconciliation = fake.events.length;
|
|
const updatesAfterReconciliation = fake.stats.updates;
|
|
assert.equal(reconciled.status, 'paid');
|
|
assert.equal(reconciled.paygate_paid_txid_in, null);
|
|
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
throw new Error('paid recovery must not request pricing');
|
|
});
|
|
const retryDelivery = mock.fn(async () => fake.getInvoice(invoice.id));
|
|
await handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ value_coin: '4.56' }),
|
|
{
|
|
paygateClient: { convertPaymentToUsd },
|
|
deliverInvoice: retryDelivery
|
|
}
|
|
);
|
|
|
|
const enriched = fake.getInvoice(invoice.id);
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(retryDelivery.mock.callCount(), 1);
|
|
assert.equal(enriched.paygate_paid_txid_in, PAYGATE_FIXTURE.txidIn);
|
|
assert.equal(enriched.status, reconciled.status);
|
|
assert.equal(enriched.paygate_paid_value, reconciled.paygate_paid_value);
|
|
assert.equal(enriched.amount_crypto_received, reconciled.amount_crypto_received);
|
|
assert.deepEqual(enriched.paid_at, reconciled.paid_at);
|
|
assert.equal(fake.stats.identityUpdates, 1);
|
|
assert.equal(fake.stats.updates, updatesAfterReconciliation);
|
|
assert.equal(fake.events.length, eventsAfterReconciliation);
|
|
});
|
|
|
|
test('callback enriches an admin-reconciled delivered invoice without repricing or redelivery', async (t) => {
|
|
setEnv(t, { VIP_PAYGATE_MIN_PAID_RATIO: '0.60' });
|
|
const invoice = makePaygateInvoice({
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const reconciliationDeps = makeCallbackDeps(fake, {
|
|
paygateClient: makePaidReconciliationClient(4.56)
|
|
});
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
actorType: 'admin',
|
|
actorId: 'admin-1',
|
|
paygateClient: reconciliationDeps.paygateClient,
|
|
deliverInvoice: reconciliationDeps.deliverInvoice
|
|
});
|
|
const reconciled = fake.getInvoice(invoice.id);
|
|
const eventsAfterReconciliation = fake.events.length;
|
|
const updatesAfterReconciliation = fake.stats.updates;
|
|
assert.equal(reconciled.status, 'delivered');
|
|
assert.equal(reconciled.paygate_paid_txid_in, null);
|
|
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
throw new Error('delivered callback must not request pricing');
|
|
});
|
|
const retryDelivery = mock.fn(async () => fake.getInvoice(invoice.id));
|
|
await handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ value_coin: '4.56' }),
|
|
{
|
|
paygateClient: { convertPaymentToUsd },
|
|
deliverInvoice: retryDelivery
|
|
}
|
|
);
|
|
|
|
const enriched = fake.getInvoice(invoice.id);
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(retryDelivery.mock.callCount(), 0);
|
|
assert.equal(enriched.paygate_paid_txid_in, PAYGATE_FIXTURE.txidIn);
|
|
assert.equal(enriched.status, reconciled.status);
|
|
assert.equal(enriched.paygate_paid_value, reconciled.paygate_paid_value);
|
|
assert.equal(enriched.amount_crypto_received, reconciled.amount_crypto_received);
|
|
assert.deepEqual(enriched.paid_at, reconciled.paid_at);
|
|
assert.equal(fake.stats.identityUpdates, 1);
|
|
assert.equal(fake.stats.updates, updatesAfterReconciliation);
|
|
assert.equal(fake.events.length, eventsAfterReconciliation);
|
|
});
|
|
|
|
test('callback enrichment rejects an inbound transaction already owned by another invoice', async () => {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'delivered',
|
|
amount_crypto_received: 5,
|
|
paygate_paid_coin: 'polygon_usdc',
|
|
paygate_paid_value: 5,
|
|
paygate_paid_txid_in: null,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut,
|
|
vip_key_value: 'VIP-TEST'
|
|
}),
|
|
makePaygateInvoice({
|
|
id: 52,
|
|
public_id: 'inv_aaaaaaaaaaaaaaaaaaaaaaaa',
|
|
paygate_callback_reference: 'b'.repeat(48),
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn
|
|
})
|
|
]);
|
|
const convertPaymentToUsd = mock.fn(async () => 5);
|
|
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ value_coin: '5.00' }),
|
|
{ paygateClient: { convertPaymentToUsd } }
|
|
),
|
|
(error) => error.code === 'PAYGATE_TXID_ALREADY_USED' && error.statusCode === 409
|
|
);
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(fake.getInvoice(51).paygate_paid_txid_in, null);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
});
|
|
|
|
test('callback enrichment maps a duplicate-key claim race to the generic conflict', async () => {
|
|
const duplicate = new Error('Duplicate entry');
|
|
duplicate.code = 'ER_DUP_ENTRY';
|
|
duplicate.errno = 1062;
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'partial_payment',
|
|
amount_crypto_received: 3,
|
|
paygate_paid_coin: 'polygon_usdc',
|
|
paygate_paid_value: 3,
|
|
paygate_paid_txid_in: null,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut
|
|
})
|
|
], { failTxidClaim: duplicate });
|
|
|
|
await assert.rejects(
|
|
handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload({ value_coin: '3.00' }),
|
|
{ paygateClient: { async convertPaymentToUsd() { return 3; } } }
|
|
),
|
|
(error) => (
|
|
error.message === 'Callback PayGate invalide'
|
|
&& error.code === 'PAYGATE_TXID_ALREADY_USED'
|
|
&& error.statusCode === 409
|
|
)
|
|
);
|
|
assert.equal(fake.getInvoice(51).paygate_paid_txid_in, null);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
});
|
|
|
|
test('cancelled callback neither enriches identity nor requests pricing', async () => {
|
|
const fake = createFakeVipInvoicePool([
|
|
makePaygateInvoice({
|
|
status: 'cancelled',
|
|
paygate_paid_txid_in: null
|
|
})
|
|
]);
|
|
const convertPaymentToUsd = mock.fn(async () => {
|
|
throw new Error('cancelled callback must not request pricing');
|
|
});
|
|
|
|
const result = await handlePaygateCallback(
|
|
fake.pool,
|
|
buildCallbackPayload(),
|
|
{ paygateClient: { convertPaymentToUsd } }
|
|
);
|
|
|
|
assert.equal(result.status, 'cancelled');
|
|
assert.equal(convertPaymentToUsd.mock.callCount(), 0);
|
|
assert.equal(fake.getInvoice(51).paygate_paid_txid_in, null);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
});
|
|
|
|
test('a locally paid invoice is delivered without another PayGate status call', async () => {
|
|
const invoice = makePaygateInvoice({
|
|
status: 'paid',
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const fetchPaymentStatus = mock.fn(async () => ({ status: 'unpaid' }));
|
|
|
|
await refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
paygateClient: { fetchPaymentStatus }
|
|
});
|
|
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 0);
|
|
assert.equal(fake.getInvoice(invoice.id).status, 'delivered');
|
|
assert.match(fake.getInvoice(invoice.id).vip_key_value, /^VIP-[A-F0-9]{16}$/);
|
|
assert.equal(fake.events.at(-1).eventType, 'invoice_delivered');
|
|
});
|
|
|
|
test('local paid recovery rejects every invalid stored PayGate USD amount before delivery', async (t) => {
|
|
for (const invalidAmount of INVALID_STORED_PAYGATE_AMOUNTS) {
|
|
await t.test(invalidAmount.name, async () => {
|
|
const invoice = makePaygateInvoice({
|
|
amount_usd: invalidAmount.value,
|
|
status: 'paid',
|
|
paygate_ipn_token: PAYGATE_FIXTURE.ipnTokenEncoded,
|
|
amount_crypto_received: 5,
|
|
paygate_paid_coin: 'polygon_usdc',
|
|
paygate_paid_value: 5,
|
|
paygate_paid_txid_in: PAYGATE_FIXTURE.txidIn,
|
|
paygate_paid_txid: PAYGATE_FIXTURE.txidOut,
|
|
paid_at: new Date('2026-07-28T12:30:00Z')
|
|
});
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const before = fake.getInvoice(invoice.id);
|
|
const fetchPaymentStatus = mock.fn(async () => ({ status: 'unpaid' }));
|
|
|
|
await assert.rejects(
|
|
refreshInvoiceStatus(fake.pool, invoice, {
|
|
allowPaygateReconciliation: true,
|
|
paygateClient: { fetchPaymentStatus }
|
|
}),
|
|
isStoredPaygateAmountError
|
|
);
|
|
|
|
assert.deepEqual(fake.getInvoice(invoice.id), before);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(fetchPaymentStatus.mock.callCount(), 0);
|
|
assert.equal(
|
|
fake.calls.some(({ sql }) => /access_keys|status = 'delivered'/i.test(sql)),
|
|
false
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('manual admin validation rejects an invalid stored PayGate USD amount before mutation', async () => {
|
|
const invoice = makePaygateInvoice({ amount_usd: 0 });
|
|
const fake = createFakeVipInvoicePool([invoice]);
|
|
const before = fake.getInvoice(invoice.id);
|
|
|
|
await assert.rejects(
|
|
forceValidateInvoice(fake.pool, invoice.id, { userId: 'admin-1' }),
|
|
isStoredPaygateAmountError
|
|
);
|
|
|
|
assert.deepEqual(fake.getInvoice(invoice.id), before);
|
|
assert.equal(fake.stats.updates, 0);
|
|
assert.equal(fake.stats.identityUpdates, 0);
|
|
assert.equal(fake.events.length, 0);
|
|
assert.equal(
|
|
fake.calls.some(({ sql }) => /access_keys|status = 'delivered'/i.test(sql)),
|
|
false
|
|
);
|
|
});
|