Skip to content

Commit 2219a82

Browse files
feat(billing): config-gated referral grant — listener + reconcile cron (closes #3842) (#3843)
* feat(billing): config-gated referral grant on invitation.accepted (#3842) Implements the standard referral reward prescribed by modules/invitations/README.md (architecture A): the P8a no-op seam in billing.init.js becomes the grant listener, entirely gated by config.billing.referral (stack default OFF — zero behavior change; downstream flips { enabled, referrerUnits, refereeUnits } in {project}.config.js). - map user-scoped actors onto the org-scoped ledger: new BillingReferralService credits each side's currentOrganization (active-membership fallback) on the BillingExtraBalance ledger via creditGrant — kind:'topup', source:'referral', expiry from referral.expiryDays (same sweep as pack credits) - idempotency keys referral:<invitationId>:referrer|referee enforced by the house atomic 'ledger.refId' $ne guard (embedded-array ledger — same mechanism as creditPack/debit); creditGrant gains { refId, expiresAt } options (signup grant unchanged); source enums (Mongoose+Zod) gain 'referral'; new findExistingRefIds - async listener self-guards rejections (emit-site catch is sync-only): everything wrapped, failures logged and left to the cron - referrer skipped when invitedBy null or invitedBy === acceptedUserId (cheap self-referral floor — full guard is #3833) - reconcile cron crons/billing.referralReconcile.js (jitter + distributed lock, gates on referral.enabled): scans ALL accepted invitations vs grant ledger keys, back-fills misses idempotently — the listener is latency, the cron is truth; actors without an org yet (mailer flow defers org provisioning to email verification) stay pending until provisioned - index invitations.invitedBy (schema-declared) + InvitationRepository.findAccepted - docs: invitations README #5 prescription marked shipped (#3842), TODO(#5) refs resolved, crons README rows, MIGRATIONS.md entry (downstream action = flip config + add the CronJob manifest when enabling) - tests: unit (service grants/skips/idempotency, init listener gate+self-guard, creditGrant options, findExistingRefIds, cron logic, findAccepted) + integration (real invited signup credits BOTH orgs; service+event replays cannot double-credit; disabled credits nothing) Closes #3842 * refactor(billing): referral grant review fixes — expectedGrantKeys single rule source (#3842) - extract pure expectedGrantKeys(invite, cfg) in billing.referral.service: THE single source of the side rules (zero units / null invitedBy / self-referral floor). grantForInvitation derives its sides from it, the reconcile cron uses it for candidates, and the cron unit test calls the REAL function (no hand-mirrored rule block — no drift when #3833 adds guards) - deterministic org fallback: active-membership lookup sorts createdAt asc (oldest membership wins); MembershipRepository.findOne gains an optional { sort } modifier (backward compatible) - integration race test: Promise.all double grantForInvitation against real Mongo — exactly one applied:true and one ledger entry per side-key (pins the document-locking atomicity claim) - test cleanup: afterAll now covers ref-guest-off/ref-guest-race users - docs: MIGRATIONS.md notes the first reconcile run retro-grants ALL previously accepted invitations (pre-seed referral:<id>:* keys if unwanted) + merge-safe-everywhere / do NOT enable before #3833 — mirrored in the referral config block comment Verified: ESLint clean; full suite 179 suites / 2404 tests green (incl. new race + rule tests). * fix(billing): fold CodeRabbit/Copilot review comments (pass 1) - MIGRATIONS.md: add blank lines around fenced code blocks (MD031) - billing.referral.service: JSDoc acceptedUserId type → string|null (matches impl/tests) - billing.referral.service: guard expiryDays 0/negative — throw instead of silently becoming a permanent grant (only null is the "never expire" sentinel) - billing.referral.service.unit.tests: cover expiryDays:0 → throws
1 parent 5b899f2 commit 2219a82

22 files changed

Lines changed: 1584 additions & 91 deletions

MIGRATIONS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Referral grant — config-gated `invitation.accepted` listener in billing (2026-06-12)
8+
9+
Standard referral reward (#3842, the real tracker behind the old in-code `TODO(#5)` refs). The `invitation.accepted` no-op seam in `billing.init.js` is now the **config-gated grant listener**: on every accepted invite it idempotently credits meter units to the **referrer**'s and **referee**'s organizations on the `BillingExtraBalance` ledger (`kind:'topup'`, `source:'referral'`, keys `referral:<invitationId>:referrer|referee`, expiry like pack credits).
10+
11+
### What changed (this repo)
12+
13+
- **New config knob** (`modules/billing/config/billing.development.config.js`) — stack default **OFF**, zero behavior change for existing deployments:
14+
15+
```js
16+
billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0, expiryDays: 365 } }
17+
```
18+
19+
- **`billing.init.js`** — the P8a no-op listener is replaced by the grant impl (async, self-guarded: a grant failure is logged, never escapes as an unhandledRejection). Skips the referrer grant when `invitedBy` is null or `invitedBy === acceptedUserId` (cheap self-referral floor; the full guard is #3833).
20+
- **New service** `modules/billing/services/billing.referral.service.js` — maps user-scoped referral actors onto the org-scoped ledger (actor's `currentOrganization`, active-membership fallback; an actor without an org yet — e.g. mailer-configured signups before email verification — is left to the cron).
21+
- **`creditGrant`** (`billing.extraBalance.repository.js`) now accepts `{ refId, expiresAt }` options (explicit idempotency key + expiry); backward compatible — signup grant unchanged. `source` enums (Mongoose + Zod) gain `'referral'`. New `findExistingRefIds` helper.
22+
- **New reconcile cron** `modules/billing/crons/billing.referralReconcile.js` (house cron pattern: jitter + distributed lock `billing.referralReconcile` 10 min TTL; gates on `billing.referral.enabled`, NOT `meterMode`): scans ALL `invitations { status:'accepted' }` vs the grant ledger keys and back-fills misses idempotently. The listener is latency; the cron is truth.
23+
- **New index** `invitations.invitedBy` (schema-declared, built by Mongoose autoIndex at boot) — the reconcile + future referral lists query it.
24+
25+
### Action required for downstream projects (`/update-project`)
26+
27+
1. All changes are devkit-owned stack files → arrive via `/update-stack` (`--theirs`). Default OFF: **no action = no behavior change**.
28+
2. **To enable referral rewards**, flip the knob in `config/defaults/{project}.config.js` (NEVER edit `billing.init.js`):
29+
30+
```js
31+
billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500 } } // Trawl-decided values
32+
```
33+
34+
⚠️ Merging is safe everywhere (default OFF); do NOT enable until pierreb-devkit/Node#3833 lands — only the cheap self-referral floor ships here.
35+
3. When enabling, add a k8s CronJob manifest for `billing.referralReconcile.js` in the infra repo (mirror the existing billing cron manifests; recommended daily `0 4 * * *`). Also confirm `billing.extrasExpiration.js` runs — referral credits expire through the same sweep. Note: the first reconcile run retro-grants every previously accepted invitation — if unwanted, pre-seed the `referral:<id>:*` ledger keys before enabling.
36+
4. The `invitations.invitedBy` index is created automatically at boot (autoIndex, small collection — no manual migration needed).
37+
38+
---
39+
740
## org.addMember + membership consent split (2026-06-10)
841

942
Phase 5a of the invitations↔org decouple epic (#3813). Replaces the deleted org email-invite with a **consent-safe add-member flow**: an owner/admin adds an *existing* user, creating a **PENDING `owner_add`** membership that the **invited user** must accept — the owner can NEVER approve it (consent invariant).

modules/billing/billing.init.js

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,31 +45,42 @@ export default async (app) => {
4545
// Wire billing email listeners (quota warnings + payment-failed notifications).
4646
setupBillingEmails();
4747

48-
// Referral substrate (#5) — billing is an OPTIONAL consumer of the invitations
49-
// fire-and-forget `invitation.accepted` event (dependency direction billing →
50-
// invitations is fine: billing imports the events singleton, invitations never
51-
// imports billing). This listener is a deliberate NO-OP that only PROVES the event
52-
// seam works end-to-end (the payload arrives here on every invite acceptance); the
53-
// actual credit-grant logic lands in #5. Wrapped so a future grant impl can't crash
54-
// boot/signup. Mirrors the other cross-module listeners wired on this init.
48+
// Referral grant (#3842, ex TODO(#5)) — billing is an OPTIONAL consumer of the
49+
// invitations fire-and-forget `invitation.accepted` event (dependency direction
50+
// billing → invitations is fine: billing imports the events singleton, invitations
51+
// never imports billing). The STANDARD grant lives HERE, in the stack, entirely
52+
// CONFIG-GATED: `config.billing.referral = { enabled:false, referrerUnits:0,
53+
// refereeUnits:0, expiryDays:365 }` (stack default OFF — zero behavior until a
54+
// downstream flips it in {project}.config.js; this file is NEVER edited downstream,
55+
// drift gate + ISO-merge). Custom rewards (cashback, webhooks) live in a project-only
56+
// module listening to the same event. See modules/invitations/README.md.
5557
/**
56-
* @desc No-op referral seam listener for invitation acceptance events (P8a).
57-
* Proves the cross-module event contract end-to-end; credit-grant logic lands in #5.
58+
* @desc Referral grant listener for invitation acceptance events (#3842).
59+
* Idempotently credits the referrer's and referee's organizations via
60+
* BillingReferralService (keys `referral:<invitationId>:referrer|referee`).
61+
* Self-guarded: `EventEmitter.emit` is synchronous, so the emit-site try/catch in
62+
* invitations.service only catches SYNC throws — an async rejection escaping here
63+
* would surface as an unhandledRejection. It never does: everything is wrapped, a
64+
* failed grant is logged and left to the reconcile cron (the listener is latency;
65+
* crons/billing.referralReconcile.js is truth).
5866
* @param {{invitationId: string, email: string, invitedBy: (string|null), acceptedUserId: string}} payload - Accepted invitation event payload.
59-
* @returns {void}
67+
* @returns {Promise<void>} settles when the grant attempt completes (never rejects)
6068
*/
61-
// eslint-disable-next-line no-unused-vars
62-
invitationEvents.on('invitation.accepted', (payload) => {
63-
// TODO(#5): implement the STANDARD grant HERE, in the stack, entirely CONFIG-GATED:
64-
// config.billing.referral = { enabled: false, referrerUnits: 0, refereeUnits: 0 }
65-
// if (!config.billing?.referral?.enabled) return; → grant idempotently
66-
// (key on `referral:${payload.invitationId}:referrer|referee`).
67-
// Downstream projects NEVER edit this file (drift gate + ISO-merge) — they flip the
68-
// config in their {project}.config.js. Custom rewards (cashback, webhooks) live in a
69-
// project-only module listening to the same event. See modules/invitations/README.md.
70-
// TODO(#5): grant credits to payload.invitedBy (skip null) + optionally acceptedUserId.
71-
// TODO(#5): async grant listener must self-guard rejections — the emit-site try/catch only catches sync throws.
72-
// No-op for P8a — the seam is the deliverable, not the grant.
69+
invitationEvents.on('invitation.accepted', async (payload) => {
70+
try {
71+
if (!config.billing?.referral?.enabled) return; // downstream flips this — default OFF
72+
// Lazy import keeps the boot graph unchanged when the feature is off and avoids
73+
// import-time model resolution in the service's organization fallback path.
74+
const { default: BillingReferralService } = await import('./services/billing.referral.service.js');
75+
await BillingReferralService.grantForInvitation(payload);
76+
} catch (err) {
77+
// ⚠️ MANDATORY self-guard (see lib/events.js): never let a rejection escape.
78+
logger.error('[billing] referral grant failed — reconcile cron will back-fill', {
79+
invitationId: String(payload?.invitationId ?? ''),
80+
err: err?.message,
81+
stack: err?.stack,
82+
});
83+
}
7384
});
7485

7586
// Update analytics group properties when a subscription plan changes

modules/billing/config/billing.development.config.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,32 @@ const config = {
123123
* Example: [{ packId: 'pack_500k', meterUnits: 500000, stripePriceId: 'price_xxx' }]
124124
*/
125125
packs: [],
126+
/**
127+
* Referral grant (#3842) — stack default: OFF. The `invitation.accepted` listener
128+
* in billing.init.js credits meter units to the referrer's and/or referee's
129+
* organization when an invited signup completes. Entirely config-gated: downstream
130+
* projects NEVER edit billing.init.js — they flip this block in {project}.config.js:
131+
* billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500 } }
132+
*
133+
* enabled — master switch; when false the listener returns immediately (no-op).
134+
* referrerUnits — units credited to the INVITER's currentOrganization (0 = skip side).
135+
* refereeUnits — units credited to the ACCEPTED USER's organization (0 = skip side).
136+
* expiryDays — referral credits expire after N days (same expiry mechanism as
137+
* pack.expiryDays — swept by crons/billing.extrasExpiration.js).
138+
* null = never expire.
139+
*
140+
* Pair with the reconcile cron (crons/billing.referralReconcile.js): the listener
141+
* is in-process fire-and-forget (latency); the cron back-fills missed grants (truth).
142+
*
143+
* ⚠️ Merging is safe everywhere (default OFF); do NOT enable until
144+
* pierreb-devkit/Node#3833 lands — only the cheap self-referral floor ships here.
145+
*/
146+
referral: {
147+
enabled: false,
148+
referrerUnits: 0,
149+
refereeUnits: 0,
150+
expiryDays: 365,
151+
},
126152
},
127153
stripe: {
128154
secretKey: process.env.DEVKIT_NODE_stripe_secretKey ?? '',

modules/billing/crons/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Standalone CLI scripts intended to be executed as Kubernetes CronJobs.
55
Relocated here from `scripts/crons/` as of 2026-05-01 (#3546) — billing logic belongs in the billing module.
66
The old paths at `scripts/crons/billing.*.js` no longer exist. See `docs/migrations/2026-05-01-billing-crons-module-relocation.md` for the cutover procedure.
77

8-
All scripts gate on `config.billing.meterMode === true` and exit 0 immediately when the flag is `false` (default).
8+
All scripts gate on `config.billing.meterMode === true` and exit 0 immediately when the flag is `false` (default) — except `billing.referralReconcile.js`, which gates on `config.billing.referral.enabled === true` instead (referral grants are independent of meter consumption).
99
No `node-cron` dependency — orchestration is handled by Kubernetes CronJob manifests.
1010

1111
## Scripts
@@ -15,13 +15,15 @@ No `node-cron` dependency — orchestration is handled by Kubernetes CronJob man
1515
| `billing.weeklyReset.js` | Reset meter counters for orgs whose billing period rolled over | Daily `0 1 * * *` |
1616
| `billing.extrasExpiration.js` | Expire topup ledger entries past their `expiresAt` date | Daily `0 2 * * *` |
1717
| `billing.dunningSweep.js` | Downgrade stale `past_due` subs (>14d) to `unpaid` + `free` | Daily `0 3 * * *` |
18+
| `billing.referralReconcile.js` | Back-fill referral grants missed by the in-process `invitation.accepted` listener (#3842) | Daily `0 4 * * *` |
1819

1920
## Usage
2021

2122
```sh
2223
NODE_ENV=production node modules/billing/crons/billing.weeklyReset.js
2324
NODE_ENV=production node modules/billing/crons/billing.extrasExpiration.js
2425
NODE_ENV=production node modules/billing/crons/billing.dunningSweep.js
26+
NODE_ENV=production node modules/billing/crons/billing.referralReconcile.js
2527
```
2628

2729
Exit code 0 = success (or meterMode disabled). Exit code 1 = at least one error or fatal failure.
@@ -119,6 +121,8 @@ const orgs = allOrgs.filter(o => {
119121

120122
All scripts check `config.billing.meterMode` at startup. Downstream projects must set this flag to `true` in their project config to activate billing crons. The devkit default is `false` — all crons are no-ops until explicitly enabled.
121123

124+
Exception: `billing.referralReconcile.js` checks `config.billing.referral.enabled` instead (devkit default `false` — same no-op-until-enabled semantics, gated on the referral feature rather than the meter).
125+
122126
## Concurrency control
123127

124128
All billing crons acquire a distributed lock (`lib/services/distributedLock.js`) before
@@ -132,6 +136,7 @@ Lock names and TTLs:
132136
| `billing.weeklyReset` | 10 min | `billing.weeklyReset.js` |
133137
| `billing.dunningSweep` | 15 min | `billing.dunningSweep.js` |
134138
| `billing.extrasExpiration` | 5 min | `billing.extrasExpiration.js` |
139+
| `billing.referralReconcile` | 10 min | `billing.referralReconcile.js` |
135140

136141
If you see `lock held by another pod, skipping` in logs, that is expected when
137142
two pods race after a K8s `concurrencyPolicy` bypass (e.g. pod crash after
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Cron script — referral grant reconcile sweep (#3842).
3+
*
4+
* The `invitation.accepted` listener in billing.init.js is in-process fire-and-forget:
5+
* a crash between accept and grant loses the event, and a mailer-configured signup can
6+
* fire it before the referee's organization exists. This sweep is the truth: it scans
7+
* ALL `invitations { status:'accepted' }`, diffs the expected grant keys
8+
* (`referral:<invitationId>:referrer|referee`) against the BillingExtraBalance ledger,
9+
* and back-fills misses idempotently via BillingReferralService.grantForInvitation
10+
* (the atomic `ledger.refId` guard makes overlap with the listener harmless).
11+
*
12+
* No-op when config.billing.referral.enabled === false (default).
13+
* Intended to run as a Kubernetes CronJob — see modules/billing/crons/README.md.
14+
*
15+
* Usage:
16+
* NODE_ENV=production node modules/billing/crons/billing.referralReconcile.js
17+
*/
18+
19+
import { randomUUID } from 'node:crypto';
20+
21+
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
22+
23+
const [
24+
{ default: config },
25+
{ default: mongooseService },
26+
{ default: logger },
27+
{ applyJitter },
28+
{ getCronJitterMaxMs },
29+
{ acquireLock, releaseLock },
30+
] = await Promise.all([
31+
import('../../../config/index.js'),
32+
import('../../../lib/services/mongoose.js'),
33+
import('../../../lib/services/logger.js'),
34+
import('../lib/billing.cron-utils.js'),
35+
import('../lib/billing.constants.js'),
36+
import('../../../lib/services/distributedLock.js'),
37+
]);
38+
39+
if (!config?.billing?.referral?.enabled) {
40+
logger.info('[cron.referralReconcile] referral disabled — skipping.');
41+
process.exit(0);
42+
}
43+
44+
const LOCK_NAME = 'billing.referralReconcile';
45+
const LOCK_TTL_MS = 10 * 60 * 1000; // 10 min
46+
47+
const startMs = Date.now();
48+
logger.info('[cron.referralReconcile] start');
49+
50+
let lockHolder = null;
51+
try {
52+
await applyJitter(getCronJitterMaxMs());
53+
await mongooseService.loadModels();
54+
await mongooseService.connect();
55+
56+
lockHolder = `${process.env.HOSTNAME ?? 'unknown'}:${randomUUID()}`;
57+
const acquired = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: lockHolder });
58+
if (!acquired) {
59+
logger.info('[cron.referralReconcile] lock held by another pod, skipping');
60+
process.exitCode = 0;
61+
} else {
62+
try {
63+
const [
64+
{ default: BillingReferralService },
65+
{ default: BillingExtraBalanceRepository },
66+
{ default: InvitationRepository },
67+
] = await Promise.all([
68+
import('../services/billing.referral.service.js'),
69+
import('../repositories/billing.extraBalance.repository.js'),
70+
import('../../invitations/repositories/invitations.repository.js'),
71+
]);
72+
73+
const cfg = config.billing.referral;
74+
const accepted = await InvitationRepository.findAccepted();
75+
76+
// Expected keys per invitation — derived from the service's expectedGrantKeys
77+
// (the SINGLE rule source shared with grantForInvitation): the cron never
78+
// re-encodes the side rules, so a new guard (e.g. #3833) can never drift.
79+
const candidates = [];
80+
for (const invite of accepted) {
81+
const expected = BillingReferralService.expectedGrantKeys(invite, cfg).map(({ key }) => key);
82+
if (expected.length > 0) candidates.push({ invite, expected });
83+
}
84+
85+
const allKeys = candidates.flatMap((c) => c.expected);
86+
const existing = new Set(await BillingExtraBalanceRepository.findExistingRefIds(allKeys));
87+
88+
let backfilled = 0;
89+
let pending = 0;
90+
let errors = 0;
91+
92+
for (const { invite, expected } of candidates) {
93+
if (expected.every((key) => existing.has(key))) continue; // fully granted
94+
try {
95+
const result = await BillingReferralService.grantForInvitation({
96+
invitationId: String(invite._id),
97+
invitedBy: invite.invitedBy ? String(invite.invitedBy) : null,
98+
acceptedUserId: invite.acceptedUserId ? String(invite.acceptedUserId) : null,
99+
});
100+
const sides = [result.referrer, result.referee].filter(Boolean);
101+
const applied = sides.filter((s) => s.applied).length;
102+
// `no_organization` = the actor has no workspace yet (e.g. email verification
103+
// pending) — stays pending, retried on the next run once the org exists.
104+
const waiting = sides.filter((s) => s.reason === 'no_organization').length;
105+
if (applied > 0) {
106+
backfilled += applied;
107+
logger.info('[cron.referralReconcile] back-filled', { invitationId: String(invite._id), applied });
108+
}
109+
if (waiting > 0) pending += waiting;
110+
} catch (err) {
111+
errors += 1;
112+
logger.error('[cron.referralReconcile] grantForInvitation failed', {
113+
invitationId: String(invite._id),
114+
err: err?.message,
115+
stack: err?.stack,
116+
});
117+
}
118+
}
119+
120+
logger.info('[cron.referralReconcile] complete', {
121+
accepted: accepted.length,
122+
backfilled,
123+
pending,
124+
errors,
125+
durationMs: Date.now() - startMs,
126+
});
127+
process.exitCode = errors > 0 ? 1 : 0;
128+
} finally {
129+
// releaseLock failure is non-fatal: lock auto-expires on TTL.
130+
// Log separately to preserve any original work error.
131+
try {
132+
await releaseLock({ name: LOCK_NAME, holder: lockHolder });
133+
} catch (releaseErr) {
134+
logger.error('[cron.referralReconcile] failed to release lock — will auto-expire on TTL', {
135+
err: releaseErr,
136+
cron: LOCK_NAME,
137+
});
138+
}
139+
}
140+
}
141+
} catch (err) {
142+
logger.error('[cron.referralReconcile] failed', { err: err?.message, stack: err?.stack });
143+
process.exitCode = 1;
144+
} finally {
145+
await mongooseService.disconnect?.();
146+
}
147+
process.exit(process.exitCode ?? 0);

modules/billing/models/billing.extraBalance.model.mongoose.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ const LedgerEntrySchema = new Schema(
6969
* Credit source tag — discriminates pack purchases from grants.
7070
* 'signup_grant' — one-shot free tier grant on org creation.
7171
* 'adjustment' — manual ops credit (non-Stripe).
72+
* 'referral' — referral grant on invitation acceptance (#3842), keyed
73+
* `referral:<invitationId>:referrer|referee` in refId.
7274
* Omitted for kind='topup' entries created by creditPack (Stripe path).
7375
*/
7476
source: {
7577
type: String,
76-
enum: ['signup_grant', 'adjustment'],
78+
enum: ['signup_grant', 'adjustment', 'referral'],
7779
},
7880
at: {
7981
type: Date,

0 commit comments

Comments
 (0)