openvibers/openvibe.billing Commits

A provider event that updates a payment intent or a subscription is marked processed only when the update committed: applyPlan called the async plan.apply without awaiting it (the PostgreSQL move made it async), so it ran after the event's transaction had stamped processed_at, the provider was told it succeeded, and a failure was an unhandled rejection with the update lost. It is awaited inside the transaction; a failure rolls the stamp back, records last_error and is retried by processPending. test/provider-update-tx.test.js: a rejecting update leaves the event unprocessed with its error (fails on the old code), a successful one still applies. No other dropped promise in server/ (full scan). Found by the estate-wide floating-promise sweep; fixed by DeepSeek, reviewed by Claude. npm test 20/20. npm test 20/20 test files passed.

OP OpenVibers committed 29 Sept 2026 · 1dd658e36e · 2 files, +85 −1


Changes

ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex f4fe32c..8893122 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -66,7 +66,7 @@ async function applyPlan(ctx, plan, row) {
const settled = (r) => (r.replay ? { effect: 'duplicate_receipt', txn_id: r.txn && r.txn.id } : { effect: 'settled', txn_id: r.txn.id });
switch (plan.effect) {
case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };
-- case 'update': plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
++ case 'update': await plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
case 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));
case 'subscription': return settled(await subscriptions.pay(ctx, withEvent(plan.args)));
case 'tip': return settled(await transfers.fromReceipt(ctx, withEvent(plan.args)));
ddiff --git a/test/provider-update-tx.test.js b/test/provider-update-tx.test.js
nnew file mode 100644
iindex 0000000..15397e7
---- /dev/null
++++ b/test/provider-update-tx.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * A provider 'update' plan (a state change, no money: linking a Stripe subscription, ending one)
++ * is applied inside the event's own transaction — the same one that stamps processed_at. Its
++ * apply() is async (providers/stripe.js), so it must be awaited: a dropped promise ran on the pool
++ * after the transaction had committed, so a failed update still marked the event processed, told
++ * the provider success, left the update half-done and raised an unhandled rejection.
++ *
++ * The 'flaky' adapter below is injected through app.locals.adapters (what createApp handed the
++ * webhook router) so the failure is deterministic instead of needing a real provider outage.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ console.log('provider update plans run inside the event transaction');
++
++ let applies = 0;
++ let outcome = 'ok';
++ t.app.locals.adapters.flaky = {
++ name: 'flaky',
++ enabled: true,
++ verify: async () => ({ ok: true }),
++ parse: (req) => {
++ const p = JSON.parse(req.rawBody.toString('utf8'));
++ return { eventId: p.id, type: p.type, payload: p };
++ },
++ interpret: async () => ({
++ effect: 'update',
++ reason: 'flaky update',
++ // A real plan's apply does a write (mergeMetadata, subscriptions.setStatus) and can fail.
++ apply: async () => { applies += 1; if (outcome !== 'ok') throw new Error(`provider update failed: ${outcome}`); },
++ }),
++ };
++
++ const deliver = async (id) => {
++ const res = await fetch(`${t.base}/webhooks/flaky`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, type: 'flaky.updated' }) });
++ const text = await res.text();
++ return { status: res.status, text, json: JSON.parse(text) };
++ };
++ const stored = async (id) => await t.db.prepare('SELECT * FROM provider_events WHERE provider = ? AND provider_event_id = ?').get('flaky', id);
++
++ // A dropped promise rejects with nobody listening: Node would abort the process before the
++ // assertions below could say why. Hold the rejection here so this file reports it as a failure.
++ const dropped = [];
++ const onDropped = (e) => dropped.push(e);
++ process.on('unhandledRejection', onDropped);
++
++ await check("an update whose apply rejects leaves the event unprocessed and reports the failure", async () => {
++ dropped.length = 0;
++ outcome = 'transient';
++ try {
++ const r = await deliver('evt-update-fails');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(applies, 1, 'the plan was applied');
++ assert.strictEqual(r.json.processed, false, 'the provider must not be told the update succeeded');
++ assert.strictEqual(r.json.result, null, 'a failed plan has no result');
++ const row = await stored('evt-update-fails');
++ assert.strictEqual(row.processed_at, null, 'processed_at must stay NULL so the event is retried');
++ assert.strictEqual(row.result, null);
++ assert.match(row.last_error, /provider update failed: transient/);
++ assert.strictEqual(row.attempts, 1);
++ assert.deepStrictEqual(dropped, [], 'the rejection must be handled, not dropped');
++ } finally {
++ process.removeListener('unhandledRejection', onDropped);
++ outcome = 'ok';
++ }
++ });
++
++ await check('an update whose apply succeeds is applied and the event marked processed', async () => {
++ const r = await deliver('evt-update-ok');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.processed, true, r.text);
++ assert.deepStrictEqual(r.json.result, { effect: 'updated', reason: 'flaky update' });
++ const row = await stored('evt-update-ok');
++ assert.ok(row.processed_at, 'the event is marked processed');
++ assert.strictEqual(row.last_error, null);
++ });
++
++ delete t.app.locals.adapters.flaky;
++ await t.close();
++ done();
++})();