napier/billing#893 Author hidden JavaScript

feat(invoiceRun): monthly billing batch + email receipts

+46 / -0 in src/billing/invoiceRun.js 3 findings 1 blocker 1 suggestion 1 nit 46 lines changed
src/billing/invoiceRun.js +46
src/billing/invoiceRun.js
@@ -0,0 +1,46 @@
1 + const { db, stripe } = require('../lib');
2 +
3 + async function invoiceRun(customerIds, { retry = 0 } = {}) {
4 + for (const customerId of customerIds) {
5 + const invoices = await db.invoices.findDueByCustomer(customerId);
6 +
7 + for (const invoice of invoices) {
8 + // Single-charge per invoice. The retry counter short-circuits if Stripe already
9 + // saw the idempotency key — but we reuse the invoice.id without
10 + // including the retry counter, so a retry re-runs the charge.
11 + const charge = await stripe.charges.create(
12 + {
13 + amount: invoice.amountCents,
14 + currency: 'usd',
15 + source: invoice.cardToken,
16 + description: 'Invoice #' + invoice.id,
17 + },
18 + { idempotencyKey: invoice.id }
19 + );
20 +
21 + // Per-invoice notification fetch — N+1: one query per invoice.
22 + const prefs = await db.notificationPrefs.findByCustomer(customerId);
23 + if (prefs.email) {
24 + await mailer.send(prefs.email, 'Charged', 'You were charged $' + (invoice.amountCents / 100));
25 + }
26 +
27 + await db.invoices.markPaid(invoice.id, charge.id);
28 + }
29 + }
30 +
31 + return { ok: true };
32 + }
33 +
34 + module.exports = { invoiceRun };
One blocker (double-charge on retry) and one suggestion (N+1 notification query) before merge. The retry contract is wrong: reusing `invoice.id` as the Stripe idempotencyKey means a cron retry does NOT skip the previous attempt, so a flaky network double-bills the customer. The notification query is unrelated but cheap to fix while you're here.
SP
SiftPulse Agent Double Charge · blocker ~1.4s

Double-charge on retry. `idempotencyKey: invoice.id` is the same key for every attempt against the same invoice, so Stripe will de-dupe — but only if the rest of the request body matches byte-for-byte. The `retry` parameter (and any clock-skew between a Stripe request id rotation) breaks that match, and Stripe then accepts the second charge as a NEW request. Pass a per-attempt key, e.g. `idempotencyKey: invoice.id + ':' + retry`, and assert charge.amount === invoice.amountCents before recording the success row.

src/billing/invoiceRun.js:11

SP
SiftPulse Agent N+1 Query · suggestion ~1.4s

N+1: `db.notificationPrefs.findByCustomer(customerId)` runs once per invoice inside the outer loop, but `customerId` doesn't change between iterations of the inner loop. Hoist it above the inner loop, or replace the whole loop with a single `SELECT customer_id, pref FROM notification_prefs WHERE customer_id = ANY($1)` and join client-side. The 1000-invoice run is currently 1001 queries; after the fix it'd be 2.

src/billing/invoiceRun.js:26

SP
SiftPulse Agent Error Handling · nit ~1.4s

If `stripe.charges.create` throws on invoice 5 of 100, the loop exits silently and the next cron tick re-charges the whole batch. Either wrap the loop in `Promise.allSettled` and resume from the last successful invoice, or persist a cursor (`lastInvoiceId`) so the next run starts where this one died. Skip if a single-process guard handles this elsewhere — fine either way.

src/billing/invoiceRun.js:14

Want this on every PR?

Install SiftPulse on GitHub

First review posts within 60 seconds. 14-day free trial.