diff --git a/MIGRATIONS.md b/MIGRATIONS.md index f3106a42c..f4f148859 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -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::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 } } + ``` + +- **`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::*` 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). diff --git a/modules/billing/billing.init.js b/modules/billing/billing.init.js index 1b254c9f4..610ff6488 100644 --- a/modules/billing/billing.init.js +++ b/modules/billing/billing.init.js @@ -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::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} 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 diff --git a/modules/billing/config/billing.development.config.js b/modules/billing/config/billing.development.config.js index 964b65ffe..7ca1f84e5 100644 --- a/modules/billing/config/billing.development.config.js +++ b/modules/billing/config/billing.development.config.js @@ -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 ?? '', diff --git a/modules/billing/crons/README.md b/modules/billing/crons/README.md index 0e4153345..6aac1b921 100644 --- a/modules/billing/crons/README.md +++ b/modules/billing/crons/README.md @@ -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 @@ -15,6 +15,7 @@ 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 @@ -22,6 +23,7 @@ No `node-cron` dependency — orchestration is handled by Kubernetes CronJob man 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. @@ -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 @@ -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 diff --git a/modules/billing/crons/billing.referralReconcile.js b/modules/billing/crons/billing.referralReconcile.js new file mode 100644 index 000000000..cc31f8239 --- /dev/null +++ b/modules/billing/crons/billing.referralReconcile.js @@ -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::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)); + + 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); diff --git a/modules/billing/models/billing.extraBalance.model.mongoose.js b/modules/billing/models/billing.extraBalance.model.mongoose.js index 270a587d4..ab98a62db 100644 --- a/modules/billing/models/billing.extraBalance.model.mongoose.js +++ b/modules/billing/models/billing.extraBalance.model.mongoose.js @@ -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::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, diff --git a/modules/billing/models/billing.extraBalance.schema.js b/modules/billing/models/billing.extraBalance.schema.js index 8a0285e49..855c357ef 100644 --- a/modules/billing/models/billing.extraBalance.schema.js +++ b/modules/billing/models/billing.extraBalance.schema.js @@ -18,13 +18,15 @@ const LedgerKind = z.enum(['topup', 'debit', 'refund', 'expiration', 'adjustment * Allowed grant sources — mirrors the Mongoose enum on LedgerEntrySchema.source. * 'signup_grant' — one-shot free tier grant on org creation (kind='topup'). * 'adjustment' — reserved for future non-Stripe manual credits. + * 'referral' — referral grant on invitation acceptance (#3842, kind='topup'), + * keyed `referral::referrer|referee` in refId. * NOTE: 'adjustment' here is a source tag (provenance), distinct from * LedgerKind 'adjustment' (balance mutation type). Existing creditCompensation() * writes kind='adjustment' entries WITHOUT setting source — source is only set by * creditGrant() and future grant methods. Do not assume kind='adjustment' implies * source='adjustment'. */ -const GrantSource = z.enum(['signup_grant', 'adjustment']); +const GrantSource = z.enum(['signup_grant', 'adjustment', 'referral']); /** * Single ledger entry schema. @@ -113,12 +115,17 @@ const ExtraBalanceDebit = z.object({ /** * Schema for creditGrant input. * Unlike creditPack, no stripeSessionId is required — idempotency is - * derived from `source + orgId` (synthetic key stored as refId). + * derived from `source + orgId` (synthetic key stored as refId) UNLESS the + * caller supplies an explicit `refId` (#3842 referral grants — several grants + * per org, one per invitation, so the synthetic per-org key cannot apply). + * `expiresAt` mirrors the creditPack expiry mechanism (extrasExpiration sweep). */ const ExtraBalanceCreditGrant = z.object({ orgId: z.string().trim().regex(objectIdRegex, 'orgId must be a valid ObjectId'), amount: z.number().int().min(1, 'amount must be >= 1'), source: GrantSource, + refId: z.string().trim().min(1, 'refId must be a non-empty string').optional(), + expiresAt: z.coerce.date().optional().nullable(), }); export default { diff --git a/modules/billing/repositories/billing.extraBalance.repository.js b/modules/billing/repositories/billing.extraBalance.repository.js index 3035a1ef3..dbb4e590a 100644 --- a/modules/billing/repositories/billing.extraBalance.repository.js +++ b/modules/billing/repositories/billing.extraBalance.repository.js @@ -165,35 +165,50 @@ const debit = async (orgId, amount, refId) => { /** * @function creditGrant - * @description Atomically credit extra meter units for a non-Stripe grant (e.g. signup free tier). - * Idempotent: if a ledger entry with the same synthetic refId - * (`-`) already exists, the update is a no-op and - * applied=false is returned. + * @description Atomically credit extra meter units for a non-Stripe grant (e.g. signup free + * tier, referral grant). Idempotent: if a ledger entry with the same refId + * already exists, the update is a no-op and applied=false is returned. * 2-step pattern aligned with creditPack: * Step 1 — ensure doc exists (atomic getOrCreate, no-op on replay). * Step 2 — idempotency-guarded credit (no upsert). - * Synthetic idempotency key: `-` stored as ledger.refId. + * Idempotency key: `options.refId` when supplied (#3842 referral grants — + * several grants per org, one per invitation, e.g. + * `referral::referrer`), otherwise the synthetic per-org key + * `-` (signup grant — one per org). + * `options.expiresAt` mirrors the creditPack expiry mechanism: the entry is + * swept by crons/billing.extrasExpiration.js once past its expiry. * No stripeSessionId required. * @param {string} orgId - The organization ObjectId (string). * @param {number} amount - Meter units to credit (must be > 0). - * @param {string} source - Grant source tag (e.g. 'signup_grant'). + * @param {string} source - Grant source tag (e.g. 'signup_grant', 'referral'). + * @param {Object} [options] + * @param {string} [options.refId] - Explicit idempotency key (defaults to `-`). + * @param {Date|null} [options.expiresAt=null] - Optional expiry date for the grant entry. * @returns {Promise<{doc: Object|null, applied: boolean, reason?: string}>} */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik -const creditGrant = async (orgId, amount, source) => { +const creditGrant = async (orgId, amount, source, { refId = null, expiresAt = null } = {}) => { if (!isValidOrgId(orgId)) return { doc: null, applied: false }; if (!Number.isFinite(amount) || amount <= 0) throw new Error('invalid argument: amount must be a positive finite number'); if (typeof source !== 'string' || source.trim() === '') throw new Error('invalid argument: source must be a non-empty string'); + if (refId !== null && (typeof refId !== 'string' || refId.trim() === '')) throw new Error('invalid argument: refId must be a non-empty string when provided'); // Validate source against the enum before writing — findOneAndUpdate does not run validators. - BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse({ orgId, amount, source: source.trim() }); + BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse({ + orgId, + amount, + source: source.trim(), + ...(refId ? { refId: refId.trim() } : {}), + ...(expiresAt ? { expiresAt } : {}), + }); - const idempotencyKey = `${source.trim()}-${orgId}`; + const idempotencyKey = refId ? refId.trim() : `${source.trim()}-${orgId}`; const entry = { kind: 'topup', amount, source: source.trim(), refId: idempotencyKey, at: new Date(), + ...(expiresAt ? { expiresAt } : {}), }; // Step 1: ensure the document exists (atomic getOrCreate, no-op if already present). @@ -526,6 +541,29 @@ const findLedgerByOrg = async (orgId) => { return doc?.ledger ?? null; }; +/** + * @function findExistingRefIds + * @description Return the subset of `refIds` that already exist as ledger entry refIds + * (any org). Used by the referral reconcile cron (#3842) to diff the + * expected grant keys (`referral::referrer|referee`) against + * the ledger and back-fill only the misses. + * Server-side aggregation — the first $match uses the sparse + * `{ 'ledger.refId': 1, 'ledger.source': 1 }` index to narrow the doc set. + * @param {string[]} refIds - Candidate idempotency keys to look up. + * @returns {Promise} The refIds already present in any ledger. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik +const findExistingRefIds = async (refIds) => { + if (!Array.isArray(refIds) || refIds.length === 0) return []; + const rows = await BillingExtraBalance().aggregate([ + { $match: { 'ledger.refId': { $in: refIds } } }, + { $unwind: '$ledger' }, + { $match: { 'ledger.refId': { $in: refIds } } }, + { $group: { _id: '$ledger.refId' } }, + ]).exec(); + return rows.map((r) => String(r._id)); +}; + /** * Sum absolute debit entries within a time window using a server-side aggregation. * Avoids loading the full ledger into memory — O(1) payload regardless of ledger size. @@ -572,6 +610,7 @@ export default { getBalance, listLedgerPage, findOrgsWithExpiringTopups, + findExistingRefIds, findLedgerByOrg, sumDebitsByWindow, }; diff --git a/modules/billing/services/billing.referral.service.js b/modules/billing/services/billing.referral.service.js new file mode 100644 index 000000000..33898f1e0 --- /dev/null +++ b/modules/billing/services/billing.referral.service.js @@ -0,0 +1,214 @@ +/** + * Module dependencies + */ +import config from '../../../config/index.js'; +import logger from '../../../lib/services/logger.js'; +import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; + +/** + * @desc Standard referral grant (#3842) — credits meter units to the referrer's and/or + * referee's organization when an invited signup completes. Mirrors the pack-credit + * mechanism exactly: same BillingExtraBalance ledger, kind='topup', `source:'referral'` + * marker, optional expiry swept by crons/billing.extrasExpiration.js. + * + * Entirely config-gated (`config.billing.referral` — stack default OFF); downstream + * projects flip the config, they never edit billing code. + * + * Idempotency: every grant is keyed `referral::referrer|referee` — + * the repository's atomic `'ledger.refId': { $ne: key }` guard (single + * findOneAndUpdate, serialized by MongoDB document-level locking) makes a replayed + * event a no-op (applied:false). This is the house ledger-dedup mechanism shared by + * creditPack / creditGrant / debit — the ledger is an embedded array, so a unique + * index cannot enforce within-document uniqueness; the atomic guard does. + * + * Callers: the `invitation.accepted` listener in billing.init.js (latency) and the + * reconcile cron crons/billing.referralReconcile.js (truth — back-fills missed + * events; EventEmitter is in-process fire-and-forget). + */ + +/** + * @function referralKeys + * @description Build the two idempotency keys for an invitation's referral grants. + * @param {string} invitationId - The accepted invitation id. + * @returns {{referrer: string, referee: string}} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const referralKeys = (invitationId) => ({ + referrer: `referral:${invitationId}:referrer`, + referee: `referral:${invitationId}:referee`, +}); + +/** + * @function expectedGrantKeys + * @description THE single source of the grant side-rules (#3842): given an invitation + * (doc with `_id` or `invitation.accepted` payload with `invitationId`) + * and the referral config, return the grant sides the pair implies as + * `[{ side, key }]`. A side with 0 configured units, an actor-less invite + * (`invitedBy` null), and the cheap self-referral floor + * (`invitedBy === acceptedUserId`; the full guard is #3833) produce NO + * entry. Pure — no I/O. Both `grantForInvitation` (grants) and + * crons/billing.referralReconcile.js (candidate diff) derive from it, so a + * new guard (e.g. #3833) lands in ONE place and the two callers can never + * drift. + * @param {Object} invite - Invitation doc (`_id`) or accepted payload (`invitationId`). + * @param {Object} cfg - The `config.billing.referral` block. + * @returns {Array<{side: 'referrer'|'referee', key: string}>} The implied grant sides. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const expectedGrantKeys = (invite, cfg) => { + if (!cfg?.enabled) return []; + const invitationId = invite?._id ?? invite?.invitationId; + if (!invitationId) return []; + const keys = referralKeys(String(invitationId)); + const { invitedBy, acceptedUserId } = invite; + const expected = []; + // Referee side — needs configured units and a referee user (legacy rows may have none). + if (cfg.refereeUnits > 0 && acceptedUserId) expected.push({ side: 'referee', key: keys.referee }); + // Referrer side — skip zero units, actor-less invites, and trivial self-referrals. + if (cfg.referrerUnits > 0 && invitedBy && String(invitedBy) !== String(acceptedUserId)) { + expected.push({ side: 'referrer', key: keys.referrer }); + } + return expected; +}; + +/** + * @function resolveGrantOrganization + * @description Resolve the organization to credit for a user — referral actors are USERS + * but billing is ORGANIZATION-scoped. Prefers the user's `currentOrganization` + * (set by org provisioning at signup, before the invite finalizes); falls back + * to the user's first ACTIVE membership. Returns null when the user has no + * organization yet — e.g. the mailer-configured signup flow defers org + * provisioning to email verification, so the listener can fire before the + * referee has a workspace. A null here is NOT an error: the grant is skipped + * and the reconcile cron back-fills it on a later run, once the org exists. + * + * Dependencies are imported lazily: organizations.membership.repository + * resolves its Mongoose model at import time, and a static chain + * billing → organizations could recreate the service cycle the + * extraBalance repository already avoids dynamically (Opus H4 precedent). + * @param {string} userId - The user ObjectId (string). + * @returns {Promise} The organization id to credit, or null. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const resolveGrantOrganization = async (userId) => { + if (!userId) return null; + const { default: UserService } = await import('../../users/services/users.service.js'); + const user = await UserService.getBrut({ id: String(userId) }); + if (!user) return null; + if (user.currentOrganization) { + return String(user.currentOrganization._id || user.currentOrganization); + } + // Fallback: the user's active membership (e.g. currentOrganization unset edge cases). + const [{ default: MembershipRepository }, { MEMBERSHIP_STATUSES }] = await Promise.all([ + import('../../organizations/repositories/organizations.membership.repository.js'), + import('../../organizations/lib/constants.js'), + ]); + // Oldest membership wins (createdAt asc) — deterministic + auditable when a user has several. + const membership = await MembershipRepository.findOne( + { userId: String(userId), status: MEMBERSHIP_STATUSES.ACTIVE }, + { sort: { createdAt: 1 } }, + ); + if (!membership?.organizationId) return null; + return String(membership.organizationId._id || membership.organizationId); +}; + +/** + * @function grantSide + * @description Credit one side (referrer or referee) of a referral grant idempotently. + * @param {Object} params + * @param {string} params.userId - The user whose organization receives the credit. + * @param {number} params.units - Units to credit (already validated > 0 by the caller). + * @param {string} params.key - The idempotency key (`referral::`). + * @param {Date|null} params.expiresAt - Optional expiry for the grant entry. + * @returns {Promise<{applied: boolean, reason?: string, organizationId?: string}>} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const grantSide = async ({ userId, units, key, expiresAt }) => { + const organizationId = await resolveGrantOrganization(userId); + if (!organizationId) { + // No workspace yet (e.g. email verification pending) — the reconcile cron retries. + logger.info('[billing.referral] no organization to credit yet — left to the reconcile cron', { + userId: String(userId), + key, + }); + return { applied: false, reason: 'no_organization' }; + } + const result = await BillingExtraBalanceRepository.creditGrant(organizationId, units, 'referral', { + refId: key, + expiresAt, + }); + if (result.applied) { + logger.info('[billing.referral] credited', { organizationId, units, key }); + return { applied: true, organizationId }; + } + // Idempotent replay (duplicate key) — expected on event replays and cron overlap. + return { applied: false, reason: result.reason ?? 'duplicate_grant', organizationId }; +}; + +/** + * @function grantForInvitation + * @description Apply the standard referral grant for one accepted invitation: + * - referee grant → the accepted user's organization (`refereeUnits`), + * - referrer grant → the inviter's organization (`referrerUnits`), + * skipped when `invitedBy` is null (actor-less invite) or when + * `invitedBy === acceptedUserId` (cheap self-referral floor — the full + * guard is #3833). + * No-op (`skipped:'disabled'`) when `config.billing.referral.enabled` is + * false; a side with 0 configured units is skipped. WHICH sides to grant is + * derived from `expectedGrantKeys` (the single rule source shared with the + * reconcile cron) — the branches below only label WHY an absent side was + * skipped (observability). Each side is keyed + * `referral::` so replays can never double-credit. + * May reject on infrastructure errors — callers own their failure handling + * (the listener self-guards, the cron counts errors). + * @param {Object} payload - The `invitation.accepted` payload (or its cron reconstruction). + * @param {string} payload.invitationId - The accepted invitation id (idempotency root). + * @param {string|null} payload.invitedBy - The inviter user id, or null. + * @param {string|null} payload.acceptedUserId - The referee (just-created user) id, or null for legacy/cron rows. + * @returns {Promise<{skipped?: string, referrer?: Object, referee?: Object}>} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const grantForInvitation = async ({ invitationId, invitedBy, acceptedUserId } = {}) => { + const cfg = config.billing?.referral; + if (!cfg?.enabled) return { skipped: 'disabled' }; + if (!invitationId) return { skipped: 'no_invitation_id' }; + + // Single rule source: expectedGrantKeys decides WHICH sides to grant. + const expected = new Map( + expectedGrantKeys({ invitationId, invitedBy, acceptedUserId }, cfg).map(({ side, key }) => [side, key]), + ); + if (cfg.expiryDays !== null && cfg.expiryDays !== undefined && cfg.expiryDays <= 0) { + throw new Error('billing.referral.expiryDays must be > 0 or null (use null for no expiry)'); + } + const expiresAt = cfg.expiryDays != null + ? new Date(Date.now() + cfg.expiryDays * 24 * 60 * 60 * 1000) + : null; + const result = {}; + + // Referee side — the accepted user's workspace doubles its welcome. + if (expected.has('referee')) { + result.referee = await grantSide({ userId: acceptedUserId, units: cfg.refereeUnits, key: expected.get('referee'), expiresAt }); + } else { + result.referee = { applied: false, reason: acceptedUserId ? 'zero_units' : 'no_accepted_user' }; + } + + // Referrer side — skip actor-less invites and trivial self-referrals (#3833 owns the full guard). + if (expected.has('referrer')) { + result.referrer = await grantSide({ userId: invitedBy, units: cfg.referrerUnits, key: expected.get('referrer'), expiresAt }); + } else if (!invitedBy) { + result.referrer = { applied: false, reason: 'no_inviter' }; + } else if (String(invitedBy) === String(acceptedUserId)) { + result.referrer = { applied: false, reason: 'self_referral' }; + } else { + result.referrer = { applied: false, reason: 'zero_units' }; + } + + return result; +}; + +export default { + referralKeys, + expectedGrantKeys, + resolveGrantOrganization, + grantForInvitation, +}; diff --git a/modules/billing/tests/billing.cron.referralReconcile.unit.tests.js b/modules/billing/tests/billing.cron.referralReconcile.unit.tests.js new file mode 100644 index 000000000..2b310f651 --- /dev/null +++ b/modules/billing/tests/billing.cron.referralReconcile.unit.tests.js @@ -0,0 +1,212 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.referralReconcile cron logic (#3842). + * + * The script itself is a standalone CLI (process.exit) — like the other billing cron + * tests, this suite exercises the script's decision logic. Candidate selection runs + * against the REAL BillingReferralService.expectedGrantKeys (the single rule source + * shared with grantForInvitation — no hand-mirrored rule block, no drift risk); only + * the grant write path (grantForInvitation) and the repositories are stubbed. + * + * Tests cover: + * - referral.enabled gate (early exit when false — NOT meterMode) + * - expected keys come from the real rule source (zero units / null invitedBy / self-referral) + * - fully-granted invitations are skipped (no grant call) + * - misses are back-filled via grantForInvitation (idempotent by construction) + * - error counting + continuation + */ +describe('billing.referralReconcile cron — logic:', () => { + let BillingReferralService; + let BillingExtraBalanceRepository; + let InvitationRepository; + let mockConfig; + + const inviterId = '64b2f0000000000000000010'; + const refereeId = '64b2f0000000000000000020'; + + /** + * Reproduce the cron's candidate-selection + back-fill pass over the fixtures. + * Mirrors modules/billing/crons/billing.referralReconcile.js. + * @returns {Promise<{backfilled: number, pending: number, errors: number, grantCalls: number}>} + */ + const runReconcilePass = async () => { + const cfg = mockConfig.billing.referral; + const accepted = await InvitationRepository.findAccepted(); + + // REAL rule source — same call the cron makes; kills the mirror-drift risk. + 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)); + + let backfilled = 0; + let pending = 0; + let errors = 0; + let grantCalls = 0; + + for (const { invite, expected } of candidates) { + if (expected.every((key) => existing.has(key))) continue; + try { + grantCalls += 1; + 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); + backfilled += sides.filter((s) => s.applied).length; + pending += sides.filter((s) => s.reason === 'no_organization').length; + } catch { + errors += 1; + } + } + + return { backfilled, pending, errors, grantCalls }; + }; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500, expiryDays: 365 } }, + }; + + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + })); + + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: { + findExistingRefIds: jest.fn().mockResolvedValue([]), + creditGrant: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../invitations/repositories/invitations.repository.js', () => ({ + default: { + findAccepted: jest.fn().mockResolvedValue([]), + }, + })); + + // REAL referral service (its static deps — config/logger/repo — are mocked above): + // expectedGrantKeys/referralKeys are exercised for real, only the grant write path + // is stubbed per test via the spy below. + const [serviceMod, repoMod, invitationMod] = await Promise.all([ + import('../services/billing.referral.service.js'), + import('../repositories/billing.extraBalance.repository.js'), + import('../../invitations/repositories/invitations.repository.js'), + ]); + BillingReferralService = serviceMod.default; + BillingExtraBalanceRepository = repoMod.default; + InvitationRepository = invitationMod.default; + jest.spyOn(BillingReferralService, 'grantForInvitation').mockResolvedValue({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('gate: skips when referral.enabled is false (the cron gates on referral, NOT meterMode)', () => { + mockConfig.billing.referral.enabled = false; + // Simulate the script gate logic inline (same shape as the other cron suites). + const shouldSkip = !mockConfig?.billing?.referral?.enabled; + expect(shouldSkip).toBe(true); + // meterMode plays no role in the decision: + mockConfig.billing.meterMode = true; + expect(!mockConfig?.billing?.referral?.enabled).toBe(true); + }); + + test('no accepted invitations → no-op (no diff, no grants)', async () => { + const { backfilled, errors, grantCalls } = await runReconcilePass(); + expect(backfilled).toBe(0); + expect(errors).toBe(0); + expect(grantCalls).toBe(0); + expect(BillingReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('expected keys come from the REAL expectedGrantKeys (zero units, null invitedBy, self-referral floor)', async () => { + mockConfig.billing.referral.referrerUnits = 0; // referrer side disabled by config + InvitationRepository.findAccepted.mockResolvedValue([ + { _id: 'inv1', invitedBy: inviterId, acceptedUserId: refereeId }, // referee key only (referrerUnits=0) + { _id: 'inv2', invitedBy: null, acceptedUserId: refereeId }, // referee key only (no inviter) + { _id: 'inv3', invitedBy: refereeId, acceptedUserId: refereeId }, // referee key only (self-referral) + { _id: 'inv4', invitedBy: inviterId, acceptedUserId: null }, // NO key (legacy row, no referee) + ]); + BillingReferralService.grantForInvitation.mockResolvedValue({ referee: { applied: true } }); + + const { grantCalls } = await runReconcilePass(); + + // inv4 produced no expected key → never a candidate; the other 3 are back-filled. + expect(grantCalls).toBe(3); + const diffedKeys = BillingExtraBalanceRepository.findExistingRefIds.mock.calls[0][0]; + expect(diffedKeys).toEqual(['referral:inv1:referee', 'referral:inv2:referee', 'referral:inv3:referee']); + }); + + test('fully-granted invitations are skipped; only misses are back-filled', async () => { + InvitationRepository.findAccepted.mockResolvedValue([ + { _id: 'done', invitedBy: inviterId, acceptedUserId: refereeId }, + { _id: 'miss', invitedBy: inviterId, acceptedUserId: refereeId }, + ]); + BillingExtraBalanceRepository.findExistingRefIds.mockResolvedValue([ + 'referral:done:referrer', + 'referral:done:referee', + 'referral:miss:referee', // referrer side missing → still a candidate + ]); + BillingReferralService.grantForInvitation.mockResolvedValue({ + referrer: { applied: true }, + referee: { applied: false, reason: 'duplicate_grant' }, // idempotent replay on the granted side + }); + + const { backfilled, errors } = await runReconcilePass(); + + expect(BillingReferralService.grantForInvitation).toHaveBeenCalledTimes(1); + expect(BillingReferralService.grantForInvitation).toHaveBeenCalledWith({ + invitationId: 'miss', + invitedBy: inviterId, + acceptedUserId: refereeId, + }); + expect(backfilled).toBe(1); // only the missing referrer side landed + expect(errors).toBe(0); + }); + + test('no_organization sides count as pending (retried next run), not errors', async () => { + InvitationRepository.findAccepted.mockResolvedValue([ + { _id: 'wait', invitedBy: null, acceptedUserId: refereeId }, + ]); + BillingReferralService.grantForInvitation.mockResolvedValue({ + referrer: { applied: false, reason: 'no_inviter' }, + referee: { applied: false, reason: 'no_organization' }, // org not provisioned yet (email verification) + }); + + const { backfilled, pending, errors } = await runReconcilePass(); + + expect(backfilled).toBe(0); + expect(pending).toBe(1); + expect(errors).toBe(0); + }); + + test('counts errors and continues when grantForInvitation throws', async () => { + InvitationRepository.findAccepted.mockResolvedValue([ + { _id: 'bad', invitedBy: inviterId, acceptedUserId: refereeId }, + { _id: 'good', invitedBy: inviterId, acceptedUserId: refereeId }, + ]); + BillingReferralService.grantForInvitation + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce({ referrer: { applied: true }, referee: { applied: true } }); + + const { backfilled, errors } = await runReconcilePass(); + + expect(errors).toBe(1); + expect(backfilled).toBe(2); + expect(BillingReferralService.grantForInvitation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/modules/billing/tests/billing.extraBalance.unit.tests.js b/modules/billing/tests/billing.extraBalance.unit.tests.js index 490107968..ffe118b15 100644 --- a/modules/billing/tests/billing.extraBalance.unit.tests.js +++ b/modules/billing/tests/billing.extraBalance.unit.tests.js @@ -1091,3 +1091,176 @@ describe('ExtraBalanceCreditGrant schema:', () => { expect(result.error).toBeDefined(); }); }); + +/** + * Referral grant extensions (#3842) — creditGrant {refId, expiresAt} options, + * 'referral' source, findExistingRefIds reconcile helper. + */ +describe('Referral grant extensions (#3842):', () => { + const orgId = '507f1f77bcf86cd799439011'; + + describe('schema', () => { + let schema; + + beforeEach(async () => { + jest.resetModules(); + const mod = await import('../models/billing.extraBalance.schema.js'); + schema = mod.default; + }); + + test('GrantSource accepts referral', () => { + const result = schema.GrantSource.safeParse('referral'); + expect(result.error).toBeFalsy(); + }); + + test('LedgerEntry accepts a referral topup with refId + expiresAt', () => { + const result = schema.LedgerEntry.safeParse({ + kind: 'topup', + amount: 500, + source: 'referral', + refId: 'referral:64b2f0000000000000000001:referee', + expiresAt: new Date('2027-06-12'), + }); + expect(result.error).toBeFalsy(); + }); + + test('ExtraBalanceCreditGrant accepts explicit refId + expiresAt', () => { + const result = schema.ExtraBalanceCreditGrant.safeParse({ + orgId, + amount: 1000, + source: 'referral', + refId: 'referral:64b2f0000000000000000001:referrer', + expiresAt: new Date('2027-06-12'), + }); + expect(result.error).toBeFalsy(); + }); + + test('ExtraBalanceCreditGrant rejects an empty refId', () => { + const result = schema.ExtraBalanceCreditGrant.safeParse({ + orgId, + amount: 1000, + source: 'referral', + refId: '', + }); + expect(result.error).toBeDefined(); + }); + }); + + describe('repository', () => { + let BillingExtraBalanceRepository; + let mockModel; + + /** + * @param {Object[]} rows - Rows the aggregation should resolve with. + * @returns {{exec: Function}} A chainable aggregate stub. + */ + const makeAggregateMock = (rows) => ({ + exec: jest.fn().mockResolvedValue(rows), + }); + + beforeEach(async () => { + jest.resetModules(); + mockModel = { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), + updateMany: jest.fn(), + exists: jest.fn(), + aggregate: jest.fn(), + }; + jest.unstable_mockModule('mongoose', () => ({ + default: { + model: jest.fn(() => mockModel), + Types: { ObjectId: { isValid: jest.fn(() => true) } }, + }, + })); + const mod = await import('../repositories/billing.extraBalance.repository.js'); + BillingExtraBalanceRepository = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('creditGrant — {refId, expiresAt} options', () => { + test('uses the explicit refId as idempotency key and stamps source/expiresAt on the entry', async () => { + const key = 'referral:64b2f0000000000000000001:referrer'; + const expiresAt = new Date('2027-06-12'); + let step2Filter; + let step2Update; + mockModel.findOneAndUpdate + .mockResolvedValueOnce({ organization: orgId, ledger: [], cachedBalance: 0 }) + .mockImplementation((filter, update) => { + step2Filter = filter; + step2Update = update; + return Promise.resolve({ organization: orgId, cachedBalance: 1000 }); + }); + + const result = await BillingExtraBalanceRepository.creditGrant(orgId, 1000, 'referral', { refId: key, expiresAt }); + + expect(result.applied).toBe(true); + expect(step2Filter).toMatchObject({ organization: orgId, 'ledger.refId': { $ne: key } }); + expect(step2Update.$push.ledger).toMatchObject({ + kind: 'topup', + amount: 1000, + source: 'referral', + refId: key, + expiresAt, + }); + }); + + test('replayed explicit refId returns applied:false reason duplicate_grant (no double-credit)', async () => { + mockModel.findOneAndUpdate + .mockResolvedValueOnce({ organization: orgId, ledger: [], cachedBalance: 0 }) + .mockResolvedValueOnce(null); // idempotency guard excluded the doc + + const result = await BillingExtraBalanceRepository.creditGrant(orgId, 1000, 'referral', { + refId: 'referral:64b2f0000000000000000001:referee', + }); + + expect(result.applied).toBe(false); + expect(result.reason).toBe('duplicate_grant'); + }); + + test('without options the synthetic - key is preserved (signup grant unchanged)', async () => { + let step2Filter; + mockModel.findOneAndUpdate + .mockResolvedValueOnce({ organization: orgId, ledger: [], cachedBalance: 0 }) + .mockImplementation((filter) => { + step2Filter = filter; + return Promise.resolve({ organization: orgId, cachedBalance: 500 }); + }); + + await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant'); + + expect(step2Filter).toMatchObject({ 'ledger.refId': { $ne: `signup_grant-${orgId}` } }); + }); + + test('throws on an empty-string refId option', async () => { + await expect( + BillingExtraBalanceRepository.creditGrant(orgId, 500, 'referral', { refId: ' ' }), + ).rejects.toThrow('invalid argument: refId must be a non-empty string when provided'); + }); + }); + + describe('findExistingRefIds', () => { + test('returns [] without querying when input is empty or not an array', async () => { + expect(await BillingExtraBalanceRepository.findExistingRefIds([])).toEqual([]); + expect(await BillingExtraBalanceRepository.findExistingRefIds(undefined)).toEqual([]); + expect(mockModel.aggregate).not.toHaveBeenCalled(); + }); + + test('returns the refIds found by the aggregation and filters on $in', async () => { + const keys = ['referral:a:referrer', 'referral:a:referee', 'referral:b:referee']; + mockModel.aggregate.mockReturnValue(makeAggregateMock([{ _id: 'referral:a:referrer' }, { _id: 'referral:a:referee' }])); + + const found = await BillingExtraBalanceRepository.findExistingRefIds(keys); + + expect(found).toEqual(['referral:a:referrer', 'referral:a:referee']); + const pipeline = mockModel.aggregate.mock.calls[0][0]; + expect(JSON.stringify(pipeline)).toContain('"ledger.refId"'); + expect(pipeline[0].$match['ledger.refId'].$in).toEqual(keys); + }); + }); + }); +}); diff --git a/modules/billing/tests/billing.init.unit.tests.js b/modules/billing/tests/billing.init.unit.tests.js index 05261d609..9ea2983cb 100644 --- a/modules/billing/tests/billing.init.unit.tests.js +++ b/modules/billing/tests/billing.init.unit.tests.js @@ -14,6 +14,7 @@ describe('billing.init unit tests:', () => { let mockMongoose; let mockLogger; let mockInvitationEvents; + let mockReferralService; const mockApp = {}; @@ -66,6 +67,13 @@ describe('billing.init unit tests:', () => { default: mockInvitationEvents, })); + // #3842: the listener lazy-imports the referral service — stub it so the wiring + // tests stay isolated from users/organizations resolution. + mockReferralService = { grantForInvitation: jest.fn().mockResolvedValue({}) }; + jest.unstable_mockModule('../services/billing.referral.service.js', () => ({ + default: mockReferralService, + })); + // Stub billing.email so boot validator tests don't wire real email listeners jest.unstable_mockModule('../billing.email.js', () => ({ setupBillingEmails: jest.fn(), @@ -87,15 +95,65 @@ describe('billing.init unit tests:', () => { await expect(billingInit(mockApp)).resolves.toBeUndefined(); }); - test('P8a: wires a (no-op) invitation.accepted listener that does not throw on emit', async () => { - await billingInit(mockApp); - // The seam is proven by the listener being registered on the invitations emitter. - const acceptedCall = mockInvitationEvents.on.mock.calls.find(([evt]) => evt === 'invitation.accepted'); - expect(acceptedCall).toBeDefined(); - const handler = acceptedCall[1]; - expect(typeof handler).toBe('function'); - // No-op: invoking it with a payload must not throw (and returns nothing). - expect(() => handler({ invitationId: 'i1', email: 'a@b.co', invitedBy: 'x', acceptedUserId: 'u1' })).not.toThrow(); + describe('#3842 referral grant listener (invitation.accepted):', () => { + const payload = { invitationId: 'i1', email: 'a@b.co', invitedBy: 'x', acceptedUserId: 'u1' }; + + /** + * Boot the module and return the registered invitation.accepted handler. + * @returns {Promise} The wired listener. + */ + const getHandler = async () => { + await billingInit(mockApp); + const acceptedCall = mockInvitationEvents.on.mock.calls.find(([evt]) => evt === 'invitation.accepted'); + expect(acceptedCall).toBeDefined(); + return acceptedCall[1]; + }; + + test('wires the listener on the invitations emitter', async () => { + const handler = await getHandler(); + expect(typeof handler).toBe('function'); + }); + + test('config-gated: disabled (default) → returns immediately, the service is never imported/called', async () => { + // mockConfig.billing has NO referral block — existing deployments unaffected. + const handler = await getHandler(); + await handler(payload); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('config-gated: enabled:false explicitly → no-op', async () => { + mockConfig.billing.referral = { enabled: false, referrerUnits: 1000, refereeUnits: 500 }; + const handler = await getHandler(); + await handler(payload); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('enabled → delegates the payload to BillingReferralService.grantForInvitation', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + const handler = await getHandler(); + await handler(payload); + expect(mockReferralService.grantForInvitation).toHaveBeenCalledTimes(1); + expect(mockReferralService.grantForInvitation).toHaveBeenCalledWith(payload); + }); + + test('self-guard: a grant REJECTION is swallowed + logged, never escapes the listener', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockReferralService.grantForInvitation.mockRejectedValue(new Error('mongo down')); + const handler = await getHandler(); + // The emit-site catch is sync-only — the async listener must resolve, not reject. + await expect(handler(payload)).resolves.toBeUndefined(); + const errCall = mockLogger.error.mock.calls.find(([msg]) => msg.includes('referral grant failed')); + expect(errCall).toBeDefined(); + expect(errCall[1]).toMatchObject({ invitationId: 'i1', err: 'mongo down' }); + }); + + test('self-guard: even a malformed payload cannot make the listener throw/reject', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockReferralService.grantForInvitation.mockRejectedValue(new Error('boom')); + const handler = await getHandler(); + await expect(handler(undefined)).resolves.toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalled(); + }); }); test('resolves without error when meterMode=true and no legacy docs', async () => { diff --git a/modules/billing/tests/billing.referral.integration.tests.js b/modules/billing/tests/billing.referral.integration.tests.js new file mode 100644 index 000000000..023f00cf5 --- /dev/null +++ b/modules/billing/tests/billing.referral.integration.tests.js @@ -0,0 +1,280 @@ +/** + * Module dependencies. + */ +import request from 'supertest'; +import path from 'path'; + +import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; +import { bootstrap } from '../../../lib/app.js'; +import config from '../../../config/index.js'; + +/** + * Billing referral grant integration tests (#3842). + * + * Drives a REAL invited signup (admin creates invite → closed-signup token path) with + * `config.billing.referral` enabled and asserts both organizations are credited on the + * BillingExtraBalance ledger with the `referral::referrer|referee` keys, + * then proves a replayed `invitation.accepted` event / grant call cannot double-credit. + */ +describe('Billing referral grant (#3842):', () => { + let app; + let UserService; + let BillingExtraBalanceRepository; + let BillingReferralService; + let invitationEvents; + + let originalUp; + let originalCap; + let originalReferral; + + const ADMIN_EMAIL = 'ref-admin@test.com'; + const GUEST_EMAIL = 'ref-guest@example.com'; + const GUEST_OFF_EMAIL = 'ref-guest-off@example.com'; + const GUEST_RACE_EMAIL = 'ref-guest-race@example.com'; + const PASSWORD = 'W@os.jsI$Aw3$0m3'; + const REFERRER_UNITS = 1000; + const REFEREE_UNITS = 500; + + beforeAll(async () => { + const init = await bootstrap(); + app = init.app; + UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + BillingExtraBalanceRepository = (await import(path.resolve('./modules/billing/repositories/billing.extraBalance.repository.js'))).default; + BillingReferralService = (await import(path.resolve('./modules/billing/services/billing.referral.service.js'))).default; + invitationEvents = (await import(path.resolve('./modules/invitations/lib/events.js'))).default; + }); + + afterAll(async () => { + for (const email of [ADMIN_EMAIL, GUEST_EMAIL, GUEST_OFF_EMAIL, GUEST_RACE_EMAIL]) { + try { + const existing = await UserService.getBrut({ email }); + if (existing) await UserService.remove(existing); + } catch (_) { /* cleanup */ } + } + }); + + beforeEach(() => { + originalUp = config.sign.up; + originalCap = config.sign.cap; + originalReferral = config.billing?.referral; + config.billing = config.billing || {}; + config.billing.referral = { + enabled: true, + referrerUnits: REFERRER_UNITS, + refereeUnits: REFEREE_UNITS, + expiryDays: 365, + }; + }); + + afterEach(() => { + config.sign.up = originalUp; + config.sign.cap = originalCap; + config.billing.referral = originalReferral; + }); + + /** + * Create an admin user and return a bound supertest agent with the auth cookie. + * Pattern mirrors invitations.integration.tests.js. + * @returns {Promise} Supertest agent with admin JWT cookie + */ + async function createAdminAndSignin() { + try { + const existing = await UserService.getBrut({ email: ADMIN_EMAIL }); + if (existing) await UserService.remove(existing); + } catch (_) { /* ignore */ } + + const savedUp = config.sign.up; + config.sign.up = true; + const adminAgent = request.agent(app); + const signupRes = await adminAgent + .post('/api/auth/signup') + .send({ firstName: 'Ref', lastName: 'Admin', email: ADMIN_EMAIL, password: PASSWORD, provider: 'local' }) + .expect(200); + config.sign.up = savedUp; + + const brut = await UserService.getBrut({ id: signupRes.body.user.id }); + await UserService.update(brut, { roles: ['user', 'admin'] }, 'admin'); + + await adminAgent + .post('/api/auth/signin') + .send({ email: ADMIN_EMAIL, password: PASSWORD }) + .expect(200); + + return adminAgent; + } + + /** + * Poll the org ledger until an entry with `refId` appears (the grant listener is + * fire-and-forget — the signup response does not await it). + * @param {string} orgId - Organization id whose ledger to poll. + * @param {string} refId - The grant idempotency key to wait for. + * @param {number} [timeoutMs=5000] - Give-up timeout. + * @returns {Promise} The ledger entry, or null on timeout. + */ + async function waitForLedgerEntry(orgId, refId, timeoutMs = 5000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const ledger = await BillingExtraBalanceRepository.findLedgerByOrg(orgId); + const entry = (ledger ?? []).find((e) => e.refId === refId); + if (entry) return entry; + await new Promise((resolve) => { setTimeout(resolve, 100); }); + } + return null; + } + + test('invited signup with referral enabled credits BOTH organizations; replays cannot double-credit', async () => { + // 1. Admin (the inviter) + invite, while signup is still open. + const adminAgent = await createAdminAndSignin(); + const created = await adminAgent.post('/api/invitations').send({ email: GUEST_EMAIL }); + expect(created.status).toBe(200); + const { token } = created.body.data; + const invitationId = created.body.data.id; + + const admin = await UserService.getBrut({ email: ADMIN_EMAIL }); + const inviterOrgId = String(admin.currentOrganization._id || admin.currentOrganization); + const inviterBalanceBefore = await BillingExtraBalanceRepository.getBalance(inviterOrgId); + + // 2. Close public signup — the invite opens the gate, accept() emits the event. + config.sign.up = false; + config.sign.cap = null; + + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email: GUEST_EMAIL, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + const guest = await UserService.getBrut({ email: GUEST_EMAIL }); + const refereeOrgId = String(guest.currentOrganization._id || guest.currentOrganization); + expect(refereeOrgId).not.toBe(inviterOrgId); + + // 3. Both sides credited (the listener is async — poll until it settles). + const refereeKey = `referral:${invitationId}:referee`; + const referrerKey = `referral:${invitationId}:referrer`; + + const refereeEntry = await waitForLedgerEntry(refereeOrgId, refereeKey); + expect(refereeEntry).not.toBeNull(); + expect(refereeEntry).toMatchObject({ kind: 'topup', source: 'referral', amount: REFEREE_UNITS }); + expect(refereeEntry.expiresAt).toBeDefined(); + + const referrerEntry = await waitForLedgerEntry(inviterOrgId, referrerKey); + expect(referrerEntry).not.toBeNull(); + expect(referrerEntry).toMatchObject({ kind: 'topup', source: 'referral', amount: REFERRER_UNITS }); + + const inviterBalanceAfter = await BillingExtraBalanceRepository.getBalance(inviterOrgId); + expect(inviterBalanceAfter - inviterBalanceBefore).toBe(REFERRER_UNITS); + const refereeBalanceAfter = await BillingExtraBalanceRepository.getBalance(refereeOrgId); + + // 4. Replay safety (deterministic): a direct service replay is the exact grant path + // the listener/cron re-run — both sides must come back duplicate_grant. + const replay = await BillingReferralService.grantForInvitation({ + invitationId, + invitedBy: String(admin._id), + acceptedUserId: String(guest._id), + }); + expect(replay.referee).toMatchObject({ applied: false, reason: 'duplicate_grant' }); + expect(replay.referrer).toMatchObject({ applied: false, reason: 'duplicate_grant' }); + + // 5. Replay safety (event path): re-emitting invitation.accepted must not credit either org. + invitationEvents.emit('invitation.accepted', { + invitationId, + email: GUEST_EMAIL, + invitedBy: String(admin._id), + acceptedUserId: String(guest._id), + }); + // Give the async listener time to settle, then assert balances are unchanged. + await new Promise((resolve) => { setTimeout(resolve, 500); }); + expect(await BillingExtraBalanceRepository.getBalance(inviterOrgId)).toBe(inviterBalanceAfter); + expect(await BillingExtraBalanceRepository.getBalance(refereeOrgId)).toBe(refereeBalanceAfter); + + // 6. Exactly ONE entry per key on each ledger (no duplicates slipped through). + const inviterLedger = await BillingExtraBalanceRepository.findLedgerByOrg(inviterOrgId); + expect(inviterLedger.filter((e) => e.refId === referrerKey)).toHaveLength(1); + const refereeLedger = await BillingExtraBalanceRepository.findLedgerByOrg(refereeOrgId); + expect(refereeLedger.filter((e) => e.refId === refereeKey)).toHaveLength(1); + + // 7. The reconcile diff helper sees both keys as granted (cron would skip this invite). + const existing = await BillingExtraBalanceRepository.findExistingRefIds([referrerKey, refereeKey]); + expect(new Set(existing)).toEqual(new Set([referrerKey, refereeKey])); + }, 30000); + + test('concurrent grantForInvitation calls (Promise.all) → exactly ONE applied:true and ONE ledger entry per side-key', async () => { + // Pins the document-locking atomicity claim: two racers against REAL Mongo, the + // atomic `'ledger.refId': { $ne: key }` guard must serialize to a single credit. + const email = GUEST_RACE_EMAIL; + const adminAgent = await createAdminAndSignin(); + const created = await adminAgent.post('/api/invitations').send({ email }); + expect(created.status).toBe(200); + const { token } = created.body.data; + const invitationId = created.body.data.id; + + // Disable referral during signup so the accept listener does NOT grant — + // the two racers below must be the FIRST writers on both keys. + config.billing.referral.enabled = false; + config.sign.up = false; + config.sign.cap = null; + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + await new Promise((resolve) => { setTimeout(resolve, 300); }); // let the (disabled) listener settle + config.billing.referral.enabled = true; + + const admin = await UserService.getBrut({ email: ADMIN_EMAIL }); + const guest = await UserService.getBrut({ email }); + const payload = { + invitationId, + invitedBy: String(admin._id), + acceptedUserId: String(guest._id), + }; + + const [first, second] = await Promise.all([ + BillingReferralService.grantForInvitation(payload), + BillingReferralService.grantForInvitation(payload), + ]); + + // Exactly one racer wins each side; the loser surfaces the idempotent no-op. + for (const side of ['referrer', 'referee']) { + const applied = [first[side], second[side]].filter((s) => s?.applied === true); + expect(applied).toHaveLength(1); + } + + // Exactly one ledger entry per key — the race never double-credited. + const inviterOrgId = String(admin.currentOrganization._id || admin.currentOrganization); + const refereeOrgId = String(guest.currentOrganization._id || guest.currentOrganization); + const inviterLedger = await BillingExtraBalanceRepository.findLedgerByOrg(inviterOrgId); + expect(inviterLedger.filter((e) => e.refId === `referral:${invitationId}:referrer`)).toHaveLength(1); + const refereeLedger = await BillingExtraBalanceRepository.findLedgerByOrg(refereeOrgId); + expect(refereeLedger.filter((e) => e.refId === `referral:${invitationId}:referee`)).toHaveLength(1); + }, 30000); + + test('referral disabled → an invited signup credits NOTHING (zero behavior when off)', async () => { + config.billing.referral = { enabled: false, referrerUnits: REFERRER_UNITS, refereeUnits: REFEREE_UNITS, expiryDays: 365 }; + + // Fresh guest for this test (the previous one is cleaned in afterAll but emails must differ). + const email = GUEST_OFF_EMAIL; + const adminAgent = await createAdminAndSignin(); + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + const invitationId = created.body.data.id; + + config.sign.up = false; + config.sign.cap = null; + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + // The listener returns immediately — no referral key may ever appear. + await new Promise((resolve) => { setTimeout(resolve, 500); }); + const existing = await BillingExtraBalanceRepository.findExistingRefIds([ + `referral:${invitationId}:referrer`, + `referral:${invitationId}:referee`, + ]); + expect(existing).toEqual([]); + + try { + const guest = await UserService.getBrut({ email }); + if (guest) await UserService.remove(guest); + } catch (_) { /* cleanup */ } + }, 30000); +}); diff --git a/modules/billing/tests/billing.referral.service.unit.tests.js b/modules/billing/tests/billing.referral.service.unit.tests.js new file mode 100644 index 000000000..9a2ddec71 --- /dev/null +++ b/modules/billing/tests/billing.referral.service.unit.tests.js @@ -0,0 +1,252 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.referral.service (#3842) — the standard referral grant. + * + * Covers: + * - config gate (disabled → no-op, no repository calls) + * - both grants with the correct units / idempotency keys / source / expiry + * - invitedBy null → referee only + * - invitedBy === acceptedUserId → referrer skipped (self-referral floor) + * - zero configured units → side skipped + * - no organization resolvable → side left to the reconcile cron + * - membership fallback when currentOrganization is unset + * - idempotent replay (duplicate_grant surfaces, never double-credits) + */ +describe('billing.referral.service unit tests:', () => { + let BillingReferralService; + let mockConfig; + let mockRepository; + let mockUserService; + let mockMembershipRepository; + let mockLogger; + + const invitationId = '64b2f0000000000000000001'; + const inviterId = '64b2f0000000000000000010'; + const refereeId = '64b2f0000000000000000020'; + const inviterOrgId = '507f1f77bcf86cd799439011'; + const refereeOrgId = '507f1f77bcf86cd799439022'; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { + referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500, expiryDays: 365 }, + }, + }; + + mockRepository = { + creditGrant: jest.fn().mockResolvedValue({ doc: {}, applied: true }), + findExistingRefIds: jest.fn().mockResolvedValue([]), + }; + + // Users keyed by id — getBrut({ id }) resolves from this map. + const users = { + [inviterId]: { _id: inviterId, currentOrganization: inviterOrgId }, + [refereeId]: { _id: refereeId, currentOrganization: refereeOrgId }, + }; + mockUserService = { + getBrut: jest.fn(({ id }) => Promise.resolve(users[id] ?? null)), + _users: users, + }; + + mockMembershipRepository = { findOne: jest.fn().mockResolvedValue(null) }; + mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ default: mockRepository })); + jest.unstable_mockModule('../../users/services/users.service.js', () => ({ default: mockUserService })); + jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({ + default: mockMembershipRepository, + })); + jest.unstable_mockModule('../../organizations/lib/constants.js', () => ({ + MEMBERSHIP_STATUSES: { ACTIVE: 'active', PENDING: 'pending' }, + })); + + const mod = await import('../services/billing.referral.service.js'); + BillingReferralService = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('referralKeys builds the referrer/referee idempotency keys from the invitationId', () => { + expect(BillingReferralService.referralKeys(invitationId)).toEqual({ + referrer: `referral:${invitationId}:referrer`, + referee: `referral:${invitationId}:referee`, + }); + }); + + describe('expectedGrantKeys — the single rule source shared with the reconcile cron:', () => { + test('standard invite → both sides as [{ side, key }] (referee first, like the grant order)', () => { + const cfg = mockConfig.billing.referral; + // Accepts an invitation doc (_id) AND the accepted-event payload (invitationId) — same result. + const expected = [ + { side: 'referee', key: `referral:${invitationId}:referee` }, + { side: 'referrer', key: `referral:${invitationId}:referrer` }, + ]; + expect(BillingReferralService.expectedGrantKeys({ _id: invitationId, invitedBy: inviterId, acceptedUserId: refereeId }, cfg)).toEqual(expected); + expect(BillingReferralService.expectedGrantKeys({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }, cfg)).toEqual(expected); + }); + + test('side rules: zero units / null invitedBy / self-referral / disabled / no id drop entries', () => { + const cfg = mockConfig.billing.referral; + const invite = { _id: invitationId, invitedBy: inviterId, acceptedUserId: refereeId }; + + // referrerUnits 0 → referee side only. + expect(BillingReferralService.expectedGrantKeys(invite, { ...cfg, referrerUnits: 0 })) + .toEqual([{ side: 'referee', key: `referral:${invitationId}:referee` }]); + // Actor-less invite → no referrer key. + expect(BillingReferralService.expectedGrantKeys({ ...invite, invitedBy: null }, cfg)) + .toEqual([{ side: 'referee', key: `referral:${invitationId}:referee` }]); + // Self-referral floor → no referrer key. + expect(BillingReferralService.expectedGrantKeys({ ...invite, invitedBy: refereeId }, cfg)) + .toEqual([{ side: 'referee', key: `referral:${invitationId}:referee` }]); + // Legacy row without a referee → no referee key. + expect(BillingReferralService.expectedGrantKeys({ ...invite, acceptedUserId: null }, cfg)) + .toEqual([{ side: 'referrer', key: `referral:${invitationId}:referrer` }]); + // Disabled config / missing id → no keys at all. + expect(BillingReferralService.expectedGrantKeys(invite, { ...cfg, enabled: false })).toEqual([]); + expect(BillingReferralService.expectedGrantKeys({ invitedBy: inviterId, acceptedUserId: refereeId }, cfg)).toEqual([]); + }); + }); + + test('disabled → no-op: returns skipped and never touches the ledger or users', async () => { + mockConfig.billing.referral.enabled = false; + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result).toEqual({ skipped: 'disabled' }); + expect(mockRepository.creditGrant).not.toHaveBeenCalled(); + expect(mockUserService.getBrut).not.toHaveBeenCalled(); + }); + + test('missing referral config block behaves as disabled (existing deployments unaffected)', async () => { + delete mockConfig.billing.referral; + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result).toEqual({ skipped: 'disabled' }); + expect(mockRepository.creditGrant).not.toHaveBeenCalled(); + }); + + test('enabled → grants BOTH sides with the correct org / units / keys / source / expiry', async () => { + const before = Date.now(); + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result.referee).toMatchObject({ applied: true, organizationId: refereeOrgId }); + expect(result.referrer).toMatchObject({ applied: true, organizationId: inviterOrgId }); + expect(mockRepository.creditGrant).toHaveBeenCalledTimes(2); + + const calls = mockRepository.creditGrant.mock.calls; + const refereeCall = calls.find(([, , , opts]) => opts.refId === `referral:${invitationId}:referee`); + const referrerCall = calls.find(([, , , opts]) => opts.refId === `referral:${invitationId}:referrer`); + expect(refereeCall).toEqual([refereeOrgId, 500, 'referral', expect.objectContaining({ refId: `referral:${invitationId}:referee` })]); + expect(referrerCall).toEqual([inviterOrgId, 1000, 'referral', expect.objectContaining({ refId: `referral:${invitationId}:referrer` })]); + + // Expiry mirrors the pack expiryDays mechanism: now + 365d (small clock tolerance). + const expected = 365 * 24 * 60 * 60 * 1000; + for (const [, , , opts] of calls) { + expect(opts.expiresAt).toBeInstanceOf(Date); + const delta = opts.expiresAt.getTime() - before; + expect(delta).toBeGreaterThanOrEqual(expected - 5000); + expect(delta).toBeLessThanOrEqual(expected + 5000); + } + }); + + test('expiryDays null → grants never expire (expiresAt null)', async () => { + mockConfig.billing.referral.expiryDays = null; + + await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + for (const [, , , opts] of mockRepository.creditGrant.mock.calls) { + expect(opts.expiresAt).toBeNull(); + } + }); + + test('expiryDays 0 → throws (0 is not a valid expiry; use null for no-expiry)', async () => { + mockConfig.billing.referral.expiryDays = 0; + + await expect( + BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }), + ).rejects.toThrow('billing.referral.expiryDays must be > 0 or null'); + }); + + test('invitedBy null → referee grant only, referrer skipped with no_inviter', async () => { + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: null, acceptedUserId: refereeId }); + + expect(result.referrer).toEqual({ applied: false, reason: 'no_inviter' }); + expect(result.referee).toMatchObject({ applied: true, organizationId: refereeOrgId }); + expect(mockRepository.creditGrant).toHaveBeenCalledTimes(1); + expect(mockRepository.creditGrant.mock.calls[0][3].refId).toBe(`referral:${invitationId}:referee`); + }); + + test('invitedBy === acceptedUserId → referrer skipped (self-referral floor), referee still granted', async () => { + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: refereeId, acceptedUserId: refereeId }); + + expect(result.referrer).toEqual({ applied: false, reason: 'self_referral' }); + expect(result.referee).toMatchObject({ applied: true }); + expect(mockRepository.creditGrant).toHaveBeenCalledTimes(1); + }); + + test('zero configured units skip the side without resolving the user', async () => { + mockConfig.billing.referral.refereeUnits = 0; + mockConfig.billing.referral.referrerUnits = 0; + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result.referee).toEqual({ applied: false, reason: 'zero_units' }); + expect(result.referrer).toEqual({ applied: false, reason: 'zero_units' }); + expect(mockRepository.creditGrant).not.toHaveBeenCalled(); + }); + + test('actor without any organization → side skipped with no_organization (left to the cron)', async () => { + mockUserService._users[refereeId] = { _id: refereeId, currentOrganization: null }; + mockMembershipRepository.findOne.mockResolvedValue(null); + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result.referee).toEqual({ applied: false, reason: 'no_organization' }); + // The referrer side is unaffected. + expect(result.referrer).toMatchObject({ applied: true, organizationId: inviterOrgId }); + expect(mockRepository.creditGrant).toHaveBeenCalledTimes(1); + }); + + test('currentOrganization unset → falls back to the user active membership', async () => { + mockUserService._users[refereeId] = { _id: refereeId, currentOrganization: null }; + mockMembershipRepository.findOne.mockResolvedValue({ organizationId: { _id: refereeOrgId, name: 'Org' } }); + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: null, acceptedUserId: refereeId }); + + // Deterministic fallback: oldest active membership wins (createdAt asc). + expect(mockMembershipRepository.findOne).toHaveBeenCalledWith( + { userId: refereeId, status: 'active' }, + { sort: { createdAt: 1 } }, + ); + expect(result.referee).toMatchObject({ applied: true, organizationId: refereeOrgId }); + }); + + test('idempotent replay: duplicate_grant from the repository surfaces as applied:false (no double-credit, no throw)', async () => { + mockRepository.creditGrant.mockResolvedValue({ doc: null, applied: false, reason: 'duplicate_grant' }); + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result.referee).toMatchObject({ applied: false, reason: 'duplicate_grant' }); + expect(result.referrer).toMatchObject({ applied: false, reason: 'duplicate_grant' }); + // Two guarded calls happened, both no-ops — the atomic refId guard did the dedup. + expect(mockRepository.creditGrant).toHaveBeenCalledTimes(2); + }); + + test('missing invitationId → skipped (no idempotency root, never grant unkeyed)', async () => { + const result = await BillingReferralService.grantForInvitation({ invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result).toEqual({ skipped: 'no_invitation_id' }); + expect(mockRepository.creditGrant).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/invitations/README.md b/modules/invitations/README.md index 8fd62e822..9ded19cd7 100644 --- a/modules/invitations/README.md +++ b/modules/invitations/README.md @@ -15,7 +15,8 @@ about organizations: getting an invited person into an org is the 2-step flow ## The referral substrate (what "Referral rewards — coming soon" hooks into) -The reward **seam** ships; the reward **logic** is deliberately deferred (#5, gates in #3833). +The reward **seam** ships, and the STANDARD reward logic now ships too (#3842 — the +config-gated grant listener in `billing.init.js`, default OFF; product gates in #3833). Two primitives are written on every accepted invite — on **both** the token-signup path and the OAuth path (shared `accept(invite, userId)`): @@ -50,13 +51,12 @@ from the payload — reward either side, or both. ### A. Standard grant — ships IN the stack, downstream enables it by CONFIG -The grant listener is implemented **once, upstream, in `billing.init.js`** (#5 — the -no-op seam with the `TODO(#5)` is already there), entirely gated by config: +**Shipped (#3842).** The grant listener is implemented **once, upstream, in +`billing.init.js`** (it replaced the P8a no-op seam), entirely gated by config: ```js -// modules/billing/config/billing.development.config.js — stack default: OFF -// (this block does NOT exist yet — #5 adds it together with the listener impl) -billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0 } } +// modules/billing/config/billing.development.config.js — stack default: OFF (shipped) +billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0, expiryDays: 365 } } ``` ```js @@ -64,34 +64,36 @@ billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0 } } billing: { referral: { enabled: true, referrerUnits: 500, refereeUnits: 200 } } ``` -The stack listener (#5 implementation sketch — lives upstream, never downstream): - -```js -invitationEvents.on('invitation.accepted', async ({ invitationId, invitedBy, acceptedUserId }) => { - try { - const cfg = config.billing?.referral; - if (!cfg?.enabled) return; // downstream flips this - if (invitedBy && cfg.referrerUnits) await grantCredits({ userId: invitedBy, units: cfg.referrerUnits, key: `referral:${invitationId}:referrer` }); - if (cfg.refereeUnits) await grantCredits({ userId: acceptedUserId, units: cfg.refereeUnits, key: `referral:${invitationId}:referee` }); - } catch (err) { - // ⚠️ MANDATORY self-guard: EventEmitter.emit is synchronous — the emit-site - // try/catch in invitations.service only catches SYNC throws. An async - // listener's rejection escapes as an unhandledRejection. Never let it. - logger.error('[billing] referral grant failed', { err: err?.message, stack: err?.stack }); - } -}); -``` - -Rules that make this production-grade: - -- **Idempotency** — key every grant on `invitationId` (unique index on the ledger `key`): - a replayed/duplicate event can never double-credit. -- **Reconcile cron (safety net)** — EventEmitter is in-process fire-and-forget; a crash - between accept and grant loses the event. Pair the listener with a periodic script - (k8s CronJob, pattern: `modules/billing/crons/`) that scans - ALL `invitations { status:'accepted' }` vs the grant ledger keys and back-fills misses - (scan all accepted, not just `invitedBy:{$ne:null}` — referee grants exist even when - `invitedBy` is null, so a referrer-only scan would miss referee-only back-fills). +The listener delegates to `modules/billing/services/billing.referral.service.js` +(`grantForInvitation`), which: + +- maps the **user-scoped** referral actors onto the **organization-scoped** billing + ledger: each side credits the actor's `currentOrganization` (active-membership + fallback) on the `BillingExtraBalance` ledger via `creditGrant` — `kind:'topup'`, + `source:'referral'`, expiry from `billing.referral.expiryDays` (same sweep as pack + credits, `crons/billing.extrasExpiration.js`); +- skips the referrer grant when `invitedBy` is null, and when + `invitedBy === acceptedUserId` (cheap self-referral floor — the full guard is #3833); +- when an actor has no organization yet (mailer-configured signups provision the org at + email verification, AFTER the event), the side is left to the reconcile cron; +- is **self-guarded**: the listener wraps everything in try/catch + `logger.error` — an + async rejection must never escape (`EventEmitter.emit` is synchronous; the emit-site + try/catch only covers sync throws, see `lib/events.js`). + +Rules that make this production-grade (both shipped): + +- **Idempotency** — every grant is keyed `referral:${invitationId}:referrer|referee` + (`ledger.refId`): a replayed/duplicate event can never double-credit. The ledger is an + embedded array, so enforcement is the house atomic + `'ledger.refId': { $ne: key }` findOneAndUpdate guard (same mechanism as + creditPack/debit), supported by the sparse `{ 'ledger.refId': 1, 'ledger.source': 1 }` + index. +- **Reconcile cron (safety net)** — `crons/billing.referralReconcile.js` (k8s CronJob, + house cron pattern): EventEmitter is in-process fire-and-forget; a crash between + accept and grant loses the event. The cron scans ALL `invitations + { status:'accepted' }` vs the grant ledger keys and back-fills misses idempotently + (it scans all accepted, not just `invitedBy:{$ne:null}` — referee grants exist even + when `invitedBy` is null, so a referrer-only scan would miss referee-only back-fills). The listener is latency; the cron is truth. ### A'. Custom rewards (cashback, Stripe credit note, partner webhook) — project-only module @@ -119,7 +121,7 @@ project's custom one can coexist); each owns its own failure handling. Derive the reward at quota/entitlement time instead of granting: ```js -// illustrative — a countAccepted helper does not exist yet; #5 adds it (or an equivalent query) +// illustrative — a countAccepted helper does not exist yet (add it, or an equivalent query) const accepted = await InvitationRepository.countAccepted({ invitedBy: userId }); const bonus = accepted * config.billing.referral.referrerUnits; ``` @@ -148,5 +150,5 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts The Vue module (`src/modules/invitations/` in Devkit Vue) ships the admin beta-gate tab and the account **Referrals** tab (invite a contact, my invites + status chips, a referral -summary, and the "Referral rewards — coming soon" placeholder where #5's balance lands — -the placeholder is contractually digit-free until real numbers exist). +summary, and the "Referral rewards — coming soon" placeholder where the #3842 grant +balance lands — the placeholder is contractually digit-free until real numbers exist). diff --git a/modules/invitations/invitations.init.js b/modules/invitations/invitations.init.js index 67b5fedef..643ea5e3b 100644 --- a/modules/invitations/invitations.init.js +++ b/modules/invitations/invitations.init.js @@ -82,7 +82,7 @@ export default async () => { // Return the resolved (+claimed, local) invite plus finalize/release closures // bound to it. The accept/release logic stays in this module; auth just relays. // P8a: `finalize` now routes through InvitationsService.accept, which finalizes - // the invite AND wires the referral substrate (#5) — stamps referredBy on the new + // the invite AND wires the referral substrate (#3842) — stamps referredBy on the new // user (server-side) + emits `invitation.accepted`. The closure name stays // `finalize` so auth.controller relays it unchanged (auth never imports us); accept // is a superset of finalize. Fires on BOTH the token AND the OAuth path (both go diff --git a/modules/invitations/lib/events.js b/modules/invitations/lib/events.js index ecde1bb78..398637a13 100644 --- a/modules/invitations/lib/events.js +++ b/modules/invitations/lib/events.js @@ -15,10 +15,11 @@ import { EventEmitter } from 'events'; * SYNCHRONOUS listener throw — `EventEmitter.emit` is synchronous, so it returns * before any async listener settles. An ASYNC listener (e.g. `async (p) => { await * grantCredits() }`) that REJECTS escapes the emit-site try/catch as an - * unhandledRejection AFTER emit returns. Therefore a future async listener (e.g. the - * #5 credit-grant) MUST own its own internal try/catch and never let a rejection - * escape, OR the emit seam must switch to an awaited `Promise.allSettled` fan-out — - * the current synchronous guard will NOT catch an async rejection. + * unhandledRejection AFTER emit returns. Therefore an async listener (e.g. the + * #3842 credit-grant in billing.init.js, which complies) MUST own its own internal + * try/catch and never let a rejection escape, OR the emit seam must switch to an + * awaited `Promise.allSettled` fan-out — the current synchronous guard will NOT + * catch an async rejection. * Payload: { * invitationId: String — the accepted invite's id * email: String — the invite's pinned (lowercased) email diff --git a/modules/invitations/models/invitations.model.mongoose.js b/modules/invitations/models/invitations.model.mongoose.js index 33286bb54..9b3b57399 100644 --- a/modules/invitations/models/invitations.model.mongoose.js +++ b/modules/invitations/models/invitations.model.mongoose.js @@ -45,6 +45,8 @@ const InvitationMongoose = new Schema( InvitationMongoose.index({ token: 1 }, { unique: true }); InvitationMongoose.index({ email: 1 }); +// #3842 — referral queries hit invitedBy (reconcile cron + future referral lists). +InvitationMongoose.index({ invitedBy: 1 }); /** * @desc Return document id as hex string diff --git a/modules/invitations/repositories/invitations.repository.js b/modules/invitations/repositories/invitations.repository.js index fa0823642..d66446cbf 100644 --- a/modules/invitations/repositories/invitations.repository.js +++ b/modules/invitations/repositories/invitations.repository.js @@ -102,6 +102,17 @@ const releaseStaleClaims = (cutoff) => */ const list = () => Invitation.find({}).select('-token').populate('invitedBy', 'email firstName lastName').sort('-createdAt').exec(); +/** + * @desc List ALL accepted invitations, lean + minimal projection — the referral + * reconcile cron (#3842) diffs them against the billing grant ledger keys and + * back-fills misses. Scans all accepted (not just `invitedBy:{$ne:null}`): referee + * grants exist even when invitedBy is null, so a referrer-only scan would miss + * referee-only back-fills. + * @returns {Promise>} + */ +const findAccepted = () => + Invitation.find({ status: 'accepted' }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec(); + /** * @desc Get one invitation by id * @param {String} id @@ -124,4 +135,4 @@ const revoke = (id) => { returnDocument: 'after' }, ).exec(); -export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, get, revoke }; +export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, get, revoke }; diff --git a/modules/invitations/services/invitations.service.js b/modules/invitations/services/invitations.service.js index 400de6bec..0b1fda2ed 100644 --- a/modules/invitations/services/invitations.service.js +++ b/modules/invitations/services/invitations.service.js @@ -211,10 +211,11 @@ const finalize = async (id, userId) => { * via UserService.updateById (raw update — bypasses the client whitelist + Zod, * so this is the ONLY way the field is ever written; never from a client body). * invitations already depends on users (the E9 guard), so this keeps auth import-free. - * 3. emits `invitation.accepted` so optional consumers (billing #5 credit-grant) can - * react fire-and-forget. + * 3. emits `invitation.accepted` so optional consumers (the billing #3842 credit-grant) + * can react fire-and-forget. * - * Referral substrate (#5) — NO credit-grant logic here; this only wires the field + event. + * Referral substrate — NO credit-grant logic here; this only wires the field + event + * (the config-gated grant listener lives in billing.init.js, #3842). * * `invitedBy` may be null (admin-created invite with no inviter). We DECIDE to ALWAYS * emit on accept (the canonical "an invite was consumed" signal) with `invitedBy:null` diff --git a/modules/invitations/tests/invitations.repository.unit.tests.js b/modules/invitations/tests/invitations.repository.unit.tests.js index 4046a02f1..5fcac7fa9 100644 --- a/modules/invitations/tests/invitations.repository.unit.tests.js +++ b/modules/invitations/tests/invitations.repository.unit.tests.js @@ -9,6 +9,7 @@ const chain = { select: jest.fn(() => chain), populate: jest.fn(() => chain), sort: jest.fn(() => chain), + lean: jest.fn(() => chain), exec, }; const save = jest.fn(); @@ -126,6 +127,15 @@ describe('InvitationRepository', () => { expect(chain.sort).toHaveBeenCalledWith('-createdAt'); }); + test('findAccepted scans status:accepted lean with the minimal referral projection (#3842)', async () => { + exec.mockResolvedValue([{ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }]); + const result = await InvitationRepository.findAccepted(); + // Scans ALL accepted (not invitedBy:{$ne:null}) — referee grants exist without an inviter. + expect(InvitationModel.find).toHaveBeenCalledWith({ status: 'accepted' }, { invitedBy: 1, acceptedUserId: 1 }); + expect(chain.lean).toHaveBeenCalled(); + expect(result).toEqual([{ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }]); + }); + test('get returns null for an invalid ObjectId without hitting the DB', async () => { isValid.mockReturnValue(false); const result = await InvitationRepository.get('not-an-id'); diff --git a/modules/organizations/repositories/organizations.membership.repository.js b/modules/organizations/repositories/organizations.membership.repository.js index 96644e15b..fa6e0cdee 100644 --- a/modules/organizations/repositories/organizations.membership.repository.js +++ b/modules/organizations/repositories/organizations.membership.repository.js @@ -52,9 +52,16 @@ const get = (id) => { * @function findOne * @description Data access operation to fetch a single membership matching a filter. * @param {Object} filter - The filter to apply to the query. + * @param {Object} [options] - Optional query modifiers. + * @param {Object} [options.sort] - Mongo sort spec applied before picking the document + * (e.g. `{ createdAt: 1 }` for a deterministic pick). * @returns {Promise} The found membership or null. */ -const findOne = (filter) => Membership.findOne(filter).populate(defaultPopulate).exec(); +const findOne = (filter, { sort } = {}) => { + const query = Membership.findOne(filter).populate(defaultPopulate); + if (sort) query.sort(sort); + return query.exec(); +}; /** * @function update