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.
Changes
@@@ -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)));nnew file mode 100644@@@ -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();++})();
@@ -66,7 +66,7 @@ async function applyPlan(ctx, plan, row) {
@@ -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 });
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) {
switch (plan.effect) {
case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };
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 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));
case 'subscription': return settled(await subscriptions.pay(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)));
case 'tip': return settled(await transfers.fromReceipt(ctx, withEvent(plan.args)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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();
+})();