Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import config from '../../config/index.js';
import AnalyticsService from '../../lib/services/analytics.js';
import logger from '../../lib/services/logger.js';
import billingEvents from './lib/events.js';
import invitationEvents from '../invitations/lib/events.js';
import BillingUsageRepository from './repositories/billing.usage.repository.js';
import { getAlertThresholdPercents } from './lib/billing.constants.js';
import { setupBillingEmails } from './billing.email.js';
Expand Down Expand Up @@ -44,6 +45,26 @@ 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.
/**
* @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.
* @param {{invitationId: string, email: string, invitedBy: (string|null), acceptedUserId: string}} payload - Accepted invitation event payload.
* @returns {void}
*/
// eslint-disable-next-line no-unused-vars
invitationEvents.on('invitation.accepted', (payload) => {
// TODO(#5): grant referral credits to payload.invitedBy (skip when invitedBy is null).
// 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.
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Update analytics group properties when a subscription plan changes
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
try {
Expand Down
19 changes: 19 additions & 0 deletions modules/billing/tests/billing.init.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('billing.init unit tests:', () => {
let mockDistinct;
let mockMongoose;
let mockLogger;
let mockInvitationEvents;

const mockApp = {};

Expand Down Expand Up @@ -58,6 +59,13 @@ describe('billing.init unit tests:', () => {
default: { on: jest.fn(), emit: jest.fn() },
}));

// P8a: billing is an optional consumer of the invitations `invitation.accepted`
// event — stub the singleton so the unit test asserts the listener wiring in isolation.
mockInvitationEvents = { on: jest.fn(), emit: jest.fn() };
jest.unstable_mockModule('../../invitations/lib/events.js', () => ({
default: mockInvitationEvents,
}));

// Stub billing.email so boot validator tests don't wire real email listeners
jest.unstable_mockModule('../billing.email.js', () => ({
setupBillingEmails: jest.fn(),
Expand All @@ -79,6 +87,17 @@ 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();
});

test('resolves without error when meterMode=true and no legacy docs', async () => {
mockConfig.billing.meterMode = true;
mockConfig.billing.plans = ['free', 'growth', 'pro'];
Expand Down
19 changes: 17 additions & 2 deletions modules/invitations/invitations.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,25 @@ export default async () => {
}
if (!invite) return undefined;
// Return the resolved (+claimed, local) invite plus finalize/release closures
// bound to its id. finalize/release logic stays in this module; auth just relays.
// 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
// 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
// through this same closure), so OAuth-invited users are credited too.

/**
* @desc Finalize accepted invite and run referral side-effects (P8a).
* Delegates to InvitationsService.accept so auth stays import-free.
* @param {String} userId - the just-created user id
* @returns {Promise<Object|null>} finalized invitation document, or null if not finalized
*/
const finalizeInvite = (userId) => InvitationsService.accept(invite, userId);

return {
invite,
finalize: (userId) => InvitationsService.finalize(invite.id, userId),
finalize: finalizeInvite,
release: () => InvitationsService.release(invite.id),
Comment thread
PierreBrisorgueil marked this conversation as resolved.
};
});
Expand Down
23 changes: 20 additions & 3 deletions modules/invitations/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,27 @@ import { EventEmitter } from 'events';
* Singleton emitter for invitation events. Config-free / import-safe.
*
* Events:
* - `invitation.accepted` — emitted when an invite is consumed by a signup
* Payload: { invitationId, email, invitedBy, acceptedUserId }
* - `invitation.accepted` — emitted (P8a) by InvitationsService.accept when an invite
* is consumed by a successful signup (BOTH the local two-phase token path AND the
* OAuth-by-email path go through the same accept seam). Fire-and-forget. Always
* emitted on accept.
* ⚠️ The try/catch around the `emit` call (accept seam) only guards against a
* 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.
* Payload: {
* invitationId: String — the accepted invite's id
* email: String — the invite's pinned (lowercased) email
* invitedBy: ObjectId|null — the inviter user id, or null for an admin-created
* invite with no inviter
* acceptedUserId: String — the just-created user's id (= the new user's referredBy
* source when invitedBy is set)
* }
*
* NOTE: no event is emitted yet — P8 wires the actual `invitation.accepted` emit.
* This file ships the singleton + the error-listener hook (registered in
* invitations.init.js after config is ready) so it stays config-free and
* importable without ordering hazards (mirrors billing/lib/events.js).
Expand Down
75 changes: 75 additions & 0 deletions modules/invitations/services/invitations.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import crypto from 'crypto';
import InvitationRepository from '../repositories/invitations.repository.js';
import { DEFAULT_INVITE_EXPIRES_IN_DAYS, STALE_CLAIM_MINUTES } from '../lib/constants.js';
import UserService from '../../users/services/users.service.js';
import invitationEvents from '../lib/events.js';
import config from '../../../config/index.js';
import mails from '../../../lib/helpers/mailer/index.js';
import getBaseUrl from '../../../lib/helpers/getBaseUrl.js';
Expand Down Expand Up @@ -201,6 +202,79 @@ const finalize = async (id, userId) => {
return result;
};

/**
* @desc P8a — the shared ACCEPT seam, invoked by the eligibility closure on FULL
* signup success for BOTH paths (local two-phase token AND OAuth-by-email). It:
* 1. finalizes the invite (status:'accepted', acceptedAt, acceptedUserId:userId,
* usedAt — burns single-use; already idempotent on acceptedAt:null in the repo),
* 2. stamps `referredBy = invite.invitedBy` on the just-created user, SERVER-SIDE,
* 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.
*
* Referral substrate (#5) — NO credit-grant logic here; this only wires the field + event.
*
* `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`
* in that case, but to SKIP the referredBy write when there is no inviter (leaving the
* user's `referredBy` at its `default:null` — writing null is a redundant no-op).
*
* If finalize() returns null (duplicate-accept / revoked / missing invite), the method
* returns null immediately — no referral side-effects fire for non-finalized invites.
*
* Both side-effects are best-effort: a referredBy-write failure or a listener throw must
* NOT roll back an already-burned invite or break the signup response (the invite is
* finalized first; the referral wiring is downstream of account creation). Errors are
* logged. The emit is wrapped because a synchronous listener throw would otherwise
* propagate out of `emit` into the signup flow.
*
* @param {Object} invite - the resolved invite doc (has id, invitedBy, email)
* @param {String} userId - the just-created user's id
* @returns {Promise<Object|null>} the finalized invite doc, or null if nothing to finalize
*/
const accept = async (invite, userId) => {
const result = await finalize(invite.id, userId);
// Guard: only wire referral side-effects when the invite was actually finalized.
// finalize() returns null on duplicate-accept / revoked / missing — we must not
// emit a consumed-invite signal or stamp referredBy for an invite that did not land.
if (!result) {
return null;
}
const invitedBy = invite.invitedBy || null;
// Stamp the referral link server-side (skip the redundant null write).
if (invitedBy) {
try {
await UserService.updateById(userId, { referredBy: invitedBy });
} catch (err) {
// Best-effort: the invite is already accepted; a referredBy write failure must
// not break signup. Surface it as a warning for follow-up (referral attribution
// would be lost for this user, but the account is valid).
logger.warn('invitations: failed to set referredBy on accepted signup', {
invitationId: String(invite.id),
userId: String(userId),
invitedBy: String(invitedBy),
message: err?.message,
});
}
}
// Always emit on accept (invitedBy may be null) so the event is the single canonical
// "invite consumed" signal. Guard the emit so a synchronous listener throw cannot
// escape into the signup flow (an emitted 'error' would crash without the init listener).
try {
invitationEvents.emit('invitation.accepted', {
invitationId: invite.id,
email: invite.email,
invitedBy,
acceptedUserId: userId,
});
} catch (err) {
logger.warn('invitations: invitation.accepted listener threw', { message: err?.message });
}
return result;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* @desc E2 — release a claimed-but-unfinalized invite (on any pre-response signup
* failure) so it can be retried. Checks the repository return.
Expand Down Expand Up @@ -244,6 +318,7 @@ export default {
assertInvitedByEmail,
claim,
finalize,
accept,
release,
sweepStaleClaims,
list,
Expand Down
13 changes: 10 additions & 3 deletions modules/invitations/tests/invitations.init.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const mockService = {
assertInvitedByEmail: jest.fn(),
claim: jest.fn(),
finalize: jest.fn(),
accept: jest.fn(),
release: jest.fn(),
sweepStaleClaims: jest.fn(),
};
Expand Down Expand Up @@ -74,9 +75,13 @@ describe('invitations.init', () => {
// E2: the resolved invite was atomically claimed by token before returning.
expect(mockService.claim).toHaveBeenCalledWith('tok');
expect(result.invite).toBe(invite);
// returned finalize/release closures bind exactly this invite id
// returned finalize/release closures bind exactly this invite. P8a: the `finalize`
// closure routes through accept(invite, userId) (finalize + referral wiring); the
// closure name is unchanged so auth relays it verbatim. accept receives the WHOLE
// invite (needs invitedBy/email for referredBy + the event payload), not just the id.
await result.finalize('u9');
expect(mockService.finalize).toHaveBeenCalledWith('i7', 'u9');
expect(mockService.accept).toHaveBeenCalledWith(invite, 'u9');
expect(mockService.finalize).not.toHaveBeenCalled();
await result.release();
expect(mockService.release).toHaveBeenCalledWith('i7');
});
Expand Down Expand Up @@ -117,8 +122,10 @@ describe('invitations.init', () => {
// OAuth has no token to claim — claim must NOT be invoked on this path.
expect(mockService.claim).not.toHaveBeenCalled();
expect(result.invite).toBe(invite);
// P8a: the OAuth path's finalize closure also routes through accept — so an
// OAuth-invited user gets referredBy set + invitation.accepted emitted too.
await result.finalize('u2');
expect(mockService.finalize).toHaveBeenCalledWith('o1', 'u2');
expect(mockService.accept).toHaveBeenCalledWith(invite, 'u2');
});

test('(c) OAuth: does NOT resolve an invite when the provider email is unverified (E7)', async () => {
Expand Down
99 changes: 99 additions & 0 deletions modules/invitations/tests/invitations.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,105 @@ describe('Signup invitations:', () => {
});
});

describe('P8a referral substrate (referredBy + invitation.accepted)', () => {
let invitationEvents;
let originalUp; let originalCap;

beforeAll(async () => {
invitationEvents = (await import(path.resolve('./modules/invitations/lib/events.js'))).default;
});

beforeEach(() => { originalUp = config.sign.up; originalCap = config.sign.cap; });
afterEach(async () => {
config.sign.up = originalUp; config.sign.cap = originalCap;
jest.restoreAllMocks();
for (const email of ['p8a-token@example.com', 'p8a-oauth@example.com', 'p8a-event@example.com']) {
try {
const existing = await UserService.getBrut({ email });
if (existing) await UserService.remove(existing);
} catch (_) { /* cleanup */ }
}
});

test('token path: invited signup sets referredBy = inviter id; a client-supplied referredBy in the body is IGNORED', async () => {
const adminAgent = await createAdminAndSignin();
const email = 'p8a-token@example.com';
const created = await adminAgent.post('/api/invitations').send({ email });
const { token } = created.body.data;
// The inviter is the admin who created the invite.
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
const inviterId = String(admin._id);

config.sign.up = false; config.sign.cap = null;

// Attacker tries to self-assign a DIFFERENT referrer via the signup body.
const res = await request(app)
.post(`/api/auth/signup?inviteToken=${token}`)
.send({ email, password: 'Sup3rStr0ng!', referredBy: '64b2f0000000000000000999' });
expect(res.status).toBe(200);

const brut = await UserService.getBrut({ email });
// referredBy is the SERVER-resolved inviter, NOT the client-supplied id.
expect(String(brut.referredBy)).toBe(inviterId);
expect(String(brut.referredBy)).not.toBe('64b2f0000000000000000999');
});

test('OAuth path: an email-matched invited OAuth signup ALSO sets referredBy = inviter id', async () => {
const AuthController = (await import(path.resolve('./modules/auth/controllers/auth.controller.js'))).default;
const adminAgent = await createAdminAndSignin();
const email = 'p8a-oauth@example.com';
await adminAgent.post('/api/invitations').send({ email });
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
const inviterId = String(admin._id);

config.sign.up = false; config.sign.cap = null;

const profil = {
firstName: 'Referred', // NB: the name Zod regex rejects digits — no 'P8a'
lastName: 'OAuth',
email,
avatar: '',
providerData: { sub: 'google-p8a-oauth-sub' },
emailVerifiedByProvider: true,
};
const createdUser = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');
expect(createdUser.email).toBe(email);

const brut = await UserService.getBrut({ email });
// OAuth-invited users are credited too (the same accept seam runs).
expect(String(brut.referredBy)).toBe(inviterId);
});

test('invitation.accepted fires with the documented payload on accept (proves the billing listener seam end-to-end)', async () => {
const adminAgent = await createAdminAndSignin();
const email = 'p8a-event@example.com';
const created = await adminAgent.post('/api/invitations').send({ email });
const { token } = created.body.data;
const invitationId = created.body.data.id;
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
const inviterId = String(admin._id);

// Spy on the REAL events singleton (billing's no-op listener is also attached
// to it at boot — this proves the event reaches consumers end-to-end).
const emitSpy = jest.spyOn(invitationEvents, 'emit');

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);
const newUser = await UserService.getBrut({ email });

const acceptedCall = emitSpy.mock.calls.find(([evt]) => evt === 'invitation.accepted');
expect(acceptedCall).toBeDefined();
const payload = acceptedCall[1];
expect(payload.invitationId).toBe(invitationId);
expect(payload.email).toBe(email);
expect(String(payload.invitedBy)).toBe(inviterId);
expect(String(payload.acceptedUserId)).toBe(String(newUser._id));
});
});

describe('E4 cap unify — blank cap means UNCAPPED at the signup gate', () => {
let originalUp; let originalCap;
beforeEach(() => { originalUp = config.sign.up; originalCap = config.sign.cap; });
Expand Down
Loading
Loading