Skip to content

Commit 7d4f605

Browse files
feat(billing): instant referee grant on organization provisioning (#3859)
* feat(organizations): organization.provisioned signup event New config-free singleton lib/events.js (mirrors invitations/lib/events.js) emitted at every handleSignupOrganization exit path that returns a real organization — fresh create AND A4 convergence (consumers must be idempotent; double-fire is by design). Sync try/catch at the emit site; the mandatory 'error' listener lives in the new organizations.init.js (auto-discovered via the modules/*/*.init.js glob). With a mailer configured this fires at email verification, the exact moment a referral grant becomes possible. refs #3844 * feat(invitations): findByAcceptedUserId repository lookup Lean one-row lookup { status:'accepted', acceptedUserId } with the same minimal projection as findAccepted — resolves the referral idempotency keys from a user id at organization-provisioning time (user.referredBy alone cannot: it carries the inviter but not the invitationId the ledger keys need). refs #3844 * feat(billing): instant referee grant on organization.provisioned With a mailer configured the referee's organization is provisioned at email verification — after invitation.accepted fired — so the #3842 listener landed no_organization and the referee grant waited for the reconcile cron (up to 24h). New config-gated, self-guarded listener re-runs the idempotent grantForInvitation at the exact provisioning moment (ledger refId guard makes listener/cron double-fire harmless); the cron stays the truth/safety net. refs #3844 * test(billing): mailer-on timing integration for instant grant Reproduces the email-verification gap (accepted invite + referredBy, no ledger keys), re-runs handleSignupOrganization as verifyEmail does (A4 convergence), and asserts the organization.provisioned listener credits the referee instantly with replay coming back duplicate_grant. refs #3844 * docs(billing): neutralize downstream name in event comments Replaces a named downstream consumer in two billing-event comments with neutral wording (public-OSS no-downstream-refs rule). refs #3844 * fix(invitations): guard findByAcceptedUserId against invalid id H1: add mongoose.Types.ObjectId.isValid guard to findByAcceptedUserId (mirrors the sibling get() pattern); invalid id returns null without hitting the DB. Add unit test asserting invalid id → null + no findOne. M2: replace two setTimeout(resolve, 300) disabled-listener sleeps in billing.referral.integration with setImmediate drain — the 300ms was guarding a DISABLED listener that returns immediately, so a microtask drain is sufficient and deterministic. refs #3844
1 parent cda74a1 commit 7d4f605

9 files changed

Lines changed: 425 additions & 6 deletions

modules/billing/billing.init.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AnalyticsService from '../../lib/services/analytics.js';
77
import logger from '../../lib/services/logger.js';
88
import billingEvents from './lib/events.js';
99
import invitationEvents from '../invitations/lib/events.js';
10+
import organizationEvents from '../organizations/lib/events.js';
1011
import BillingUsageRepository from './repositories/billing.usage.repository.js';
1112
import { getAlertThresholdPercents } from './lib/billing.constants.js';
1213
import { setupBillingEmails } from './billing.email.js';
@@ -83,6 +84,54 @@ export default async (app) => {
8384
}
8485
});
8586

87+
// Instant referee grant (#3844) — billing is an OPTIONAL consumer of the organizations
88+
// fire-and-forget `organization.provisioned` event (dependency direction billing →
89+
// organizations mirrors billing → invitations: billing imports the events singleton,
90+
// organizations never imports billing). With a mailer configured the referee's org is
91+
// only provisioned at EMAIL VERIFICATION — after `invitation.accepted` already fired —
92+
// so the #3842 listener lands `no_organization` and the grant waits for the reconcile
93+
// cron (≤24h). This listener closes that gap at the exact provisioning moment; the
94+
// cron stays the truth/safety net. Same config gate — zero behavior until a project
95+
// flips `config.billing.referral.enabled`.
96+
/**
97+
* @desc Instant referee referral grant on organization provisioning (#3844).
98+
* Resolves the freshly-provisioned user's accepted invitation and re-runs the
99+
* idempotent grantForInvitation (ledger refId guard `referral:<invitationId>:*`),
100+
* so a double-fire with the #3842 listener or the reconcile cron is harmless
101+
* (duplicate_grant). Self-guarded: never lets a rejection escape (see
102+
* organizations/lib/events.js).
103+
* @param {{userId: string, organizationId: string}} payload - Provisioned organization event payload.
104+
* @returns {Promise<void>} settles when the grant attempt completes (never rejects)
105+
*/
106+
organizationEvents.on('organization.provisioned', async (payload) => {
107+
try {
108+
if (!config.billing?.referral?.enabled) return; // downstream flips this — default OFF
109+
const userId = payload?.userId ? String(payload.userId) : null;
110+
if (!userId) return;
111+
// Lazy imports keep the boot graph unchanged when the feature is off and avoid
112+
// import-time model resolution (mirrors the invitation.accepted listener above).
113+
const { default: UserService } = await import('../users/services/users.service.js');
114+
const user = await UserService.getBrut({ id: userId });
115+
if (!user?.referredBy) return; // not an invited signup — nothing to grant
116+
const { default: InvitationRepository } = await import('../invitations/repositories/invitations.repository.js');
117+
const invitation = await InvitationRepository.findByAcceptedUserId(userId);
118+
if (!invitation) return; // referredBy without a finalized invite — the reconcile cron owns the edge
119+
const { default: BillingReferralService } = await import('./services/billing.referral.service.js');
120+
await BillingReferralService.grantForInvitation({
121+
invitationId: String(invitation._id),
122+
invitedBy: invitation.invitedBy ? String(invitation.invitedBy) : null,
123+
acceptedUserId: String(invitation.acceptedUserId),
124+
});
125+
} catch (err) {
126+
// ⚠️ MANDATORY self-guard (see organizations/lib/events.js): never let a rejection escape.
127+
logger.error('[billing] instant referee grant failed — reconcile cron will back-fill', {
128+
userId: String(payload?.userId ?? ''),
129+
err: err?.message,
130+
stack: err?.stack,
131+
});
132+
}
133+
});
134+
86135
// Update analytics group properties when a subscription plan changes
87136
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
88137
try {
@@ -95,12 +144,12 @@ export default async (app) => {
95144
// Ops alerting — real-money events that require immediate human review.
96145
//
97146
// NOTE: devkit has no ntfy helper; the structured logger is the alert sink here.
98-
// Downstream projects (e.g. trawl_node) wire the actual ntfy push by re-listening
99-
// on the same billingEvents singleton and calling their own ntfy service.
147+
// A downstream consumer wires the actual ntfy push by re-listening on the same
148+
// billingEvents singleton and calling its own ntfy service.
100149
// Priority annotations below document the intended ntfy priority for downstream use.
101150

102151
// billing.dispute.opened — priority 5 (urgent): 7-day evidence window starts now.
103-
// Downstream projects (e.g. trawl_node) re-listen on billingEvents for ntfy push.
152+
// A downstream consumer re-listens on billingEvents for ntfy push.
104153
billingEvents.on('billing.dispute.opened', (payload) => {
105154
const { disputeId, chargeId, organizationId, stripeSessionId, amount, reason } = payload;
106155
logger.error('[billing.init] ALERT: dispute opened — 7-day evidence window — manual review required', {

modules/billing/tests/billing.init.unit.tests.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ describe('billing.init unit tests:', () => {
1515
let mockLogger;
1616
let mockInvitationEvents;
1717
let mockReferralService;
18+
let mockOrganizationEvents;
19+
let mockUserService;
20+
let mockInvitationRepository;
1821

1922
const mockApp = {};
2023

@@ -74,6 +77,23 @@ describe('billing.init unit tests:', () => {
7477
default: mockReferralService,
7578
}));
7679

80+
// #3844: the organizations `organization.provisioned` singleton — stub like invitationEvents.
81+
mockOrganizationEvents = { on: jest.fn(), emit: jest.fn() };
82+
jest.unstable_mockModule('../../organizations/lib/events.js', () => ({
83+
default: mockOrganizationEvents,
84+
}));
85+
86+
// #3844: the listener lazy-imports UserService + InvitationRepository — stub both.
87+
mockUserService = { getBrut: jest.fn().mockResolvedValue(null) };
88+
jest.unstable_mockModule('../../users/services/users.service.js', () => ({
89+
default: mockUserService,
90+
}));
91+
92+
mockInvitationRepository = { findByAcceptedUserId: jest.fn().mockResolvedValue(null) };
93+
jest.unstable_mockModule('../../invitations/repositories/invitations.repository.js', () => ({
94+
default: mockInvitationRepository,
95+
}));
96+
7797
// Stub billing.email so boot validator tests don't wire real email listeners
7898
jest.unstable_mockModule('../billing.email.js', () => ({
7999
setupBillingEmails: jest.fn(),
@@ -156,6 +176,103 @@ describe('billing.init unit tests:', () => {
156176
});
157177
});
158178

179+
describe('#3844 instant referee grant listener (organization.provisioned):', () => {
180+
const payload = { userId: 'u1', organizationId: 'o1' };
181+
const invitation = { _id: 'i9', invitedBy: 'x', acceptedUserId: 'u1' };
182+
183+
/**
184+
* Boot the module and return the registered organization.provisioned handler.
185+
* @returns {Promise<Function>} The wired listener.
186+
*/
187+
const getHandler = async () => {
188+
await billingInit(mockApp);
189+
const provisionedCall = mockOrganizationEvents.on.mock.calls.find(([evt]) => evt === 'organization.provisioned');
190+
expect(provisionedCall).toBeDefined();
191+
return provisionedCall[1];
192+
};
193+
194+
test('wires the listener on the organizations emitter', async () => {
195+
const handler = await getHandler();
196+
expect(typeof handler).toBe('function');
197+
});
198+
199+
test('config-gated: disabled (default) → returns immediately, no lookup, no grant', async () => {
200+
// mockConfig.billing has NO referral block — existing deployments unaffected.
201+
const handler = await getHandler();
202+
await handler(payload);
203+
expect(mockUserService.getBrut).not.toHaveBeenCalled();
204+
expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled();
205+
});
206+
207+
test('user without referredBy → no invitation lookup, no grant', async () => {
208+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
209+
mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: null });
210+
const handler = await getHandler();
211+
await handler(payload);
212+
expect(mockUserService.getBrut).toHaveBeenCalledWith({ id: 'u1' });
213+
expect(mockInvitationRepository.findByAcceptedUserId).not.toHaveBeenCalled();
214+
expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled();
215+
});
216+
217+
test('referredBy set but no accepted invitation found → no grant (the cron owns the edge)', async () => {
218+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
219+
mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' });
220+
mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(null);
221+
const handler = await getHandler();
222+
await handler(payload);
223+
expect(mockInvitationRepository.findByAcceptedUserId).toHaveBeenCalledWith('u1');
224+
expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled();
225+
});
226+
227+
test('happy path → grant called once with the exact stringified invitation payload', async () => {
228+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
229+
mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' });
230+
mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(invitation);
231+
const handler = await getHandler();
232+
await handler(payload);
233+
expect(mockReferralService.grantForInvitation).toHaveBeenCalledTimes(1);
234+
expect(mockReferralService.grantForInvitation).toHaveBeenCalledWith({
235+
invitationId: 'i9',
236+
invitedBy: 'x',
237+
acceptedUserId: 'u1',
238+
});
239+
});
240+
241+
test('admin-created invite (invitedBy null) → grant called with invitedBy:null', async () => {
242+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
243+
mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' });
244+
mockInvitationRepository.findByAcceptedUserId.mockResolvedValue({ _id: 'i9', invitedBy: null, acceptedUserId: 'u1' });
245+
const handler = await getHandler();
246+
await handler(payload);
247+
expect(mockReferralService.grantForInvitation).toHaveBeenCalledWith({
248+
invitationId: 'i9',
249+
invitedBy: null,
250+
acceptedUserId: 'u1',
251+
});
252+
});
253+
254+
test('self-guard: a grant REJECTION is swallowed + logged, never escapes the listener', async () => {
255+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
256+
mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' });
257+
mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(invitation);
258+
mockReferralService.grantForInvitation.mockRejectedValue(new Error('mongo down'));
259+
const handler = await getHandler();
260+
// The emit-site catch is sync-only — the async listener must resolve, not reject.
261+
await expect(handler(payload)).resolves.toBeUndefined();
262+
const errCall = mockLogger.error.mock.calls.find(([msg]) => msg.includes('instant referee grant failed'));
263+
expect(errCall).toBeDefined();
264+
expect(errCall[1]).toMatchObject({ userId: 'u1', err: 'mongo down' });
265+
});
266+
267+
test('self-guard: even a malformed payload cannot make the listener throw/reject', async () => {
268+
mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 };
269+
const handler = await getHandler();
270+
await expect(handler(undefined)).resolves.toBeUndefined();
271+
expect(mockUserService.getBrut).not.toHaveBeenCalled();
272+
expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled();
273+
});
274+
});
275+
159276
test('resolves without error when meterMode=true and no legacy docs', async () => {
160277
mockConfig.billing.meterMode = true;
161278
mockConfig.billing.plans = ['free', 'growth', 'pro'];

modules/billing/tests/billing.referral.integration.tests.js

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('Billing referral grant (#3842):', () => {
2222
let BillingExtraBalanceRepository;
2323
let BillingReferralService;
2424
let invitationEvents;
25+
let OrganizationsService;
2526

2627
let originalUp;
2728
let originalCap;
@@ -31,6 +32,7 @@ describe('Billing referral grant (#3842):', () => {
3132
const GUEST_EMAIL = 'ref-guest@example.com';
3233
const GUEST_OFF_EMAIL = 'ref-guest-off@example.com';
3334
const GUEST_RACE_EMAIL = 'ref-guest-race@example.com';
35+
const GUEST_VERIFY_EMAIL = 'ref-guest-verify@example.com';
3436
const PASSWORD = 'W@os.jsI$Aw3$0m3';
3537
const REFERRER_UNITS = 1000;
3638
const REFEREE_UNITS = 500;
@@ -42,10 +44,11 @@ describe('Billing referral grant (#3842):', () => {
4244
BillingExtraBalanceRepository = (await import(path.resolve('./modules/billing/repositories/billing.extraBalance.repository.js'))).default;
4345
BillingReferralService = (await import(path.resolve('./modules/billing/services/billing.referral.service.js'))).default;
4446
invitationEvents = (await import(path.resolve('./modules/invitations/lib/events.js'))).default;
47+
OrganizationsService = (await import(path.resolve('./modules/organizations/services/organizations.service.js'))).default;
4548
});
4649

4750
afterAll(async () => {
48-
for (const email of [ADMIN_EMAIL, GUEST_EMAIL, GUEST_OFF_EMAIL, GUEST_RACE_EMAIL]) {
51+
for (const email of [ADMIN_EMAIL, GUEST_EMAIL, GUEST_OFF_EMAIL, GUEST_RACE_EMAIL, GUEST_VERIFY_EMAIL]) {
4952
try {
5053
const existing = await UserService.getBrut({ email });
5154
if (existing) await UserService.remove(existing);
@@ -197,6 +200,64 @@ describe('Billing referral grant (#3842):', () => {
197200
expect(new Set(existing)).toEqual(new Set([referrerKey, refereeKey]));
198201
}, 30000);
199202

203+
test('#3844 org provisioned AFTER acceptance (mailer-on timing) → organization.provisioned credits the referee instantly; replay → duplicate_grant', async () => {
204+
// 1. Admin (the inviter) + invite, while signup is still open.
205+
const adminAgent = await createAdminAndSignin();
206+
const created = await adminAgent.post('/api/invitations').send({ email: GUEST_VERIFY_EMAIL });
207+
expect(created.status).toBe(200);
208+
const { token } = created.body.data;
209+
const invitationId = created.body.data.id;
210+
211+
// 2. Sign the guest up with referral DISABLED so the #3842 invitation.accepted
212+
// listener does NOT credit — reproducing the mailer-on gap: accepted invite +
213+
// referredBy stamped, org existing, but ZERO referral keys on the ledger yet.
214+
config.billing.referral.enabled = false;
215+
config.sign.up = false;
216+
config.sign.cap = null;
217+
const res = await request(app)
218+
.post(`/api/auth/signup?inviteToken=${token}`)
219+
.send({ email: GUEST_VERIFY_EMAIL, password: 'Sup3rStr0ng!' });
220+
expect(res.status).toBe(200);
221+
await new Promise((resolve) => { setImmediate(resolve); }); // let the (disabled) listeners settle
222+
config.billing.referral.enabled = true;
223+
224+
const guest = await UserService.getBrut({ email: GUEST_VERIFY_EMAIL });
225+
expect(guest.referredBy).toBeTruthy(); // stamped by the accept seam
226+
const refereeOrgId = String(guest.currentOrganization._id || guest.currentOrganization);
227+
const refereeKey = `referral:${invitationId}:referee`;
228+
const referrerKey = `referral:${invitationId}:referrer`;
229+
expect(await BillingExtraBalanceRepository.findExistingRefIds([refereeKey, referrerKey])).toEqual([]);
230+
231+
// 3. Re-run handleSignupOrganization exactly as verifyEmail does (A4 convergence —
232+
// the org already exists) → emits organization.provisioned → instant grant.
233+
guest.emailVerified = true; // verifyEmail mutates the local object the same way
234+
await OrganizationsService.handleSignupOrganization(guest);
235+
236+
// 4. The referee is credited instantly (the listener is async — poll until it settles)…
237+
const refereeEntry = await waitForLedgerEntry(refereeOrgId, refereeKey);
238+
expect(refereeEntry).not.toBeNull();
239+
expect(refereeEntry).toMatchObject({ kind: 'topup', source: 'referral', amount: REFEREE_UNITS });
240+
241+
// …and grantForInvitation back-fills the referrer side too (idempotent both ways).
242+
const admin = await UserService.getBrut({ email: ADMIN_EMAIL });
243+
const inviterOrgId = String(admin.currentOrganization._id || admin.currentOrganization);
244+
const referrerEntry = await waitForLedgerEntry(inviterOrgId, referrerKey);
245+
expect(referrerEntry).not.toBeNull();
246+
247+
// 5. Replay (cron-overlap): the same grant path must come back duplicate_grant.
248+
const replay = await BillingReferralService.grantForInvitation({
249+
invitationId,
250+
invitedBy: String(admin._id),
251+
acceptedUserId: String(guest._id),
252+
});
253+
expect(replay.referee).toMatchObject({ applied: false, reason: 'duplicate_grant' });
254+
expect(replay.referrer).toMatchObject({ applied: false, reason: 'duplicate_grant' });
255+
256+
// 6. Exactly ONE referee entry — listener + replay never double-credited.
257+
const refereeLedger = await BillingExtraBalanceRepository.findLedgerByOrg(refereeOrgId);
258+
expect(refereeLedger.filter((e) => e.refId === refereeKey)).toHaveLength(1);
259+
}, 30000);
260+
200261
test('concurrent grantForInvitation calls (Promise.all) → exactly ONE applied:true and ONE ledger entry per side-key', async () => {
201262
// Pins the document-locking atomicity claim: two racers against REAL Mongo, the
202263
// atomic `'ledger.refId': { $ne: key }` guard must serialize to a single credit.
@@ -216,7 +277,7 @@ describe('Billing referral grant (#3842):', () => {
216277
.post(`/api/auth/signup?inviteToken=${token}`)
217278
.send({ email, password: 'Sup3rStr0ng!' });
218279
expect(res.status).toBe(200);
219-
await new Promise((resolve) => { setTimeout(resolve, 300); }); // let the (disabled) listener settle
280+
await new Promise((resolve) => { setImmediate(resolve); }); // let the (disabled) listener settle
220281
config.billing.referral.enabled = true;
221282

222283
const admin = await UserService.getBrut({ email: ADMIN_EMAIL });

modules/invitations/repositories/invitations.repository.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ const list = (filter = {}) => Invitation.find(filter).select('-token').populate(
117117
const findAccepted = () =>
118118
Invitation.find({ status: 'accepted' }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec();
119119

120+
/**
121+
* @desc Find the accepted invitation consumed by a given user (#3844) — the instant
122+
* referee grant listener resolves the `referral:<invitationId>:*` idempotency keys
123+
* from it when `organization.provisioned` fires at email verification. Lean +
124+
* minimal projection (same shape as findAccepted). One row max in practice: an
125+
* invite is single-use and burns on accept.
126+
* @param {String} userId - the accepted (invited) user's id
127+
* @returns {Promise<{_id: Object, invitedBy: (Object|null), acceptedUserId: Object}|null>}
128+
*/
129+
const findByAcceptedUserId = (userId) =>
130+
(mongoose.Types.ObjectId.isValid(userId)
131+
? Invitation.findOne({ status: 'accepted', acceptedUserId: userId }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec()
132+
: null);
133+
120134
/**
121135
* @desc Get one invitation by id
122136
* @param {String} id
@@ -151,4 +165,4 @@ const revoke = (id) =>
151165
{ returnDocument: 'after' },
152166
).exec();
153167

154-
export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, get, revoke };
168+
export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, findByAcceptedUserId, get, revoke };

0 commit comments

Comments
 (0)