Skip to content

Commit d73af9f

Browse files
committed
refactor(billing): DRY up auto-topup logic (Commit 3.1)
- Extract shared helpers to auto-topup-helpers.ts: - fetchPaymentMethods() - fetches card + link payment methods - findValidPaymentMethod() - validates payment methods (expiration check) - createPaymentIntent() - creates Stripe payment intent with idempotency - getOrSetDefaultPaymentMethod() - gets/sets default payment method - Reduce duplication between user and org auto-topup flows - All 81 billing tests pass
1 parent fd43ff3 commit d73af9f

File tree

3 files changed

+196
-149
lines changed

3 files changed

+196
-149
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { stripeServer } from '@codebuff/internal/util/stripe'
2+
3+
import type { Logger } from '@codebuff/common/types/contracts/logger'
4+
import type Stripe from 'stripe'
5+
6+
/**
7+
* Fetches both card and link payment methods for a Stripe customer.
8+
*/
9+
export async function fetchPaymentMethods(
10+
stripeCustomerId: string,
11+
): Promise<Stripe.PaymentMethod[]> {
12+
const [cardPaymentMethods, linkPaymentMethods] = await Promise.all([
13+
stripeServer.paymentMethods.list({
14+
customer: stripeCustomerId,
15+
type: 'card',
16+
}),
17+
stripeServer.paymentMethods.list({
18+
customer: stripeCustomerId,
19+
type: 'link',
20+
}),
21+
])
22+
23+
return [...cardPaymentMethods.data, ...linkPaymentMethods.data]
24+
}
25+
26+
/**
27+
* Finds the first valid (non-expired) payment method from a list.
28+
* Cards are checked for expiration, link methods are always valid.
29+
*/
30+
export function findValidPaymentMethod(
31+
paymentMethods: Stripe.PaymentMethod[],
32+
): 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+
})
46+
}
47+
48+
export interface PaymentIntentParams {
49+
amountInCents: number
50+
stripeCustomerId: string
51+
paymentMethodId: string
52+
description: string
53+
idempotencyKey: string
54+
metadata: Record<string, string>
55+
}
56+
57+
/**
58+
* Creates a Stripe payment intent with idempotency key for safe retries.
59+
*/
60+
export async function createPaymentIntent(
61+
params: PaymentIntentParams,
62+
): Promise<Stripe.PaymentIntent> {
63+
const {
64+
amountInCents,
65+
stripeCustomerId,
66+
paymentMethodId,
67+
description,
68+
idempotencyKey,
69+
metadata,
70+
} = params
71+
72+
return stripeServer.paymentIntents.create(
73+
{
74+
amount: amountInCents,
75+
currency: 'usd',
76+
customer: stripeCustomerId,
77+
payment_method: paymentMethodId,
78+
off_session: true,
79+
confirm: true,
80+
description,
81+
metadata,
82+
},
83+
{
84+
idempotencyKey,
85+
},
86+
)
87+
}
88+
89+
/**
90+
* Gets the default payment method for a customer, or selects and sets the first available one.
91+
* Returns the payment method ID to use.
92+
*/
93+
export async function getOrSetDefaultPaymentMethod(params: {
94+
stripeCustomerId: string
95+
paymentMethods: Stripe.PaymentMethod[]
96+
logger: Logger
97+
logContext: Record<string, unknown>
98+
}): Promise<string> {
99+
const { stripeCustomerId, paymentMethods, logger, logContext } = params
100+
101+
// Get the customer to check for default payment method
102+
const customer = await stripeServer.customers.retrieve(stripeCustomerId)
103+
104+
// Check if there's already a default payment method
105+
if (
106+
customer &&
107+
!customer.deleted &&
108+
customer.invoice_settings?.default_payment_method
109+
) {
110+
const defaultPaymentMethodId =
111+
typeof customer.invoice_settings.default_payment_method === 'string'
112+
? customer.invoice_settings.default_payment_method
113+
: customer.invoice_settings.default_payment_method.id
114+
115+
// Verify the default payment method is still valid and available
116+
const isDefaultValid = paymentMethods.some(
117+
(pm) => pm.id === defaultPaymentMethodId,
118+
)
119+
120+
if (isDefaultValid) {
121+
logger.debug(
122+
{ ...logContext, paymentMethodId: defaultPaymentMethodId },
123+
'Using existing default payment method',
124+
)
125+
return defaultPaymentMethodId
126+
}
127+
}
128+
129+
// Use the first available and set it as default
130+
const firstPaymentMethod = paymentMethods[0]
131+
const paymentMethodToUse = firstPaymentMethod.id
132+
133+
try {
134+
await stripeServer.customers.update(stripeCustomerId, {
135+
invoice_settings: {
136+
default_payment_method: paymentMethodToUse,
137+
},
138+
})
139+
140+
logger.info(
141+
{ ...logContext, paymentMethodId: paymentMethodToUse },
142+
'Set first available payment method as default',
143+
)
144+
} catch (error) {
145+
logger.warn(
146+
{ ...logContext, paymentMethodId: paymentMethodToUse, error },
147+
'Failed to set default payment method, but will proceed with payment',
148+
)
149+
}
150+
151+
return paymentMethodToUse
152+
}

0 commit comments

Comments
 (0)