From 339982152d2296550242845e0d806e20a0b5f4dd Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:01:36 +0200 Subject: [PATCH 1/6] feat(organizations): organization.provisioned signup event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/organizations/lib/events.js | 33 ++++++++ modules/organizations/organizations.init.js | 20 +++++ .../services/organizations.service.js | 25 ++++++ ...organizations.service.signup.unit.tests.js | 82 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 modules/organizations/lib/events.js create mode 100644 modules/organizations/organizations.init.js diff --git a/modules/organizations/lib/events.js b/modules/organizations/lib/events.js new file mode 100644 index 000000000..b497c5732 --- /dev/null +++ b/modules/organizations/lib/events.js @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import { EventEmitter } from 'events'; + +/** + * Singleton emitter for organization events. Config-free / import-safe. + * + * Events: + * - `organization.provisioned` — emitted (#3844) by OrganizationsService.handleSignupOrganization + * on EVERY exit path that returns a real organization: the fresh-create paths AND the A4 + * idempotent-convergence path (downstream consumers must be idempotent — a converged retry + * double-fires by design). Fire-and-forget. With a mailer configured this fires at EMAIL + * VERIFICATION (the org is only provisioned then) — exactly the moment an instant referee + * referral grant becomes possible. + * ⚠️ The try/catch around the `emit` call (handleSignupOrganization) only guards against a + * SYNCHRONOUS listener throw — `EventEmitter.emit` is synchronous, so it returns before any + * async listener settles. An ASYNC listener that REJECTS escapes the emit-site try/catch as + * an unhandledRejection AFTER emit returns. Therefore an async listener (e.g. the #3844 + * instant referee grant in billing.init.js, which complies) MUST own its own internal + * try/catch and never let a rejection escape. + * Payload: { + * userId: String — the signing-up (or converging) user's id + * organizationId: String — the provisioned (or converged-to) organization's id + * } + * + * This file ships ONLY the singleton; the mandatory 'error' listener is registered in + * organizations.init.js after config is ready, so this file stays config-free and + * importable without ordering hazards (mirrors invitations/lib/events.js). + */ +const organizationEvents = new EventEmitter(); + +export default organizationEvents; diff --git a/modules/organizations/organizations.init.js b/modules/organizations/organizations.init.js new file mode 100644 index 000000000..999f17008 --- /dev/null +++ b/modules/organizations/organizations.init.js @@ -0,0 +1,20 @@ +/** + * Module dependencies + */ +import logger from '../../lib/services/logger.js'; +import organizationEvents from './lib/events.js'; + +/** + * Organizations module initialisation. + * + * Nothing to boot today beyond event hygiene: the `organization.provisioned` + * singleton (lib/events.js) stays config-free / import-safe, so the mandatory + * 'error' listener is registered HERE, after config is ready (an unhandled + * 'error' emit would crash the process — Node default behaviour; mirrors + * invitations.init.js / billing.init.js). + * + * @returns {Promise} + */ +export default async () => { + organizationEvents.on('error', (err) => logger.error('[organizationEvents] uncaught error', { err })); +}; diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 7a1271bfa..9384396e5 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -11,6 +11,7 @@ import MembershipRepository from '../repositories/organizations.membership.repos import UserService from '../../users/services/users.service.js'; import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js'; import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js'; +import organizationEvents from '../lib/events.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; import { isPublicDomain, normalizeEmailDomain } from './organizations.domain.js'; @@ -168,11 +169,33 @@ const handleSignupOrganization = async (user) => { abilities: serializeAbilities(await policy.defineAbilityFor(user, membership)), }); + /** + * Fire-and-forget `organization.provisioned` notification (#3844) — called on every + * exit path that returns a real organization (fresh create AND the A4 convergence + * retry; consumers must be idempotent, so a double-fire is harmless by design). + * The try/catch only guards SYNCHRONOUS listener throws — `EventEmitter.emit` is + * synchronous; async listeners own their own guards (see ../lib/events.js). + * @param {Object} organization - The provisioned (or converged-to) organization document. + * @returns {void} + */ + const emitProvisioned = (organization) => { + if (!organization) return; + try { + organizationEvents.emit('organization.provisioned', { + userId: String(user.id || user._id), + organizationId: String(organization._id || organization.id), + }); + } catch (err) { + logger.warn('organizations: organization.provisioned listener threw', { message: err?.message }); + } + }; + const userId = user.id || user._id; const existingMembership = await MembershipRepository.findOne({ userId, status: MEMBERSHIP_STATUSES.ACTIVE }); if (existingMembership) { // organizationId is populated (name+slug+_id) via MembershipRepository.findOne defaultPopulate — trusted shape, same contract as autoSetCurrentOrganization const existingOrg = existingMembership.organizationId; + emitProvisioned(existingOrg); return buildResult(existingOrg, existingMembership); } @@ -189,6 +212,7 @@ const handleSignupOrganization = async (user) => { user, }); + emitProvisioned(organization); return buildResult(organization, membership); } @@ -243,6 +267,7 @@ const handleSignupOrganization = async (user) => { user, }); + emitProvisioned(organization); return { ...(await buildResult(organization, membership)), ...(suggestedJoin ? { suggestedJoin } : {}), diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 621a5517a..9414bf60f 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -80,6 +80,13 @@ jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, })); +// #3844: capture organization.provisioned emits — the singleton is config-free, stub it +// so assertions see the emit without wiring real listeners. +const mockOrgEventsEmit = jest.fn(); +jest.unstable_mockModule('../lib/events.js', () => ({ + default: { emit: mockOrgEventsEmit, on: jest.fn() }, +})); + // Config store — MUST be mutated in-place (not reassigned) because jest.unstable_mockModule // captures the default export value at import time. Object.assign ensures live updates. const configStore = { organizations: {} }; @@ -589,3 +596,78 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { }); }); }); + +describe('handleSignupOrganization — organization.provisioned emit (#3844):', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockIsConfigured.mockReturnValue(false); + mockOrgExists.mockResolvedValue(false); + mockUpdateById.mockResolvedValue({}); + mockGrantOnSignup.mockResolvedValue(null); + mockMembershipFindOne.mockResolvedValue(null); + mockDefineAbilityFor.mockResolvedValue({ rules: [] }); + mockSerializeAbilities.mockReturnValue(['ability-stub']); + }); + + test('fresh-create path → emits ONCE with string userId + organizationId', async () => { + setupConfig({ enabled: true }); + const fakeOrg = makeFakeOrg(); + mockOrgCreate.mockResolvedValue(fakeOrg); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(mockOrgEventsEmit).toHaveBeenCalledTimes(1); + expect(mockOrgEventsEmit).toHaveBeenCalledWith('organization.provisioned', { + userId: String(user.id), + organizationId: String(fakeOrg._id), + }); + }); + + test('A4 convergence path (existing ACTIVE membership) → emits with the EXISTING org id', async () => { + setupConfig({ enabled: true }); + const existingOrg = makeFakeOrg(); + mockMembershipFindOne.mockResolvedValue({ + _id: new mongoose.Types.ObjectId(), + role: 'owner', + status: 'active', + organizationId: existingOrg, + }); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBe(existingOrg); + expect(mockOrgCreate).not.toHaveBeenCalled(); + expect(mockOrgEventsEmit).toHaveBeenCalledTimes(1); + expect(mockOrgEventsEmit).toHaveBeenCalledWith('organization.provisioned', { + userId: String(user.id), + organizationId: String(existingOrg._id), + }); + }); + + test('mailer path (email verification required) → organization null, NO emit', async () => { + setupConfig({ enabled: true }); + mockIsConfigured.mockReturnValue(true); + const user = makeUser('alice@acme.com'); + user.emailVerified = false; + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBeNull(); + expect(result.emailVerificationRequired).toBe(true); + expect(mockOrgEventsEmit).not.toHaveBeenCalled(); + }); + + test('a SYNCHRONOUS listener throw is swallowed — signup result still returned', async () => { + setupConfig({ enabled: true }); + mockOrgEventsEmit.mockImplementation(() => { throw new Error('listener exploded'); }); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + }); +}); From a6f4bb6e86829989f0c05a0782bbe3de8a5c15aa Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:02:31 +0200 Subject: [PATCH 2/6] feat(invitations): findByAcceptedUserId repository lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../repositories/invitations.repository.js | 14 +++++++++++++- .../tests/invitations.repository.unit.tests.js | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/invitations/repositories/invitations.repository.js b/modules/invitations/repositories/invitations.repository.js index 807040e5e..e055e2f45 100644 --- a/modules/invitations/repositories/invitations.repository.js +++ b/modules/invitations/repositories/invitations.repository.js @@ -117,6 +117,18 @@ const list = (filter = {}) => Invitation.find(filter).select('-token').populate( const findAccepted = () => Invitation.find({ status: 'accepted' }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec(); +/** + * @desc Find the accepted invitation consumed by a given user (#3844) — the instant + * referee grant listener resolves the `referral::*` idempotency keys + * from it when `organization.provisioned` fires at email verification. Lean + + * minimal projection (same shape as findAccepted). One row max in practice: an + * invite is single-use and burns on accept. + * @param {String} userId - the accepted (invited) user's id + * @returns {Promise<{_id: Object, invitedBy: (Object|null), acceptedUserId: Object}|null>} + */ +const findByAcceptedUserId = (userId) => + Invitation.findOne({ status: 'accepted', acceptedUserId: userId }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec(); + /** * @desc Get one invitation by id * @param {String} id @@ -151,4 +163,4 @@ const revoke = (id) => { returnDocument: 'after' }, ).exec(); -export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, get, revoke }; +export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, findByAcceptedUserId, get, revoke }; diff --git a/modules/invitations/tests/invitations.repository.unit.tests.js b/modules/invitations/tests/invitations.repository.unit.tests.js index 0956a93d1..9c0020bd3 100644 --- a/modules/invitations/tests/invitations.repository.unit.tests.js +++ b/modules/invitations/tests/invitations.repository.unit.tests.js @@ -145,6 +145,17 @@ describe('InvitationRepository', () => { expect(result).toEqual([{ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }]); }); + test('findByAcceptedUserId resolves the accepted invite consumed by a user, lean + minimal projection (#3844)', async () => { + exec.mockResolvedValue({ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }); + const result = await InvitationRepository.findByAcceptedUserId('u2'); + expect(InvitationModel.findOne).toHaveBeenCalledWith( + { status: 'accepted', acceptedUserId: 'u2' }, + { 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'); From 04bfe4b33ba4e3112b006cde625f4bda7b352c2a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:03:58 +0200 Subject: [PATCH 3/6] feat(billing): instant referee grant on organization.provisioned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/billing/billing.init.js | 49 ++++++++ .../billing/tests/billing.init.unit.tests.js | 117 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/modules/billing/billing.init.js b/modules/billing/billing.init.js index 610ff6488..5b2465554 100644 --- a/modules/billing/billing.init.js +++ b/modules/billing/billing.init.js @@ -7,6 +7,7 @@ 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 organizationEvents from '../organizations/lib/events.js'; import BillingUsageRepository from './repositories/billing.usage.repository.js'; import { getAlertThresholdPercents } from './lib/billing.constants.js'; import { setupBillingEmails } from './billing.email.js'; @@ -83,6 +84,54 @@ export default async (app) => { } }); + // Instant referee grant (#3844) — billing is an OPTIONAL consumer of the organizations + // fire-and-forget `organization.provisioned` event (dependency direction billing → + // organizations mirrors billing → invitations: billing imports the events singleton, + // organizations never imports billing). With a mailer configured the referee's org is + // only provisioned at EMAIL VERIFICATION — after `invitation.accepted` already fired — + // so the #3842 listener lands `no_organization` and the grant waits for the reconcile + // cron (≤24h). This listener closes that gap at the exact provisioning moment; the + // cron stays the truth/safety net. Same config gate — zero behavior until a project + // flips `config.billing.referral.enabled`. + /** + * @desc Instant referee referral grant on organization provisioning (#3844). + * Resolves the freshly-provisioned user's accepted invitation and re-runs the + * idempotent grantForInvitation (ledger refId guard `referral::*`), + * so a double-fire with the #3842 listener or the reconcile cron is harmless + * (duplicate_grant). Self-guarded: never lets a rejection escape (see + * organizations/lib/events.js). + * @param {{userId: string, organizationId: string}} payload - Provisioned organization event payload. + * @returns {Promise} settles when the grant attempt completes (never rejects) + */ + organizationEvents.on('organization.provisioned', async (payload) => { + try { + if (!config.billing?.referral?.enabled) return; // downstream flips this — default OFF + const userId = payload?.userId ? String(payload.userId) : null; + if (!userId) return; + // Lazy imports keep the boot graph unchanged when the feature is off and avoid + // import-time model resolution (mirrors the invitation.accepted listener above). + const { default: UserService } = await import('../users/services/users.service.js'); + const user = await UserService.getBrut({ id: userId }); + if (!user?.referredBy) return; // not an invited signup — nothing to grant + const { default: InvitationRepository } = await import('../invitations/repositories/invitations.repository.js'); + const invitation = await InvitationRepository.findByAcceptedUserId(userId); + if (!invitation) return; // referredBy without a finalized invite — the reconcile cron owns the edge + const { default: BillingReferralService } = await import('./services/billing.referral.service.js'); + await BillingReferralService.grantForInvitation({ + invitationId: String(invitation._id), + invitedBy: invitation.invitedBy ? String(invitation.invitedBy) : null, + acceptedUserId: String(invitation.acceptedUserId), + }); + } catch (err) { + // ⚠️ MANDATORY self-guard (see organizations/lib/events.js): never let a rejection escape. + logger.error('[billing] instant referee grant failed — reconcile cron will back-fill', { + userId: String(payload?.userId ?? ''), + err: err?.message, + stack: err?.stack, + }); + } + }); + // Update analytics group properties when a subscription plan changes billingEvents.on('plan.changed', ({ organizationId, newPlan }) => { try { diff --git a/modules/billing/tests/billing.init.unit.tests.js b/modules/billing/tests/billing.init.unit.tests.js index 9ea2983cb..4ab40397f 100644 --- a/modules/billing/tests/billing.init.unit.tests.js +++ b/modules/billing/tests/billing.init.unit.tests.js @@ -15,6 +15,9 @@ describe('billing.init unit tests:', () => { let mockLogger; let mockInvitationEvents; let mockReferralService; + let mockOrganizationEvents; + let mockUserService; + let mockInvitationRepository; const mockApp = {}; @@ -74,6 +77,23 @@ describe('billing.init unit tests:', () => { default: mockReferralService, })); + // #3844: the organizations `organization.provisioned` singleton — stub like invitationEvents. + mockOrganizationEvents = { on: jest.fn(), emit: jest.fn() }; + jest.unstable_mockModule('../../organizations/lib/events.js', () => ({ + default: mockOrganizationEvents, + })); + + // #3844: the listener lazy-imports UserService + InvitationRepository — stub both. + mockUserService = { getBrut: jest.fn().mockResolvedValue(null) }; + jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: mockUserService, + })); + + mockInvitationRepository = { findByAcceptedUserId: jest.fn().mockResolvedValue(null) }; + jest.unstable_mockModule('../../invitations/repositories/invitations.repository.js', () => ({ + default: mockInvitationRepository, + })); + // Stub billing.email so boot validator tests don't wire real email listeners jest.unstable_mockModule('../billing.email.js', () => ({ setupBillingEmails: jest.fn(), @@ -156,6 +176,103 @@ describe('billing.init unit tests:', () => { }); }); + describe('#3844 instant referee grant listener (organization.provisioned):', () => { + const payload = { userId: 'u1', organizationId: 'o1' }; + const invitation = { _id: 'i9', invitedBy: 'x', acceptedUserId: 'u1' }; + + /** + * Boot the module and return the registered organization.provisioned handler. + * @returns {Promise} The wired listener. + */ + const getHandler = async () => { + await billingInit(mockApp); + const provisionedCall = mockOrganizationEvents.on.mock.calls.find(([evt]) => evt === 'organization.provisioned'); + expect(provisionedCall).toBeDefined(); + return provisionedCall[1]; + }; + + test('wires the listener on the organizations emitter', async () => { + const handler = await getHandler(); + expect(typeof handler).toBe('function'); + }); + + test('config-gated: disabled (default) → returns immediately, no lookup, no grant', async () => { + // mockConfig.billing has NO referral block — existing deployments unaffected. + const handler = await getHandler(); + await handler(payload); + expect(mockUserService.getBrut).not.toHaveBeenCalled(); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('user without referredBy → no invitation lookup, no grant', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: null }); + const handler = await getHandler(); + await handler(payload); + expect(mockUserService.getBrut).toHaveBeenCalledWith({ id: 'u1' }); + expect(mockInvitationRepository.findByAcceptedUserId).not.toHaveBeenCalled(); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('referredBy set but no accepted invitation found → no grant (the cron owns the edge)', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' }); + mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(null); + const handler = await getHandler(); + await handler(payload); + expect(mockInvitationRepository.findByAcceptedUserId).toHaveBeenCalledWith('u1'); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + + test('happy path → grant called once with the exact stringified invitation payload', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' }); + mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(invitation); + const handler = await getHandler(); + await handler(payload); + expect(mockReferralService.grantForInvitation).toHaveBeenCalledTimes(1); + expect(mockReferralService.grantForInvitation).toHaveBeenCalledWith({ + invitationId: 'i9', + invitedBy: 'x', + acceptedUserId: 'u1', + }); + }); + + test('admin-created invite (invitedBy null) → grant called with invitedBy:null', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' }); + mockInvitationRepository.findByAcceptedUserId.mockResolvedValue({ _id: 'i9', invitedBy: null, acceptedUserId: 'u1' }); + const handler = await getHandler(); + await handler(payload); + expect(mockReferralService.grantForInvitation).toHaveBeenCalledWith({ + invitationId: 'i9', + invitedBy: null, + acceptedUserId: 'u1', + }); + }); + + test('self-guard: a grant REJECTION is swallowed + logged, never escapes the listener', async () => { + mockConfig.billing.referral = { enabled: true, referrerUnits: 1000, refereeUnits: 500 }; + mockUserService.getBrut.mockResolvedValue({ _id: 'u1', referredBy: 'x' }); + mockInvitationRepository.findByAcceptedUserId.mockResolvedValue(invitation); + 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('instant referee grant failed')); + expect(errCall).toBeDefined(); + expect(errCall[1]).toMatchObject({ userId: 'u1', 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 }; + const handler = await getHandler(); + await expect(handler(undefined)).resolves.toBeUndefined(); + expect(mockUserService.getBrut).not.toHaveBeenCalled(); + expect(mockReferralService.grantForInvitation).not.toHaveBeenCalled(); + }); + }); + test('resolves without error when meterMode=true and no legacy docs', async () => { mockConfig.billing.meterMode = true; mockConfig.billing.plans = ['free', 'growth', 'pro']; From b639d8f9da1e4062fbdb94949202e68d96d97c11 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:05:04 +0200 Subject: [PATCH 4/6] 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 --- .../billing.referral.integration.tests.js | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/modules/billing/tests/billing.referral.integration.tests.js b/modules/billing/tests/billing.referral.integration.tests.js index 023f00cf5..3cac5dcfb 100644 --- a/modules/billing/tests/billing.referral.integration.tests.js +++ b/modules/billing/tests/billing.referral.integration.tests.js @@ -22,6 +22,7 @@ describe('Billing referral grant (#3842):', () => { let BillingExtraBalanceRepository; let BillingReferralService; let invitationEvents; + let OrganizationsService; let originalUp; let originalCap; @@ -31,6 +32,7 @@ describe('Billing referral grant (#3842):', () => { 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 GUEST_VERIFY_EMAIL = 'ref-guest-verify@example.com'; const PASSWORD = 'W@os.jsI$Aw3$0m3'; const REFERRER_UNITS = 1000; const REFEREE_UNITS = 500; @@ -42,10 +44,11 @@ describe('Billing referral grant (#3842):', () => { 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; + OrganizationsService = (await import(path.resolve('./modules/organizations/services/organizations.service.js'))).default; }); afterAll(async () => { - for (const email of [ADMIN_EMAIL, GUEST_EMAIL, GUEST_OFF_EMAIL, GUEST_RACE_EMAIL]) { + for (const email of [ADMIN_EMAIL, GUEST_EMAIL, GUEST_OFF_EMAIL, GUEST_RACE_EMAIL, GUEST_VERIFY_EMAIL]) { try { const existing = await UserService.getBrut({ email }); if (existing) await UserService.remove(existing); @@ -197,6 +200,64 @@ describe('Billing referral grant (#3842):', () => { expect(new Set(existing)).toEqual(new Set([referrerKey, refereeKey])); }, 30000); + test('#3844 org provisioned AFTER acceptance (mailer-on timing) → organization.provisioned credits the referee instantly; replay → duplicate_grant', 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_VERIFY_EMAIL }); + expect(created.status).toBe(200); + const { token } = created.body.data; + const invitationId = created.body.data.id; + + // 2. Sign the guest up with referral DISABLED so the #3842 invitation.accepted + // listener does NOT credit — reproducing the mailer-on gap: accepted invite + + // referredBy stamped, org existing, but ZERO referral keys on the ledger yet. + 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: GUEST_VERIFY_EMAIL, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + await new Promise((resolve) => { setTimeout(resolve, 300); }); // let the (disabled) listeners settle + config.billing.referral.enabled = true; + + const guest = await UserService.getBrut({ email: GUEST_VERIFY_EMAIL }); + expect(guest.referredBy).toBeTruthy(); // stamped by the accept seam + const refereeOrgId = String(guest.currentOrganization._id || guest.currentOrganization); + const refereeKey = `referral:${invitationId}:referee`; + const referrerKey = `referral:${invitationId}:referrer`; + expect(await BillingExtraBalanceRepository.findExistingRefIds([refereeKey, referrerKey])).toEqual([]); + + // 3. Re-run handleSignupOrganization exactly as verifyEmail does (A4 convergence — + // the org already exists) → emits organization.provisioned → instant grant. + guest.emailVerified = true; // verifyEmail mutates the local object the same way + await OrganizationsService.handleSignupOrganization(guest); + + // 4. The referee is credited instantly (the listener is async — poll until it settles)… + const refereeEntry = await waitForLedgerEntry(refereeOrgId, refereeKey); + expect(refereeEntry).not.toBeNull(); + expect(refereeEntry).toMatchObject({ kind: 'topup', source: 'referral', amount: REFEREE_UNITS }); + + // …and grantForInvitation back-fills the referrer side too (idempotent both ways). + const admin = await UserService.getBrut({ email: ADMIN_EMAIL }); + const inviterOrgId = String(admin.currentOrganization._id || admin.currentOrganization); + const referrerEntry = await waitForLedgerEntry(inviterOrgId, referrerKey); + expect(referrerEntry).not.toBeNull(); + + // 5. Replay (cron-overlap): the same grant path 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' }); + + // 6. Exactly ONE referee entry — listener + replay never double-credited. + const refereeLedger = await BillingExtraBalanceRepository.findLedgerByOrg(refereeOrgId); + expect(refereeLedger.filter((e) => e.refId === refereeKey)).toHaveLength(1); + }, 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. From eefa8fe7f7aed0ad143e54be02a98dd9e8f916f9 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:24:07 +0200 Subject: [PATCH 5/6] 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 --- modules/billing/billing.init.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/billing/billing.init.js b/modules/billing/billing.init.js index 5b2465554..070924aab 100644 --- a/modules/billing/billing.init.js +++ b/modules/billing/billing.init.js @@ -144,12 +144,12 @@ export default async (app) => { // Ops alerting — real-money events that require immediate human review. // // NOTE: devkit has no ntfy helper; the structured logger is the alert sink here. - // Downstream projects (e.g. trawl_node) wire the actual ntfy push by re-listening - // on the same billingEvents singleton and calling their own ntfy service. + // A downstream consumer wires the actual ntfy push by re-listening on the same + // billingEvents singleton and calling its own ntfy service. // Priority annotations below document the intended ntfy priority for downstream use. // billing.dispute.opened — priority 5 (urgent): 7-day evidence window starts now. - // Downstream projects (e.g. trawl_node) re-listen on billingEvents for ntfy push. + // A downstream consumer re-listens on billingEvents for ntfy push. billingEvents.on('billing.dispute.opened', (payload) => { const { disputeId, chargeId, organizationId, stripeSessionId, amount, reason } = payload; logger.error('[billing.init] ALERT: dispute opened — 7-day evidence window — manual review required', { From b94f736165f650404dce8bb44c57c7585b266ed5 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 18:44:20 +0200 Subject: [PATCH 6/6] fix(invitations): guard findByAcceptedUserId against invalid id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../billing/tests/billing.referral.integration.tests.js | 4 ++-- modules/invitations/repositories/invitations.repository.js | 4 +++- .../invitations/tests/invitations.repository.unit.tests.js | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/billing/tests/billing.referral.integration.tests.js b/modules/billing/tests/billing.referral.integration.tests.js index 3cac5dcfb..df36d4373 100644 --- a/modules/billing/tests/billing.referral.integration.tests.js +++ b/modules/billing/tests/billing.referral.integration.tests.js @@ -218,7 +218,7 @@ describe('Billing referral grant (#3842):', () => { .post(`/api/auth/signup?inviteToken=${token}`) .send({ email: GUEST_VERIFY_EMAIL, password: 'Sup3rStr0ng!' }); expect(res.status).toBe(200); - await new Promise((resolve) => { setTimeout(resolve, 300); }); // let the (disabled) listeners settle + await new Promise((resolve) => { setImmediate(resolve); }); // let the (disabled) listeners settle config.billing.referral.enabled = true; const guest = await UserService.getBrut({ email: GUEST_VERIFY_EMAIL }); @@ -277,7 +277,7 @@ describe('Billing referral grant (#3842):', () => { .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 + await new Promise((resolve) => { setImmediate(resolve); }); // let the (disabled) listener settle config.billing.referral.enabled = true; const admin = await UserService.getBrut({ email: ADMIN_EMAIL }); diff --git a/modules/invitations/repositories/invitations.repository.js b/modules/invitations/repositories/invitations.repository.js index e055e2f45..dc996d294 100644 --- a/modules/invitations/repositories/invitations.repository.js +++ b/modules/invitations/repositories/invitations.repository.js @@ -127,7 +127,9 @@ const findAccepted = () => * @returns {Promise<{_id: Object, invitedBy: (Object|null), acceptedUserId: Object}|null>} */ const findByAcceptedUserId = (userId) => - Invitation.findOne({ status: 'accepted', acceptedUserId: userId }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec(); + (mongoose.Types.ObjectId.isValid(userId) + ? Invitation.findOne({ status: 'accepted', acceptedUserId: userId }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec() + : null); /** * @desc Get one invitation by id diff --git a/modules/invitations/tests/invitations.repository.unit.tests.js b/modules/invitations/tests/invitations.repository.unit.tests.js index 9c0020bd3..d267f12e6 100644 --- a/modules/invitations/tests/invitations.repository.unit.tests.js +++ b/modules/invitations/tests/invitations.repository.unit.tests.js @@ -156,6 +156,13 @@ describe('InvitationRepository', () => { expect(result).toEqual({ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }); }); + test('findByAcceptedUserId returns null for an invalid ObjectId without hitting the DB (#3844)', async () => { + isValid.mockReturnValue(false); + const result = await InvitationRepository.findByAcceptedUserId('not-an-id'); + expect(result).toBeNull(); + expect(InvitationModel.findOne).not.toHaveBeenCalled(); + }); + test('get returns null for an invalid ObjectId without hitting the DB', async () => { isValid.mockReturnValue(false); const result = await InvitationRepository.get('not-an-id');