Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ Use this file as a compact memory of recurring AI mistakes.
- [2026-09-25] billing/extras: the expiry sweep (`addExpirationEntries`) removed a pack's FULL amount even when part or all of it was already consumed or refunded -> phantom debt the next pack paid twice. Now the ledger is replayed in array order, debits (and a pack's own refunds, matched by `stripeSessionId`) are attributed to live credits earliest-expiry-first (no-expiry credits last, uncovered debt repaid by the next credit), and an expiring pack removes only its own remainder at its `expiresAt`; a fully spent pack gets a zero-amount `expiration` marker (schemas allow 0 for that kind only, hidden from `listLedgerPage`) so the `expire-<topupId>` guard still holds. The write is one `findOneAndUpdate` guarded by the snapshot's ledger `$size` (append-only ⇒ unchanged length = unchanged ledger), retried on a concurrent write. Legacy full-amount entries are left as is; see pierreb-devkit/Node#4120
- [2026-09-25] billing/stripe: `billing.plans.service.js fetchPlansFromStripe` fell back to the raw Stripe product id (`product.metadata?.planId || product.id`) when a product carried no `planId` metadata -> ANY active Stripe product (a one-time pack, a recurring product sold outside the plans catalogue via a Payment Link) advertised itself as a public plan via `GET /api/billing/plans`, often with a null price id; fix = filter to `product.metadata?.planId` truthy BEFORE mapping, drop the id fallback entirely — a product now needs `metadata.planId` to be listed. Left every OTHER `metadata?.planId ||` fallback untouched (`billing.planResolver.js`, `billing.webhook.service.js`, `billing.admin.service.js`) since those resolve an EXISTING subscription's plan, not the public catalogue, and must keep working for a recurring product sold outside it; see pierreb-devkit/Node#4113
- [2026-09-25] auth: `oauthCallback` never provisioned an org, and review of the first fix caught that gating on `!user.currentOrganization` would also fire for an EXISTING org-less user (removed from org, pending join) on every login -> gate on `info.created` instead (set by `checkOAuthUserProfile`'s create branch, relayed via passport's verify-callback `info`), so only a genuine new signup provisions. `oauthCallback` also needed an outer try/catch (passport invokes it fire-and-forget) and a `headersSent` guard before any fallback redirect; see pierreb-devkit/Node#4115
- [2026-09-25] billing: computing a percent-of-grant level as `(1 - threshold/100) * signupGrant` (e.g. `500 * (1 - 80/100)`) lands on `99.99999999999997`, not `100`, due to float imprecision -> silently misses an exact `post === level` boundary crossing; use `signupGrant * (100 - threshold) / 100` instead, which is exact at common values; see pierreb-devkit/Node#4117
18 changes: 18 additions & 0 deletions config/templates/billing-credit-exhausted.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<title></title>
</head>
<body>
<p>Hello,</p>
<p>Your <b>{{appName}}</b> account is <b>out of credits</b>.</p>
<p>
Usage is paused until you add more credits or upgrade your plan — nothing is charged automatically.
</p>
<p>Add credits or upgrade your plan on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
<br />
<p>The <b>{{appName}}</b> Team.</p>
<br />
<i style="color: #9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
</body>
</html>
18 changes: 18 additions & 0 deletions config/templates/billing-credit-warning.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<title></title>
</head>
<body>
<p>Hello,</p>
<p>Your <b>{{appName}}</b> account has <b>{{remaining}} credits left</b>.</p>
<p>
Usage keeps working while credits remain. Once they run out, usage is paused until you add credits — nothing is charged automatically.
</p>
<p>Add more credits or upgrade your plan on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
<br />
<p>The <b>{{appName}}</b> Team.</p>
<br />
<i style="color: #9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
</body>
</html>
31 changes: 31 additions & 0 deletions modules/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,37 @@ Consumers wanting clean-break behavior on downgrade should pass `{ preserveUsage
- **Runaway detector:** the negative-balance alert (`billing.extras.runaway_debit`) only fires on plans with a weekly quota (`meterQuota > 0`).
- **Overflow debt is repaid once per week.** Units consumed past the quota are debited from extras, which may go negative. On each `resetWeek` (weeks with a quota > 0 only) it repays debt from the target week's REMAINING quota: `settle = min(meterQuota − meterUsed, overflowDebt)` — the week is often already partly used, since the cron anchors on the current time. Extras are credited `settle` via an `adjustment` entry with refId `settle:<weekKey>`, then the week doc is charged that stored credit once (`meterUsed += settle`, guarded by the `settle:<weekKey>` key in `consumedAttributionKeys`) — a re-run, retry or concurrent reset never credits or charges twice. What does not fit stays as debt for the next reset. Refund debt and pack-expiry shortfall (the part of a pack clawback or a pack expiry that took the balance below zero) are never settled from quota — only a new pack repays them. The expiry sweep removes only a pack's own unspent units at its `expiresAt` (spending is attributed earliest-expiry-first; a fully spent pack gets a zero-amount marker, hidden from the customer ledger), so new expiries only create debt for usage recorded between a pack's `expiresAt` and the sweep (that part is never settled from quota); legacy full-amount expiration entries are left as is. Plans with `meterQuota = 0` are unchanged.

## Credit-balance alerts (plans without a weekly quota)

A plan with `meterQuota: 0` and a one-shot `signupGrant` (e.g. the stack default `free`
plan) has no weekly usage doc to alert against — every unit is debited straight from
`BillingExtraBalance.cachedBalance`, so that balance IS the limit. `incrementMeter` detects
crossings of the configured `billing.alerts.thresholdPercents` (filtered to 80/100, same
supported set as the weekly-quota alerts) against `plan.signupGrant * (100 - threshold) / 100`
(not `(1 - threshold/100) * signupGrant` — that form hits float imprecision at common values,
e.g. `500 * (1 - 80/100) = 99.99999999999997`, silently missing an exact boundary crossing),
comparing the debit's own pre/post balance, and emits `billing.extras.balance_threshold_crossed`
(`{ organizationId, threshold, remaining, planId }`). `billing.email.js` sends a
credit-warning (80%) or credit-exhausted (100%) email off of it.

- **Stateless — no `alertedAtN` field.** The weekly `alertedAt80`/`alertedAt100` fields
are scoped to a week and would re-fire every week against a lifetime balance, so this
path re-derives the crossing from the debit itself each time. A pack or referral credit
that pushes the balance back above a level means the next crossing alerts again — intended.
- **Accepted limitation — "% of grant" only fits the signup grant.** Once a pack purchase
tops up the same `cachedBalance`, "percent of grant remaining" is ambiguous, so this only
applies when `meterQuota === 0` and the plan has a valid `signupGrant`. Copy always speaks
in absolute credits left, never a percentage.
- **Accepted limitation — expiry and refunds don't alert.** A balance drop from a pack
expiring (`crons/billing.extrasExpiration.js`) or a refund (`billing.refund.service.js`)
does not go through `incrementMeter`'s debit path, so it never triggers this crossing check.
- **Accepted limitation — a debit that fails outright never alerts either.** The crossing
check only runs on `debitResult.applied && debitResult.doc` (see below); if
`BillingExtraService.debit()` itself throws, the catch block logs a warning and the
usage stays counted but unreconciled — there is no retry cron anymore (the outbox
pattern was dropped, see "Extras debit reliability" below), so that debit never reaches
the crossing check and never alerts.

## Extras debit reliability

`attribute()` returns optimistically after usage increment + outbox row insert. Extras debit happens out of band; if it fails, cron `retry-pending-extras-debit` reconciles on the configured retry interval. After the configured failed-attempt limit, the outbox row is marked `failed` and the configured exhausted event is emitted for alerting.
Expand Down
42 changes: 40 additions & 2 deletions modules/billing/billing.email.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ export const sendBillingEmail = (mailOpts, context) => {
* Call once from billing.init.js after config is ready.
*
* Listeners registered:
* - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners
* - payment.failed — sends payment-failed email prompting card update
* - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners
* - billing.extras.balance_threshold_crossed — sends a credit-warning or credit-exhausted email
* (one-shot signup-grant plans, no weekly quota — see README.md § Credit-balance alerts)
* - payment.failed — sends payment-failed email prompting card update
*
* Template resolution: devkit ships generic templates in config/templates/billing-*.html.
* Downstream projects override by placing same-named files in their own config/templates/
Expand Down Expand Up @@ -93,6 +95,42 @@ export const setupBillingEmails = () => {
});
});

// ── billing.extras.balance_threshold_crossed — credit-balance alerts (#4117) ──
// One-shot signup-grant plans (no weekly quota): 80%-consumed warning or fully-out
// email. Copy speaks in absolute credits left, never "% of grant" — a pack or
// referral top-up can raise the balance again, at which point "% of grant" is
// meaningless (see README.md § Credit-balance alerts). No org name in the copy.

billingEvents.on('billing.extras.balance_threshold_crossed', ({ organizationId, threshold, remaining }) => {
if (threshold !== 80 && threshold !== 100) return;

const appName = config.app?.title ?? '';
const billingUrl = config.app?.url ? `${config.app.url}/billing` : '';
const isWarning = threshold === 80;

resolveOrgAdminEmails(organizationId).then((emails) => {
if (!emails.length) return;
for (const email of emails) {
sendBillingEmail(
{
to: email,
subject: isWarning
? `${appName} — your credits are running low`
: `${appName} — you are out of credits`,
template: isWarning ? 'billing-credit-warning' : 'billing-credit-exhausted',
params: {
remaining: remaining ?? 0,
billingUrl,
appName,
appContact: config.app?.contact ?? '',
},
},
isWarning ? 'billing.extras.balance_threshold_crossed@80' : 'billing.extras.balance_threshold_crossed@100',
);
}
});
});

// ── payment.failed — update card prompt ─────────────────────────────────────

billingEvents.on('payment.failed', ({ organizationId }) => {
Expand Down
4 changes: 4 additions & 0 deletions modules/billing/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { EventEmitter } from 'events';
* cannot be resolved to a plan (unmapped priceId AND no valid metadata.planId) — the write
* retains the last-known plan (or 'free' for a genuinely new org) instead of forcing 'free'.
* Payload: { organizationId, stripeSubscriptionId, priceId, retainedPlan, hadKnownPlan, eventId }
* - `billing.extras.balance_threshold_crossed` — emitted when an extras-balance debit on a
* one-shot signup-grant plan (meterQuota=0) crosses a configured percent-of-grant level
* (stateless — no alertedAtN dedup field; a later credit lets the next crossing alert again).
* Payload: { organizationId, threshold, remaining, planId }
*
* NOTE: The 'error' event listener is registered in billing.init.js (after config is ready)
* to avoid module-load-time config reads in this low-level singleton.
Expand Down
48 changes: 48 additions & 0 deletions modules/billing/services/billing.usage.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ const reset = (organizationId) => UsageRepository.reset(organizationId, currentM
* 4. If quota is exceeded, debits extras balance directly (atomic single-doc).
* On debit failure, logs a warning — usage is already counted.
* 5. Detects configured threshold crossings (emits meter.threshold_crossed event, once per cycle).
* 6. On a one-shot signup-grant plan (meterQuota=0), detects extras-balance
* threshold crossings against the debit's own pre/post balance and emits
* billing.extras.balance_threshold_crossed (stateless, re-fires after a credit).
*
* Returns applied=false when the idempotencyKey was already consumed (replay).
*
Expand Down Expand Up @@ -218,6 +221,51 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
});
}
}

// Credit-balance alerts (#4117) — plans without a weekly quota (meterQuota=0,
// a one-shot signupGrant). There is no weekly usage doc to dedup against here
// (alertedAt80/alertedAt100 are scoped to a week and would re-fire every week
// against a lifetime balance — see billing.email.js), so this is stateless:
// detect a crossing purely from THIS debit's own pre/post balance snapshot.
// A later credit (pack, referral) that pushes the balance back above a level
// lets the next crossing alert again — intended.
// Limitation: "% of grant" only makes sense for the one-shot signup grant; once
// a pack tops up the same cachedBalance the denominator is ambiguous, so this is
// scoped to the signup-grant case only (see README.md).
if (
effectiveQuota === 0
&& Number.isFinite(activePlan?.signupGrant)
&& activePlan.signupGrant > 0
) {
const post = currentBalance;
const pre = post + extrasConsumed;
// DESC order (100 before 80, from getAlertThresholdPercents()) — emit only the
// deepest crossing per debit (one debit crossing both levels → one email).
for (const threshold of getAlertThresholdPercents()) {
// Credit-balance alerts support only 80%/100% — reuse the weekly-quota
// alertedAtN schema fields (thresholdFields, above) as the single source
// of truth for the supported set, instead of a second duplicated list.
if (!thresholdFields[threshold]) continue;
// `signupGrant * (100 - threshold) / 100`, not `(1 - threshold/100) * signupGrant` —
// the latter hits float imprecision at common values (e.g. 500 * (1 - 80/100) =
// 99.99999999999997, not 100), which would silently miss an exact boundary crossing.
const level = (activePlan.signupGrant * (100 - threshold)) / 100;
if (!(pre > level && post <= level)) continue;
try {
billingEvents.emit('billing.extras.balance_threshold_crossed', {
organizationId,
threshold,
remaining: Math.max(0, post),
planId,
});
} catch (evtErr) {
logger.error('[billing.usage] billing.extras.balance_threshold_crossed listener failed', {
error: evtErr?.message ?? String(evtErr),
});
}
break;
}
}
}
} catch (err) {
// Usage is already counted. Log for monitoring — a retry cron or manual backfill
Expand Down
Loading
Loading