From 88add6786b2418963643079d2391d1a8c391d131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 25 Aug 2026 05:07:56 +0200 Subject: [PATCH 1/4] feat(web): add org credit and invoice page procedures --- .../src/lib/creditTransactions.page.test.ts | 117 ++++++++++++++++++ apps/web/src/lib/creditTransactions.ts | 95 +++++++++++++- apps/web/src/lib/stripe/index.test.ts | 76 ++++++++++++ apps/web/src/lib/stripe/index.ts | 68 ++++++++++ .../organizations/organization-router.ts | 45 ++++++- 5 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/creditTransactions.page.test.ts diff --git a/apps/web/src/lib/creditTransactions.page.test.ts b/apps/web/src/lib/creditTransactions.page.test.ts new file mode 100644 index 0000000000..ec3a94205c --- /dev/null +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect } from '@jest/globals'; +import { insertTestUser } from '../tests/helpers/user.helper'; +import { createTestOrganization } from '../tests/helpers/organization.helper'; + +import { + getCreditTransactionsForOrganization, + getCreditTransactionsForOrganizationPage, +} from '@/lib/creditTransactions'; +import { db, pool } from './drizzle'; +import { credit_transactions } from '@kilocode/db/schema'; + +function whereClause(text: string): string { + const match = text.match(/\bwhere\s+(.+?)\s+order by\s/); + return match ? match[1] : ''; +} + +describe('getCreditTransactionsForOrganizationPage', () => { + test('pages 26 transactions into 25 entries and matches the summary for the excluded set', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('page org', user.id, 0); + + const purchases = Array.from({ length: 26 }, () => ({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 1_000_000, + description: 'purchase', + })); + await db.insert(credit_transactions).values(purchases); + + // kpo:consumption rows must be absent from both the page and the summary. + await db.insert(credit_transactions).values([ + { + kilo_user_id: user.id, + organization_id: org.id, + is_free: true, + amount_microdollars: 5_000_000, + credit_category: 'kpo:consumption:models', + description: 'consumption', + }, + { + kilo_user_id: user.id, + organization_id: org.id, + is_free: true, + amount_microdollars: 5_000_000, + credit_category: 'kpo:consumption:models', + description: 'consumption', + }, + ]); + + const page = await getCreditTransactionsForOrganizationPage(org.id); + + expect(page.entries).toHaveLength(25); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBe(25); + expect(page.entries.every(entry => !entry.credit_category?.startsWith('kpo:consumption'))).toBe( + true + ); + + expect(page.summary).toEqual({ + total_promotional_musd: 0, + total_purchased_musd: 26_000_000, + credit_transaction_count: 26, + }); + }); + + test('returns empty entries, hasMore false, and zero summary for an empty organization', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('empty page org', user.id, 0); + + const page = await getCreditTransactionsForOrganizationPage(org.id); + + expect(page.entries).toEqual([]); + expect(page.hasMore).toBe(false); + expect(page.nextCursor).toBeNull(); + expect(page.summary).toEqual({ + total_promotional_musd: 0, + total_purchased_musd: 0, + credit_transaction_count: 0, + }); + }); + + test('page SQL keeps the old where clause and adds id ordering plus limit+1', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('sql page org', user.id, 0); + + const querySpy = jest.spyOn(pool, 'query'); + + await getCreditTransactionsForOrganization(org.id); + await getCreditTransactionsForOrganizationPage(org.id); + + const captured = (querySpy.mock.calls as unknown as unknown[][]).map(call => { + const first = call[0]; + const text = + typeof first === 'string' ? first : ((first as { text?: string } | null)?.text ?? ''); + return { text, params: (call[1] ?? []) as unknown[] }; + }); + + const oldQuery = captured.find(call => call.text.includes('from "credit_transactions"')); + const pageQuery = captured.find(call => call.text.includes('"id" desc')); + + expect(oldQuery).toBeDefined(); + expect(pageQuery).toBeDefined(); + + expect(whereClause(pageQuery!.text)).toBe(whereClause(oldQuery!.text)); + + expect(pageQuery!.text).toContain('"created_at" desc'); + expect(pageQuery!.text.indexOf('"created_at" desc')).toBeLessThan( + pageQuery!.text.indexOf('"id" desc') + ); + expect(oldQuery!.text).not.toContain('"id" desc'); + + expect(pageQuery!.params).toContain(26); + + querySpy.mockRestore(); + }); +}); diff --git a/apps/web/src/lib/creditTransactions.ts b/apps/web/src/lib/creditTransactions.ts index 03b289786d..cdaf367ac3 100644 --- a/apps/web/src/lib/creditTransactions.ts +++ b/apps/web/src/lib/creditTransactions.ts @@ -3,7 +3,7 @@ import { db, readDb, sql } from './drizzle'; import type { Organization } from '@kilocode/db/schema'; import { credit_transactions, kilo_pass_issuance_items, kilocode_users } from '@kilocode/db/schema'; -type CreditSummary = { +export type CreditSummary = { total_promotional_musd: number; total_purchased_musd: number; credit_transaction_count: number; @@ -35,6 +35,33 @@ export async function getCreditTransactionsSummaryByUserId( }; } +export async function getCreditTransactionsSummaryForOrganization( + organizationId: Organization['id'] +): Promise { + const { rows } = await db.execute( + sql` + select + coalesce(sum(amount_microdollars) filter (where is_free),0) :: bigint total_promotional_musd, + coalesce(sum(amount_microdollars) filter (where not is_free),0) :: bigint total_purchased_musd, + count(*) as credit_transaction_count + from public.credit_transactions + where organization_id = ${organizationId} + and (credit_category is null or credit_category not like 'kpo:consumption:%') + ` + ); + const result = rows[0] as { + total_promotional_musd: bigint; + total_purchased_musd: bigint; + credit_transaction_count: bigint; + }; + + return { + total_promotional_musd: Number(result.total_promotional_musd), + total_purchased_musd: Number(result.total_purchased_musd), + credit_transaction_count: Number(result.credit_transaction_count), + }; +} + export type CreditInfo = { balance: number; isDepleted: boolean; @@ -66,6 +93,7 @@ export async function summarizeUserPayments(kiloUserId: string, fromDb: typeof d )[0]; } +// old form: array capped at 100, no cursor; remove when every client pages. export async function getCreditTransactionsForOrganization(organizationId: Organization['id']) { return db .select({ @@ -100,6 +128,71 @@ export async function getCreditTransactionsForOrganization(organizationId: Organ .limit(100); } +const CREDIT_TRANSACTIONS_PAGE_SIZE = 25; + +type OrganizationCreditTransaction = Awaited< + ReturnType +>[number]; + +export type CreditTransactionsPage = { + entries: OrganizationCreditTransaction[]; + nextCursor: number | null; + hasMore: boolean; + summary: CreditSummary; +}; + +export async function getCreditTransactionsForOrganizationPage( + organizationId: Organization['id'], + cursor: number = 0 +): Promise { + const [transactions, summary] = await Promise.all([ + db + .select({ + id: credit_transactions.id, + kilo_user_id: credit_transactions.kilo_user_id, + amount_microdollars: credit_transactions.amount_microdollars, + expiration_baseline_microdollars_used: + credit_transactions.expiration_baseline_microdollars_used, + original_baseline_microdollars_used: + credit_transactions.original_baseline_microdollars_used, + is_free: credit_transactions.is_free, + description: credit_transactions.description, + original_transaction_id: credit_transactions.original_transaction_id, + stripe_payment_id: credit_transactions.stripe_payment_id, + coinbase_credit_block_id: credit_transactions.coinbase_credit_block_id, + credit_category: credit_transactions.credit_category, + expiry_date: credit_transactions.expiry_date, + created_at: credit_transactions.created_at, + organization_id: credit_transactions.organization_id, + check_category_uniqueness: credit_transactions.check_category_uniqueness, + }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.organization_id, organizationId), + or( + isNull(credit_transactions.credit_category), + notLike(credit_transactions.credit_category, 'kpo:consumption:%') + ) + ) + ) + .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) + .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1) + .offset(cursor), + getCreditTransactionsSummaryForOrganization(organizationId), + ]); + + const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE; + const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE); + + return { + entries, + nextCursor: hasMore ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : null, + hasMore, + summary, + }; +} + export async function getAdminCreditTransactionsForOrganization( organizationId: Organization['id'] ) { diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index bdfb618ee4..2f010834e7 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,6 +62,7 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, + getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3967,3 +3968,78 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', } ); }); + +describe('getStripeInvoicesPage', () => { + test('returns hasMore, entries, and nextCursor from the last invoice', async () => { + const { client } = await import('@/lib/stripe-client'); + + const invoices = [ + { + id: 'in_page_1', + object: 'invoice', + number: 'INV-1', + status: 'paid', + amount_due: 100, + currency: 'usd', + created: 1000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + { + id: 'in_page_2', + object: 'invoice', + number: 'INV-2', + status: 'paid', + amount_due: 200, + currency: 'usd', + created: 2000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + ] as unknown as Stripe.Invoice[]; + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); + + const result = await getStripeInvoicesPage('cus_page_test'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_page_test', limit: 25 }) + ); + expect(result.hasMore).toBe(true); + expect(result.entries).toHaveLength(2); + expect(result.nextCursor).toBe('in_page_2'); + + listSpy.mockRestore(); + }); + + test('passes starting_after and date threshold through to Stripe', async () => { + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: [], + has_more: false, + } as unknown as Awaited>); + + const threshold = new Date('2026-01-01T00:00:00.000Z'); + const result = await getStripeInvoicesPage('cus_page_test', threshold, 'in_cursor'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus_page_test', + limit: 25, + starting_after: 'in_cursor', + created: { gte: Math.floor(threshold.getTime() / 1000) }, + }) + ); + expect(result.hasMore).toBe(false); + expect(result.entries).toEqual([]); + expect(result.nextCursor).toBeNull(); + + listSpy.mockRestore(); + }); +}); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 15dffb87a9..b0bb49ad54 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -656,6 +656,7 @@ async function recordKiloclawEarlybirdPurchase(user: User, charge: Stripe.Charge } } +// old form: array limit 100, no hasMore; remove when every client pages. export async function getStripeInvoices( stripeCustomerId: string, dateThreshold?: Date | null @@ -703,6 +704,73 @@ export async function getStripeInvoices( }); } +function mapStripeInvoicesToUnified(invoices: Stripe.Invoice[]): UnifiedInvoice[] { + return invoices.map(invoice => { + // Classify as 'seats' if any line item has seats metadata or a known paid seat price ID + const isSeatInvoice = + invoice.lines?.data?.some(line => { + const hasSeatsMetadata = + line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); + const priceId = line.pricing?.price_details?.price; + const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); + return hasSeatsMetadata || hasSeatPriceId; + }) ?? false; + + const firstLineDescription = invoice.lines?.data?.[0]?.description || null; + + return { + id: invoice.id || '', + number: invoice.number, + status: invoice.status || 'unknown', + amount_due: invoice.amount_due || 0, + currency: invoice.currency || 'usd', + created: invoice.created || 0, + hosted_invoice_url: invoice.hosted_invoice_url || null, + invoice_pdf: invoice.invoice_pdf || null, + invoice_type: isSeatInvoice ? 'seats' : 'topup', + description: firstLineDescription, + }; + }); +} + +export type StripeInvoicesPage = { + entries: UnifiedInvoice[]; + hasMore: boolean; + nextCursor: string | null; +}; + +export async function getStripeInvoicesPage( + stripeCustomerId: string, + dateThreshold?: Date | null, + startingAfter?: string | null +): Promise { + const listParams: Stripe.InvoiceListParams = { + customer: stripeCustomerId, + limit: 25, + expand: ['data.payment_intent', 'data.lines.data'], + }; + + if (dateThreshold) { + listParams.created = { + gte: Math.floor(dateThreshold.getTime() / 1000), // Convert to Unix timestamp + }; + } + + if (startingAfter) { + listParams.starting_after = startingAfter; + } + + const invoices = await client.invoices.list(listParams); + const entries = mapStripeInvoicesToUnified(invoices.data); + const lastInvoice = invoices.data[invoices.data.length - 1]; + + return { + entries, + hasMore: invoices.has_more, + nextCursor: lastInvoice ? lastInvoice.id : null, + }; +} + async function handlePaymentMethodEvent( event: | Stripe.PaymentMethodAttachedEvent diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 2cce2120e8..6bc4a698c3 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -31,7 +31,7 @@ import { } from '@/lib/organizations/organizations'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { resolveEffectiveOrganizationSsoPolicy } from '@/lib/organizations/organization-sso-policy'; -import { getStripeInvoices } from '@/lib/stripe'; +import { getStripeInvoices, getStripeInvoicesPage } from '@/lib/stripe'; import { adminProcedure, baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { OrganizationIdInputSchema, @@ -48,7 +48,10 @@ import { organizationsUsageDetailsRouter } from '@/routers/organizations/organiz import { TRPCError } from '@trpc/server'; import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import * as z from 'zod'; -import { getCreditTransactionsForOrganization } from '@/lib/creditTransactions'; +import { + getCreditTransactionsForOrganization, + getCreditTransactionsForOrganizationPage, +} from '@/lib/creditTransactions'; import { getCreditBlocks } from '@/lib/getCreditBlocks'; import { processOrganizationExpirations } from '@/lib/creditExpiration'; import { credit_transactions } from '@kilocode/db/schema'; @@ -105,6 +108,14 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ period: TimePeriodSchema.optional().default('month'), }); +const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ + cursor: z.number().int().min(0).default(0), +}); + +const OrganizationInvoicesPageInputSchema = OrganizationInvoicesInputSchema.extend({ + cursor: z.string().optional(), +}); + function daysAgo(days: number): Date { const now = new Date(); return new Date(now.getTime() - days * 24 * 60 * 60 * 1000); @@ -583,6 +594,15 @@ export const organizationsRouter = createTRPCRouter({ return await getCreditTransactionsForOrganization(opts.input.organizationId); }), + creditTransactionsPage: organizationMemberProcedure + .input(OrganizationTransactionsPageInputSchema) + .query(async opts => { + return await getCreditTransactionsForOrganizationPage( + opts.input.organizationId, + opts.input.cursor + ); + }), + getCreditBlocks: organizationMemberProcedure.query(async opts => { const now = new Date(); const organizationId = opts.input.organizationId; @@ -648,4 +668,25 @@ export const organizationsRouter = createTRPCRouter({ const invoices = await getStripeInvoices(stripeId, dateThreshold); return invoices; }), + + invoicesPage: organizationBillingProcedure + .input(OrganizationInvoicesPageInputSchema) + .query(async opts => { + const organization = await getOrganizationById(opts.input.organizationId); + if (!organization) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }); + } + + const dateThreshold = getDateThreshold(opts.input.period); + + let stripeId = organization.stripe_customer_id; + if (!stripeId) { + stripeId = await getOrCreateStripeCustomerIdForOrganization(opts.input.organizationId); + } + + return await getStripeInvoicesPage(stripeId, dateThreshold, opts.input.cursor); + }), }); From 3fb80d492250acdc61aef9eacc76bbfa90df9649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 00:44:34 +0200 Subject: [PATCH 2/4] test(web): fix stripe invoice page test spy isolation --- apps/web/src/lib/stripe/index.test.ts | 89 ++++++++++++++++----------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index 2f010834e7..ba5832f8cf 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -62,7 +62,6 @@ import { processStripePaymentEventHook, handleSuccessfulChargeWithPayment, isCardFingerprintEligibleForFreeCredits, - getStripeInvoicesPage, } from '@/lib/stripe'; import { type User, @@ -3971,8 +3970,6 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', describe('getStripeInvoicesPage', () => { test('returns hasMore, entries, and nextCursor from the last invoice', async () => { - const { client } = await import('@/lib/stripe-client'); - const invoices = [ { id: 'in_page_1', @@ -4000,46 +3997,64 @@ describe('getStripeInvoicesPage', () => { }, ] as unknown as Stripe.Invoice[]; - const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ - data: invoices, - has_more: true, - } as unknown as Awaited>); + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); - const result = await getStripeInvoicesPage('cus_page_test'); + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: true, + } as unknown as Awaited>); - expect(listSpy).toHaveBeenCalledWith( - expect.objectContaining({ customer: 'cus_page_test', limit: 25 }) - ); - expect(result.hasMore).toBe(true); - expect(result.entries).toHaveLength(2); - expect(result.nextCursor).toBe('in_page_2'); + const result = await stripe.getStripeInvoicesPage('cus_page_test'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_page_test', limit: 25 }) + ); + expect(result.hasMore).toBe(true); + expect(result.entries).toHaveLength(2); + expect(result.nextCursor).toBe('in_page_2'); - listSpy.mockRestore(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); test('passes starting_after and date threshold through to Stripe', async () => { - const { client } = await import('@/lib/stripe-client'); - - const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ - data: [], - has_more: false, - } as unknown as Awaited>); - - const threshold = new Date('2026-01-01T00:00:00.000Z'); - const result = await getStripeInvoicesPage('cus_page_test', threshold, 'in_cursor'); - - expect(listSpy).toHaveBeenCalledWith( - expect.objectContaining({ - customer: 'cus_page_test', - limit: 25, - starting_after: 'in_cursor', - created: { gte: Math.floor(threshold.getTime() / 1000) }, - }) - ); - expect(result.hasMore).toBe(false); - expect(result.entries).toEqual([]); - expect(result.nextCursor).toBeNull(); + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: [], + has_more: false, + } as unknown as Awaited>); + + const threshold = new Date('2026-01-01T00:00:00.000Z'); + const result = await stripe.getStripeInvoicesPage('cus_page_test', threshold, 'in_cursor'); + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus_page_test', + limit: 25, + starting_after: 'in_cursor', + created: { gte: Math.floor(threshold.getTime() / 1000) }, + }) + ); + expect(result.hasMore).toBe(false); + expect(result.entries).toEqual([]); + expect(result.nextCursor).toBeNull(); - listSpy.mockRestore(); + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } }); }); From f791d1752a0a0faec8b1d4a027ef822b25b6b4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 06:23:51 +0200 Subject: [PATCH 3/4] refactor(web): reuse unified invoice mapping in array path --- apps/web/src/lib/stripe/index.ts | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index b0bb49ad54..24995c7af7 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -676,32 +676,7 @@ export async function getStripeInvoices( const invoices = await client.invoices.list(listParams); const invoiceData: Stripe.Invoice[] = invoices.data; - return invoiceData.map(invoice => { - // Classify as 'seats' if any line item has seats metadata or a known paid seat price ID - const isSeatInvoice = - invoice.lines?.data?.some(line => { - const hasSeatsMetadata = - line.metadata != null && Object.prototype.hasOwnProperty.call(line.metadata, 'seats'); - const priceId = line.pricing?.price_details?.price; - const hasSeatPriceId = priceId != null && KNOWN_SEAT_PRICE_IDS.has(priceId); - return hasSeatsMetadata || hasSeatPriceId; - }) ?? false; - - const firstLineDescription = invoice.lines?.data?.[0]?.description || null; - - return { - id: invoice.id || '', - number: invoice.number, - status: invoice.status || 'unknown', - amount_due: invoice.amount_due || 0, - currency: invoice.currency || 'usd', - created: invoice.created || 0, - hosted_invoice_url: invoice.hosted_invoice_url || null, - invoice_pdf: invoice.invoice_pdf || null, - invoice_type: isSeatInvoice ? 'seats' : 'topup', - description: firstLineDescription, - }; - }); + return mapStripeInvoicesToUnified(invoiceData); } function mapStripeInvoicesToUnified(invoices: Stripe.Invoice[]): UnifiedInvoice[] { From 69835e4ba451e2e83337d5c09d41edea94154838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 26 Aug 2026 17:29:58 +0200 Subject: [PATCH 4/4] fix(web): make ledger and invoice cursors honest Page the organization credit ledger by keyset ((created_at, id) of the last row) instead of by OFFSET. The ledger grows at the head, so a transaction inserted between two requests shifted every later page and page 2 repeated a row page 1 already showed. Return a Stripe invoice cursor only when Stripe reports has_more, so a full final page no longer advertises a next page that is always empty. --- .../src/lib/creditTransactions.page.test.ts | 36 +++++++++++++++- apps/web/src/lib/creditTransactions.ts | 41 ++++++++++++++++--- apps/web/src/lib/stripe/index.test.ts | 40 ++++++++++++++++++ apps/web/src/lib/stripe/index.ts | 5 ++- .../organizations/organization-router.ts | 2 +- 5 files changed, 115 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/creditTransactions.page.test.ts b/apps/web/src/lib/creditTransactions.page.test.ts index ec3a94205c..b015ce3147 100644 --- a/apps/web/src/lib/creditTransactions.page.test.ts +++ b/apps/web/src/lib/creditTransactions.page.test.ts @@ -52,7 +52,7 @@ describe('getCreditTransactionsForOrganizationPage', () => { expect(page.entries).toHaveLength(25); expect(page.hasMore).toBe(true); - expect(page.nextCursor).toBe(25); + expect(page.nextCursor).toBe(`${page.entries[24]!.created_at}|${page.entries[24]!.id}`); expect(page.entries.every(entry => !entry.credit_category?.startsWith('kpo:consumption'))).toBe( true ); @@ -114,4 +114,38 @@ describe('getCreditTransactionsForOrganizationPage', () => { querySpy.mockRestore(); }); + + // An OFFSET cursor breaks here: a row inserted at the head between the two + // requests shifts every later page, so page 2 repeats a page-1 row. + test('keeps page 2 disjoint from page 1 when a new transaction lands between requests', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('stable page org', user.id, 0); + + await db.insert(credit_transactions).values( + Array.from({ length: 30 }, (_, index) => ({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 1_000_000, + description: `purchase ${index}`, + })) + ); + + const first = await getCreditTransactionsForOrganizationPage(org.id); + expect(first.hasMore).toBe(true); + + await db.insert(credit_transactions).values({ + kilo_user_id: user.id, + organization_id: org.id, + is_free: false, + amount_microdollars: 9_000_000, + description: 'inserted between pages', + }); + + const second = await getCreditTransactionsForOrganizationPage(org.id, first.nextCursor); + + const firstIds = new Set(first.entries.map(entry => entry.id)); + expect(second.entries.some(entry => firstIds.has(entry.id))).toBe(false); + expect(second.entries).toHaveLength(5); + }); }); diff --git a/apps/web/src/lib/creditTransactions.ts b/apps/web/src/lib/creditTransactions.ts index cdaf367ac3..d8e5d61c18 100644 --- a/apps/web/src/lib/creditTransactions.ts +++ b/apps/web/src/lib/creditTransactions.ts @@ -136,15 +136,39 @@ type OrganizationCreditTransaction = Awaited< export type CreditTransactionsPage = { entries: OrganizationCreditTransaction[]; - nextCursor: number | null; + nextCursor: string | null; hasMore: boolean; summary: CreditSummary; }; +/** + * Opaque keyset cursor: the ordering key of the last row a page returned, + * as `|`. An OFFSET cursor is not stable here — the ledger + * grows at the head, so a row inserted between two requests shifts every + * later page and page 2 repeats a row page 1 already showed. + * + * `created_at` is read in `mode: 'string'`, so the value keeps the full + * Postgres microsecond precision a JS `Date` would round away. + */ +function encodeLedgerCursor(row: { created_at: string; id: string }): string { + return `${row.created_at}|${row.id}`; +} + +function decodeLedgerCursor(cursor: string): { createdAt: string; id: string } | null { + const separator = cursor.indexOf('|'); + if (separator <= 0 || separator === cursor.length - 1) { + return null; + } + return { createdAt: cursor.slice(0, separator), id: cursor.slice(separator + 1) }; +} + export async function getCreditTransactionsForOrganizationPage( organizationId: Organization['id'], - cursor: number = 0 + cursor?: string | null ): Promise { + // A malformed cursor reads as "start from the top" rather than throwing: the + // value is opaque to the client and a stale one must not break the screen. + const decoded = cursor ? decodeLedgerCursor(cursor) : null; const [transactions, summary] = await Promise.all([ db .select({ @@ -173,21 +197,26 @@ export async function getCreditTransactionsForOrganizationPage( or( isNull(credit_transactions.credit_category), notLike(credit_transactions.credit_category, 'kpo:consumption:%') - ) + ), + // Row-value comparison in the same (created_at desc, id desc) order, + // so later pages stay disjoint from the ones already shown. + decoded + ? sql`(${credit_transactions.created_at}, ${credit_transactions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id}::uuid)` + : undefined ) ) .orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id)) - .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1) - .offset(cursor), + .limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1), getCreditTransactionsSummaryForOrganization(organizationId), ]); const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE; const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE); + const lastEntry = entries.at(-1); return { entries, - nextCursor: hasMore ? cursor + CREDIT_TRANSACTIONS_PAGE_SIZE : null, + nextCursor: hasMore && lastEntry ? encodeLedgerCursor(lastEntry) : null, hasMore, summary, }; diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index ba5832f8cf..2ae88348b9 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -4024,6 +4024,46 @@ describe('getStripeInvoicesPage', () => { } }); + test('returns no cursor on a full final page', async () => { + const invoices = [ + { + id: 'in_final', + object: 'invoice', + number: 'INV-9', + status: 'paid', + amount_due: 100, + currency: 'usd', + created: 1000, + hosted_invoice_url: null, + invoice_pdf: null, + lines: { data: [] }, + }, + ] as unknown as Stripe.Invoice[]; + + try { + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + const stripe = await import('@/lib/stripe'); + const { client } = await import('@/lib/stripe-client'); + + const listSpy = jest.spyOn(client.invoices, 'list').mockResolvedValue({ + data: invoices, + has_more: false, + } as unknown as Awaited>); + + const result = await stripe.getStripeInvoicesPage('cus_page_test'); + + expect(result.hasMore).toBe(false); + expect(result.entries).toHaveLength(1); + expect(result.nextCursor).toBeNull(); + + listSpy.mockRestore(); + }); + } finally { + jest.resetModules(); + } + }); + test('passes starting_after and date threshold through to Stripe', async () => { try { jest.resetModules(); diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 24995c7af7..dd6a131302 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -739,10 +739,13 @@ export async function getStripeInvoicesPage( const entries = mapStripeInvoicesToUnified(invoices.data); const lastInvoice = invoices.data[invoices.data.length - 1]; + // Tie the cursor to Stripe's own continuation signal. A full final page has + // a last invoice but no next page, and advertising its id as a cursor makes + // the caller fetch an empty page it can never end on. return { entries, hasMore: invoices.has_more, - nextCursor: lastInvoice ? lastInvoice.id : null, + nextCursor: invoices.has_more ? (lastInvoice?.id ?? null) : null, }; } diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 6bc4a698c3..fec7c18a5b 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -109,7 +109,7 @@ const OrganizationInvoicesInputSchema = OrganizationIdInputSchema.extend({ }); const OrganizationTransactionsPageInputSchema = OrganizationIdInputSchema.extend({ - cursor: z.number().int().min(0).default(0), + cursor: z.string().optional(), }); const OrganizationInvoicesPageInputSchema = OrganizationInvoicesInputSchema.extend({