Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/lib/integrations/facebook/webhook-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { createFacebookClient, FacebookAPIError } from './graph-api-client';
import { FACEBOOK_CONFIG } from './constants';
import * as crypto from 'crypto';
import { decrypt } from './encryption';
import { prisma } from '@/lib/prisma';

Expand Down Expand Up @@ -318,7 +319,7 @@ export class WebhookManager {
verifyToken: string
): Promise<boolean> {
try {
const challenge = Math.random().toString(36).substring(7);
const challenge = crypto.randomBytes(8).toString('hex');
const url = new URL(callbackUrl);
url.searchParams.set('hub.mode', 'subscribe');
url.searchParams.set('hub.challenge', challenge);
Expand Down
6 changes: 4 additions & 2 deletions src/lib/subscription/billing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { prisma } from '@/lib/prisma';
import { getAppUrl } from '@/lib/utils';
import * as crypto from 'crypto';
import { getRemainingDays, getExpiryDate, isExpiringSoon } from './state-machine';
import { getEffectiveFeatureLimits, getUsageStats } from './feature-enforcer';
import { getPaymentGateway } from './payment-gateway';
Expand Down Expand Up @@ -746,13 +747,14 @@ function getRetryDelay(retryCount: number): number {
return delays[Math.min(retryCount, delays.length - 1)];
}

async function createInvoiceForPayment(
export async function createInvoiceForPayment(
subscriptionId: string,
Comment on lines +750 to 751
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Exporting createInvoiceForPayment appears to be solely for unit testing (no other production references found). This increases the module’s public surface area; consider keeping the DB-writing function private and instead exporting/testing a small pure helper that generates the invoice number (or test via the existing public entrypoint that calls it).

Copilot uses AI. Check for mistakes.
amount: number,
periodStart: Date,
periodEnd: Date
): Promise<void> {
const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).slice(2, 7).toUpperCase()}`;
const randomPart = crypto.randomBytes(4).toString('base64url').toUpperCase().slice(0, 5);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The generated invoiceNumber still has very low entropy: 4 random bytes are encoded and then truncated to 5 chars (and uppercased), which is only ~26 bits of search space. If invoice numbers are meant to be unguessable, consider using a longer random segment (e.g., >=12 bytes) and avoid uppercasing/truncation that reduces the alphabet and discards entropy.

Suggested change
const randomPart = crypto.randomBytes(4).toString('base64url').toUpperCase().slice(0, 5);
const randomPart = crypto.randomBytes(12).toString('base64url');

Copilot uses AI. Check for mistakes.
const invoiceNumber = `INV-${Date.now()}-${randomPart}`;

await prisma.invoice.create({
data: {
Expand Down
56 changes: 56 additions & 0 deletions src/test/services/billing-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { prisma } from '@/lib/prisma';
import { createInvoiceForPayment } from '@/lib/subscription/billing-service';

// Mock prisma
vi.mock('@/lib/prisma', () => ({
prisma: {
invoice: {
create: vi.fn().mockResolvedValue({ id: 'inv_123' }),
},
},
}));

describe('BillingService', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('createInvoiceForPayment', () => {
it('should generate an invoice number with the correct format', async () => {
const subscriptionId = 'sub_123';
const amount = 1000;
const periodStart = new Date();
const periodEnd = new Date();

await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);

expect(prisma.invoice.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.invoice.create).mock.calls[0][0];
const invoiceNumber = call.data.invoiceNumber;

// Pattern: INV-TIMESTAMP-RANDOM
// INV- (4 chars)
// TIMESTAMP (digits)
// - (1 char)
// RANDOM (5 chars ALPHANUMERIC-ish)
expect(invoiceNumber).toMatch(/^INV-\d+-[0-9A-Z_-]{5}$/);
Comment on lines +36 to +37
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This format assertion is tightly coupled to the current implementation (exactly 5 chars and forced uppercase). If the generator is strengthened (longer token, preserving base64url case), this test will fail even though behavior is still correct. Consider loosening the pattern (e.g., length >= N and base64url charset) and/or stubbing Date.now/randomBytes for a deterministic unit test.

Suggested change
// RANDOM (5 chars ALPHANUMERIC-ish)
expect(invoiceNumber).toMatch(/^INV-\d+-[0-9A-Z_-]{5}$/);
// RANDOM (>= 5 chars, base64url-safe charset)
expect(invoiceNumber).toMatch(/^INV-\d+-[A-Za-z0-9_-]{5,}$/);

Copilot uses AI. Check for mistakes.
});

it('should generate different invoice numbers for each call', async () => {
const subscriptionId = 'sub_123';
const amount = 1000;
const periodStart = new Date();
const periodEnd = new Date();

await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);
await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);

expect(prisma.invoice.create).toHaveBeenCalledTimes(2);
const firstCall = vi.mocked(prisma.invoice.create).mock.calls[0][0];
const secondCall = vi.mocked(prisma.invoice.create).mock.calls[1][0];

expect(firstCall.data.invoiceNumber).not.toBe(secondCall.data.invoiceNumber);
Comment on lines +46 to +53
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This test can be flaky because it relies on real randomness for uniqueness. Even if collisions are unlikely, it’s better to stub the random generator (and optionally Date.now) to return two different known values and assert the invoice numbers differ for the expected reason.

Suggested change
await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);
await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);
expect(prisma.invoice.create).toHaveBeenCalledTimes(2);
const firstCall = vi.mocked(prisma.invoice.create).mock.calls[0][0];
const secondCall = vi.mocked(prisma.invoice.create).mock.calls[1][0];
expect(firstCall.data.invoiceNumber).not.toBe(secondCall.data.invoiceNumber);
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
const randomSpy = vi
.spyOn(Math, 'random')
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.9);
try {
await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);
await createInvoiceForPayment(subscriptionId, amount, periodStart, periodEnd);
} finally {
dateNowSpy.mockRestore();
randomSpy.mockRestore();
}
expect(prisma.invoice.create).toHaveBeenCalledTimes(2);
const firstCall = vi.mocked(prisma.invoice.create).mock.calls[0][0];
const secondCall = vi.mocked(prisma.invoice.create).mock.calls[1][0];
const firstInvoiceNumber = firstCall.data.invoiceNumber as string;
const secondInvoiceNumber = secondCall.data.invoiceNumber as string;
const firstMatch = firstInvoiceNumber.match(/^(.+)-([0-9A-Z_-]{5})$/);
const secondMatch = secondInvoiceNumber.match(/^(.+)-([0-9A-Z_-]{5})$/);
expect(firstMatch).not.toBeNull();
expect(secondMatch).not.toBeNull();
if (!firstMatch || !secondMatch) return;
const firstPrefix = firstMatch[1];
const firstRandomPart = firstMatch[2];
const secondPrefix = secondMatch[1];
const secondRandomPart = secondMatch[2];
// With Date.now stubbed, the prefix (INV + timestamp) should be identical,
// and only the random suffix should differ between calls.
expect(firstPrefix).toBe(secondPrefix);
expect(firstRandomPart).not.toBe(secondRandomPart);

Copilot uses AI. Check for mistakes.
});
});
});
Loading