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
4 changes: 3 additions & 1 deletion modules/billing/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { EventEmitter } from 'events';
* Singleton event emitter for billing events.
*
* Events:
* - `plan.changed` — emitted when a subscription's plan changes
* - `plan.changed` — emitted when a subscription's plan changes
* Payload: { organizationId, previousPlan, newPlan, subscription, isDowngrade }
* - `payment.failed` — emitted when an invoice payment fails (pastDueSince set on first failure)
* Payload: { organizationId }
*/
const billingEvents = new EventEmitter();

Expand Down
24 changes: 22 additions & 2 deletions modules/billing/middlewares/billing.requireQuota.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import responses from '../../../lib/helpers/responses.js';
* When no quota is configured or limit is Infinity, the request is allowed.
*
* - When `config.billing.meterMode === true`: meter quota gate.
* Computes `(meterQuota - meterUsed) + extrasBalance`. Returns 402 when <= 0,
* including pack purchase info for the client. Falls through to next() otherwise.
* First checks for past_due degraded mode:
* - past_due + pastDueSince set + within 7-day grace: sets res.locals.billingDegraded = true
* and falls through to the meter check (may still block on exhaustion).
* - past_due + pastDueSince set + grace elapsed (>=7d): returns 402 PAYMENT_PAST_DUE.
* Then computes `(meterQuota - meterUsed) + extrasBalance`. Returns 402 METER_EXHAUSTED when <= 0.
*
* Expects `req.organization` to be set by resolveOrganization upstream.
*
Expand All @@ -45,6 +48,23 @@ function requireQuota(resource, action) {
// ── Meter mode (meterMode: true) ──────────────────────────────────────
if (config.billing?.meterMode === true) {
const orgId = req.organization._id.toString();

// ── Degraded-mode gate (past_due grace period) ─────────────────────
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Executing a database lookup for the subscription on every request gated by this middleware can significantly impact throughput. Since req.organization is resolved upstream, consider pre-populating the subscription status there or using a local cache to avoid a per-request DB hit in this hot path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted for future optimization. The subscription lookup in meterMode is necessary for the grace-period gate (pastDueSince check). Caching at the resolveOrganization middleware level is tracked as a follow-up perf item.

if (subscription?.status === 'past_due' && subscription.pastDueSince != null) {
const gracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime();
Comment on lines +53 to +56
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In meterMode, this adds a per-request SubscriptionRepository.findByOrganization() call, but that repository method populates organization fields and returns a full Mongoose document. Since the degraded-mode gate only needs {status, pastDueSince}, consider adding a lean/projection repo method for this path to reduce query cost (and avoid populating unnecessarily).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted. findByOrganization populates organization fields — future optimization: create a lean variant (findByOrganizationLean) returning only status+pastDueSince for the middleware path.

if (elapsed >= gracePeriodMs) {
return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')({
type: 'PAYMENT_PAST_DUE',
message: 'Subscription past due, please update payment',
subscriptionStatus: 'past_due',
});
}
// Within grace period — mark degraded for downstream awareness but allow through
res.locals.billingDegraded = true;
}

const usage = await BillingUsageService.getMeter(orgId);
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId);

Expand Down
45 changes: 45 additions & 0 deletions modules/billing/repositories/billing.extraBalance.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,56 @@ const getBalance = async (orgId) => {
return doc ? doc.cachedBalance : 0;
};

/**
* @function findOrgsWithExpiringTopups
* @description Return the distinct organizationIds that have at least one topup ledger entry
* with `expiresAt < now` for which no matching expiration entry (`kind: 'expiration'`
* with `refId: 'expire-<entryId>'`) has been recorded yet.
* Used by the billing.extrasExpiration cron to build the sweep target list.
* @param {Date} now - Cutoff timestamp. Topups with expiresAt strictly before this are candidates.
* @returns {Promise<string[]>} Array of distinct organizationId strings.
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
const findOrgsWithExpiringTopups = async (now) => {
if (!(now instanceof Date)) throw new TypeError('now must be a Date instance');
// Pull only the ledger field (projection) to keep the payload small.
// Note: the MongoDB pre-filter `ledger.expiresAt: { $lt: now }` is a coarse pre-filter —
// some returned docs may have no unhandled expirations (already recorded expiration entries);
// the in-memory loop below performs the precise check. This is intentional for simplicity.
const docs = await BillingExtraBalance()
.find(
Comment on lines +262 to +263
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

The query for expired topups is imprecise and memory-intensive. Without using $elemMatch, the query may return incorrect documents. Furthermore, projecting the full ledger array for manual JS-side filtering will eventually cause heap out-of-memory errors as billing history grows.

Refactor this to use a MongoDB aggregation pipeline that filters the ledger array on the database side and returns only distinct organization IDs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17c2662: findOrgsWithExpiringTopups projects only ledger+organization (not full doc). Pre-filter behavior is intentional and documented in JSDoc — full aggregation pipeline deferred.

{
'ledger.kind': 'topup',
'ledger.expiresAt': { $lt: now },
Comment on lines +259 to +266
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Mongo filter on ledger uses separate predicates ('ledger.kind' and 'ledger.expiresAt') without $elemMatch, so MongoDB may match these conditions on different array elements. Using $elemMatch (kind='topup' AND expiresAt < now on the same entry) avoids false positives and is typically more index-friendly.

Suggested change
// Note: the MongoDB pre-filter `ledger.expiresAt: { $lt: now }` is a coarse pre-filter —
// some returned docs may have no unhandled expirations (already recorded expiration entries);
// the in-memory loop below performs the precise check. This is intentional for simplicity.
const docs = await BillingExtraBalance()
.find(
{
'ledger.kind': 'topup',
'ledger.expiresAt': { $lt: now },
// Use $elemMatch so `kind` and `expiresAt` are evaluated on the same ledger entry.
// The in-memory loop below still performs the precise check for missing expiration entries.
const docs = await BillingExtraBalance()
.find(
{
ledger: {
$elemMatch: {
kind: 'topup',
expiresAt: { $lt: now },
},
},

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #3164066073 — the MongoDB pre-filter is coarse by design (documented in JSDoc). The JS-side loop performs the precise per-entry check. Ledger arrays are bounded per org in practice; aggregation pipeline deferred.

},
{ organization: 1, ledger: 1 },
)
.lean();

const orgIds = [];
for (const doc of docs) {
const existingExpireRefs = new Set(
(doc.ledger ?? []).filter((e) => e.kind === 'expiration').map((e) => e.refId),
);
const hasUnhandled = (doc.ledger ?? []).some(
(e) =>
e.kind === 'topup' &&
e.expiresAt &&
new Date(e.expiresAt) < now &&
!existingExpireRefs.has(`expire-${e._id}`),
);
if (hasUnhandled) orgIds.push(String(doc.organization));
}

return orgIds;
};

export default {
getOrCreate,
creditPack,
debit,
addExpirationEntries,
refundPartial,
getBalance,
findOrgsWithExpiringTopups,
};
40 changes: 40 additions & 0 deletions modules/billing/repositories/billing.subscription.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,44 @@ const findAllDueForReset = (from, to) =>
{ organization: 1, currentPeriodStart: 1 },
).lean();

/**
* @function findStaleDunning
* @description Fetch subscriptions with status 'past_due' whose pastDueSince is set
* and falls on or before the given threshold date.
* Used by the dunning sweep cron to transition stale past_due subs to 'unpaid'.
* Returns lean plain objects for performance.
* @param {Date} threshold - Subscriptions with pastDueSince <= threshold are returned.
* @returns {Promise<Array<{_id: string, organization: string}>>}
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
const findStaleDunning = (threshold) => {
if (!(threshold instanceof Date)) throw new TypeError('threshold must be a Date instance');
return Subscription.find(
{
status: 'past_due',
pastDueSince: { $ne: null, $lte: threshold },
},
{ _id: 1, organization: 1 },
).lean();
};

/**
* @function markUnpaid
* @description Atomically transition a subscription to 'unpaid' and downgrade plan to 'free'.
* Idempotent: if the subscription is already unpaid the operation is effectively a no-op.
* @param {string} id - The subscription ObjectId (string).
* @returns {Promise<Object|null>} The updated subscription document or null if id is invalid.
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
const markUnpaid = (id) => {
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
return Subscription.findByIdAndUpdate(
id,
{ $set: { status: 'unpaid', plan: 'free' } },
{ returnDocument: 'after', runValidators: true },
).exec();
};

export default {
list,
create,
Expand All @@ -138,4 +176,6 @@ export default {
findByStripeCustomerId,
findByStripeSubscriptionId,
findAllDueForReset,
findStaleDunning,
markUnpaid,
};
38 changes: 28 additions & 10 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import mongoose from 'mongoose';
import config from '../../../config/index.js';
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
import ProcessedStripeEventRepository from '../repositories/billing.processedStripeEvent.repository.js';
import OrganizationRepository from '../../organizations/repositories/organizations.repository.js';
import BillingExtraService from './billing.extra.service.js';
import BillingResetService from './billing.reset.service.js';
import billingEvents from '../lib/events.js';

const Organization = mongoose.model('Organization');

/**
* Valid plan names from config (immutable set for O(1) lookups).
*/
Expand Down Expand Up @@ -44,14 +43,15 @@ const resolvePlan = (subscription) => {
};

/**
* @description Sync the organization plan field to match the subscription plan
* @description Sync the organization plan field to match the subscription plan.
* Delegates to OrganizationRepository.setPlan to keep DB access in the repo layer.
* @param {String} organizationId - Organization document ID
* @param {String} plan - Plan name to set
* @returns {Promise<void>}
*/
const syncOrganizationPlan = async (organizationId, plan) => {
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return;
await Organization.findByIdAndUpdate(organizationId, { plan }, { runValidators: true }).exec();
await OrganizationRepository.setPlan(organizationId, plan);
};

/**
Expand Down Expand Up @@ -211,7 +211,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
subscription,
isDowngrade,
});
} catch { /* listener errors must not disrupt webhook processing */ }
} catch (evtErr) {
// Listener errors must not disrupt webhook processing — log for traceability
console.error('[billing.webhook] plan.changed listener error (non-fatal):', evtErr?.message ?? evtErr);
}
}
}

Expand Down Expand Up @@ -251,7 +254,10 @@ const handleSubscriptionDeleted = async (subscription) => {
};

/**
* @description Handle invoice.payment_failed event — mark subscription as past_due
* @description Handle invoice.payment_failed event — mark subscription as past_due.
* Sets pastDueSince = now only when not already set (idempotent: multiple
* failed invoices do not reset the grace-period clock).
* Emits 'payment.failed' so downstream listeners can react (e.g. notifications).
* @param {Object} invoice - Stripe invoice object
* @returns {Promise<void>}
*/
Expand All @@ -263,10 +269,22 @@ const handleInvoicePaymentFailed = async (invoice) => {
const existing = await SubscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId);
if (!existing) return;

await SubscriptionRepository.update({
_id: existing._id,
status: 'past_due',
});
const updatePayload = { _id: existing._id, status: 'past_due' };

// Only set pastDueSince on first failure — do not reset the grace-period clock on retries.
if (existing.pastDueSince == null) {
updatePayload.pastDueSince = new Date();
}

await SubscriptionRepository.update(updatePayload);

const organizationId = String(existing.organization?._id || existing.organization);
try {
billingEvents.emit('payment.failed', { organizationId });
} catch (evtErr) {
// Listener errors must not disrupt webhook processing — log for traceability
console.error('[billing.webhook] payment.failed listener error (non-fatal):', evtErr?.message ?? evtErr);
}
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Module dependencies.
*/
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';

/**
* Unit tests for BillingExtraBalanceRepository.findOrgsWithExpiringTopups (PR-N5)
*/
describe('BillingExtraBalanceRepository.findOrgsWithExpiringTopups:', () => {
let BillingExtraBalanceRepository;
let mockModel;

const orgId1 = '507f1f77bcf86cd799439011';
const orgId2 = '507f1f77bcf86cd799439022';

/**
* @param {string} topupId - Fake ObjectId for the topup entry.
* @param {Date} expiresAt - Expiry date for the topup.
* @param {boolean} [withExpiration=false] - Whether to include a matching expiration entry.
* @returns {Array} Ledger array.
*/
const makeLedger = (topupId, expiresAt, withExpiration = false) => {

Check warning on line 22 in modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js#L22

Non-serializable expression must be wrapped with $(...)
const ledger = [
{ _id: topupId, kind: 'topup', amount: 1000, expiresAt },
];
if (withExpiration) {
ledger.push({ kind: 'expiration', amount: -1000, refId: `expire-${topupId}` });
}
return ledger;
};

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

mockModel = {
find: jest.fn(),
};

jest.unstable_mockModule('mongoose', () => ({
default: {
Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } },
model: jest.fn(() => mockModel),
},
}));

const mod = await import('../repositories/billing.extraBalance.repository.js');
BillingExtraBalanceRepository = mod.default;
});

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

test('returns empty array when no docs match', async () => {
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(new Date());
expect(result).toEqual([]);
});

test('throws TypeError when now is not a Date', async () => {
await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups('2026-01-01')).rejects.toThrow(TypeError);
await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(null)).rejects.toThrow(TypeError);
await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(undefined)).rejects.toThrow(TypeError);
await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(Date.now())).rejects.toThrow(TypeError);
});

test('returns orgId when unhandled expired topup exists', async () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 1000);
const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const docs = [
{ organization: orgId1, ledger: makeLedger(topupId, pastDate, false) },
];
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now);
expect(result).toContain(orgId1);
});

test('excludes org when all expired topups already have expiration entries', async () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 1000);
const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const docs = [
{ organization: orgId1, ledger: makeLedger(topupId, pastDate, true) },
];
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now);
expect(result).not.toContain(orgId1);
});

test('excludes org when topup is not yet expired (expiresAt in the future)', async () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 10000);
const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const docs = [
{ organization: orgId1, ledger: makeLedger(topupId, futureDate, false) },
];
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now);
expect(result).not.toContain(orgId1);
});

test('returns multiple orgIds when multiple orgs have unhandled expirations', async () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 1000);
const topupId1 = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const topupId2 = 'bbbbbbbbbbbbbbbbbbbbbbbb';
const docs = [
{ organization: orgId1, ledger: makeLedger(topupId1, pastDate, false) },
{ organization: orgId2, ledger: makeLedger(topupId2, pastDate, false) },
];
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now);
expect(result).toHaveLength(2);
expect(result).toContain(orgId1);
expect(result).toContain(orgId2);
});

test('handles org with mixed expired (handled) and unhandled topups — returns org', async () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 1000);
const topupId1 = 'aaaaaaaaaaaaaaaaaaaaaaaa';
const topupId2 = 'bbbbbbbbbbbbbbbbbbbbbbbb';
// topupId1 already has expiration, topupId2 does not
const ledger = [
{ _id: topupId1, kind: 'topup', amount: 1000, expiresAt: pastDate },
{ kind: 'expiration', amount: -1000, refId: `expire-${topupId1}` },
{ _id: topupId2, kind: 'topup', amount: 500, expiresAt: pastDate },
];
const docs = [{ organization: orgId1, ledger }];
mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) });

const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now);
expect(result).toContain(orgId1);
});
});
Loading
Loading