Skip to content

Commit 3a3ca09

Browse files
feat(billing): credit signupGrant on org creation post-signup (#3663)
Adds BillingSignupGrantService.grantOnSignup (best-effort, never throws) and hooks it into createOrganizationForUser outside the rollback catch block. Free orgs receive 500 compute at signup via the idempotent creditGrant method. Warns on duplicate grant (idempotency replay) and missing plan config.
1 parent 41e7bd9 commit 3a3ca09

4 files changed

Lines changed: 229 additions & 2 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import logger from '../../../lib/services/logger.js';
5+
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
6+
import BillingPlanService from './billing.plan.service.js';
7+
8+
/**
9+
* @desc Service for crediting the one-shot signup grant to fresh organizations.
10+
* Wraps the repository call in a best-effort try/catch so billing failures
11+
* never block the signup path (N2 spec: best-effort).
12+
*/
13+
14+
/**
15+
* @function grantOnSignup
16+
* @description Credit the configured signupGrant to a freshly-created organization.
17+
* - Looks up the plan definition via BillingPlanService.getActivePlan.
18+
* - If the plan has a signupGrant field, delegates to
19+
* BillingExtraBalanceRepository.creditGrant (idempotent via refId).
20+
* - If the plan has no signupGrant, returns null (no-op).
21+
* - Catches all errors and logs them; NEVER throws (best-effort).
22+
*
23+
* @param {Object} params
24+
* @param {string} params.orgId - The organization ObjectId (string).
25+
* @param {string} [params.planId='free'] - The plan identifier. Defaults to 'free' (the
26+
* default fresh-signup plan — non-free signups don't flow through createOrganizationForUser).
27+
* @returns {Promise<Object|null>} Repository result or null (no-op / error swallowed).
28+
*/
29+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
30+
const grantOnSignup = async ({ orgId, planId = 'free' }) => {
31+
try {
32+
const plan = BillingPlanService.getActivePlan(planId);
33+
if (!plan) {
34+
logger.warn('[billing.signupGrant] plan not found — no grant credited', { orgId, planId });
35+
return null;
36+
}
37+
if (!plan.signupGrant) return null;
38+
39+
const result = await BillingExtraBalanceRepository.creditGrant(orgId, plan.signupGrant, 'signup_grant');
40+
if (result?.applied === false) {
41+
// Idempotent no-op — org already has a signup_grant entry (e.g. admin re-ran or replay).
42+
logger.warn('[billing.signupGrant] duplicate grant skipped (idempotent)', { orgId, planId, reason: result.reason });
43+
} else {
44+
logger.info('[billing.signupGrant] credited', { orgId, planId, amount: plan.signupGrant, applied: result?.applied });
45+
}
46+
return result;
47+
} catch (err) {
48+
// Best-effort: billing failure MUST NOT fail signup.
49+
logger.error('[billing.signupGrant] failed (best-effort)', { orgId, planId, error: err?.message, stack: err?.stack });
50+
return null;
51+
}
52+
};
53+
54+
export default {
55+
grantOnSignup,
56+
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing.signupGrant.service.js
8+
*/
9+
describe('BillingSignupGrantService unit tests:', () => {
10+
let BillingSignupGrantService;
11+
let mockRepository;
12+
let mockPlanService;
13+
let mockLogger;
14+
15+
const orgId = '507f1f77bcf86cd799439011';
16+
17+
beforeEach(async () => {
18+
jest.resetModules();
19+
20+
mockRepository = {
21+
creditGrant: jest.fn(),
22+
};
23+
24+
mockPlanService = {
25+
getActivePlan: jest.fn(),
26+
};
27+
28+
mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
29+
30+
jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({
31+
default: mockRepository,
32+
}));
33+
34+
jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
35+
default: mockPlanService,
36+
}));
37+
38+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
39+
default: mockLogger,
40+
}));
41+
42+
const mod = await import('../services/billing.signupGrant.service.js');
43+
BillingSignupGrantService = mod.default;
44+
});
45+
46+
afterEach(() => {
47+
jest.restoreAllMocks();
48+
});
49+
50+
describe('grantOnSignup', () => {
51+
it('credits the configured signupGrant when plan has one', async () => {
52+
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
53+
mockRepository.creditGrant.mockResolvedValue({ doc: {}, applied: true });
54+
55+
await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' });
56+
57+
expect(mockPlanService.getActivePlan).toHaveBeenCalledWith('free');
58+
expect(mockRepository.creditGrant).toHaveBeenCalledWith(orgId, 500, 'signup_grant');
59+
});
60+
61+
it('does nothing when plan has no signupGrant', async () => {
62+
mockPlanService.getActivePlan.mockReturnValue({ planId: 'growth', meterQuota: 1600, ratios: {} });
63+
64+
const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'growth' });
65+
66+
expect(mockRepository.creditGrant).not.toHaveBeenCalled();
67+
expect(result).toBeNull();
68+
});
69+
70+
it('swallows credit errors (best-effort — signup must not fail)', async () => {
71+
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
72+
mockRepository.creditGrant.mockRejectedValue(new Error('db unavailable'));
73+
74+
await expect(
75+
BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' }),
76+
).resolves.toBeNull();
77+
78+
expect(mockLogger.error).toHaveBeenCalledWith(
79+
expect.stringContaining('[billing.signupGrant]'),
80+
expect.objectContaining({ orgId, planId: 'free' }),
81+
);
82+
});
83+
84+
it('defaults planId to "free" when not provided', async () => {
85+
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
86+
mockRepository.creditGrant.mockResolvedValue({ doc: {}, applied: true });
87+
88+
await BillingSignupGrantService.grantOnSignup({ orgId });
89+
90+
expect(mockPlanService.getActivePlan).toHaveBeenCalledWith('free');
91+
});
92+
93+
it('returns null and logs warn when plan config is not found', async () => {
94+
mockPlanService.getActivePlan.mockReturnValue(null);
95+
96+
const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'unknown' });
97+
98+
expect(mockRepository.creditGrant).not.toHaveBeenCalled();
99+
expect(result).toBeNull();
100+
expect(mockLogger.warn).toHaveBeenCalledWith(
101+
expect.stringContaining('[billing.signupGrant]'),
102+
expect.objectContaining({ orgId, planId: 'unknown' }),
103+
);
104+
});
105+
106+
it('logs warn on duplicate grant (applied: false — idempotent no-op)', async () => {
107+
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
108+
mockRepository.creditGrant.mockResolvedValue({ doc: null, applied: false, reason: 'duplicate_grant' });
109+
110+
const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' });
111+
112+
expect(result?.applied).toBe(false);
113+
expect(mockLogger.warn).toHaveBeenCalledWith(
114+
expect.stringContaining('duplicate grant skipped'),
115+
expect.objectContaining({ orgId, planId: 'free', reason: 'duplicate_grant' }),
116+
);
117+
expect(mockLogger.info).not.toHaveBeenCalled();
118+
});
119+
});
120+
});

modules/organizations/services/organizations.service.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import MembershipService from './organizations.membership.service.js';
1212
import UserService from '../../users/services/users.service.js';
1313
import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js';
1414
import { MEMBERSHIP_ROLES } from '../lib/constants.js';
15+
import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js';
1516

1617
/**
1718
* @desc Strip sensitive fields from an organization document before returning to public flows.
@@ -79,6 +80,7 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
7980
let membership;
8081
const currentSlug = attempt === 0 ? slug : `${slug}-${attempt}`;
8182

83+
let created = false;
8284
try {
8385
organization = await OrganizationsRepository.create({
8486
name,
@@ -95,8 +97,7 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
9597
});
9698

9799
await UserService.updateById(userId, { currentOrganization: organization._id });
98-
99-
return { organization, membership };
100+
created = true;
100101
} catch (err) {
101102
// Clean up any partially created artifacts to avoid orphaned records
102103
if (membership) {
@@ -111,6 +112,13 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
111112
}
112113
throw err;
113114
}
115+
116+
if (created) {
117+
// N2: best-effort signup grant — called outside the try/catch so billing failure
118+
// never triggers org/membership rollback. grantOnSignup always resolves (never throws).
119+
await BillingSignupGrantService.grantOnSignup({ orgId: organization._id.toString(), planId: 'free' });
120+
return { organization, membership };
121+
}
114122
}
115123

116124
// Fallback: re-generate slug from scratch if all retries exhausted

modules/organizations/tests/organizations.integration.tests.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,49 @@ describe('Organizations integration tests:', () => {
145145
});
146146
});
147147

148+
describe('N2 — signup grant credited on org creation', () => {
149+
let grantUser;
150+
let BillingExtraBalanceRepository;
151+
152+
beforeAll(async () => {
153+
config.organizations = { enabled: false };
154+
BillingExtraBalanceRepository = (await import(path.resolve('./modules/billing/repositories/billing.extraBalance.repository.js'))).default;
155+
156+
// Reuse the top-level agent (same bootstrap instance) — no duplicate Express app.
157+
const res = await agent
158+
.post('/api/auth/signup')
159+
.send({
160+
firstName: 'Grant',
161+
lastName: 'Test',
162+
email: 'signup-grant-test@test.com',
163+
password: 'W@os.jsI$Aw3$0m3',
164+
provider: 'local',
165+
})
166+
.expect(200);
167+
grantUser = res.body.user;
168+
});
169+
170+
test('credits 500 compute to the org ExtraBalance ledger at signup', async () => {
171+
// Resolve the org created for this user
172+
const memberships = await MembershipRepository.list({ userId: grantUser._id || grantUser.id });
173+
expect(memberships.length).toBeGreaterThan(0);
174+
const orgId = (memberships[0].organizationId._id || memberships[0].organizationId).toString();
175+
176+
const balance = await BillingExtraBalanceRepository.getBalance(orgId);
177+
expect(balance).toBe(500);
178+
179+
const ledger = await BillingExtraBalanceRepository.findLedgerByOrg(orgId);
180+
expect(ledger).not.toBeNull();
181+
const grantEntry = ledger.find((e) => e.source === 'signup_grant');
182+
expect(grantEntry).toBeDefined();
183+
expect(grantEntry.amount).toBe(500);
184+
});
185+
186+
afterAll(async () => {
187+
await cleanupUser(grantUser);
188+
});
189+
});
190+
148191
// Mongoose disconnect
149192
afterAll(async () => {
150193
config.organizations = { ...originalOrganizations };

0 commit comments

Comments
 (0)