Skip to content

Compute pricing layer (upstream Node) #3533

@PierreBrisorgueil

Description

@PierreBrisorgueil

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

  • scripts/crons/billing.weeklyReset.js (NEW) — CLI standalone idempotent. Calls BillingResetService.resetAllDue(). Doc deployment via Kubernetes CronJob.
  • scripts/crons/billing.extrasExpiration.js (NEW) — CLI calling BillingExtraService.expireOldEntries() per org.
  • scripts/crons/billing.dunningSweep.js (NEW) — sweeps Subscription.status === 'past_due' with pastDueSince > 14d → set status: 'unpaid', plan free, sync Organization.plan.
  • scripts/crons/README.md (NEW) — Kubernetes CronJob examples, crontab examples.
  • services/billing.webhook.service.js (MODIFY) — handleInvoicePaymentFailed set Subscription.pastDueSince = now, emit billingEvents.emit('payment.failed').
  • middlewares/billing.requireQuota.js (MODIFY) — when subscription past_due AND currentPeriodStart + 7d < now → degraded mode (allow current quota, block new checkout/extras purchase).
  • Tests : billing.dunningSweep.unit.tests.js (NEW), extend billing.webhook.integration.tests.js.

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

  • All 5 PRs merged to master
  • Tests green, coverage threshold maintained
  • Migration scripts tested on staging Mongo dump
  • Stripe SDK ≥ 22.x verified for automatic_tax support
  • Devkit version bumped, downstream update-stack ready

Linked

Metadata

Metadata

Assignees

No one assigned

    Labels

    Effort hard (w)Work qualificationFeatA new featureP1Critical — must be done firstbillingPhase 3 — Stripe billing integration

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions