From f2b008747642e8cf860af9f569fabe6f164a4ddd Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 11:35:14 -0700 Subject: [PATCH 1/3] fix(billing): stop showing "View all" when there are no more invoices Show fewer invoices (10) and derive hasMore from the finalized-invoice count instead of Stripe's raw has_more, which counted drafts we filter out client-side and could report more invoices than actually exist. --- .../app/api/billing/invoices/route.test.ts | 98 +++++++++++++++++++ apps/sim/app/api/billing/invoices/route.ts | 67 +++++++++---- 2 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 apps/sim/app/api/billing/invoices/route.test.ts 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..dd0b27ccffb --- /dev/null +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -0,0 +1,98 @@ +/** + * @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 () => { + // 10 finalized invoices exactly at MAX_INVOICES, plus 1 draft, and Stripe has + // nothing further — the old limit:MAX_INVOICES fetch would have missed the + // draft entirely and reported has_more from a truncated raw page. + 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 () => { + mockStripeInvoicesList + .mockResolvedValueOnce({ + data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })), + 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(body.invoices).toHaveLength(1) + expect(body.hasMore).toBe(false) + }) +}) diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index aaed9ce865c..85ece278836 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -14,7 +14,13 @@ 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 /** * Lists finalized Stripe invoices for the caller's billing customer (personal @@ -69,23 +75,48 @@ 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 }) + // Stripe's raw pagination cursor counts drafts, which we filter out below — so + // `has_more` on a single page can be true even when there are no more *finalized* + // invoices to show. Page until we've collected enough finalized invoices to know + // for certain whether "View all" should render, or Stripe's list is exhausted. + const finalized: Awaited>['data'] = [] + let startingAfter: string | undefined + let stripeHasMore = true + + for ( + let page = 0; + page < MAX_STRIPE_PAGES && stripeHasMore && finalized.length <= MAX_INVOICES; + page++ + ) { + const result = await stripe.invoices.list({ + customer: stripeCustomerId, + limit: STRIPE_PAGE_SIZE, + starting_after: startingAfter, + }) + + finalized.push( + ...result.data.filter( + (invoice) => invoice.id && invoice.status && invoice.status !== 'draft' + ) + ) + stripeHasMore = result.has_more + startingAfter = result.data.at(-1)?.id + } + + const hasMore = finalized.length > MAX_INVOICES + const invoices = finalized.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 }) From 46e65cf0b8541164a0d5e2cd877c867d89aa0d0a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 11:39:09 -0700 Subject: [PATCH 2/3] chore(billing): move invoice pagination explanation into TSDoc Extract collectFinalizedInvoices with a TSDoc comment explaining the Stripe has_more/draft-filtering rationale, instead of an inline comment block. --- .../app/api/billing/invoices/route.test.ts | 3 - apps/sim/app/api/billing/invoices/route.ts | 70 +++++++++++-------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index dd0b27ccffb..61d19e3d0d3 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -47,9 +47,6 @@ describe('GET /api/billing/invoices', () => { }) it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => { - // 10 finalized invoices exactly at MAX_INVOICES, plus 1 draft, and Stripe has - // nothing further — the old limit:MAX_INVOICES fetch would have missed the - // draft entirely and reported has_more from a truncated raw page. const finalized = Array.from({ length: 10 }, () => makeInvoice()) mockStripeInvoicesList.mockResolvedValueOnce({ data: [...finalized, makeInvoice({ status: 'draft' })], diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts index 85ece278836..7ed2b968b4c 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' @@ -22,6 +23,46 @@ const STRIPE_PAGE_SIZE = MAX_INVOICES + 1 /** Safety cap on pagination when a customer has many draft invoices interspersed. */ const MAX_STRIPE_PAGES = 5 +/** + * 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. + * + * 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 (either past `MAX_INVOICES` or Stripe is exhausted) is what + * lets the caller derive an accurate `hasMore` for the "View all" affordance. + */ +async function collectFinalizedInvoices( + stripe: Stripe, + stripeCustomerId: string +): Promise { + const finalized: Stripe.Invoice[] = [] + let startingAfter: string | undefined + let stripeHasMore = true + + for ( + let page = 0; + page < MAX_STRIPE_PAGES && stripeHasMore && finalized.length <= MAX_INVOICES; + page++ + ) { + const result = await stripe.invoices.list({ + customer: stripeCustomerId, + limit: STRIPE_PAGE_SIZE, + starting_after: startingAfter, + }) + + finalized.push( + ...result.data.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft') + ) + stripeHasMore = result.has_more + startingAfter = result.data.at(-1)?.id + } + + return finalized +} + /** * Lists finalized Stripe invoices for the caller's billing customer (personal * or organization-scoped). Returns an empty list when there is no Stripe @@ -75,34 +116,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - // Stripe's raw pagination cursor counts drafts, which we filter out below — so - // `has_more` on a single page can be true even when there are no more *finalized* - // invoices to show. Page until we've collected enough finalized invoices to know - // for certain whether "View all" should render, or Stripe's list is exhausted. - const finalized: Awaited>['data'] = [] - let startingAfter: string | undefined - let stripeHasMore = true - - for ( - let page = 0; - page < MAX_STRIPE_PAGES && stripeHasMore && finalized.length <= MAX_INVOICES; - page++ - ) { - const result = await stripe.invoices.list({ - customer: stripeCustomerId, - limit: STRIPE_PAGE_SIZE, - starting_after: startingAfter, - }) - - finalized.push( - ...result.data.filter( - (invoice) => invoice.id && invoice.status && invoice.status !== 'draft' - ) - ) - stripeHasMore = result.has_more - startingAfter = result.data.at(-1)?.id - } - + const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId) const hasMore = finalized.length > MAX_INVOICES const invoices = finalized.slice(0, MAX_INVOICES).map((invoice) => ({ id: invoice.id as string, From 2bee0c350193611aa515afe5833cebe14f03b9b3 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 11:41:31 -0700 Subject: [PATCH 3/3] fix(billing): don't hide View all when the page-cap is hit inconclusively Greptile P1: if MAX_STRIPE_PAGES is exhausted while the finalized count sits exactly at MAX_INVOICES and Stripe still has_more, hasMore was silently returned as false. collectFinalizedInvoices now also returns whether Stripe's cursor was still open at exit, and the route ORs that into hasMore so the safety cap can never suppress "View all". Also asserts starting_after cursor propagation across pages and adds a test for the safety-cap-hit case. --- .../app/api/billing/invoices/route.test.ts | 30 ++++++++++++----- apps/sim/app/api/billing/invoices/route.ts | 32 +++++++++++++------ 2 files changed, 44 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 61d19e3d0d3..0247d5efda6 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -74,22 +74,36 @@ 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' })) mockStripeInvoicesList - .mockResolvedValueOnce({ - data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })), - has_more: true, - }) - .mockResolvedValueOnce({ - data: [makeInvoice()], - has_more: false, - }) + .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 7ed2b968b4c..cd5753831a0 100644 --- a/apps/sim/app/api/billing/invoices/route.ts +++ b/apps/sim/app/api/billing/invoices/route.ts @@ -23,28 +23,40 @@ 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. + * 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 (either past `MAX_INVOICES` or Stripe is exhausted) is what - * lets the caller derive an accurate `hasMore` for the "View all" affordance. + * 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 finalized: Stripe.Invoice[] = [] +): Promise { + const invoices: Stripe.Invoice[] = [] let startingAfter: string | undefined let stripeHasMore = true for ( let page = 0; - page < MAX_STRIPE_PAGES && stripeHasMore && finalized.length <= MAX_INVOICES; + page < MAX_STRIPE_PAGES && stripeHasMore && invoices.length <= MAX_INVOICES; page++ ) { const result = await stripe.invoices.list({ @@ -53,14 +65,14 @@ async function collectFinalizedInvoices( starting_after: startingAfter, }) - finalized.push( + invoices.push( ...result.data.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft') ) stripeHasMore = result.has_more startingAfter = result.data.at(-1)?.id } - return finalized + return { invoices, stripeHasMore } } /** @@ -117,8 +129,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId) - const hasMore = finalized.length > MAX_INVOICES - const invoices = finalized.slice(0, MAX_INVOICES).map((invoice) => ({ + 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,