From 5261c728280681efb59a44ac82435e97804ec246 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:13:52 -0700 Subject: [PATCH 1/3] feat(billing): show invoice descriptions and cap in-app list at 5 --- .../app/api/billing/invoices/route.test.ts | 30 +++++++++++++++--- apps/sim/app/api/billing/invoices/route.ts | 31 +++++++++++-------- .../settings/components/billing/billing.tsx | 6 +++- apps/sim/lib/api/contracts/subscription.ts | 2 ++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index 985b20c99c8..44f175c63af 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -48,7 +48,7 @@ describe('GET /api/billing/invoices', () => { }) it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => { - const finalized = Array.from({ length: 10 }, () => makeInvoice()) + const finalized = Array.from({ length: 5 }, () => makeInvoice()) mockStripeInvoicesList.mockResolvedValueOnce({ data: [...finalized, makeInvoice({ status: 'draft' })], has_more: false, @@ -58,22 +58,44 @@ describe('GET /api/billing/invoices', () => { const response = await GET(request) const body = await response.json() - expect(body.invoices).toHaveLength(10) + expect(body.invoices).toHaveLength(5) expect(body.hasMore).toBe(false) }) it('reports hasMore when there are genuinely more finalized invoices', async () => { - const finalized = Array.from({ length: 11 }, () => makeInvoice()) + const finalized = Array.from({ length: 6 }, () => makeInvoice()) mockStripeInvoicesList.mockResolvedValueOnce({ data: finalized, has_more: false }) const request = createMockRequest('GET') const response = await GET(request) const body = await response.json() - expect(body.invoices).toHaveLength(10) + expect(body.invoices).toHaveLength(5) expect(body.hasMore).toBe(true) }) + it('surfaces the line-item description, preferring the top-level invoice description', async () => { + mockStripeInvoicesList.mockResolvedValueOnce({ + data: [ + makeInvoice({ lines: { data: [{ description: 'Sim Max' }] } }), + makeInvoice({ + description: 'Usage overage', + lines: { data: [{ description: 'ignored line' }] }, + }), + makeInvoice(), + ], + has_more: false, + }) + + const request = createMockRequest('GET') + const response = await GET(request) + const body = await response.json() + + expect(body.invoices[0].description).toBe('Sim Max') + expect(body.invoices[1].description).toBe('Usage overage') + expect(body.invoices[2].description).toBeNull() + }) + it('pages through further drafts to confirm hasMore when the first page is inconclusive', async () => { const firstPage = Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })) mockStripeInvoicesList diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index cd5753831a0..a9b814b73b4 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -14,8 +14,8 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('BillingInvoices') -/** Cap the number of invoices returned to the most recent statements. */ -const MAX_INVOICES = 10 +/** Cap the number of invoices returned to the most recent statements; the UI links out to Stripe's portal for the full history. */ +const MAX_INVOICES = 5 /** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */ const STRIPE_PAGE_SIZE = MAX_INVOICES + 1 @@ -63,6 +63,7 @@ async function collectFinalizedInvoices( customer: stripeCustomerId, limit: STRIPE_PAGE_SIZE, starting_after: startingAfter, + expand: ['data.lines'], }) invoices.push( @@ -130,17 +131,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId) const hasMore = finalized.invoices.length > MAX_INVOICES || finalized.stripeHasMore - const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => ({ - id: invoice.id as string, - number: invoice.number ?? null, - created: invoice.created, - total: invoice.total, - amountPaid: invoice.amount_paid, - currency: invoice.currency, - status: invoice.status ?? null, - hostedInvoiceUrl: invoice.hosted_invoice_url ?? null, - invoicePdf: invoice.invoice_pdf ?? null, - })) + const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => { + const lineDescription = invoice.lines?.data.find((line) => line.description)?.description + return { + id: invoice.id as string, + number: invoice.number ?? null, + created: invoice.created, + total: invoice.total, + amountPaid: invoice.amount_paid, + currency: invoice.currency, + status: invoice.status ?? null, + description: invoice.description ?? lineDescription ?? null, + hostedInvoiceUrl: invoice.hosted_invoice_url ?? null, + invoicePdf: invoice.invoice_pdf ?? null, + } + }) return NextResponse.json({ success: true, invoices, hasMore }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 2af65a79791..56de071b7e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -424,6 +424,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps id: invoice.id, date: formatDate(new Date(invoice.created * 1000)), amount: formatInvoiceAmount(invoice.total, invoice.currency), + description: invoice.description, badge: getInvoiceStatusBadge(invoice.status), url: invoice.hostedInvoiceUrl ?? invoice.invoicePdf, })) @@ -607,7 +608,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors' const rowContent = ( <> - + {invoice.date} @@ -616,6 +617,9 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps {invoice.amount} + + {invoice.description ?? ''} + ) diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index 395293477ad..5c7aef3c46d 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -253,6 +253,8 @@ export const invoiceItemSchema = z.object({ amountPaid: z.number(), currency: z.string(), status: z.string().nullable(), + /** Primary line-item / invoice description, e.g. "Usage overage" or the plan name. */ + description: z.string().nullable(), hostedInvoiceUrl: z.string().nullable(), invoicePdf: z.string().nullable(), }) From 5ebcf9390d7f7cb2859f527078ceee1822591627 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:32:36 -0700 Subject: [PATCH 2/3] test(billing): model six-item Stripe page boundary in pagination mocks --- apps/sim/app/api/billing/invoices/route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index 44f175c63af..58a95c888ca 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -97,7 +97,7 @@ describe('GET /api/billing/invoices', () => { }) it('pages through further drafts to confirm hasMore when the first page is inconclusive', async () => { - const firstPage = Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })) + const firstPage = Array.from({ length: 6 }, () => makeInvoice({ status: 'draft' })) mockStripeInvoicesList .mockResolvedValueOnce({ data: firstPage, has_more: true }) .mockResolvedValueOnce({ data: [makeInvoice()], has_more: false }) @@ -117,7 +117,7 @@ describe('GET /api/billing/invoices', () => { it('reports hasMore when the MAX_STRIPE_PAGES safety cap is hit while Stripe still has more', async () => { mockStripeInvoicesList.mockResolvedValue({ - data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })), + data: Array.from({ length: 6 }, () => makeInvoice({ status: 'draft' })), has_more: true, }) From 6e958c75e4f70c6503655b2294bf2fb0e29bb05c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 14:38:09 -0700 Subject: [PATCH 3/3] fix(billing): decouple Stripe scan page size from invoice display cap --- apps/sim/app/api/billing/invoices/route.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index a9b814b73b4..89e422ebec3 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -17,8 +17,13 @@ const logger = createLogger('BillingInvoices') /** Cap the number of invoices returned to the most recent statements; the UI links out to Stripe's portal for the full history. */ const MAX_INVOICES = 5 -/** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */ -const STRIPE_PAGE_SIZE = MAX_INVOICES + 1 +/** + * Stripe list page size when scanning for finalized invoices. Kept independent of + * (and larger than) `MAX_INVOICES` so lowering the display cap never shrinks the + * draft-scan window — a long tail of interspersed draft invoices could otherwise + * bury finalized statements past the `MAX_STRIPE_PAGES` cap and hide the section. + */ +const STRIPE_PAGE_SIZE = 20 /** Safety cap on pagination when a customer has many draft invoices interspersed. */ const MAX_STRIPE_PAGES = 5