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
42 changes: 40 additions & 2 deletions apps/api/src/__tests__/Customer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
jest.mock('../modules/Database');
jest.mock('../utils/IdGenerator', () => ({
GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)),
GenerateInvoicePrefix: jest.fn(() => 'TESTPREF'),
}));
jest.mock('../utils/Timestamp', () => ({
Now: jest.fn(() => GetFixedTimestamp()),
Expand Down Expand Up @@ -53,11 +54,11 @@ describe('CustomerModule', () => {
expect(customer.email).toBeNull();
expect(customer.individual_name).toBeNull();
expect(customer.invoice_credit_balance).toEqual({});
expect(customer.invoice_prefix).toBeNull();
expect(customer.invoice_prefix).toBe('TESTPREF');
expect(customer.livemode).toBe(false);
expect(customer.metadata).toEqual({});
expect(customer.name).toBeNull();
expect(customer.next_invoice_sequence).toBeNull();
expect(customer.next_invoice_sequence).toBe(1);
expect(customer.phone).toBeNull();
expect(customer.preferred_locales).toBeNull();
expect(customer.shipping).toBeNull();
Expand Down Expand Up @@ -548,6 +549,43 @@ describe('CustomerModule', () => {
});
});

describe('ClaimNextInvoiceNumber', () => {
it('should allocate PREFIX-0001 and advance next_invoice_sequence', async () => {
mockDb.FindOneAndUpdateByFilter = jest.fn().mockResolvedValue({
id: 'cus_z_1',
invoice_prefix: 'NO8CVRTT',
next_invoice_sequence: 2,
});

const number = await module.ClaimNextInvoiceNumber('cus_z_1');

expect(number).toBe('NO8CVRTT-0001');
expect(mockDb.FindOneAndUpdateByFilter).toHaveBeenCalledWith(
'Customers',
{ id: 'cus_z_1' },
{ $inc: { next_invoice_sequence: 1 } }
);
});

it('should format subsequent sequences as PREFIX-0002', async () => {
mockDb.FindOneAndUpdateByFilter = jest.fn().mockResolvedValue({
id: 'cus_z_1',
invoice_prefix: 'NO8CVRTT',
next_invoice_sequence: 3,
});

await expect(module.ClaimNextInvoiceNumber('cus_z_1')).resolves.toBe(
'NO8CVRTT-0002'
);
});

it('should throw when the customer cannot be updated', async () => {
mockDb.FindOneAndUpdateByFilter = jest.fn().mockResolvedValue(null);

await expect(module.ClaimNextInvoiceNumber('missing')).rejects.toThrow();
});
});

describe('DeleteCustomer', () => {
it('should delete the customer and return confirmation', async () => {
mockDb.Get = jest
Expand Down
32 changes: 31 additions & 1 deletion apps/api/src/__tests__/Invoice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ describe('InvoiceModule', () => {
} as unknown as jest.Mocked<EventService>;
customerModule = {
GetCustomer: jest.fn().mockResolvedValue(MockCustomer()),
ClaimNextInvoiceNumber: jest.fn().mockResolvedValue('TESTPREF-0001'),
} as unknown as jest.Mocked<CustomerModule>;
invoiceItemModule = {
ListAllPendingInvoiceItems: jest.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -393,7 +394,7 @@ describe('InvoiceModule', () => {
.mockResolvedValueOnce({
...existing,
status: 'open',
number: 'TEST001-0001',
number: 'TESTPREF-0001',
status_transitions: {
...existing.status_transitions,
finalized_at: GetFixedTimestamp(),
Expand All @@ -402,12 +403,16 @@ describe('InvoiceModule', () => {

const result = await module.FinalizeInvoice(existing.id);

expect(customerModule.ClaimNextInvoiceNumber).toHaveBeenCalledWith(
CUSTOMER_ID
);
expect(mockDb.Update).toHaveBeenCalledWith(
'Invoices',
existing.id,
expect.objectContaining({
status: 'open',
ending_balance: 0,
number: 'TESTPREF-0001',
})
);
expect(eventService.Emit).toHaveBeenCalledWith(
Expand All @@ -418,6 +423,31 @@ describe('InvoiceModule', () => {
expect(result.status).toBe('open');
});

it('should keep an existing invoice number without claiming a new sequence', async () => {
const existing = DraftInvoice({
number: 'CUSTOM-0042',
amount_due: 0,
});
mockDb.Get = jest
.fn()
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce({
...existing,
status: 'open',
});

await module.FinalizeInvoice(existing.id);

expect(customerModule.ClaimNextInvoiceNumber).not.toHaveBeenCalled();
expect(mockDb.Update).toHaveBeenCalledWith(
'Invoices',
existing.id,
expect.objectContaining({
number: 'CUSTOM-0042',
})
);
});

it('should create a PaymentIntent and confirmation_secret when amount_due > 0', async () => {
const existing = DraftInvoice({
number: null,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/__tests__/Setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function CreateMockDatabase(): jest.Mocked<Database> {
mockDb.Set = jest.fn().mockResolvedValue(undefined);
mockDb.Get = jest.fn().mockResolvedValue(null);
mockDb.Update = jest.fn().mockResolvedValue(undefined);
mockDb.FindOneAndUpdateByFilter = jest.fn().mockResolvedValue(null);
mockDb.Delete = jest.fn().mockResolvedValue({ deletedCount: 1 });
mockDb.Find = jest.fn().mockResolvedValue([]);
mockDb.Find2Custom = jest.fn().mockResolvedValue([]);
Expand Down
37 changes: 34 additions & 3 deletions apps/api/src/modules/Customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { Database } from './Database';
import { EventService } from './EventService';
import { GenerateId } from '../utils/IdGenerator';
import { GenerateId, GenerateInvoicePrefix } from '../utils/IdGenerator';
import {
Customer as CustomerType,
CustomerAddress,
Expand Down Expand Up @@ -47,6 +47,14 @@ function ToCustomerAddress(input: AddressInput): CustomerAddress {
};
}

/** Formats a customer-level invoice number like Stripe (`ABCD1234-0001`). */
function FormatCustomerInvoiceNumber(
invoicePrefix: string,
sequence: number
): string {
return `${invoicePrefix}-${String(sequence).padStart(4, '0')}`;
}

export class CustomerModule {
private readonly db: Database;
private readonly eventService: EventService | null;
Expand Down Expand Up @@ -131,7 +139,7 @@ export class CustomerModule {
email: input.email ?? null,
individual_name: input.individual_name ?? null,
invoice_credit_balance: {},
invoice_prefix: input.invoice_prefix ?? null,
invoice_prefix: input.invoice_prefix ?? GenerateInvoicePrefix(),
invoice_settings: {
custom_fields: input.invoice_settings?.custom_fields ?? null,
default_payment_method:
Expand All @@ -152,7 +160,7 @@ export class CustomerModule {
livemode: GetAppConfig().livemode,
metadata: input.metadata ?? {},
name: input.name ?? null,
next_invoice_sequence: input.next_invoice_sequence ?? null,
next_invoice_sequence: input.next_invoice_sequence ?? 1,
phone: input.phone ?? null,
preferred_locales: input.preferred_locales ?? null,
shipping: input.shipping
Expand Down Expand Up @@ -221,6 +229,29 @@ export class CustomerModule {
return this.db.Get<CustomerType>('Customers', id);
}

/**
* Atomically allocate the next Stripe-style invoice number for a customer
* (`{invoice_prefix}-{NNNN}`) and advance `next_invoice_sequence`.
*/
async ClaimNextInvoiceNumber(customerId: string): Promise<string> {
const updated = await this.db.FindOneAndUpdateByFilter<CustomerType>(
'Customers',
{ id: customerId },
{ $inc: { next_invoice_sequence: 1 } }
);

if (!updated?.invoice_prefix || updated.next_invoice_sequence == null) {
throw new AppError(
ERRORS.CUSTOMER_NOT_FOUND.message,
ERRORS.CUSTOMER_NOT_FOUND.status,
ERRORS.CUSTOMER_NOT_FOUND.type
);
}

const sequence = updated.next_invoice_sequence - 1;
return FormatCustomerInvoiceNumber(updated.invoice_prefix, sequence);
}

/**
* Batch-load customers by id, scoped to a single platform account.
* Used by the expansion engine to avoid N+1 lookups.
Expand Down
33 changes: 26 additions & 7 deletions apps/api/src/modules/Invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,16 @@ export class InvoiceModule {
this.AssertStatus(previous, ['draft'], 'finalize');

const now = Now();
const invoiceNumber =
previous.number ?? (await this.AllocateInvoiceNumber(previous));
const updatePayload: Partial<InvoiceType> = {
status: 'open',
auto_advance:
validatedInput.auto_advance !== undefined
? validatedInput.auto_advance
: previous.auto_advance,
ending_balance: previous.starting_balance,
number: previous.number ?? this.GenerateInvoiceNumber(previous),
number: invoiceNumber,
status_transitions: {
...previous.status_transitions,
finalized_at: now,
Expand Down Expand Up @@ -1245,12 +1247,29 @@ export class InvoiceModule {
};
}

private GenerateInvoiceNumber(invoice: InvoiceType): string {
const suffix = invoice.id
.replace(/^in_z_?/, '')
.slice(0, 8)
.toUpperCase();
return `${suffix}-0001`;
/**
* Allocate a Stripe-style invoice number from the customer's
* `invoice_prefix` + `next_invoice_sequence` (e.g. `NO8CVRTT-0001`).
*/
private async AllocateInvoiceNumber(invoice: InvoiceType): Promise<string> {
if (!this.customerModule) {
throw new AppError(
'CustomerModule not configured',
ERRORS.INVALID_REQUEST.status,
ERRORS.INVALID_REQUEST.type
);
}
const customerId = ExpandableId(invoice.customer);
if (!customerId) {
throw new AppError(
ERRORS.CUSTOMER_NOT_FOUND.message,
ERRORS.CUSTOMER_NOT_FOUND.status,
ERRORS.CUSTOMER_NOT_FOUND.type
);
}
// Ensure the customer belongs to this platform before claiming a number.
await this.RequireCustomer(customerId, invoice.platform_account);
return this.customerModule.ClaimNextInvoiceNumber(customerId);
}

private AssertDraftOnlyUpdates(
Expand Down
20 changes: 16 additions & 4 deletions apps/api/src/utils/IdGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import * as crypto from 'crypto';
const URL_SAFE_CHARS =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

/** Uppercase alphanumeric charset used for customer invoice prefixes (Stripe-shaped). */
const INVOICE_PREFIX_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';

/**
* Generates a random ID with a prefix, similar to Stripe IDs.
* e.g. acct_1SVYFEIS97JJCA0T
Expand All @@ -12,7 +15,7 @@ const URL_SAFE_CHARS =
* @returns The generated ID string
*/
export function GenerateId(prefix: string, length: number = 16): string {
return `${prefix}_${RandomAlphanumeric(length)}`;
return `${prefix}_${RandomFromCharset(URL_SAFE_CHARS, length)}`;
}

/**
Expand All @@ -26,16 +29,25 @@ export function GenerateUrlSlug(
livemode: boolean = false,
length: number = 18
): string {
const slug = RandomAlphanumeric(length);
const slug = RandomFromCharset(URL_SAFE_CHARS, length);
return livemode ? slug : `test_${slug}`;
}

function RandomAlphanumeric(length: number): string {
/**
* Generates a customer invoice prefix (3–12 uppercase letters/numbers).
* Matches Stripe's default 8-character customer-level invoice prefixes.
*/
export function GenerateInvoicePrefix(length: number = 8): string {
const clamped = Math.min(12, Math.max(3, length));
return RandomFromCharset(INVOICE_PREFIX_CHARS, clamped);
}

function RandomFromCharset(charset: string, length: number): string {
const randomBytes = crypto.randomBytes(length);
let result = '';

for (let i = 0; i < length; i++) {
result += URL_SAFE_CHARS[randomBytes[i] % URL_SAFE_CHARS.length];
result += charset[randomBytes[i] % charset.length];
}

return result;
Expand Down
Loading