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
33 changes: 33 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ Breaking changes and upgrade notes for downstream projects.

---

## Referral grant — config-gated `invitation.accepted` listener in billing (2026-06-12)

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).

### What changed (this repo)

- **New config knob** (`modules/billing/config/billing.development.config.js`) — stack default **OFF**, zero behavior change for existing deployments:

```js
billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0, expiryDays: 365 } }
```
Comment thread
PierreBrisorgueil marked this conversation as resolved.

- **`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).
- **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).
- **`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.
- **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.
- **New index** `invitations.invitedBy` (schema-declared, built by Mongoose autoIndex at boot) — the reconcile + future referral lists query it.

### Action required for downstream projects (`/update-project`)

1. All changes are devkit-owned stack files → arrive via `/update-stack` (`--theirs`). Default OFF: **no action = no behavior change**.
2. **To enable referral rewards**, flip the knob in `config/defaults/{project}.config.js` (NEVER edit `billing.init.js`):

```js
billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500 } } // Trawl-decided values
```

⚠️ Merging is safe everywhere (default OFF); do NOT enable until pierreb-devkit/Node#3833 lands — only the cheap self-referral floor ships here.
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.
4. The `invitations.invitedBy` index is created automatically at boot (autoIndex, small collection — no manual migration needed).

---

## org.addMember + membership consent split (2026-06-10)

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).
Expand Down
55 changes: 33 additions & 22 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,42 @@ export default async (app) => {
// Wire billing email listeners (quota warnings + payment-failed notifications).
setupBillingEmails();

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

// Update analytics group properties when a subscription plan changes
Expand Down
26 changes: 26 additions & 0 deletions modules/billing/config/billing.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ const config = {
* Example: [{ packId: 'pack_500k', meterUnits: 500000, stripePriceId: 'price_xxx' }]
*/
packs: [],
/**
* Referral grant (#3842) — stack default: OFF. The `invitation.accepted` listener
* in billing.init.js credits meter units to the referrer's and/or referee's
* organization when an invited signup completes. Entirely config-gated: downstream
* projects NEVER edit billing.init.js — they flip this block in {project}.config.js:
* billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500 } }
*
* enabled — master switch; when false the listener returns immediately (no-op).
* referrerUnits — units credited to the INVITER's currentOrganization (0 = skip side).
* refereeUnits — units credited to the ACCEPTED USER's organization (0 = skip side).
* expiryDays — referral credits expire after N days (same expiry mechanism as
* pack.expiryDays — swept by crons/billing.extrasExpiration.js).
* null = never expire.
*
* Pair with the reconcile cron (crons/billing.referralReconcile.js): the listener
* is in-process fire-and-forget (latency); the cron back-fills missed grants (truth).
*
* ⚠️ Merging is safe everywhere (default OFF); do NOT enable until
* pierreb-devkit/Node#3833 lands — only the cheap self-referral floor ships here.
*/
referral: {
enabled: false,
referrerUnits: 0,
refereeUnits: 0,
expiryDays: 365,
},
},
stripe: {
secretKey: process.env.DEVKIT_NODE_stripe_secretKey ?? '',
Expand Down
7 changes: 6 additions & 1 deletion modules/billing/crons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Standalone CLI scripts intended to be executed as Kubernetes CronJobs.
Relocated here from `scripts/crons/` as of 2026-05-01 (#3546) — billing logic belongs in the billing module.
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.

All scripts gate on `config.billing.meterMode === true` and exit 0 immediately when the flag is `false` (default).
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).
No `node-cron` dependency — orchestration is handled by Kubernetes CronJob manifests.

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

## Usage

```sh
NODE_ENV=production node modules/billing/crons/billing.weeklyReset.js
NODE_ENV=production node modules/billing/crons/billing.extrasExpiration.js
NODE_ENV=production node modules/billing/crons/billing.dunningSweep.js
NODE_ENV=production node modules/billing/crons/billing.referralReconcile.js
```

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

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.

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).

## Concurrency control

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

If you see `lock held by another pod, skipping` in logs, that is expected when
two pods race after a K8s `concurrencyPolicy` bypass (e.g. pod crash after
Expand Down
147 changes: 147 additions & 0 deletions modules/billing/crons/billing.referralReconcile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Cron script — referral grant reconcile sweep (#3842).
*
* The `invitation.accepted` listener in billing.init.js is in-process fire-and-forget:
* a crash between accept and grant loses the event, and a mailer-configured signup can
* fire it before the referee's organization exists. This sweep is the truth: it scans
* ALL `invitations { status:'accepted' }`, diffs the expected grant keys
* (`referral:<invitationId>:referrer|referee`) against the BillingExtraBalance ledger,
* and back-fills misses idempotently via BillingReferralService.grantForInvitation
* (the atomic `ledger.refId` guard makes overlap with the listener harmless).
*
* No-op when config.billing.referral.enabled === false (default).
* Intended to run as a Kubernetes CronJob — see modules/billing/crons/README.md.
*
* Usage:
* NODE_ENV=production node modules/billing/crons/billing.referralReconcile.js
*/

import { randomUUID } from 'node:crypto';

process.env.NODE_ENV = process.env.NODE_ENV || 'development';

const [
{ default: config },
{ default: mongooseService },
{ default: logger },
{ applyJitter },
{ getCronJitterMaxMs },
{ acquireLock, releaseLock },
] = await Promise.all([
import('../../../config/index.js'),
import('../../../lib/services/mongoose.js'),
import('../../../lib/services/logger.js'),
import('../lib/billing.cron-utils.js'),
import('../lib/billing.constants.js'),
import('../../../lib/services/distributedLock.js'),
]);

if (!config?.billing?.referral?.enabled) {
logger.info('[cron.referralReconcile] referral disabled — skipping.');
process.exit(0);
}

const LOCK_NAME = 'billing.referralReconcile';
const LOCK_TTL_MS = 10 * 60 * 1000; // 10 min

const startMs = Date.now();
logger.info('[cron.referralReconcile] start');

let lockHolder = null;
try {
await applyJitter(getCronJitterMaxMs());
await mongooseService.loadModels();
await mongooseService.connect();

lockHolder = `${process.env.HOSTNAME ?? 'unknown'}:${randomUUID()}`;
const acquired = await acquireLock({ name: LOCK_NAME, ttlMs: LOCK_TTL_MS, holder: lockHolder });
if (!acquired) {
logger.info('[cron.referralReconcile] lock held by another pod, skipping');
process.exitCode = 0;
} else {
try {
const [
{ default: BillingReferralService },
{ default: BillingExtraBalanceRepository },
{ default: InvitationRepository },
] = await Promise.all([
import('../services/billing.referral.service.js'),
import('../repositories/billing.extraBalance.repository.js'),
import('../../invitations/repositories/invitations.repository.js'),
]);

const cfg = config.billing.referral;
const accepted = await InvitationRepository.findAccepted();

// Expected keys per invitation — derived from the service's expectedGrantKeys
// (the SINGLE rule source shared with grantForInvitation): the cron never
// re-encodes the side rules, so a new guard (e.g. #3833) can never drift.
const candidates = [];
for (const invite of accepted) {
const expected = BillingReferralService.expectedGrantKeys(invite, cfg).map(({ key }) => key);
if (expected.length > 0) candidates.push({ invite, expected });
}

const allKeys = candidates.flatMap((c) => c.expected);
const existing = new Set(await BillingExtraBalanceRepository.findExistingRefIds(allKeys));

Comment thread
PierreBrisorgueil marked this conversation as resolved.
let backfilled = 0;
let pending = 0;
let errors = 0;

for (const { invite, expected } of candidates) {
if (expected.every((key) => existing.has(key))) continue; // fully granted
try {
const result = await BillingReferralService.grantForInvitation({
invitationId: String(invite._id),
invitedBy: invite.invitedBy ? String(invite.invitedBy) : null,
acceptedUserId: invite.acceptedUserId ? String(invite.acceptedUserId) : null,
});
const sides = [result.referrer, result.referee].filter(Boolean);
const applied = sides.filter((s) => s.applied).length;
// `no_organization` = the actor has no workspace yet (e.g. email verification
// pending) — stays pending, retried on the next run once the org exists.
const waiting = sides.filter((s) => s.reason === 'no_organization').length;
if (applied > 0) {
backfilled += applied;
logger.info('[cron.referralReconcile] back-filled', { invitationId: String(invite._id), applied });
}
if (waiting > 0) pending += waiting;
} catch (err) {
errors += 1;
logger.error('[cron.referralReconcile] grantForInvitation failed', {
invitationId: String(invite._id),
err: err?.message,
stack: err?.stack,
});
}
}

logger.info('[cron.referralReconcile] complete', {
accepted: accepted.length,
backfilled,
pending,
errors,
durationMs: Date.now() - startMs,
});
process.exitCode = errors > 0 ? 1 : 0;
} finally {
// releaseLock failure is non-fatal: lock auto-expires on TTL.
// Log separately to preserve any original work error.
try {
await releaseLock({ name: LOCK_NAME, holder: lockHolder });
} catch (releaseErr) {
logger.error('[cron.referralReconcile] failed to release lock — will auto-expire on TTL', {
err: releaseErr,
cron: LOCK_NAME,
});
}
}
}
} catch (err) {
logger.error('[cron.referralReconcile] failed', { err: err?.message, stack: err?.stack });
process.exitCode = 1;
} finally {
await mongooseService.disconnect?.();
}
process.exit(process.exitCode ?? 0);
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ const LedgerEntrySchema = new Schema(
* Credit source tag — discriminates pack purchases from grants.
* 'signup_grant' — one-shot free tier grant on org creation.
* 'adjustment' — manual ops credit (non-Stripe).
* 'referral' — referral grant on invitation acceptance (#3842), keyed
* `referral:<invitationId>:referrer|referee` in refId.
* Omitted for kind='topup' entries created by creditPack (Stripe path).
*/
source: {
type: String,
enum: ['signup_grant', 'adjustment'],
enum: ['signup_grant', 'adjustment', 'referral'],
},
at: {
type: Date,
Expand Down
Loading
Loading