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
35 changes: 35 additions & 0 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 BillingSignupGrantService from './services/billing.signupGrant.service.js';
import { getAlertThresholdPercents } from './lib/billing.constants.js';
import { setupBillingEmails } from './billing.email.js';

Expand Down Expand Up @@ -132,6 +133,40 @@ export default async (app) => {
}
});

// Signup grant (#3952) — billing is an OPTIONAL consumer of the organizations fire-and-forget
// `organization.created` event (dependency direction billing → organizations, same as
// `organization.provisioned` above: billing imports the events singleton, organizations never
// imports billing). BOTH organization-creation call sites (organizations.crud.service.js::create
// AND organizations.service.js::createOrganizationForUser) emit this instead of calling
// BillingSignupGrantService directly, so organizations stays removable without billing.
// Unlike the two referral listeners above, this one is NOT config-gated — grantOnSignup is core
// signup behavior (it already no-ops when the plan defines no signupGrant).
/**
* @desc One-shot signup grant listener for organization-creation events (#3952).
* Credits the configured signupGrant to the freshly created organization via
* BillingSignupGrantService.grantOnSignup (idempotent via refId `signup_grant-<orgId>`,
* never throws). Self-guarded: `EventEmitter.emit` is synchronous, so the emit-site
* try/catch in organizations only catches SYNC throws — an async rejection escaping
* here would surface as an unhandledRejection. It never does: grantOnSignup already
* swallows its own errors; this wraps it too for defense-in-depth (mirrors the instant
* referee grant listener above).
* @param {{orgId: string, planId: string}} payload - Created organization event payload.
* @returns {Promise<void>} settles when the grant attempt completes (never rejects)
*/
organizationEvents.on('organization.created', async (payload) => {
try {
await BillingSignupGrantService.grantOnSignup({ orgId: payload?.orgId, planId: payload?.planId });
} catch (err) {
// ⚠️ MANDATORY self-guard (see organizations/lib/events.js): never let a rejection escape.
logger.error('[billing] signup grant failed via organization.created listener', {
orgId: String(payload?.orgId ?? ''),
planId: String(payload?.planId ?? ''),
err: err?.message,
stack: err?.stack,
});
}
});

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

const mockApp = {};

Expand Down Expand Up @@ -94,6 +95,13 @@ describe('billing.init unit tests:', () => {
default: mockInvitationRepository,
}));

// #3952: the organization.created listener delegates to BillingSignupGrantService — stub it
// so the wiring tests stay isolated from the real grant/plan/repository resolution.
mockSignupGrantService = { grantOnSignup: jest.fn().mockResolvedValue({ applied: true }) };
jest.unstable_mockModule('../services/billing.signupGrant.service.js', () => ({
default: mockSignupGrantService,
}));

// Stub billing.email so boot validator tests don't wire real email listeners
jest.unstable_mockModule('../billing.email.js', () => ({
setupBillingEmails: jest.fn(),
Expand Down Expand Up @@ -273,6 +281,57 @@ describe('billing.init unit tests:', () => {
});
});

describe('#3952 signup grant listener (organization.created):', () => {
const payload = { orgId: 'org1', planId: 'free' };

/**
* Boot the module and return the registered organization.created handler.
* @returns {Promise<Function>} The wired listener.
*/
const getHandler = async () => {
await billingInit(mockApp);
const createdCall = mockOrganizationEvents.on.mock.calls.find(([evt]) => evt === 'organization.created');
expect(createdCall).toBeDefined();
return createdCall[1];
};

test('wires the listener on the organizations emitter', async () => {
const handler = await getHandler();
expect(typeof handler).toBe('function');
});

test('NOT config-gated — delegates to BillingSignupGrantService.grantOnSignup with no referral config set', async () => {
// mockConfig.billing has NO referral block — unlike the two referral listeners above,
// this one must still fire (grantOnSignup is core signup behavior, not a referral reward).
const handler = await getHandler();
await handler(payload);
expect(mockSignupGrantService.grantOnSignup).toHaveBeenCalledTimes(1);
expect(mockSignupGrantService.grantOnSignup).toHaveBeenCalledWith({ orgId: 'org1', planId: 'free' });
});

test('passes the payload through verbatim (orgId + planId from both org-creation call sites)', async () => {
const handler = await getHandler();
await handler({ orgId: 'org2', planId: 'growth' });
expect(mockSignupGrantService.grantOnSignup).toHaveBeenCalledWith({ orgId: 'org2', planId: 'growth' });
});

test('self-guard: a grant REJECTION is swallowed + logged, never escapes the listener', async () => {
mockSignupGrantService.grantOnSignup.mockRejectedValue(new Error('mongo down'));
const handler = await getHandler();
// The emit-site catch (organizations) 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('signup grant failed via organization.created listener'));
expect(errCall).toBeDefined();
expect(errCall[1]).toMatchObject({ orgId: 'org1', planId: 'free', err: 'mongo down' });
});

test('self-guard: even a malformed payload cannot make the listener throw/reject', async () => {
const handler = await getHandler();
await expect(handler(undefined)).resolves.toBeUndefined();
expect(mockSignupGrantService.grantOnSignup).toHaveBeenCalledWith({ orgId: undefined, planId: undefined });
});
});

test('resolves without error when meterMode=true and no legacy docs', async () => {
mockConfig.billing.meterMode = true;
mockConfig.billing.plans = ['free', 'growth', 'pro'];
Expand Down
12 changes: 12 additions & 0 deletions modules/billing/tests/billing.referral.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ describe('Billing referral grant (#3842):', () => {
.send({ email: ADMIN_EMAIL, password: PASSWORD })
.expect(200);

// #3952: the admin's own org creation also fires `organization.created` → billing's
// fire-and-forget signupGrant listener. Wait for it to settle here so every downstream
// balance-delta assertion (referral credits) starts from a stable, fully-settled baseline.
const adminOrgId = String(brut.currentOrganization?._id || brut.currentOrganization);
const adminGrant = await waitForLedgerEntry(adminOrgId, `signup_grant-${adminOrgId}`);
expect(adminGrant).not.toBeNull();

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return adminAgent;
}

Expand Down Expand Up @@ -165,6 +172,11 @@ describe('Billing referral grant (#3842):', () => {

const inviterBalanceAfter = await BillingExtraBalanceRepository.getBalance(inviterOrgId);
expect(inviterBalanceAfter - inviterBalanceBefore).toBe(REFERRER_UNITS);
// #3952: the referee's own org creation also fires `organization.created` → signupGrant.
// Wait for it to settle so the "unchanged after replay" check below (step 5) has a stable
// snapshot — otherwise a late-landing signup grant could be mistaken for a replay double-credit.
const refereeGrant = await waitForLedgerEntry(refereeOrgId, `signup_grant-${refereeOrgId}`);
expect(refereeGrant).not.toBeNull();
const refereeBalanceAfter = await BillingExtraBalanceRepository.getBalance(refereeOrgId);

// 4. Replay safety (deterministic): a direct service replay is the exact grant path
Expand Down
14 changes: 14 additions & 0 deletions modules/organizations/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ import { EventEmitter } from 'events';
* Singleton emitter for organization events. Config-free / import-safe.
*
* Events:
* - `organization.created` — emitted (#3952) by BOTH org-creation call sites
* (organizations.crud.service.js::create, the generic POST /api/organizations path, AND
* organizations.service.js::createOrganizationForUser, the signup path) immediately after
* the organization + owner membership are durably created. This is the sanctioned seam for
* billing's one-shot signupGrant (billing.init.js), replacing a direct import of
* BillingSignupGrantService from organizations — the organizations module must stay
* removable without billing. Fire-and-forget; the emit-site try/catch only guards a
* SYNCHRONOUS listener throw — see the `organization.provisioned` note below for the
* async-rejection caveat, which applies identically here (the listener owns its own guard).
* Payload: {
* orgId: String — the freshly created organization's id
* planId: String — the plan to evaluate the grant against (both call sites pass 'free' —
* the only plan a fresh org can have at creation time)
* }
* - `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
Expand Down
20 changes: 12 additions & 8 deletions modules/organizations/services/organizations.crud.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const normalizeDomain = (value = '') => value.trim().toLowerCase();
import OrganizationsRepository from '../repositories/organizations.repository.js';
import MembershipRepository from '../repositories/organizations.membership.repository.js';
import UserService from '../../users/services/users.service.js';
import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js';
import organizationEvents from '../lib/events.js';
import { slugify } from '../helpers/organizations.slug.js';
import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES } from '../lib/constants.js';
import { runOrganizationRemovedHandlers } from '../lib/orgRemoval.registry.js';
Expand Down Expand Up @@ -121,13 +121,17 @@ const create = async (body, user) => {
throw err;
}

// Best-effort signup grant — mirrors organizations.service.js::createOrganizationForUser.
// Every org-creation path (including this generic one behind POST /api/organizations)
// must credit the configured one-shot signupGrant, else a fresh org on a plan that
// defines one starts at 0 balance. Called outside the rollback try/catch so a billing
// failure never rolls back the org; grantOnSignup never throws and is idempotent
// (refId signup_grant-<orgId>). No-op when the plan defines no signupGrant.
await BillingSignupGrantService.grantOnSignup({ orgId: result._id.toString(), planId: result.plan || 'free' });
// organization.created (#3952) — mirrors organizations.service.js::createOrganizationForUser.
// Billing subscribes from billing.init.js and credits the configured one-shot signupGrant,
// else a fresh org on a plan that defines one starts at 0 balance. Emitted outside the
// rollback try/catch so a billing failure never rolls back the org; the listener owns its
// idempotence (refId signup_grant-<orgId>) and never throws. Fire-and-forget, synchronous
// emit — the try/catch here only guards a SYNCHRONOUS listener throw (see ../lib/events.js).
try {
organizationEvents.emit('organization.created', { orgId: result._id.toString(), planId: result.plan || 'free' });
} catch (err) {
logger.warn('organizations: organization.created listener threw', { message: err?.message });
}

return result;
};
Expand Down
20 changes: 14 additions & 6 deletions modules/organizations/services/organizations.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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';

/**
Expand Down Expand Up @@ -96,9 +95,17 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
}

if (created) {
// N2: best-effort signup grant — called outside the try/catch so billing failure
// never triggers org/membership rollback. grantOnSignup always resolves (never throws).
await BillingSignupGrantService.grantOnSignup({ orgId: organization._id.toString(), planId: 'free' });
// organization.created (#3952) — N2 signup grant moved off a direct billing import onto
// this event; billing subscribes from billing.init.js and credits the one-shot signupGrant.
// Emitted outside the try/catch above so billing failure never triggers org/membership
// rollback; the listener owns its own guard (never throws/rejects out). Fire-and-forget,
// synchronous emit — this try/catch only guards a SYNCHRONOUS listener throw (mirrors
// emitProvisioned below / see ../lib/events.js).
try {
organizationEvents.emit('organization.created', { orgId: organization._id.toString(), planId: 'free' });
} catch (err) {
logger.warn('organizations: organization.created listener threw', { message: err?.message });
}
return { organization, membership };
}
}
Expand All @@ -124,8 +131,9 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
* If domainMatching is on, the user's email domain is not public, and an existing org matches,
* a `suggestedJoin: { orgId, orgName }` hint (name-only) is returned alongside the new org.
*
* signupGrant: credited inside createOrganizationForUser (best-effort, never throws). Called
* exactly once per real new org — no double-credit possible.
* signupGrant: createOrganizationForUser emits `organization.created` (#3952) exactly once per
* real new org — no double-credit possible; billing's subscriber (billing.init.js) credits the
* grant asynchronously, best-effort, never throwing back into this flow.
*
* @param {Object} user - The user object returned by UserService.create (with id, email, firstName, lastName).
* @returns {Promise<{organization: Object|null, membership: Object|null, abilities: Array, emailVerificationRequired?: boolean, suggestedJoin?: {orgId: string, orgName: string}}>}
Expand Down
41 changes: 26 additions & 15 deletions modules/organizations/tests/organizations.crud.grant.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
import { jest, describe, test, expect, beforeEach } from '@jest/globals';

/**
* Unit tests — verify organizations.crud.service.create() credits the one-shot
* signup grant on the generic POST /api/organizations path, mirroring the
* invite/verify path (organizations.service.js::createOrganizationForUser). A
* fresh org on a plan that defines a signupGrant must not start at 0 balance.
* Unit tests — verify organizations.crud.service.create() emits `organization.created` on
* the generic POST /api/organizations path, mirroring the invite/verify path
* (organizations.service.js::createOrganizationForUser). Billing subscribes to this event
* from billing.init.js and credits the one-shot signupGrant (#3952) — organizations no
* longer imports the billing module directly (see modules/billing/tests/billing.init.unit.tests.js
* for the subscriber-side coverage).
*/

const mockGrantOnSignup = jest.fn().mockResolvedValue({ applied: true });
jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({
default: { grantOnSignup: mockGrantOnSignup },
const mockEmit = jest.fn();
jest.unstable_mockModule('../lib/events.js', () => ({
default: { emit: mockEmit, on: jest.fn() },
}));

const mockOrgCreate = jest.fn().mockResolvedValue({ _id: 'org1', plan: 'free' });
Expand Down Expand Up @@ -64,34 +66,43 @@ jest.unstable_mockModule('../../../config/index.js', () => ({

const { default: OrgCrudService } = await import('../services/organizations.crud.service.js');

describe('organizations.crud.service.create signup grant:', () => {
describe('organizations.crud.service.create organization.created emit:', () => {
beforeEach(() => {
mockGrantOnSignup.mockClear();
mockEmit.mockClear();
mockOrgCreate.mockResolvedValue({ _id: 'org1', plan: 'free' });
});

test('credits the one-shot signup grant for the freshly created org', async () => {
test('emits organization.created for the freshly created org', async () => {
const user = { id: 'u1', _id: 'u1', email: 'a@b.com', emailVerified: true };
const result = await OrgCrudService.create({ name: 'Test Org' }, user);

expect(result).toEqual({ _id: 'org1', plan: 'free' });
expect(mockGrantOnSignup).toHaveBeenCalledTimes(1);
expect(mockGrantOnSignup).toHaveBeenCalledWith({ orgId: 'org1', planId: 'free' });
expect(mockEmit).toHaveBeenCalledTimes(1);
expect(mockEmit).toHaveBeenCalledWith('organization.created', { orgId: 'org1', planId: 'free' });
});

test('defaults planId to free when the created org has no plan field', async () => {
mockOrgCreate.mockResolvedValueOnce({ _id: 'org2' });
const user = { id: 'u1', _id: 'u1', email: 'a@b.com', emailVerified: true };
await OrgCrudService.create({ name: 'No Plan Org' }, user);

expect(mockGrantOnSignup).toHaveBeenCalledWith({ orgId: 'org2', planId: 'free' });
expect(mockEmit).toHaveBeenCalledWith('organization.created', { orgId: 'org2', planId: 'free' });
});

test('does not credit the grant when membership creation fails (rollback exits before the grant)', async () => {
test('does not emit when membership creation fails (rollback exits before the emit)', async () => {
mockMembershipCreate.mockRejectedValueOnce(new Error('membership boom'));
const user = { id: 'u1', _id: 'u1', email: 'a@b.com', emailVerified: true };

await expect(OrgCrudService.create({ name: 'Rollback Org' }, user)).rejects.toThrow();
expect(mockGrantOnSignup).not.toHaveBeenCalled();
expect(mockEmit).not.toHaveBeenCalled();
});

test('a SYNCHRONOUS listener throw is swallowed — org creation still succeeds', async () => {
mockEmit.mockImplementationOnce(() => { throw new Error('listener exploded'); });
const user = { id: 'u1', _id: 'u1', email: 'a@b.com', emailVerified: true };

const result = await OrgCrudService.create({ name: 'Test Org' }, user);

expect(result).toEqual({ _id: 'org1', plan: 'free' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({
generateOrganizationSlug: jest.fn().mockResolvedValue('test-slug'),
}));

const mockBillingGrantOnSignup = jest.fn().mockResolvedValue(undefined);
jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({
default: { grantOnSignup: mockBillingGrantOnSignup },
}));

// --- Dynamic imports after mocks ---

const { default: OrganizationsService } = await import('../services/organizations.service.js');
Expand Down
Loading
Loading