Skip to content

Commit 8611c2a

Browse files
committed
fix(billing): address all code review findings from Phase 3
- Fix `any` type for logContext -> proper OrgAutoTopupLogContext interface - Remove stale sync_failures comment that was misleading - Preserve AutoTopupValidationError type when re-throwing (was losing error specificity) - Add expiration validation to org payment method flow (was missing unlike user flow) - Make auto_topup_threshold/amount nullable in orgs schema (consistent with users) - Improve getOrSetDefaultPaymentMethod API to return { paymentMethodId, wasUpdated } - Move message and adImpression tables from misc.ts to billing.ts (better domain organization) - Remove trivial comments describing obvious code - Add JSDoc explaining payment method type limitations - Fix misleading index comment in orgRepo - Extract isValidPaymentMethod() and filterValidPaymentMethods() helpers to eliminate duplication - Remove unused error field from OrgAutoTopupLogContext interface - Delete empty misc.ts file All 81 billing tests pass, all 13 package typechecks pass
1 parent 52cead3 commit 8611c2a

File tree

6 files changed

+155
-134
lines changed

6 files changed

+155
-134
lines changed

packages/billing/src/auto-topup-helpers.ts

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type Stripe from 'stripe'
55

66
/**
77
* Fetches both card and link payment methods for a Stripe customer.
8+
*
9+
* Note: Only 'card' and 'link' types are supported as these are the primary
10+
* payment method types used for off-session automatic charges. Other types
11+
* (e.g., 'us_bank_account', 'sepa_debit') may have different confirmation
12+
* requirements that don't work well with auto-topup flows.
813
*/
914
export async function fetchPaymentMethods(
1015
stripeCustomerId: string,
@@ -23,26 +28,41 @@ export async function fetchPaymentMethods(
2328
return [...cardPaymentMethods.data, ...linkPaymentMethods.data]
2429
}
2530

31+
/**
32+
* Checks if a payment method is valid for use.
33+
* Cards are checked for expiration, link methods are always valid.
34+
*/
35+
export function isValidPaymentMethod(pm: Stripe.PaymentMethod): boolean {
36+
if (pm.type === 'card') {
37+
return (
38+
pm.card?.exp_year !== undefined &&
39+
pm.card.exp_month !== undefined &&
40+
new Date(pm.card.exp_year, pm.card.exp_month - 1) > new Date()
41+
)
42+
}
43+
if (pm.type === 'link') {
44+
return true
45+
}
46+
return false
47+
}
48+
49+
/**
50+
* Filters payment methods to only include valid (non-expired) ones.
51+
*/
52+
export function filterValidPaymentMethods(
53+
paymentMethods: Stripe.PaymentMethod[],
54+
): Stripe.PaymentMethod[] {
55+
return paymentMethods.filter(isValidPaymentMethod)
56+
}
57+
2658
/**
2759
* Finds the first valid (non-expired) payment method from a list.
2860
* Cards are checked for expiration, link methods are always valid.
2961
*/
3062
export function findValidPaymentMethod(
3163
paymentMethods: Stripe.PaymentMethod[],
3264
): Stripe.PaymentMethod | undefined {
33-
return paymentMethods.find((pm) => {
34-
if (pm.type === 'card') {
35-
return (
36-
pm.card?.exp_year &&
37-
pm.card.exp_month &&
38-
new Date(pm.card.exp_year, pm.card.exp_month - 1) > new Date()
39-
)
40-
}
41-
if (pm.type === 'link') {
42-
return true
43-
}
44-
return false
45-
})
65+
return paymentMethods.find(isValidPaymentMethod)
4666
}
4767

4868
export interface PaymentIntentParams {
@@ -86,22 +106,25 @@ export async function createPaymentIntent(
86106
)
87107
}
88108

109+
export interface GetOrSetDefaultPaymentMethodResult {
110+
paymentMethodId: string
111+
wasUpdated: boolean
112+
}
113+
89114
/**
90115
* Gets the default payment method for a customer, or selects and sets the first available one.
91-
* Returns the payment method ID to use.
116+
* Returns the payment method ID to use and whether it was newly set as default.
92117
*/
93118
export async function getOrSetDefaultPaymentMethod(params: {
94119
stripeCustomerId: string
95120
paymentMethods: Stripe.PaymentMethod[]
96121
logger: Logger
97122
logContext: Record<string, unknown>
98-
}): Promise<string> {
123+
}): Promise<GetOrSetDefaultPaymentMethodResult> {
99124
const { stripeCustomerId, paymentMethods, logger, logContext } = params
100125

101-
// Get the customer to check for default payment method
102126
const customer = await stripeServer.customers.retrieve(stripeCustomerId)
103127

104-
// Check if there's already a default payment method
105128
if (
106129
customer &&
107130
!customer.deleted &&
@@ -112,7 +135,6 @@ export async function getOrSetDefaultPaymentMethod(params: {
112135
? customer.invoice_settings.default_payment_method
113136
: customer.invoice_settings.default_payment_method.id
114137

115-
// Verify the default payment method is still valid and available
116138
const isDefaultValid = paymentMethods.some(
117139
(pm) => pm.id === defaultPaymentMethodId,
118140
)
@@ -122,20 +144,21 @@ export async function getOrSetDefaultPaymentMethod(params: {
122144
{ ...logContext, paymentMethodId: defaultPaymentMethodId },
123145
'Using existing default payment method',
124146
)
125-
return defaultPaymentMethodId
147+
return { paymentMethodId: defaultPaymentMethodId, wasUpdated: false }
126148
}
127149
}
128150

129-
// Use the first available and set it as default
130151
const firstPaymentMethod = paymentMethods[0]
131152
const paymentMethodToUse = firstPaymentMethod.id
153+
let wasUpdated = false
132154

133155
try {
134156
await stripeServer.customers.update(stripeCustomerId, {
135157
invoice_settings: {
136158
default_payment_method: paymentMethodToUse,
137159
},
138160
})
161+
wasUpdated = true
139162

140163
logger.info(
141164
{ ...logContext, paymentMethodId: paymentMethodToUse },
@@ -148,5 +171,5 @@ export async function getOrSetDefaultPaymentMethod(params: {
148171
)
149172
}
150173

151-
return paymentMethodToUse
174+
return { paymentMethodId: paymentMethodToUse, wasUpdated }
152175
}

packages/billing/src/auto-topup.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm'
1010
import {
1111
createPaymentIntent,
1212
fetchPaymentMethods,
13+
filterValidPaymentMethods,
1314
findValidPaymentMethod,
1415
getOrSetDefaultPaymentMethod,
1516
} from './auto-topup-helpers'
@@ -184,7 +185,6 @@ export async function checkAndTriggerAutoTopup(params: {
184185
const logContext = { userId }
185186

186187
try {
187-
// Get user info
188188
const user = await db.query.user.findFirst({
189189
where: eq(schema.user.id, userId),
190190
columns: {
@@ -206,7 +206,6 @@ export async function checkAndTriggerAutoTopup(params: {
206206
return undefined
207207
}
208208

209-
// Calculate balance
210209
const { balance } = await calculateUsageAndBalance({
211210
...params,
212211
quotaResetDate: user.next_quota_reset ?? new Date(0),
@@ -252,12 +251,13 @@ export async function checkAndTriggerAutoTopup(params: {
252251
`Auto-top-up needed for user ${userId}. Will attempt to purchase ${amountToTopUp} credits.`,
253252
)
254253

255-
// Validate payment method
256254
const { blockedReason, validPaymentMethod } =
257255
await validateAutoTopupStatus(params)
258256

259257
if (blockedReason || !validPaymentMethod) {
260-
throw new Error(blockedReason || 'Auto top-up is not available.')
258+
throw new AutoTopupValidationError(
259+
blockedReason || 'Auto top-up is not available.',
260+
)
261261
}
262262

263263
try {
@@ -341,12 +341,22 @@ async function getOrganizationPaymentMethod(params: {
341341
)
342342
}
343343

344-
return getOrSetDefaultPaymentMethod({
344+
const validPaymentMethods = filterValidPaymentMethods(allPaymentMethods)
345+
346+
if (validPaymentMethods.length === 0) {
347+
throw new AutoTopupPaymentError(
348+
'No valid (non-expired) payment methods available for organization',
349+
)
350+
}
351+
352+
const { paymentMethodId } = await getOrSetDefaultPaymentMethod({
345353
stripeCustomerId,
346-
paymentMethods: allPaymentMethods,
354+
paymentMethods: validPaymentMethods,
347355
logger,
348356
logContext,
349357
})
358+
359+
return paymentMethodId
350360
}
351361

352362
async function processOrgAutoTopupPayment(params: {
@@ -412,13 +422,21 @@ async function processOrgAutoTopupPayment(params: {
412422
)
413423
}
414424

425+
interface OrgAutoTopupLogContext {
426+
organizationId: string
427+
userId: string
428+
currentBalance?: number
429+
threshold?: number | null
430+
amountToTopUp?: number
431+
}
432+
415433
export async function checkAndTriggerOrgAutoTopup(params: {
416434
organizationId: string
417435
userId: string
418436
logger: Logger
419437
}): Promise<void> {
420438
const { organizationId, userId, logger } = params
421-
const logContext: any = { organizationId, userId }
439+
const logContext: OrgAutoTopupLogContext = { organizationId, userId }
422440

423441
try {
424442
const org = await getOrganizationSettings(organizationId)
@@ -471,8 +489,6 @@ export async function checkAndTriggerOrgAutoTopup(params: {
471489
stripeCustomerId: org.stripe_customer_id,
472490
})
473491
} catch (error) {
474-
// Auto-topup failures are automatically logged to sync_failures table
475-
// by the existing error handling in processOrgAutoTopupPayment
476492
logger.error(
477493
{ ...logContext, error },
478494
'Organization auto top-up payment failed',

packages/internal/src/db/schema/billing.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { sql } from 'drizzle-orm'
22
import {
3+
boolean,
34
index,
45
integer,
6+
jsonb,
7+
numeric,
58
pgTable,
69
text,
710
timestamp,
811
} from 'drizzle-orm/pg-core'
912

13+
import type { SQL } from 'drizzle-orm'
14+
1015
import { grantTypeEnum } from './enums'
1116
import { org } from './organizations'
1217
import { user } from './users'
@@ -69,3 +74,80 @@ export const syncFailure = pgTable(
6974
.where(sql`${table.retry_count} < 5`),
7075
],
7176
)
77+
78+
// Usage tracking table - stores LLM message costs and token usage
79+
export const message = pgTable(
80+
'message',
81+
{
82+
id: text('id').primaryKey(),
83+
finished_at: timestamp('finished_at', { mode: 'date' }).notNull(),
84+
client_id: text('client_id'),
85+
client_request_id: text('client_request_id'),
86+
model: text('model').notNull(),
87+
agent_id: text('agent_id'),
88+
request: jsonb('request'),
89+
lastMessage: jsonb('last_message').generatedAlwaysAs(
90+
(): SQL => sql`${message.request} -> -1`,
91+
),
92+
reasoning_text: text('reasoning_text'),
93+
response: jsonb('response').notNull(),
94+
input_tokens: integer('input_tokens').notNull().default(0),
95+
cache_creation_input_tokens: integer('cache_creation_input_tokens'),
96+
cache_read_input_tokens: integer('cache_read_input_tokens')
97+
.notNull()
98+
.default(0),
99+
reasoning_tokens: integer('reasoning_tokens'),
100+
output_tokens: integer('output_tokens').notNull(),
101+
cost: numeric('cost', { precision: 100, scale: 20 }).notNull(),
102+
credits: integer('credits').notNull(),
103+
byok: boolean('byok').notNull().default(false),
104+
latency_ms: integer('latency_ms'),
105+
user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
106+
org_id: text('org_id').references(() => org.id, { onDelete: 'cascade' }),
107+
repo_url: text('repo_url'),
108+
},
109+
(table) => [
110+
index('message_user_id_idx').on(table.user_id),
111+
index('message_finished_at_user_id_idx').on(
112+
table.finished_at,
113+
table.user_id,
114+
),
115+
index('message_org_id_idx').on(table.org_id),
116+
index('message_org_id_finished_at_idx').on(table.org_id, table.finished_at),
117+
],
118+
)
119+
120+
// Ad impression tracking - grants credits to users for viewing ads
121+
export const adImpression = pgTable(
122+
'ad_impression',
123+
{
124+
id: text('id')
125+
.primaryKey()
126+
.$defaultFn(() => crypto.randomUUID()),
127+
user_id: text('user_id')
128+
.notNull()
129+
.references(() => user.id, { onDelete: 'cascade' }),
130+
ad_text: text('ad_text').notNull(),
131+
title: text('title').notNull(),
132+
cta: text('cta').notNull().default(''),
133+
url: text('url').notNull(),
134+
favicon: text('favicon').notNull(),
135+
click_url: text('click_url').notNull(),
136+
imp_url: text('imp_url').notNull().unique(),
137+
payout: numeric('payout', { precision: 10, scale: 6 }).notNull(),
138+
credits_granted: integer('credits_granted').notNull(),
139+
grant_operation_id: text('grant_operation_id'),
140+
served_at: timestamp('served_at', { mode: 'date', withTimezone: true })
141+
.notNull()
142+
.defaultNow(),
143+
impression_fired_at: timestamp('impression_fired_at', {
144+
mode: 'date',
145+
withTimezone: true,
146+
}),
147+
clicked_at: timestamp('clicked_at', { mode: 'date', withTimezone: true }),
148+
},
149+
(table) => [
150+
index('idx_ad_impression_user').on(table.user_id, table.served_at),
151+
index('idx_ad_impression_imp_url').on(table.imp_url),
152+
],
153+
)

packages/internal/src/db/schema/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ export * from './users'
44
export * from './organizations'
55
export * from './billing'
66
export * from './agents'
7-
export * from './misc'
7+
// Note: misc.ts is now empty - message and adImpression moved to billing.ts

0 commit comments

Comments
 (0)