diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index 985b20c99c8..58a95c888ca 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,24 +58,46 @@ 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' })) + const firstPage = Array.from({ length: 6 }, () => makeInvoice({ status: 'draft' })) mockStripeInvoicesList .mockResolvedValueOnce({ data: firstPage, has_more: true }) .mockResolvedValueOnce({ data: [makeInvoice()], has_more: false }) @@ -95,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, }) diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index cd5753831a0..89e422ebec3 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -14,11 +14,16 @@ 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 +/** + * 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 @@ -63,6 +68,7 @@ async function collectFinalizedInvoices( customer: stripeCustomerId, limit: STRIPE_PAGE_SIZE, starting_after: startingAfter, + expand: ['data.lines'], }) invoices.push( @@ -130,17 +136,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(), })