Skip to content

Commit 13aaf03

Browse files
authored
feat(billing): show invoice descriptions and cap in-app list at 5 (#5747)
* feat(billing): show invoice descriptions and cap in-app list at 5 * test(billing): model six-item Stripe page boundary in pagination mocks * fix(billing): decouple Stripe scan page size from invoice display cap
1 parent b2d9c02 commit 13aaf03

4 files changed

Lines changed: 60 additions & 22 deletions

File tree

apps/sim/app/api/billing/invoices/route.test.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('GET /api/billing/invoices', () => {
4848
})
4949

5050
it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => {
51-
const finalized = Array.from({ length: 10 }, () => makeInvoice())
51+
const finalized = Array.from({ length: 5 }, () => makeInvoice())
5252
mockStripeInvoicesList.mockResolvedValueOnce({
5353
data: [...finalized, makeInvoice({ status: 'draft' })],
5454
has_more: false,
@@ -58,24 +58,46 @@ describe('GET /api/billing/invoices', () => {
5858
const response = await GET(request)
5959
const body = await response.json()
6060

61-
expect(body.invoices).toHaveLength(10)
61+
expect(body.invoices).toHaveLength(5)
6262
expect(body.hasMore).toBe(false)
6363
})
6464

6565
it('reports hasMore when there are genuinely more finalized invoices', async () => {
66-
const finalized = Array.from({ length: 11 }, () => makeInvoice())
66+
const finalized = Array.from({ length: 6 }, () => makeInvoice())
6767
mockStripeInvoicesList.mockResolvedValueOnce({ data: finalized, has_more: false })
6868

6969
const request = createMockRequest('GET')
7070
const response = await GET(request)
7171
const body = await response.json()
7272

73-
expect(body.invoices).toHaveLength(10)
73+
expect(body.invoices).toHaveLength(5)
7474
expect(body.hasMore).toBe(true)
7575
})
7676

77+
it('surfaces the line-item description, preferring the top-level invoice description', async () => {
78+
mockStripeInvoicesList.mockResolvedValueOnce({
79+
data: [
80+
makeInvoice({ lines: { data: [{ description: 'Sim Max' }] } }),
81+
makeInvoice({
82+
description: 'Usage overage',
83+
lines: { data: [{ description: 'ignored line' }] },
84+
}),
85+
makeInvoice(),
86+
],
87+
has_more: false,
88+
})
89+
90+
const request = createMockRequest('GET')
91+
const response = await GET(request)
92+
const body = await response.json()
93+
94+
expect(body.invoices[0].description).toBe('Sim Max')
95+
expect(body.invoices[1].description).toBe('Usage overage')
96+
expect(body.invoices[2].description).toBeNull()
97+
})
98+
7799
it('pages through further drafts to confirm hasMore when the first page is inconclusive', async () => {
78-
const firstPage = Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' }))
100+
const firstPage = Array.from({ length: 6 }, () => makeInvoice({ status: 'draft' }))
79101
mockStripeInvoicesList
80102
.mockResolvedValueOnce({ data: firstPage, has_more: true })
81103
.mockResolvedValueOnce({ data: [makeInvoice()], has_more: false })
@@ -95,7 +117,7 @@ describe('GET /api/billing/invoices', () => {
95117

96118
it('reports hasMore when the MAX_STRIPE_PAGES safety cap is hit while Stripe still has more', async () => {
97119
mockStripeInvoicesList.mockResolvedValue({
98-
data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })),
120+
data: Array.from({ length: 6 }, () => makeInvoice({ status: 'draft' })),
99121
has_more: true,
100122
})
101123

apps/sim/app/api/billing/invoices/route.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414

1515
const logger = createLogger('BillingInvoices')
1616

17-
/** Cap the number of invoices returned to the most recent statements. */
18-
const MAX_INVOICES = 10
17+
/** Cap the number of invoices returned to the most recent statements; the UI links out to Stripe's portal for the full history. */
18+
const MAX_INVOICES = 5
1919

20-
/** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */
21-
const STRIPE_PAGE_SIZE = MAX_INVOICES + 1
20+
/**
21+
* Stripe list page size when scanning for finalized invoices. Kept independent of
22+
* (and larger than) `MAX_INVOICES` so lowering the display cap never shrinks the
23+
* draft-scan window — a long tail of interspersed draft invoices could otherwise
24+
* bury finalized statements past the `MAX_STRIPE_PAGES` cap and hide the section.
25+
*/
26+
const STRIPE_PAGE_SIZE = 20
2227

2328
/** Safety cap on pagination when a customer has many draft invoices interspersed. */
2429
const MAX_STRIPE_PAGES = 5
@@ -63,6 +68,7 @@ async function collectFinalizedInvoices(
6368
customer: stripeCustomerId,
6469
limit: STRIPE_PAGE_SIZE,
6570
starting_after: startingAfter,
71+
expand: ['data.lines'],
6672
})
6773

6874
invoices.push(
@@ -130,17 +136,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
130136
try {
131137
const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId)
132138
const hasMore = finalized.invoices.length > MAX_INVOICES || finalized.stripeHasMore
133-
const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => ({
134-
id: invoice.id as string,
135-
number: invoice.number ?? null,
136-
created: invoice.created,
137-
total: invoice.total,
138-
amountPaid: invoice.amount_paid,
139-
currency: invoice.currency,
140-
status: invoice.status ?? null,
141-
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
142-
invoicePdf: invoice.invoice_pdf ?? null,
143-
}))
139+
const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => {
140+
const lineDescription = invoice.lines?.data.find((line) => line.description)?.description
141+
return {
142+
id: invoice.id as string,
143+
number: invoice.number ?? null,
144+
created: invoice.created,
145+
total: invoice.total,
146+
amountPaid: invoice.amount_paid,
147+
currency: invoice.currency,
148+
status: invoice.status ?? null,
149+
description: invoice.description ?? lineDescription ?? null,
150+
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
151+
invoicePdf: invoice.invoice_pdf ?? null,
152+
}
153+
})
144154

145155
return NextResponse.json({ success: true, invoices, hasMore })
146156
} catch (error) {

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
424424
id: invoice.id,
425425
date: formatDate(new Date(invoice.created * 1000)),
426426
amount: formatInvoiceAmount(invoice.total, invoice.currency),
427+
description: invoice.description,
427428
badge: getInvoiceStatusBadge(invoice.status),
428429
url: invoice.hostedInvoiceUrl ?? invoice.invoicePdf,
429430
}))
@@ -607,7 +608,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
607608
'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors'
608609
const rowContent = (
609610
<>
610-
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
611+
<span className='flex-shrink-0 text-[var(--text-body)] text-sm'>
611612
{invoice.date}
612613
</span>
613614
<Badge variant={invoice.badge.variant} size='sm'>
@@ -616,6 +617,9 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
616617
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
617618
{invoice.amount}
618619
</span>
620+
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
621+
{invoice.description ?? ''}
622+
</span>
619623
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
620624
</>
621625
)

apps/sim/lib/api/contracts/subscription.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ export const invoiceItemSchema = z.object({
253253
amountPaid: z.number(),
254254
currency: z.string(),
255255
status: z.string().nullable(),
256+
/** Primary line-item / invoice description, e.g. "Usage overage" or the plan name. */
257+
description: z.string().nullable(),
256258
hostedInvoiceUrl: z.string().nullable(),
257259
invoicePdf: z.string().nullable(),
258260
})

0 commit comments

Comments
 (0)