Context
Trawl pricing rebases to a usage-based "compute" model (Claude-Code style). The modules/billing/ is currently identical between pierreb-devkit/Node and downstream comes-io/trawl_node, so all generic mechanics ship upstream — reusable cross-projects (Comes, Pierreb, Montaine).
Plan canonique : see Trawl roadmap (memory file) + Obsidian Projects/Trawl/Product/Trawl — Product — Roadmap.md § Compute Pricing.
Backward compat: all changes gated behind config.billing.computeMode: boolean (default false). Downstream non-compute keep working unchanged.
Scope — 5 PRs sequenced
PR-N1 — Compute layer foundation (no behavior change)
models/billing.plan.model.mongoose.js (NEW) — versioned plans {planId, version, computeQuota, stripePriceMonthly, stripePriceAnnual, ratios: Mixed, effectiveFrom, effectiveUntil, active}. Unique index {planId, version}.
models/billing.processedStripeEvent.model.mongoose.js (NEW) — {eventId: unique, type, processedAt} TTL 30d.
models/billing.usage.model.mongoose.js (MODIFY) — add weekKey: 'YYYY-Www', computeUsed, computeQuota (snapshot), planVersion, computeBreakdown: Mixed, resetAt, alertedAt80/100, consumedHistoryIds: [ObjectId]. Keep legacy month sparse for downstream non-compute.
models/billing.subscription.model.mongoose.js (MODIFY) — add planVersion, currentPeriodStart.
services/billing.plan.service.js (NEW, extracted from billing.plans.service.js) — getActivePlan, getPlanByVersion, bumpVersion, invalidateCache.
config/billing.development.config.js (MODIFY) — add billing.computeMode (default false), billing.compute.{runBaseUnits, dollarsToComputeRatio, maxComputePerScrap}, billing.packs[], stripe.prices.packs.*.
Migrations 20260501-add-compute-fields.js + 20260501-add-plan-version-and-period-start.js (additive, idempotent).
Tests : billing.plan.unit.tests.js, billing.processedStripeEvent.unit.tests.js, billing.plan.service.unit.tests.js, billing.usage.unit.tests.js (extend weekKey + consumedHistoryIds).
PR-N2 — Compute service + extras balance
models/billing.extraBalance.model.mongoose.js (NEW) — {organization, ledger: [{kind, amount, stripeSessionId?, historyId?, refId?, at, expiresAt?}], cachedBalance}. Statics creditPack, debit, getBalance, recomputeBalance. Ledger entry kinds: topup|debit|refund|expiration|adjustment.
models/billing.extraBalance.schema.js (NEW) — Zod mirror.
services/billing.compute.service.js (NEW) — computeUnitsFromHistory(costs, ratioVersion), attribute(history, organizationId), exports COMPUTE_RUN_BASE, DOLLARS_TO_COMPUTE. Idempotent on history._id.
services/billing.extra.service.js (NEW) — creditPack, debit, expireOldEntries, refundPartial, listLedger. Idempotent on stripeSessionId (creditPack) + refId (debit).
services/billing.usage.service.js (MODIFY) — add currentWeekKey(), incrementCompute(orgId, units, breakdown, idempotencyKey) with overflow → extras fallback, threshold 80/100 detection emitting compute.threshold_crossed event, getCompute(orgId).
services/billing.reset.service.js (NEW) — resetWeek(orgId, periodStart) atomic via archive-then-upsert pattern, resetAllDue().
Tests : billing.extraBalance.unit.tests.js, billing.compute.service.unit.tests.js, billing.extra.service.unit.tests.js, billing.reset.service.unit.tests.js, extend billing.usage.service.unit.tests.js.
PR-N3 — Webhook idempotency + refund flow
services/billing.webhook.service.js (MODIFY) — wrap all handlers via withIdempotency(event, handler) using ProcessedStripeEvent. Add handleCheckoutPaymentCompleted (mode=payment, extras packs). Add handleChargeRefunded (lookup ledger via metadata.packId + stripeSessionId, call refundPartial). Extend handleSubscriptionUpdated to trigger resetWeek when current_period_start changes. Add handleInvoicePaymentSucceeded (clear degraded mode).
controllers/billing.webhook.controller.js (MODIFY) — extend switch with charge.refunded, invoice.payment_succeeded.
services/billing.refund.service.js (NEW) — wrapper stripe.refunds.create({ payment_intent, amount, metadata }) + idempotency key refund_${paymentIntentId}_${amountCents ?? 'full'}.
Tests : extend billing.webhook.integration.tests.js (idempotency replay, refund, period_start change, payment_succeeded), billing.refund.service.unit.tests.js (NEW).
PR-N4 — Routes + controllers + middleware (compute mode wired)
routes/billing.routes.js (MODIFY) — add POST /billing/extras/checkout, GET /billing/extras/balance, GET /billing/extras/ledger.
controllers/billing.controller.js (MODIFY) — add extrasCheckout, extrasBalance, extrasLedger. Extend getUsage to return {computeUsed, computeQuota, computeBreakdown, extrasRemaining, weekResetAt, planVersion} when computeMode.
services/billing.service.js (MODIFY) — add createExtrasCheckout(org, packId, successUrl, cancelUrl) with mode:'payment', metadata:{organizationId, packId, kind:'extras'}, automatic_tax: { enabled: true }. Verify existing createCheckout also passes automatic_tax.
models/billing.subscription.schema.js (MODIFY) — add Zod ExtrasCheckoutRequest.
middlewares/billing.requireQuota.js (MODIFY) — refactor to gate on compute pré-check (legacy fallback when computeMode=false). When compute mode + (computeQuota - computeUsed) + extrasBalance <= 0 → 402 Payment Required.
middlewares/billing.attachUsageContext.js (NEW, optional) — decorate req.computeContext for downstream handlers + X-Compute-Remaining response header.
policies/billing.policy.js (MODIFY) — register new path subjects for extras routes.
Add idempotencyKey to stripe.checkout.sessions.create() (currently missing — double-click can create 2 sessions).
Tests : extend billing.quota.unit.tests.js (legacy + compute mode + overflow + 402), billing.extras.controller.unit.tests.js (NEW), billing.routes.integration.tests.js (NEW), extend billing.controller.unit.tests.js, billing.service.unit.tests.js.
PR-N5 — Cron scripts + dunning grace 7d + free fallback J+14
Risks / sharp edges (covered in design)
Race webhook reset vs incrementCompute in-flight → resetWeek archive-then-upsert pattern with retry-friendly atomicity
Plan-versioning cache 1h multi-pod → emit plan.versionBumped event + getPlanByVersion immutable lookup
Idempotency Stripe webhook → ProcessedStripeEvent collection with TTL 30d
Backward compat downstream non-compute → computeMode flag default false, all new code paths no-op when off
Out of scope V1
Stripe Metered Billing pur (subscription + interne tracking choisi)
Multi-account abuse detection / signup throttle
Worker proxyTierLadder (reports only final tier — handled Trawl-side)
Team / Enterprise plans
Definition of done
Linked
Context
Trawl pricing rebases to a usage-based "compute" model (Claude-Code style). The
modules/billing/is currently identical betweenpierreb-devkit/Nodeand downstreamcomes-io/trawl_node, so all generic mechanics ship upstream — reusable cross-projects (Comes, Pierreb, Montaine).Plan canonique : see Trawl roadmap (memory file) + Obsidian
Projects/Trawl/Product/Trawl — Product — Roadmap.md§ Compute Pricing.Backward compat: all changes gated behind
config.billing.computeMode: boolean(defaultfalse). Downstream non-compute keep working unchanged.Scope — 5 PRs sequenced
PR-N1 — Compute layer foundation (no behavior change)
models/billing.plan.model.mongoose.js(NEW) — versioned plans{planId, version, computeQuota, stripePriceMonthly, stripePriceAnnual, ratios: Mixed, effectiveFrom, effectiveUntil, active}. Unique index{planId, version}.models/billing.processedStripeEvent.model.mongoose.js(NEW) —{eventId: unique, type, processedAt}TTL 30d.models/billing.usage.model.mongoose.js(MODIFY) — addweekKey: 'YYYY-Www',computeUsed,computeQuota(snapshot),planVersion,computeBreakdown: Mixed,resetAt,alertedAt80/100,consumedHistoryIds: [ObjectId]. Keep legacymonthsparse for downstream non-compute.models/billing.subscription.model.mongoose.js(MODIFY) — addplanVersion,currentPeriodStart.services/billing.plan.service.js(NEW, extracted frombilling.plans.service.js) —getActivePlan,getPlanByVersion,bumpVersion,invalidateCache.config/billing.development.config.js(MODIFY) — addbilling.computeMode(default false),billing.compute.{runBaseUnits, dollarsToComputeRatio, maxComputePerScrap},billing.packs[],stripe.prices.packs.*.20260501-add-compute-fields.js+20260501-add-plan-version-and-period-start.js(additive, idempotent).billing.plan.unit.tests.js,billing.processedStripeEvent.unit.tests.js,billing.plan.service.unit.tests.js,billing.usage.unit.tests.js(extend weekKey + consumedHistoryIds).PR-N2 — Compute service + extras balance
models/billing.extraBalance.model.mongoose.js(NEW) —{organization, ledger: [{kind, amount, stripeSessionId?, historyId?, refId?, at, expiresAt?}], cachedBalance}. StaticscreditPack,debit,getBalance,recomputeBalance. Ledger entry kinds:topup|debit|refund|expiration|adjustment.models/billing.extraBalance.schema.js(NEW) — Zod mirror.services/billing.compute.service.js(NEW) —computeUnitsFromHistory(costs, ratioVersion),attribute(history, organizationId), exportsCOMPUTE_RUN_BASE,DOLLARS_TO_COMPUTE. Idempotent onhistory._id.services/billing.extra.service.js(NEW) —creditPack,debit,expireOldEntries,refundPartial,listLedger. Idempotent onstripeSessionId(creditPack) +refId(debit).services/billing.usage.service.js(MODIFY) — addcurrentWeekKey(),incrementCompute(orgId, units, breakdown, idempotencyKey)with overflow → extras fallback, threshold 80/100 detection emittingcompute.threshold_crossedevent,getCompute(orgId).services/billing.reset.service.js(NEW) —resetWeek(orgId, periodStart)atomic via archive-then-upsert pattern,resetAllDue().billing.extraBalance.unit.tests.js,billing.compute.service.unit.tests.js,billing.extra.service.unit.tests.js,billing.reset.service.unit.tests.js, extendbilling.usage.service.unit.tests.js.PR-N3 — Webhook idempotency + refund flow
services/billing.webhook.service.js(MODIFY) — wrap all handlers viawithIdempotency(event, handler)usingProcessedStripeEvent. AddhandleCheckoutPaymentCompleted(mode=payment, extras packs). AddhandleChargeRefunded(lookup ledger viametadata.packId+stripeSessionId, callrefundPartial). ExtendhandleSubscriptionUpdatedto triggerresetWeekwhencurrent_period_startchanges. AddhandleInvoicePaymentSucceeded(clear degraded mode).controllers/billing.webhook.controller.js(MODIFY) — extend switch withcharge.refunded,invoice.payment_succeeded.services/billing.refund.service.js(NEW) — wrapperstripe.refunds.create({ payment_intent, amount, metadata })+ idempotency keyrefund_${paymentIntentId}_${amountCents ?? 'full'}.billing.webhook.integration.tests.js(idempotency replay, refund, period_start change, payment_succeeded),billing.refund.service.unit.tests.js(NEW).PR-N4 — Routes + controllers + middleware (compute mode wired)
routes/billing.routes.js(MODIFY) — addPOST /billing/extras/checkout,GET /billing/extras/balance,GET /billing/extras/ledger.controllers/billing.controller.js(MODIFY) — addextrasCheckout,extrasBalance,extrasLedger. ExtendgetUsageto return{computeUsed, computeQuota, computeBreakdown, extrasRemaining, weekResetAt, planVersion}whencomputeMode.services/billing.service.js(MODIFY) — addcreateExtrasCheckout(org, packId, successUrl, cancelUrl)withmode:'payment',metadata:{organizationId, packId, kind:'extras'},automatic_tax: { enabled: true }. Verify existingcreateCheckoutalso passesautomatic_tax.models/billing.subscription.schema.js(MODIFY) — add ZodExtrasCheckoutRequest.middlewares/billing.requireQuota.js(MODIFY) — refactor to gate on compute pré-check (legacy fallback whencomputeMode=false). When compute mode +(computeQuota - computeUsed) + extrasBalance <= 0→ 402 Payment Required.middlewares/billing.attachUsageContext.js(NEW, optional) — decoratereq.computeContextfor downstream handlers +X-Compute-Remainingresponse header.policies/billing.policy.js(MODIFY) — register new path subjects for extras routes.idempotencyKeytostripe.checkout.sessions.create()(currently missing — double-click can create 2 sessions).billing.quota.unit.tests.js(legacy + compute mode + overflow + 402),billing.extras.controller.unit.tests.js(NEW),billing.routes.integration.tests.js(NEW), extendbilling.controller.unit.tests.js,billing.service.unit.tests.js.PR-N5 — Cron scripts + dunning grace 7d + free fallback J+14
scripts/crons/billing.weeklyReset.js(NEW) — CLI standalone idempotent. CallsBillingResetService.resetAllDue(). Doc deployment via Kubernetes CronJob.scripts/crons/billing.extrasExpiration.js(NEW) — CLI callingBillingExtraService.expireOldEntries()per org.scripts/crons/billing.dunningSweep.js(NEW) — sweepsSubscription.status === 'past_due'withpastDueSince > 14d→ setstatus: 'unpaid', planfree, syncOrganization.plan.scripts/crons/README.md(NEW) — Kubernetes CronJob examples, crontab examples.services/billing.webhook.service.js(MODIFY) —handleInvoicePaymentFailedsetSubscription.pastDueSince = now, emitbillingEvents.emit('payment.failed').middlewares/billing.requireQuota.js(MODIFY) — when subscriptionpast_dueANDcurrentPeriodStart + 7d < now→ degraded mode (allow current quota, block new checkout/extras purchase).billing.dunningSweep.unit.tests.js(NEW), extendbilling.webhook.integration.tests.js.Risks / sharp edges (covered in design)
incrementComputein-flight →resetWeekarchive-then-upsert pattern with retry-friendly atomicityplan.versionBumpedevent +getPlanByVersionimmutable lookupProcessedStripeEventcollection with TTL 30dcomputeModeflag default false, all new code paths no-op when offOut of scope V1
Definition of done
automatic_taxsupportupdate-stackreadyLinked