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
34 changes: 28 additions & 6 deletions apps/sim/app/api/billing/invoices/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 })
Expand All @@ -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,
})

Expand Down
40 changes: 25 additions & 15 deletions apps/sim/app/api/billing/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
waleedlatif1 marked this conversation as resolved.

/** 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
Expand Down Expand Up @@ -63,6 +68,7 @@ async function collectFinalizedInvoices(
customer: stripeCustomerId,
limit: STRIPE_PAGE_SIZE,
starting_after: startingAfter,
expand: ['data.lines'],
})

invoices.push(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down Expand Up @@ -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 = (
<>
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-body)] text-sm'>
{invoice.date}
</span>
<Badge variant={invoice.badge.variant} size='sm'>
Expand All @@ -616,6 +617,9 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
{invoice.amount}
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
{invoice.description ?? ''}
</span>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</>
)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
Loading