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
55 changes: 52 additions & 3 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:<invitationId>:*`),
* 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<void>} 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 {
Expand All @@ -95,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', {
Expand Down
117 changes: 117 additions & 0 deletions modules/billing/tests/billing.init.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ describe('billing.init unit tests:', () => {
let mockLogger;
let mockInvitationEvents;
let mockReferralService;
let mockOrganizationEvents;
let mockUserService;
let mockInvitationRepository;

const mockApp = {};

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Function>} 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'];
Expand Down
65 changes: 63 additions & 2 deletions modules/billing/tests/billing.referral.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('Billing referral grant (#3842):', () => {
let BillingExtraBalanceRepository;
let BillingReferralService;
let invitationEvents;
let OrganizationsService;

let originalUp;
let originalCap;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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) => { setImmediate(resolve); }); // 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.
Expand All @@ -216,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 });
Expand Down
16 changes: 15 additions & 1 deletion modules/invitations/repositories/invitations.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ 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:<invitationId>:*` 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) =>
(mongoose.Types.ObjectId.isValid(userId)
? Invitation.findOne({ status: 'accepted', acceptedUserId: userId }, { invitedBy: 1, acceptedUserId: 1 }).lean().exec()
: null);

/**
* @desc Get one invitation by id
* @param {String} id
Expand Down Expand Up @@ -151,4 +165,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 };
Loading
Loading