Skip to content

Commit 75ab019

Browse files
committed
refactor(billing): DRY up auto-topup logic (Commit 3.1)
- Create auto-topup-helpers.ts with shared payment method helpers - Extract fetchPaymentMethods, isValidPaymentMethod, filterValidPaymentMethods - Extract findValidPaymentMethod, createPaymentIntent, getOrSetDefaultPaymentMethod - Reduce duplication between user and org auto-topup flows
1 parent a5cd260 commit 75ab019

File tree

3 files changed

+250
-152
lines changed

3 files changed

+250
-152
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
* 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.
13+
*/
14+
export async function fetchPaymentMethods(
15+
stripeCustomerId: string,
16+
): Promise<Stripe.PaymentMethod[]> {
17+
const [cardPaymentMethods, linkPaymentMethods] = await Promise.all([
18+
stripeServer.paymentMethods.list({
19+
customer: stripeCustomerId,
20+
type: 'card',
21+
}),
22+
stripeServer.paymentMethods.list({
23+
customer: stripeCustomerId,
24+
type: 'link',
25+
}),
26+
])
27+
28+
return [...cardPaymentMethods.data, ...linkPaymentMethods.data]
29+
}
30+
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+
58+
/**
59+
* Finds the first valid (non-expired) payment method from a list.
60+
* Cards are checked for expiration, link methods are always valid.
61+
*/
62+
export function findValidPaymentMethod(
63+
paymentMethods: Stripe.PaymentMethod[],
64+
): Stripe.PaymentMethod | undefined {
65+
return paymentMethods.find(isValidPaymentMethod)
66+
}
67+
68+
export interface PaymentIntentParams {
69+
amountInCents: number
70+
stripeCustomerId: string
71+
paymentMethodId: string
72+
description: string
73+
idempotencyKey: string
74+
metadata: Record<string, string>
75+
}
76+
77+
/**
78+
* Creates a Stripe payment intent with idempotency key for safe retries.
79+
*/
80+
export async function createPaymentIntent(
81+
params: PaymentIntentParams,
82+
): Promise<Stripe.PaymentIntent> {
83+
const {
84+
amountInCents,
85+
stripeCustomerId,
86+
paymentMethodId,
87+
description,
88+
idempotencyKey,
89+
metadata,
90+
} = params
91+
92+
return stripeServer.paymentIntents.create(
93+
{
94+
amount: amountInCents,
95+
currency: 'usd',
96+
customer: stripeCustomerId,
97+
payment_method: paymentMethodId,
98+
off_session: true,
99+
confirm: true,
100+
description,
101+
metadata,
102+
},
103+
{
104+
idempotencyKey,
105+
},
106+
)
107+
}
108+
109+
export interface GetOrSetDefaultPaymentMethodResult {
110+
paymentMethodId: string
111+
wasUpdated: boolean
112+
}
113+
114+
/**
115+
* Gets the default payment method for a customer, or selects and sets the first available one.
116+
* Returns the payment method ID to use and whether it was newly set as default.
117+
*/
118+
export async function getOrSetDefaultPaymentMethod(params: {
119+
stripeCustomerId: string
120+
paymentMethods: Stripe.PaymentMethod[]
121+
logger: Logger
122+
logContext: Record<string, unknown>
123+
}): Promise<GetOrSetDefaultPaymentMethodResult> {
124+
const { stripeCustomerId, paymentMethods, logger, logContext } = params
125+
126+
const customer = await stripeServer.customers.retrieve(stripeCustomerId)
127+
128+
if (
129+
customer &&
130+
!customer.deleted &&
131+
customer.invoice_settings?.default_payment_method
132+
) {
133+
const defaultPaymentMethodId =
134+
typeof customer.invoice_settings.default_payment_method === 'string'
135+
? customer.invoice_settings.default_payment_method
136+
: customer.invoice_settings.default_payment_method.id
137+
138+
const isDefaultValid = paymentMethods.some(
139+
(pm) => pm.id === defaultPaymentMethodId,
140+
)
141+
142+
if (isDefaultValid) {
143+
logger.debug(
144+
{ ...logContext, paymentMethodId: defaultPaymentMethodId },
145+
'Using existing default payment method',
146+
)
147+
return { paymentMethodId: defaultPaymentMethodId, wasUpdated: false }
148+
}
149+
}
150+
151+
const firstPaymentMethod = paymentMethods[0]
152+
const paymentMethodToUse = firstPaymentMethod.id
153+
let wasUpdated = false
154+
155+
try {
156+
await stripeServer.customers.update(stripeCustomerId, {
157+
invoice_settings: {
158+
default_payment_method: paymentMethodToUse,
159+
},
160+
})
161+
wasUpdated = true
162+
163+
logger.info(
164+
{ ...logContext, paymentMethodId: paymentMethodToUse },
165+
'Set first available payment method as default',
166+
)
167+
} catch (error) {
168+
logger.warn(
169+
{ ...logContext, paymentMethodId: paymentMethodToUse, error },
170+
'Failed to set default payment method, but will proceed with payment',
171+
)
172+
}
173+
174+
return { paymentMethodId: paymentMethodToUse, wasUpdated }
175+
}

0 commit comments

Comments
 (0)