Skip to content

Commit 17a8d92

Browse files
authored
Merge branch 'main' into fix/move-stripe-auto-approve-to-api
2 parents e42e6ef + 64813d9 commit 17a8d92

11 files changed

Lines changed: 460 additions & 39 deletions

File tree

apps/api/src/billing/billing.service.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,87 @@ describe('BillingService', () => {
208208
);
209209
});
210210

211+
it('aggregates wallet credit balances per product in getStatus', async () => {
212+
// The customer-facing /v1/billing/status response is the only
213+
// surface the pentest + BG-check UIs read from. If we ever stop
214+
// including creditBalances here, both UIs silently regress to
215+
// paywalling users whose admin-granted credits would be consumed
216+
// by the create endpoint. Lock the contract with this test.
217+
const listBalances = jest.fn().mockResolvedValue([
218+
{
219+
id: 'bcb_1',
220+
productKey: 'pentest',
221+
skuKey: null,
222+
balance: 3,
223+
totalGranted: 5,
224+
totalConsumed: 2,
225+
totalRefunded: 0,
226+
lastSource: 'manual',
227+
updatedAt: '2026-05-01T00:00:00.000Z',
228+
},
229+
{
230+
id: 'bcb_2',
231+
productKey: 'pentest',
232+
skuKey: 'pentest_monthly_1',
233+
balance: 2,
234+
totalGranted: 2,
235+
totalConsumed: 0,
236+
totalRefunded: 0,
237+
lastSource: 'topup',
238+
updatedAt: '2026-05-01T00:00:00.000Z',
239+
},
240+
{
241+
id: 'bcb_3',
242+
productKey: 'background_check',
243+
skuKey: null,
244+
balance: 4,
245+
totalGranted: 4,
246+
totalConsumed: 0,
247+
totalRefunded: 0,
248+
lastSource: 'manual',
249+
updatedAt: '2026-05-01T00:00:00.000Z',
250+
},
251+
]);
252+
const service = new BillingService(
253+
mockStripeService({
254+
invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
255+
customers: { retrieve: jest.fn().mockResolvedValue({}) },
256+
paymentMethods: { retrieve: jest.fn() },
257+
}),
258+
{ syncSubscriptionItem: jest.fn() } as never,
259+
{ listBalances } as never,
260+
);
261+
262+
const result = await service.getStatus('org_1');
263+
264+
expect(listBalances).toHaveBeenCalledWith('org_1');
265+
expect(result.creditBalances).toEqual(
266+
expect.arrayContaining([
267+
{ productKey: 'pentest', balance: 5 },
268+
{ productKey: 'background_check', balance: 4 },
269+
]),
270+
);
271+
expect(result.creditBalances).toHaveLength(2);
272+
});
273+
274+
it('returns an empty creditBalances array when no credits service is wired in', async () => {
275+
// BillingCreditsService is @Optional() so unit tests can keep
276+
// hand-constructing BillingService without it. Verify the absent-
277+
// dependency branch produces a typesafe empty array, not undefined.
278+
const service = new BillingService(
279+
mockStripeService({
280+
invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
281+
customers: { retrieve: jest.fn().mockResolvedValue({}) },
282+
paymentMethods: { retrieve: jest.fn() },
283+
}),
284+
{ syncSubscriptionItem: jest.fn() } as never,
285+
);
286+
287+
const result = await service.getStatus('org_1');
288+
289+
expect(result.creditBalances).toEqual([]);
290+
});
291+
211292
it('marks trial eligibility false after any product subscription history', async () => {
212293
organizationBillingSubscriptionFindMany.mockResolvedValue([
213294
{

apps/api/src/billing/billing.service.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BadRequestException,
33
Injectable,
44
NotFoundException,
5+
Optional,
56
} from '@nestjs/common';
67
import { db } from '@db';
78
import {
@@ -12,6 +13,7 @@ import {
1213
isSubscriptionBillingSkuKey,
1314
} from '@trycompai/billing';
1415
import { StripeService } from '../stripe/stripe.service';
16+
import { BillingCreditsService } from './billing-credits.service';
1517
import { findOrCreateBillingCustomer } from './billing-customer';
1618
import { BillingEntitlementsService } from './billing-entitlements.service';
1719
import { listBillingInvoices } from './billing-invoices';
@@ -38,6 +40,10 @@ export class BillingService {
3840
constructor(
3941
private readonly stripeService: StripeService,
4042
private readonly entitlements: BillingEntitlementsService,
43+
// Optional so existing unit tests that hand-construct BillingService
44+
// (without a credits service) keep working. In production the
45+
// BillingModule always provides it.
46+
@Optional() private readonly credits?: BillingCreditsService,
4147
) {}
4248

4349
async getStatus(organizationId: string): Promise<BillingStatus> {
@@ -67,18 +73,31 @@ export class BillingService {
6773
db.backgroundCheckRequest.count({ where: { organizationId } }),
6874
db.securityPenetrationTestRun.count({ where: { organizationId } }),
6975
]);
70-
const [invoices, preferences, usageRows] = await Promise.all([
71-
listBillingInvoices({
72-
stripeService: this.stripeService,
73-
stripeCustomerId: billing?.stripeCustomerId ?? null,
74-
}),
75-
getBillingPreferences({
76-
stripeService: this.stripeService,
77-
stripeCustomerId: billing?.stripeCustomerId ?? null,
78-
fallbackCompanyName: organization.name,
79-
}),
80-
listBillingUsageRows({ organizationId, subscriptions }),
81-
]);
76+
const [invoices, preferences, usageRows, creditBalances] =
77+
await Promise.all([
78+
listBillingInvoices({
79+
stripeService: this.stripeService,
80+
stripeCustomerId: billing?.stripeCustomerId ?? null,
81+
}),
82+
getBillingPreferences({
83+
stripeService: this.stripeService,
84+
stripeCustomerId: billing?.stripeCustomerId ?? null,
85+
fallbackCompanyName: organization.name,
86+
}),
87+
listBillingUsageRows({ organizationId, subscriptions }),
88+
// Sum wallet balances per product. There can be multiple
89+
// BillingCreditBalance rows per (org, product) when grants are
90+
// scoped to a specific SKU, so we aggregate before returning.
91+
this.credits
92+
? this.credits.listBalances(organizationId)
93+
: Promise.resolve([]),
94+
]);
95+
96+
const creditBalancesByProduct = new Map<BillingProductKey, number>();
97+
for (const balance of creditBalances) {
98+
const current = creditBalancesByProduct.get(balance.productKey) ?? 0;
99+
creditBalancesByProduct.set(balance.productKey, current + balance.balance);
100+
}
82101

83102
return {
84103
hasBilling: !!billing,
@@ -98,6 +117,9 @@ export class BillingService {
98117
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
99118
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
100119
})),
120+
creditBalances: Array.from(creditBalancesByProduct.entries()).map(
121+
([productKey, balance]) => ({ productKey, balance }),
122+
),
101123
invoices,
102124
};
103125
}

apps/api/src/billing/billing.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { BillingProductKey } from '@trycompai/billing';
12
import type { BillingInvoice } from './billing-invoices';
23
import type { BillingPreferences } from './billing-preferences';
34

@@ -24,6 +25,15 @@ export interface BillingStatus {
2425
currentPeriodEnd: string | null;
2526
cancelAtPeriodEnd: boolean;
2627
}>;
28+
// Aggregated wallet balance per product. Mirrors what
29+
// `BillingEntitlementsService.tryConsumeIncludedUsageForProduct` falls
30+
// back to when a Stripe subscription is missing or exhausted, so the UI
31+
// can keep its allowance display in sync with the backend's actual
32+
// consumption decision.
33+
creditBalances: Array<{
34+
productKey: BillingProductKey;
35+
balance: number;
36+
}>;
2737
invoices: BillingInvoice[];
2838
}
2939

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ export interface BackgroundCheckBillingStatus {
4343
currentPeriodEnd: string | null;
4444
cancelAtPeriodEnd: boolean;
4545
}>;
46+
// Wallet credits per product. Optional because older API responses
47+
// and existing test fixtures don't set it; treated as zero balance
48+
// when absent.
49+
creditBalances?: Array<{
50+
productKey: 'pentest' | 'background_check';
51+
balance: number;
52+
}>;
4653
}
4754

4855
export function isCompletedBackgroundCheck(status: BackgroundCheckStatus): boolean {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { BackgroundCheckBillingStatus } from './backgroundCheckTypes';
3+
import { getBackgroundChecksRemaining } from './useEmployeeBackgroundCheckData';
4+
5+
function status(
6+
overrides: Partial<BackgroundCheckBillingStatus> = {},
7+
): BackgroundCheckBillingStatus {
8+
return {
9+
hasPaymentMethod: false,
10+
setupAt: null,
11+
...overrides,
12+
};
13+
}
14+
15+
describe('getBackgroundChecksRemaining', () => {
16+
it('returns null when billing status has not loaded', () => {
17+
expect(getBackgroundChecksRemaining({ billingStatus: undefined })).toBeNull();
18+
});
19+
20+
it('returns null when no subscription and no wallet credits exist', () => {
21+
expect(getBackgroundChecksRemaining({ billingStatus: status() })).toBeNull();
22+
});
23+
24+
it('returns subscription remainder when an active subscription exists', () => {
25+
expect(
26+
getBackgroundChecksRemaining({
27+
billingStatus: status({
28+
subscriptions: [
29+
{
30+
skuKey: 'background_checks_monthly_3',
31+
status: 'active',
32+
includedQuantity: 3,
33+
usedQuantity: 1,
34+
currentPeriodStart: null,
35+
currentPeriodEnd: null,
36+
cancelAtPeriodEnd: false,
37+
},
38+
],
39+
}),
40+
}),
41+
).toBe(2);
42+
});
43+
44+
it('returns wallet balance when no subscription but wallet credits exist', () => {
45+
// Admin grant flow: organization has no subscription, platform
46+
// admin granted 5 BG-check credits. Backend would consume from
47+
// wallet on POST, so the wizard must allow the request.
48+
expect(
49+
getBackgroundChecksRemaining({
50+
billingStatus: status({
51+
creditBalances: [{ productKey: 'background_check', balance: 5 }],
52+
}),
53+
}),
54+
).toBe(5);
55+
});
56+
57+
it('sums subscription remainder and wallet credits', () => {
58+
expect(
59+
getBackgroundChecksRemaining({
60+
billingStatus: status({
61+
subscriptions: [
62+
{
63+
skuKey: 'background_checks_monthly_3',
64+
status: 'trialing',
65+
includedQuantity: 3,
66+
usedQuantity: 3,
67+
currentPeriodStart: null,
68+
currentPeriodEnd: null,
69+
cancelAtPeriodEnd: false,
70+
},
71+
],
72+
creditBalances: [{ productKey: 'background_check', balance: 5 }],
73+
}),
74+
}),
75+
).toBe(5);
76+
});
77+
78+
it('ignores pentest wallet credits when computing background-check allowance', () => {
79+
expect(
80+
getBackgroundChecksRemaining({
81+
billingStatus: status({
82+
creditBalances: [{ productKey: 'pentest', balance: 10 }],
83+
}),
84+
}),
85+
).toBeNull();
86+
});
87+
88+
it('ignores cancelled subscriptions', () => {
89+
expect(
90+
getBackgroundChecksRemaining({
91+
billingStatus: status({
92+
subscriptions: [
93+
{
94+
skuKey: 'background_checks_monthly_3',
95+
status: 'canceled',
96+
includedQuantity: 3,
97+
usedQuantity: 0,
98+
currentPeriodStart: null,
99+
currentPeriodEnd: null,
100+
cancelAtPeriodEnd: false,
101+
},
102+
],
103+
creditBalances: [{ productKey: 'background_check', balance: 2 }],
104+
}),
105+
}),
106+
).toBe(2);
107+
});
108+
});

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,31 @@ export function getBackgroundChecksRemaining({
5454
}: {
5555
billingStatus: BackgroundCheckBillingStatus | undefined;
5656
}): number | null {
57-
const subscription = (billingStatus?.subscriptions ?? []).find(
57+
if (!billingStatus) return null;
58+
const subscription = (billingStatus.subscriptions ?? []).find(
5859
(item) =>
5960
getBillingSkuProductKey(item.skuKey) === 'background_check' &&
6061
(item.status === 'active' || item.status === 'trialing'),
6162
);
62-
if (!subscription) return null;
63-
return Math.max(subscription.includedQuantity - subscription.usedQuantity, 0);
63+
// Wallet credits granted by platform admins. The backend's
64+
// `BillingEntitlementsService.tryConsumeIncludedUsageForProduct`
65+
// falls back to this wallet when no active subscription exists or
66+
// the subscription's included usage is exhausted, so we mirror that
67+
// logic here — otherwise the wizard paywalls users whose admin-
68+
// granted credits would actually be consumed by the create call.
69+
const walletBalance =
70+
(billingStatus.creditBalances ?? []).find(
71+
(entry) => entry.productKey === 'background_check',
72+
)?.balance ?? 0;
73+
if (!subscription) {
74+
// Returning `null` keeps the existing "no allowance — go pick a
75+
// plan" wizard path. We only have a positive allowance if there
76+
// are wallet credits to consume.
77+
return walletBalance > 0 ? walletBalance : null;
78+
}
79+
const subscriptionRemaining = Math.max(
80+
subscription.includedQuantity - subscription.usedQuantity,
81+
0,
82+
);
83+
return subscriptionRemaining + walletBalance;
6484
}

0 commit comments

Comments
 (0)