feat(billing): config-gated referral grant — listener + reconcile cron (closes #3842)#3843
Conversation
) Implements the standard referral reward prescribed by modules/invitations/README.md (architecture A): the P8a no-op seam in billing.init.js becomes the grant listener, entirely gated by config.billing.referral (stack default OFF — zero behavior change; downstream flips { enabled, referrerUnits, refereeUnits } in {project}.config.js). - map user-scoped actors onto the org-scoped ledger: new BillingReferralService credits each side's currentOrganization (active-membership fallback) on the BillingExtraBalance ledger via creditGrant — kind:'topup', source:'referral', expiry from referral.expiryDays (same sweep as pack credits) - idempotency keys referral:<invitationId>:referrer|referee enforced by the house atomic 'ledger.refId' $ne guard (embedded-array ledger — same mechanism as creditPack/debit); creditGrant gains { refId, expiresAt } options (signup grant unchanged); source enums (Mongoose+Zod) gain 'referral'; new findExistingRefIds - async listener self-guards rejections (emit-site catch is sync-only): everything wrapped, failures logged and left to the cron - referrer skipped when invitedBy null or invitedBy === acceptedUserId (cheap self-referral floor — full guard is #3833) - reconcile cron crons/billing.referralReconcile.js (jitter + distributed lock, gates on referral.enabled): scans ALL accepted invitations vs grant ledger keys, back-fills misses idempotently — the listener is latency, the cron is truth; actors without an org yet (mailer flow defers org provisioning to email verification) stay pending until provisioned - index invitations.invitedBy (schema-declared) + InvitationRepository.findAccepted - docs: invitations README #5 prescription marked shipped (#3842), TODO(#5) refs resolved, crons README rows, MIGRATIONS.md entry (downstream action = flip config + add the CronJob manifest when enabling) - tests: unit (service grants/skips/idempotency, init listener gate+self-guard, creditGrant options, findExistingRefIds, cron logic, findAccepted) + integration (real invited signup credits BOTH orgs; service+event replays cannot double-credit; disabled credits nothing) Closes #3842
…ngle rule source (#3842) - extract pure expectedGrantKeys(invite, cfg) in billing.referral.service: THE single source of the side rules (zero units / null invitedBy / self-referral floor). grantForInvitation derives its sides from it, the reconcile cron uses it for candidates, and the cron unit test calls the REAL function (no hand-mirrored rule block — no drift when #3833 adds guards) - deterministic org fallback: active-membership lookup sorts createdAt asc (oldest membership wins); MembershipRepository.findOne gains an optional { sort } modifier (backward compatible) - integration race test: Promise.all double grantForInvitation against real Mongo — exactly one applied:true and one ledger entry per side-key (pins the document-locking atomicity claim) - test cleanup: afterAll now covers ref-guest-off/ref-guest-race users - docs: MIGRATIONS.md notes the first reconcile run retro-grants ALL previously accepted invitations (pre-seed referral:<id>:* keys if unwanted) + merge-safe-everywhere / do NOT enable before #3833 — mirrored in the referral config block comment Verified: ESLint clean; full suite 179 suites / 2404 tests green (incl. new race + rule tests).
|
Warning Review limit reached
More reviews will be available in 41 minutes and 11 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis PR implements a production-grade referral rewards system that credits referrer and referee organizations on the billing ledger when invitations are accepted. The feature is config-gated, backed by deterministic idempotency keys, and includes a cron-based reconciliation mechanism to backfill missed grants from historical accepted invitations. ChangesReferral Rewards Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3843 +/- ##
==========================================
+ Coverage 91.84% 91.96% +0.11%
==========================================
Files 159 160 +1
Lines 5212 5289 +77
Branches 1659 1698 +39
==========================================
+ Hits 4787 4864 +77
Misses 337 337
Partials 88 88
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Implements the standard, config-gated referral credit grant flow in the billing module (listener + idempotent ledger writes + reconcile cron), integrating with the invitations module’s invitation.accepted event while keeping default behavior OFF for all existing deployments.
Changes:
- Add
config.billing.referral(default OFF) and wire a self-guardedinvitation.acceptedlistener inmodules/billing/billing.init.jsthat delegates to a newBillingReferralService. - Extend the BillingExtraBalance ledger grant path to support explicit idempotency keys (
refId) and optional expiry (expiresAt), plus add a helper to lookup existing grant keys for reconciliation. - Add a standalone reconcile cron (
billing.referralReconcile.js) that scans accepted invitations and back-fills any missing referral grants idempotently.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| modules/organizations/repositories/organizations.membership.repository.js | Extend findOne to support optional sort for deterministic membership fallback selection. |
| modules/invitations/tests/invitations.repository.unit.tests.js | Add unit coverage for findAccepted() query shape (lean + minimal projection). |
| modules/invitations/services/invitations.service.js | Update inline docs to reference referral grant tracker (#3842) and clarify responsibility boundaries. |
| modules/invitations/repositories/invitations.repository.js | Add findAccepted() for reconcile scans (lean + projection). |
| modules/invitations/README.md | Update referral substrate docs to reflect shipped standard grant + reconcile cron behavior. |
| modules/invitations/models/invitations.model.mongoose.js | Add index on invitedBy for referral/reconcile query patterns. |
| modules/invitations/lib/events.js | Update event contract commentary to reference shipped billing listener (#3842). |
| modules/invitations/invitations.init.js | Update comment references from old tracker to #3842. |
| modules/billing/tests/billing.referral.service.unit.tests.js | New unit suite for referral service behavior (gating, side rules, expiry, idempotency). |
| modules/billing/tests/billing.referral.integration.tests.js | New integration suite validating end-to-end grant behavior and replay/race idempotency on real Mongo. |
| modules/billing/tests/billing.init.unit.tests.js | Update init tests to assert listener wiring, config gating, and self-guarded failure handling. |
| modules/billing/tests/billing.extraBalance.unit.tests.js | Add unit coverage for creditGrant options (refId, expiresAt) and findExistingRefIds. |
| modules/billing/tests/billing.cron.referralReconcile.unit.tests.js | Add unit coverage for reconcile candidate selection, diffing, back-fill behavior, and error counting. |
| modules/billing/services/billing.referral.service.js | New service implementing standard referral grant logic and org resolution strategy. |
| modules/billing/repositories/billing.extraBalance.repository.js | Extend creditGrant to accept { refId, expiresAt } and add findExistingRefIds() for reconciliation diffs. |
| modules/billing/models/billing.extraBalance.schema.js | Extend Zod schemas to allow referral source and grant options (refId, expiresAt). |
| modules/billing/models/billing.extraBalance.model.mongoose.js | Extend Mongoose enum to include referral as a ledger entry source. |
| modules/billing/crons/README.md | Document the new reconcile cron and its distinct gating (referral.enabled vs meterMode). |
| modules/billing/crons/billing.referralReconcile.js | New standalone cron implementing the accepted-invitations vs ledger-key diff and back-fill loop. |
| modules/billing/config/billing.development.config.js | Add billing.referral default-off config block with expiry support. |
| modules/billing/billing.init.js | Replace the prior no-op seam with the config-gated, self-guarded referral grant listener. |
| MIGRATIONS.md | Add downstream upgrade notes and operational guidance for enabling referral grants and cron scheduling. |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@MIGRATIONS.md`:
- Around line 13-16: Add blank lines immediately before and after the fenced
code block that contains the billing config snippet (the ```js block with
"billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0,
expiryDays: 365 } }") so the fence is isolated on its own lines; do the same for
the other fenced snippet around lines 27-29 to satisfy MD031 and prevent
markdown lint failures and inconsistent rendering in MIGRATIONS.md.
In `@modules/billing/crons/billing.referralReconcile.js`:
- Around line 74-87: The code currently calls
InvitationRepository.findAccepted() and builds a single in-memory
candidates/allKeys set before processing, which can OOM/timeout for large
tenants; change the flow to page/cursor through accepted invitations instead of
loading all: iterate using a batched finder (or add pagination params to
InvitationRepository.findAccepted), for each page compute expected keys via
BillingReferralService.expectedGrantKeys(invite, cfg), call
BillingExtraBalanceRepository.findExistingRefIds(...) per-batch (not once for
allKeys), and process/grant per-batch, clearing batch-local arrays before
fetching the next page so you never materialize the full backlog or a single
giant allKeys set.
In `@modules/billing/repositories/billing.extraBalance.repository.js`:
- Around line 190-211: creditGrant currently treats expiresAt with a truthy
check, so falsy but provided values (e.g., '' or 0) skip validation and are
dropped; change the logic to detect option presence (not truthiness), pass the
original options object value into
BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse, capture the parsed
result, and use the parsed.expiresAt when constructing idempotencyKey and the
ledger entry (i.e., replace the ...(expiresAt ? { expiresAt } : {}) spreads with
inclusion based on whether the caller provided expiresAt using hasOwnProperty or
similar and then use the parsed value from the parse call). Ensure you reference
creditGrant, BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse,
idempotencyKey, and the entry object when making the change.
In `@modules/billing/services/billing.referral.service.js`:
- Around line 180-182: The current logic for computing expiresAt treats
cfg.expiryDays == 0 (and negatives) as "never expire"; change it so only a
null/undefined sentinel produces a permanent grant: if cfg.expiryDays == null
then expiresAt = null; else if cfg.expiryDays > 0 compute the future Date (new
Date(Date.now() + ...)); otherwise (cfg.expiryDays === 0 or negative) set
expiresAt to an immediate expiry (e.g., new Date()) so zero does not create a
permanent grant; update the code around the expiresAt calculation (reference:
expiresAt variable and cfg.expiryDays) accordingly.
In `@modules/billing/tests/billing.referral.integration.tests.js`:
- Around line 177-187: Replace the fixed setTimeout-based sleeps after emitting
invitation.accepted with state-based polling: after calling
invitationEvents.emit(...) poll the ledger state via waitForLedgerEntry(...) or
by repeatedly calling findExistingRefIds(...) /
BillingExtraBalanceRepository.getBalance until the expected balances or absence
of new ledger entries are observed (with an overall timeout/retry interval),
then assert balances; remove the hardcoded 500ms/300ms waits and use this
polling loop to ensure the async listener has settled reliably.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 653d245e-3729-460b-abc0-53d783a8d88c
📒 Files selected for processing (22)
MIGRATIONS.mdmodules/billing/billing.init.jsmodules/billing/config/billing.development.config.jsmodules/billing/crons/README.mdmodules/billing/crons/billing.referralReconcile.jsmodules/billing/models/billing.extraBalance.model.mongoose.jsmodules/billing/models/billing.extraBalance.schema.jsmodules/billing/repositories/billing.extraBalance.repository.jsmodules/billing/services/billing.referral.service.jsmodules/billing/tests/billing.cron.referralReconcile.unit.tests.jsmodules/billing/tests/billing.extraBalance.unit.tests.jsmodules/billing/tests/billing.init.unit.tests.jsmodules/billing/tests/billing.referral.integration.tests.jsmodules/billing/tests/billing.referral.service.unit.tests.jsmodules/invitations/README.mdmodules/invitations/invitations.init.jsmodules/invitations/lib/events.jsmodules/invitations/models/invitations.model.mongoose.jsmodules/invitations/repositories/invitations.repository.jsmodules/invitations/services/invitations.service.jsmodules/invitations/tests/invitations.repository.unit.tests.jsmodules/organizations/repositories/organizations.membership.repository.js
- MIGRATIONS.md: add blank lines around fenced code blocks (MD031) - billing.referral.service: JSDoc acceptedUserId type → string|null (matches impl/tests) - billing.referral.service: guard expiryDays 0/negative — throw instead of silently becoming a permanent grant (only null is the "never expire" sentinel) - billing.referral.service.unit.tests: cover expiryDays:0 → throws
Standard referral grant (epic #3808 — the deferred "credit offer", tracker #3842)
Implements
modules/invitations/README.md§A exactly: the grant ships once, upstream, config-gated — downstream flips config, never edits stack files.What lands
billing.referralconfig (default OFF — zero impact on every existing deployment; the listener gate is the first statement, before any import/db-op):{ enabled:false, referrerUnits:0, refereeUnits:0, expiryDays:365 }.BillingReferralService.grantForInvitation— maps the user-scoped actors onto the org-scoped ledger (currentOrganization, deterministic oldest-active-membership fallback) and credits via the extendedcreditGrant(kind:'topup',source:'referral', expiry swept by the existingextrasExpirationcron).expectedGrantKeys(invite, cfg)is the single rule source (sides, self-floor, null-invitedBy) consumed by the service AND the cron AND the tests.creditPack): onefindOneAndUpdate({org, 'ledger.refId':{$ne:key}}, {$push…$inc…})— keysreferral:<invitationId>:referrer|referee. Race-proven by a real-MongoPromise.alltest.billing.init.js(replaces the P8a no-op): fully self-guarded — an async rejection can never escape (emit-site catch is sync-only).crons/billing.referralReconcile.js(house pattern: jitter/lock/exit-codes; gates onreferral.enabled): scans ALL accepted invitations vs ledger keys, back-fills idempotently — covers crash-lost events AND mailer-deferred org provisioning (event fires before the org exists on that flow).invitations.invitedByindex;TODO(#5)refs resolved to ✨ referral grant: config-gated standard listener in billing.init (referrerUnits/refereeUnits) #3842; README + MIGRATIONS updated (incl.:Verification
Lint clean; full suite 179 suites / 2404 tests green — unit (gate-off, both grants, null/self skips, duplicate replay, expectedGrantKeys shape) + integration on real Mongo (end-to-end invited signup credits BOTH orgs with exact amounts/keys/balance deltas; event-level replay; concurrent double-grant race → exactly one entry per key; disabled → zero writes).
Independent spec-compliance review ✅ (the 4 money blockers verified: zero-impact-OFF, atomic idempotency, ledger correctness incl. expiry sweep, no wrong-org credit) + code-quality review (approved; all 5 findings folded — single rule source, deterministic fallback, race test, test leak, the 2 doc sentences).
First consumer: Trawl flips
{enabled:true, referrerUnits:1000, refereeUnits:500}(comes-io/trawl_node#1449) — safe under Trawl's admin-only invites; #3833 stays mandatory before widening invite ability to users.Closes #3842. Part of #3808.
Summary by CodeRabbit
Release Notes
New Features
Documentation