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
56 changes: 56 additions & 0 deletions modules/billing/services/billing.signupGrant.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Module dependencies
*/
import logger from '../../../lib/services/logger.js';
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
import BillingPlanService from './billing.plan.service.js';

/**
* @desc Service for crediting the one-shot signup grant to fresh organizations.
* Wraps the repository call in a best-effort try/catch so billing failures
* never block the signup path (N2 spec: best-effort).
*/

/**
* @function grantOnSignup
* @description Credit the configured signupGrant to a freshly-created organization.
* - Looks up the plan definition via BillingPlanService.getActivePlan.
* - If the plan has a signupGrant field, delegates to
* BillingExtraBalanceRepository.creditGrant (idempotent via refId).
* - If the plan has no signupGrant, returns null (no-op).
* - Catches all errors and logs them; NEVER throws (best-effort).
*
* @param {Object} params
* @param {string} params.orgId - The organization ObjectId (string).
* @param {string} [params.planId='free'] - The plan identifier. Defaults to 'free' (the
* default fresh-signup plan — non-free signups don't flow through createOrganizationForUser).
* @returns {Promise<Object|null>} Repository result or null (no-op / error swallowed).
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
const grantOnSignup = async ({ orgId, planId = 'free' }) => {
try {
const plan = BillingPlanService.getActivePlan(planId);
if (!plan) {
logger.warn('[billing.signupGrant] plan not found — no grant credited', { orgId, planId });
return null;
}
if (!plan.signupGrant) return null;

const result = await BillingExtraBalanceRepository.creditGrant(orgId, plan.signupGrant, 'signup_grant');
if (result?.applied === false) {
// Idempotent no-op — org already has a signup_grant entry (e.g. admin re-ran or replay).
logger.warn('[billing.signupGrant] duplicate grant skipped (idempotent)', { orgId, planId, reason: result.reason });
Comment on lines +40 to +42
} else {
logger.info('[billing.signupGrant] credited', { orgId, planId, amount: plan.signupGrant, applied: result?.applied });
}
return result;
} catch (err) {
// Best-effort: billing failure MUST NOT fail signup.
logger.error('[billing.signupGrant] failed (best-effort)', { orgId, planId, error: err?.message, stack: err?.stack });
return null;
}
};

export default {
grantOnSignup,
};
120 changes: 120 additions & 0 deletions modules/billing/tests/billing.signupGrant.service.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Module dependencies.
*/
import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals';

/**
* Unit tests for billing.signupGrant.service.js
*/
describe('BillingSignupGrantService unit tests:', () => {
let BillingSignupGrantService;
let mockRepository;
let mockPlanService;
let mockLogger;

const orgId = '507f1f77bcf86cd799439011';

beforeEach(async () => {
jest.resetModules();

mockRepository = {
creditGrant: jest.fn(),
};

mockPlanService = {
getActivePlan: jest.fn(),
};

mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };

jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({
default: mockRepository,
}));

jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
default: mockPlanService,
}));

jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: mockLogger,
}));

const mod = await import('../services/billing.signupGrant.service.js');
BillingSignupGrantService = mod.default;
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('grantOnSignup', () => {
it('credits the configured signupGrant when plan has one', async () => {
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
mockRepository.creditGrant.mockResolvedValue({ doc: {}, applied: true });

await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' });

expect(mockPlanService.getActivePlan).toHaveBeenCalledWith('free');
expect(mockRepository.creditGrant).toHaveBeenCalledWith(orgId, 500, 'signup_grant');
});

it('does nothing when plan has no signupGrant', async () => {
mockPlanService.getActivePlan.mockReturnValue({ planId: 'growth', meterQuota: 1600, ratios: {} });

const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'growth' });

expect(mockRepository.creditGrant).not.toHaveBeenCalled();
expect(result).toBeNull();
});

it('swallows credit errors (best-effort — signup must not fail)', async () => {
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
mockRepository.creditGrant.mockRejectedValue(new Error('db unavailable'));

await expect(
BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' }),
).resolves.toBeNull();

expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('[billing.signupGrant]'),
expect.objectContaining({ orgId, planId: 'free' }),
);
});

it('defaults planId to "free" when not provided', async () => {
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
mockRepository.creditGrant.mockResolvedValue({ doc: {}, applied: true });

await BillingSignupGrantService.grantOnSignup({ orgId });

expect(mockPlanService.getActivePlan).toHaveBeenCalledWith('free');
});

it('returns null and logs warn when plan config is not found', async () => {
mockPlanService.getActivePlan.mockReturnValue(null);

const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'unknown' });

expect(mockRepository.creditGrant).not.toHaveBeenCalled();
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('[billing.signupGrant]'),
expect.objectContaining({ orgId, planId: 'unknown' }),
);
});

it('logs warn on duplicate grant (applied: false — idempotent no-op)', async () => {
mockPlanService.getActivePlan.mockReturnValue({ planId: 'free', signupGrant: 500, oneShot: true, meterQuota: 0, ratios: {} });
mockRepository.creditGrant.mockResolvedValue({ doc: null, applied: false, reason: 'duplicate_grant' });

const result = await BillingSignupGrantService.grantOnSignup({ orgId, planId: 'free' });

expect(result?.applied).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('duplicate grant skipped'),
expect.objectContaining({ orgId, planId: 'free', reason: 'duplicate_grant' }),
);
expect(mockLogger.info).not.toHaveBeenCalled();
});
});
});
12 changes: 10 additions & 2 deletions modules/organizations/services/organizations.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import MembershipService from './organizations.membership.service.js';
import UserService from '../../users/services/users.service.js';
import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js';
import { MEMBERSHIP_ROLES } from '../lib/constants.js';
import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js';

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

let created = false;
try {
organization = await OrganizationsRepository.create({
name,
Expand All @@ -95,8 +97,7 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
});

await UserService.updateById(userId, { currentOrganization: organization._id });

return { organization, membership };
created = true;
} catch (err) {
// Clean up any partially created artifacts to avoid orphaned records
if (membership) {
Expand All @@ -111,6 +112,13 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
}
throw err;
}

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' });
return { organization, membership };
}
}

// Fallback: re-generate slug from scratch if all retries exhausted
Expand Down
43 changes: 43 additions & 0 deletions modules/organizations/tests/organizations.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,49 @@ describe('Organizations integration tests:', () => {
});
});

describe('N2 — signup grant credited on org creation', () => {
let grantUser;
let BillingExtraBalanceRepository;

beforeAll(async () => {
config.organizations = { enabled: false };
BillingExtraBalanceRepository = (await import(path.resolve('./modules/billing/repositories/billing.extraBalance.repository.js'))).default;

// Reuse the top-level agent (same bootstrap instance) — no duplicate Express app.
const res = await agent
.post('/api/auth/signup')
.send({
firstName: 'Grant',
lastName: 'Test',
email: 'signup-grant-test@test.com',
password: 'W@os.jsI$Aw3$0m3',
provider: 'local',
})
.expect(200);
grantUser = res.body.user;
});

test('credits 500 compute to the org ExtraBalance ledger at signup', async () => {
// Resolve the org created for this user
const memberships = await MembershipRepository.list({ userId: grantUser._id || grantUser.id });
expect(memberships.length).toBeGreaterThan(0);
const orgId = (memberships[0].organizationId._id || memberships[0].organizationId).toString();

const balance = await BillingExtraBalanceRepository.getBalance(orgId);
expect(balance).toBe(500);

const ledger = await BillingExtraBalanceRepository.findLedgerByOrg(orgId);
expect(ledger).not.toBeNull();
const grantEntry = ledger.find((e) => e.source === 'signup_grant');
expect(grantEntry).toBeDefined();
expect(grantEntry.amount).toBe(500);
Comment on lines +170 to +183
});

afterAll(async () => {
await cleanupUser(grantUser);
});
Comment on lines +186 to +188
});

// Mongoose disconnect
afterAll(async () => {
config.organizations = { ...originalOrganizations };
Expand Down
Loading