Skip to content

Commit 07da540

Browse files
sarimrmalikmatthewlouisbrockmanben-fornefeld
authored
Fix: Add recovery dialogs for missing payment method and verification required (#305)
Co-authored-by: Matt Brockman <matt.brockman@e2b.dev> Co-authored-by: ben-fornefeld <ben.fornefeld@gmail.com>
1 parent c664bbb commit 07da540

15 files changed

Lines changed: 1436 additions & 54 deletions

src/core/modules/billing/models.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,24 @@ export interface AddOnOrderConfirmResponse {
5555
client_secret: string
5656
}
5757

58+
export interface VerificationPaymentResponse {
59+
client_secret: string
60+
amount_due_cents: number
61+
}
62+
5863
export interface PaymentMethodsCustomerSession {
5964
client_secret: string
6065
}
6166

67+
export interface PaymentMethodsSession {
68+
client_secret: string
69+
setup_intent_client_secret?: string
70+
}
71+
72+
export interface PaymentMethodsSetupSession extends PaymentMethodsSession {
73+
setup_intent_client_secret: string
74+
}
75+
6276
export interface TierLimits {
6377
sandbox_concurrency: number
6478
max_cpu: number

src/core/modules/billing/repository.server.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'server-only'
22

3+
import { z } from 'zod'
34
import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
45
import type {
56
AddOnOrderConfirmResponse,
@@ -8,8 +9,10 @@ import type {
89
CustomerPortalResponse,
910
Invoice,
1011
PaymentMethodsCustomerSession,
12+
PaymentMethodsSetupSession,
1113
TeamItems,
1214
UsageResponse,
15+
VerificationPaymentResponse,
1316
} from '@/core/modules/billing/models'
1417
import { repoErrorFromHttp } from '@/core/shared/errors'
1518
import type { TeamRequestScope } from '@/core/shared/repository-scope'
@@ -35,12 +38,24 @@ export interface BillingRepository {
3538
createOrder(itemId: string): Promise<RepoResult<AddOnOrderCreateResponse>>
3639
confirmOrder(orderId: string): Promise<RepoResult<AddOnOrderConfirmResponse>>
3740
getCustomerSession(): Promise<RepoResult<PaymentMethodsCustomerSession>>
41+
createPaymentMethodsSession(): Promise<RepoResult<PaymentMethodsSetupSession>>
42+
createVerificationPayment(): Promise<RepoResult<VerificationPaymentResponse>>
3843
}
3944

4045
async function parseText(response: Response): Promise<string> {
4146
return (await response.text()) || 'Request failed'
4247
}
4348

49+
const PaymentMethodsSessionResponseSchema = z.object({
50+
client_secret: z.string().min(1),
51+
setup_intent_client_secret: z.string().min(1),
52+
})
53+
54+
const VerificationPaymentResponseSchema = z.object({
55+
client_secret: z.string().min(1),
56+
amount_due_cents: z.number().int().positive(),
57+
})
58+
4459
export function createBillingRepository(
4560
scope: BillingScope,
4661
deps: BillingRepositoryDeps = {
@@ -249,7 +264,7 @@ export function createBillingRepository(
249264
},
250265
async getCustomerSession() {
251266
const res = await fetch(
252-
`${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`,
267+
`${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`,
253268
{
254269
method: 'POST',
255270
headers: {
@@ -265,5 +280,61 @@ export function createBillingRepository(
265280

266281
return ok((await res.json()) as PaymentMethodsCustomerSession)
267282
},
283+
async createPaymentMethodsSession() {
284+
const res = await fetch(
285+
`${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session?include_setup_intent=true`,
286+
{
287+
method: 'POST',
288+
headers: {
289+
'Content-Type': 'application/json',
290+
...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId),
291+
},
292+
}
293+
)
294+
295+
if (!res.ok) {
296+
return err(repoErrorFromHttp(res.status, await parseText(res)))
297+
}
298+
299+
const parseResult = PaymentMethodsSessionResponseSchema.safeParse(
300+
await res.json()
301+
)
302+
303+
if (!parseResult.success) {
304+
return err(
305+
repoErrorFromHttp(500, 'Invalid payment methods session response')
306+
)
307+
}
308+
309+
return ok(parseResult.data)
310+
},
311+
async createVerificationPayment() {
312+
const res = await fetch(
313+
`${deps.billingApiUrl}/teams/${scope.teamId}/verification-payment`,
314+
{
315+
method: 'POST',
316+
headers: {
317+
'Content-Type': 'application/json',
318+
...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId),
319+
},
320+
}
321+
)
322+
323+
if (!res.ok) {
324+
return err(repoErrorFromHttp(res.status, await parseText(res)))
325+
}
326+
327+
const parseResult = VerificationPaymentResponseSchema.safeParse(
328+
await res.json()
329+
)
330+
331+
if (!parseResult.success) {
332+
return err(
333+
repoErrorFromHttp(500, 'Invalid verification payment response')
334+
)
335+
}
336+
337+
return ok(parseResult.data)
338+
},
268339
}
269340
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const TEAM_BLOCKED_REASONS = {
2+
missingPayment: 'missing payment method',
3+
verification: 'verification required',
4+
} as const
5+
6+
export { TEAM_BLOCKED_REASONS }

src/core/modules/teams/models.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { components as DashboardComponents } from '@/contracts/dashboard-api'
2+
import type { TEAM_BLOCKED_REASONS } from './constants'
23

34
export type TeamModel = DashboardComponents['schemas']['UserTeam']
45
export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits']
6+
export type TeamBlockedReason =
7+
(typeof TEAM_BLOCKED_REASONS)[keyof typeof TEAM_BLOCKED_REASONS]
58

69
export type TeamMemberInfo = {
710
id: string

src/core/server/api/routers/billing.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server'
22
import { headers } from 'next/headers'
33
import { z } from 'zod'
44
import { createBillingRepository } from '@/core/modules/billing/repository.server'
5-
import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server'
65
import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors'
76
import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository'
87
import { createTRPCRouter } from '@/core/server/trpc/init'
@@ -25,12 +24,6 @@ const billingRepositoryProcedure = protectedTeamProcedure.use(
2524
)
2625
)
2726

28-
const billingAndTeamsRepositoryProcedure = billingRepositoryProcedure.use(
29-
withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({
30-
teamsRepository,
31-
}))
32-
)
33-
3427
export const billingRouter = createTRPCRouter({
3528
createCheckout: billingRepositoryProcedure
3629
.input(z.object({ tierId: z.string() }))
@@ -136,4 +129,19 @@ export const billingRouter = createTRPCRouter({
136129
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
137130
return result.data
138131
}),
132+
133+
createPaymentMethodsSession: billingRepositoryProcedure.mutation(
134+
async ({ ctx }) => {
135+
const result = await ctx.billingRepository.createPaymentMethodsSession()
136+
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
137+
return result.data
138+
}
139+
),
140+
createVerificationPayment: billingRepositoryProcedure.mutation(
141+
async ({ ctx }) => {
142+
const result = await ctx.billingRepository.createVerificationPayment()
143+
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
144+
return result.data
145+
}
146+
),
139147
})

src/features/dashboard/billing/hooks.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,21 @@ export function usePaymentElementAppearance() {
140140
'.AccordionButton--selected:hover': {
141141
backgroundColor: isDark ? '#1f1f1f' : '#f2f2f2',
142142
},
143+
'.CheckboxInput': {
144+
backgroundColor: isDark ? '#1f1f1f' : '#ffffff',
145+
border: isDark ? '1px solid #424242' : '1px solid #707070',
146+
boxShadow: 'none',
147+
},
148+
'.CheckboxInput:hover': {
149+
border: isDark ? '1px solid #848484' : '1px solid #333333',
150+
},
151+
'.CheckboxInput--checked': {
152+
backgroundColor: isDark ? '#ff8800' : '#e56f00',
153+
border: isDark ? '1px solid #ff8800' : '1px solid #e56f00',
154+
},
155+
'.CheckboxLabel': {
156+
color: isDark ? '#e6e6e6' : '#333333',
157+
},
143158
'.Spinner': {
144159
color: isDark ? '#ff8800' : '#e56f00', // accent-main-highlight
145160
borderColor: isDark

0 commit comments

Comments
 (0)