diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts new file mode 100644 index 00000000000..0247d5efda6 --- /dev/null +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetStripeClient: vi.fn(), + mockStripeInvoicesList: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/billing/stripe-client', () => ({ + getStripeClient: mockGetStripeClient, +})) + +import { GET } from '@/app/api/billing/invoices/route' + +function makeInvoice(overrides: Record = {}) { + return { + id: `in_${Math.random().toString(36).slice(2)}`, + number: 'INV-1', + created: 1700000000, + total: 1000, + amount_paid: 1000, + currency: 'usd', + status: 'paid', + hosted_invoice_url: 'https://stripe.test/invoice', + invoice_pdf: 'https://stripe.test/invoice.pdf', + ...overrides, + } +} + +describe('GET /api/billing/invoices', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + dbChainMockFns.limit.mockResolvedValue([{ customer: 'cus_1' }]) + mockGetStripeClient.mockReturnValue({ invoices: { list: mockStripeInvoicesList } }) + }) + + it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => { + const finalized = Array.from({ length: 10 }, () => makeInvoice()) + mockStripeInvoicesList.mockResolvedValueOnce({ + data: [...finalized, makeInvoice({ status: 'draft' })], + has_more: false, + }) + + const request = createMockRequest('GET') + const response = await GET(request) + const body = await response.json() + + expect(body.invoices).toHaveLength(10) + expect(body.hasMore).toBe(false) + }) + + it('reports hasMore when there are genuinely more finalized invoices', async () => { + const finalized = Array.from({ length: 11 }, () => 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.hasMore).toBe(true) + }) + + 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 + .mockResolvedValueOnce({ data: firstPage, has_more: true }) + .mockResolvedValueOnce({ data: [makeInvoice()], has_more: false }) + + const request = createMockRequest('GET') + const response = await GET(request) + const body = await response.json() + + expect(mockStripeInvoicesList).toHaveBeenCalledTimes(2) + expect(mockStripeInvoicesList).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ starting_after: firstPage.at(-1)?.id }) + ) + expect(body.invoices).toHaveLength(1) + expect(body.hasMore).toBe(false) + }) + + 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' })), + has_more: true, + }) + + const request = createMockRequest('GET') + const response = await GET(request) + const body = await response.json() + + expect(mockStripeInvoicesList).toHaveBeenCalledTimes(5) + expect(body.invoices).toHaveLength(0) + expect(body.hasMore).toBe(true) + }) +}) diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index aaed9ce865c..cd5753831a0 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -3,6 +3,7 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import type Stripe from 'stripe' import { getInvoicesContract } from '@/lib/api/contracts/subscription' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' @@ -14,7 +15,65 @@ 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 = 12 +const MAX_INVOICES = 10 + +/** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */ +const STRIPE_PAGE_SIZE = MAX_INVOICES + 1 + +/** Safety cap on pagination when a customer has many draft invoices interspersed. */ +const MAX_STRIPE_PAGES = 5 + +interface FinalizedInvoicesPage { + invoices: Stripe.Invoice[] + /** + * Whether Stripe's raw cursor still had more records after the scan + * stopped — either because `invoices.length` became conclusive, or the + * `MAX_STRIPE_PAGES` safety cap was hit first. Callers must OR this into + * `hasMore` so hitting the cap never silently hides a "View all" that a + * customer with many consecutive drafts genuinely has. + */ + stripeHasMore: boolean +} + +/** + * Pages through a customer's Stripe invoices, keeping only finalized ones, + * until either more than `MAX_INVOICES` have been collected or Stripe's list + * is exhausted (bounded by `MAX_STRIPE_PAGES`). + * + * Stripe's raw pagination cursor (`has_more`) counts draft invoices, which + * the caller filters out — so a single page can under-report finalized + * invoices while `has_more` is still true. Paging until the finalized count + * is conclusive is what lets the caller derive an accurate `hasMore` for the + * "View all" affordance. + */ +async function collectFinalizedInvoices( + stripe: Stripe, + stripeCustomerId: string +): Promise { + const invoices: Stripe.Invoice[] = [] + let startingAfter: string | undefined + let stripeHasMore = true + + for ( + let page = 0; + page < MAX_STRIPE_PAGES && stripeHasMore && invoices.length <= MAX_INVOICES; + page++ + ) { + const result = await stripe.invoices.list({ + customer: stripeCustomerId, + limit: STRIPE_PAGE_SIZE, + starting_after: startingAfter, + }) + + invoices.push( + ...result.data.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft') + ) + stripeHasMore = result.has_more + startingAfter = result.data.at(-1)?.id + } + + return { invoices, stripeHasMore } +} /** * Lists finalized Stripe invoices for the caller's billing customer (personal @@ -69,23 +128,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - const result = await stripe.invoices.list({ customer: stripeCustomerId, limit: MAX_INVOICES }) - - const invoices = result.data - .filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft') - .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, - })) - - return NextResponse.json({ success: true, invoices, hasMore: result.has_more }) + 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, + })) + + return NextResponse.json({ success: true, invoices, hasMore }) } catch (error) { logger.error('Failed to list invoices', { error, userId: session.user.id, context }) return NextResponse.json({ error: 'Failed to list invoices' }, { status: 500 })