-
-
Notifications
You must be signed in to change notification settings - Fork 10
fix(billing): credit signupGrant on the generic org-creation path #3949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
72e7033
fix(billing): credit signupGrant on the generic org-creation path
PierreBrisorgueil 6dd619b
fix(billing): route the signup-grant backfill through the runtime gra…
PierreBrisorgueil 04c2512
test(billing): assert grant is skipped when membership creation fails
PierreBrisorgueil File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
97 changes: 97 additions & 0 deletions
97
modules/billing/migrations/20260707100000-backfill-missing-signup-grant-credits.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * Migration: Backfill the one-shot signupGrant to plan orgs created without it. | ||
| * | ||
| * The signupGrant was historically credited only on the invite/verify | ||
| * org-creation path (organizations.service.js::createOrganizationForUser). The | ||
| * generic path behind POST /api/organizations (organizations.crud.service.js) | ||
| * never credited it, so orgs created that way — e.g. via a manual "create | ||
| * workspace" screen — started at 0 balance. The code gap is fixed alongside | ||
| * this migration; this repairs already-affected orgs. | ||
| * | ||
| * Delegates to BillingSignupGrantService.grantOnSignup per org so it goes | ||
| * through the EXACT runtime grant path rather than re-implementing the ledger | ||
| * write. That reuse is deliberate — it inherits, for free and consistently: | ||
| * - ObjectId casting: the repository writes via the Mongoose model, whose | ||
| * `organization` field is Schema.ObjectId, so the string orgId is cast to a | ||
| * real ObjectId (a raw-driver write keyed on a string would create orphan, | ||
| * app-invisible documents that never match existing ones). | ||
| * - the plan-definition co-presence guard (BillingPlanService.getActivePlan | ||
| * refuses a signupGrant configured without oneShot). | ||
| * - Zod amount validation (creditGrant → ExtraBalanceCreditGrant.parse). | ||
| * - idempotency via the synthetic refId `signup_grant-<orgId>`, so an org | ||
| * already credited (at signup, by the earlier 20260511 backfill, or by a | ||
| * prior run of this one) is a no-op. | ||
| * | ||
| * Config-driven: only orgs on a plan that defines a positive signupGrant are | ||
| * touched; a project that configures none is a no-op. This generalises the | ||
| * earlier 20260511 backfill, which hardcoded 500 and enumerated the | ||
| * `subscriptions` collection (missing free orgs with no subscription document); | ||
| * this one enumerates the `organizations` collection. | ||
| * | ||
| * Safe to run while the app is live: each grant is a single-document atomic | ||
| * ledger push and the migration runner serialises execution via a DB-level claim. | ||
| */ | ||
| import mongoose from 'mongoose'; | ||
| import config from '../../../config/index.js'; | ||
| import BillingSignupGrantService from '../services/billing.signupGrant.service.js'; | ||
|
|
||
| /** | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function up() { | ||
| // Plans that define a positive signupGrant — the only orgs worth scanning. | ||
| const grantPlanIds = (config?.billing?.planDefinitions ?? []) | ||
| .filter((def) => def?.planId && typeof def.signupGrant === 'number' && def.signupGrant > 0) | ||
| .map((def) => def.planId); | ||
|
|
||
| if (grantPlanIds.length === 0) { | ||
| console.info('[migration] backfill-signup-grant: no plan defines a signupGrant — nothing to do'); | ||
| return; | ||
| } | ||
|
|
||
| const organizations = mongoose.connection.db.collection('organizations'); | ||
| const cursor = organizations.find( | ||
| { plan: { $in: grantPlanIds } }, | ||
| { projection: { _id: 1, plan: 1 } }, | ||
| ); | ||
|
|
||
| let granted = 0; | ||
| let skipped = 0; | ||
| let failed = 0; | ||
|
|
||
| for await (const org of cursor) { | ||
| // grantOnSignup is idempotent (refId) and never throws — reuses the runtime | ||
| // path so ObjectId casting + validation + co-presence guard all apply. | ||
| const result = await BillingSignupGrantService.grantOnSignup({ | ||
| orgId: org._id.toString(), | ||
| planId: org.plan, | ||
| }); | ||
|
|
||
| if (result == null) { | ||
| // Plan has no exposed grant (co-presence guard) or a swallowed error — logged by the service. | ||
| failed += 1; | ||
| } else if (result.applied === false) { | ||
| // Idempotent no-op — org already had a signup_grant entry. | ||
| skipped += 1; | ||
| } else { | ||
| granted += 1; | ||
| } | ||
| } | ||
|
|
||
| console.info(`[migration] backfill-signup-grant: complete — granted=${granted} skipped=${skipped} failed=${failed}`); | ||
| } | ||
|
|
||
| /** | ||
| * Reverse: intentional no-op. | ||
| * | ||
| * This migration shares the `signup_grant` refId scheme with the app's runtime | ||
| * grant and the earlier 20260511 backfill, so signup_grant ledger entries cannot | ||
| * be attributed to this migration specifically. A blanket removal would revert | ||
| * legitimately-earned grants too. Reverting is left to a manual, audited | ||
| * operation if ever required. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| export async function down() { | ||
| console.warn('[migration] backfill-signup-grant DOWN: intentional no-op — signup_grant entries are not attributable to this migration; revert manually if required.'); | ||
| } |
104 changes: 104 additions & 0 deletions
104
modules/billing/tests/billing.migration.backfill-signup-grant.integration.tests.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| /** | ||
| * Module dependencies. | ||
| */ | ||
| import mongoose from 'mongoose'; | ||
| import { describe, beforeAll, afterEach, afterAll, test, expect } from '@jest/globals'; | ||
|
|
||
| import mongooseService from '../../../lib/services/mongoose.js'; | ||
| import config from '../../../config/index.js'; | ||
| import { up as backfillSignupGrants } from '../migrations/20260707100000-backfill-missing-signup-grant-credits.js'; | ||
|
|
||
| /** | ||
| * Integration tests for the signup-grant backfill migration. | ||
| * | ||
| * Regression guard for the string-vs-ObjectId bug: billingextrabalances.organization | ||
| * is Schema.ObjectId, so the migration MUST credit orgs through a path that casts to | ||
| * ObjectId (it delegates to grantOnSignup → the Mongoose repository) — a raw-driver | ||
| * write keyed on a string would create orphan, app-invisible ghost documents. | ||
| */ | ||
| describe('backfill-missing-signup-grant migration (integration):', () => { | ||
| let organizations; | ||
| let extraBalances; | ||
| let grantPlanId; | ||
| let grantAmount; | ||
| let createdOrgIds = []; | ||
|
|
||
| /** | ||
| * Seed an organization on the given plan and track it for cleanup. | ||
| * @param {string} plan - The plan id to set on the org. | ||
| * @returns {Promise<import('mongoose').Types.ObjectId>} The new org ObjectId. | ||
| */ | ||
| const seedOrg = async (plan) => { | ||
| const _id = new mongoose.Types.ObjectId(); | ||
| await organizations.insertOne({ _id, name: `IT ${_id.toString()}`, plan }); | ||
| createdOrgIds.push(_id); | ||
| return _id; | ||
| }; | ||
|
|
||
| beforeAll(async () => { | ||
| await mongooseService.loadModels(); | ||
| await mongooseService.connect(); | ||
| organizations = mongoose.connection.db.collection('organizations'); | ||
| extraBalances = mongoose.connection.db.collection('billingextrabalances'); | ||
| const def = (config?.billing?.planDefinitions ?? []).find( | ||
| (d) => typeof d.signupGrant === 'number' && d.signupGrant > 0, | ||
| ); | ||
| grantPlanId = def.planId; | ||
| grantAmount = def.signupGrant; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| if (createdOrgIds.length) { | ||
| await organizations.deleteMany({ _id: { $in: createdOrgIds } }); | ||
| await extraBalances.deleteMany({ organization: { $in: createdOrgIds } }); | ||
| createdOrgIds = []; | ||
| } | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await mongooseService.disconnect(); | ||
| }); | ||
|
|
||
| test('credits the configured grant to an ObjectId-keyed org missing it, readable by ObjectId', async () => { | ||
| const orgId = await seedOrg(grantPlanId); | ||
|
|
||
| await backfillSignupGrants(); | ||
|
|
||
| // Stored + readable keyed on the ObjectId — the critical-fix assertion. | ||
| const eb = await extraBalances.findOne({ organization: orgId }); | ||
| expect(eb).not.toBeNull(); | ||
| expect(eb.cachedBalance).toBe(grantAmount); | ||
| const grant = (eb.ledger || []).find((e) => e.source === 'signup_grant'); | ||
| expect(grant).toBeDefined(); | ||
| expect(grant.amount).toBe(grantAmount); | ||
| expect(grant.refId).toBe(`signup_grant-${orgId.toString()}`); | ||
| // A string-keyed lookup must NOT find a duplicate ghost document. | ||
| expect(await extraBalances.findOne({ organization: orgId.toString() })).toBeNull(); | ||
| }); | ||
|
|
||
| test('is idempotent — a second run does not double-credit', async () => { | ||
| const orgId = await seedOrg(grantPlanId); | ||
|
|
||
| await backfillSignupGrants(); | ||
| await backfillSignupGrants(); | ||
|
|
||
| const docs = await extraBalances.find({ organization: orgId }).toArray(); | ||
| expect(docs).toHaveLength(1); | ||
| expect(docs[0].cachedBalance).toBe(grantAmount); | ||
| const grants = (docs[0].ledger || []).filter((e) => e.source === 'signup_grant'); | ||
| expect(grants).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('skips an org that already holds a signup_grant entry', async () => { | ||
| const orgId = await seedOrg(grantPlanId); | ||
| const repo = (await import('../repositories/billing.extraBalance.repository.js')).default; | ||
| await repo.creditGrant(orgId.toString(), grantAmount, 'signup_grant'); | ||
|
|
||
| await backfillSignupGrants(); | ||
|
|
||
| const docs = await extraBalances.find({ organization: orgId }).toArray(); | ||
| expect(docs).toHaveLength(1); | ||
| const grants = (docs[0].ledger || []).filter((e) => e.source === 'signup_grant'); | ||
| expect(grants).toHaveLength(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
97 changes: 97 additions & 0 deletions
97
modules/organizations/tests/organizations.crud.grant.unit.tests.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * Module dependencies. | ||
| */ | ||
| 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. | ||
| */ | ||
|
|
||
| const mockGrantOnSignup = jest.fn().mockResolvedValue({ applied: true }); | ||
| jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({ | ||
| default: { grantOnSignup: mockGrantOnSignup }, | ||
| })); | ||
|
|
||
| const mockOrgCreate = jest.fn().mockResolvedValue({ _id: 'org1', plan: 'free' }); | ||
| const mockOrgRemove = jest.fn().mockResolvedValue({}); | ||
| jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ | ||
| default: { | ||
| create: mockOrgCreate, | ||
| findOne: jest.fn().mockResolvedValue(null), | ||
| remove: mockOrgRemove, | ||
| list: jest.fn().mockResolvedValue([]), | ||
| update: jest.fn(), | ||
| get: jest.fn(), | ||
| exists: jest.fn().mockResolvedValue(false), | ||
| }, | ||
| })); | ||
|
|
||
| const mockMembershipCreate = jest.fn().mockResolvedValue({ _id: 'm1' }); | ||
| jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ | ||
| default: { | ||
| create: mockMembershipCreate, | ||
| deleteMany: jest.fn(), | ||
| list: jest.fn().mockResolvedValue([]), | ||
| findOne: jest.fn().mockResolvedValue(null), | ||
| update: jest.fn(), | ||
| remove: jest.fn(), | ||
| count: jest.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| jest.unstable_mockModule('../../users/services/users.service.js', () => ({ | ||
| default: { | ||
| updateById: jest.fn().mockResolvedValue({}), | ||
| findWithFilter: jest.fn().mockResolvedValue([]), | ||
| getBrut: jest.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| jest.unstable_mockModule('../../../lib/helpers/emailVerification.js', () => ({ | ||
| assertEmailVerified: jest.fn(), | ||
| })); | ||
|
|
||
| jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ | ||
| default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, | ||
| })); | ||
|
|
||
| jest.unstable_mockModule('../../../config/index.js', () => ({ | ||
| default: { organizations: { enabled: false } }, | ||
| })); | ||
|
|
||
| const { default: OrgCrudService } = await import('../services/organizations.crud.service.js'); | ||
|
|
||
| describe('organizations.crud.service.create signup grant:', () => { | ||
| beforeEach(() => { | ||
| mockGrantOnSignup.mockClear(); | ||
| mockOrgCreate.mockResolvedValue({ _id: 'org1', plan: 'free' }); | ||
| }); | ||
|
|
||
| test('credits the one-shot signup grant 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' }); | ||
| }); | ||
|
|
||
| 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' }); | ||
| }); | ||
|
|
||
| test('does not credit the grant when membership creation fails (rollback exits before the grant)', 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(); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.