From 54fbf2fdd026e919b8834314b27e1ed6dd3acfc9 Mon Sep 17 00:00:00 2001 From: nicholas-fierro Date: Wed, 17 Sep 2025 22:59:54 -0500 Subject: [PATCH 1/2] Refactor checkout webhook for shared Supabase client and idempotent candidate/team processors --- .../api/webhooks/complete-checkout/route.ts | 425 ++++++++++++------ 1 file changed, 283 insertions(+), 142 deletions(-) diff --git a/app/(public)/api/webhooks/complete-checkout/route.ts b/app/(public)/api/webhooks/complete-checkout/route.ts index 65c2ef12..c487e9a6 100644 --- a/app/(public)/api/webhooks/complete-checkout/route.ts +++ b/app/(public)/api/webhooks/complete-checkout/route.ts @@ -8,6 +8,9 @@ import { Tables } from '@/database.types' import { getWeekendRosterRecord } from '@/actions/weekend' import { notifyAssistantHeadForTeamPayment } from '@/actions/emails' +type SupabaseServerClient = Awaited> +type PaymentProcessingResult = Result + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! if (!webhookSecret) { @@ -58,144 +61,26 @@ export async function POST(request: NextRequest) { console.log(`Processing completed checkout session:`, session) const priceId = session.metadata?.price_id + const supabase = await createClient() - // Check if this is a candidate payment switch (priceId) { - case process.env.CANDIDATE_FEE_PRICE_ID: - const candidateId = session.metadata?.candidate_id ?? null - logger.info( - `Processing candidate payment for candidate: ${candidateId}` - ) - - const candidateIsAwaitingPaymentResult = - await candidateIsAwaitingPayment(candidateId) - if (isErr(candidateIsAwaitingPaymentResult)) { - logger.error( - candidateIsAwaitingPaymentResult.error, - '💢 Candidate is not awaiting payment' - ) + case process.env.CANDIDATE_FEE_PRICE_ID: { + const candidateResult = await processCandidatePayment(supabase, session) + if (isErr(candidateResult)) { return NextResponse.json( - { error: candidateIsAwaitingPaymentResult.error }, + { error: candidateResult.error }, { status: 400 } ) } - - logger.info( - `✅ Found candidate tied to payment, and they are awaiting payment` - ) - - // This non-null assertion is safe because we check for id existance in the function above - const recordCandidatePaymentResult = await recordCandidatePayment( - candidateId!, - session - ) - if (isErr(recordCandidatePaymentResult)) { - logger.error( - recordCandidatePaymentResult.error, - '💢 Failed to record candidate payment' - ) - return NextResponse.json( - { error: recordCandidatePaymentResult.error }, - { status: 400 } - ) - } - - logger.info( - `✅ Successfully recorded candidate_payment ${recordCandidatePaymentResult.data.id} for candidate id ${candidateId}` - ) - - const confirmCandidateResult = await confirmCandidate(candidateId!) - if (isErr(confirmCandidateResult)) { - logger.error( - confirmCandidateResult.error, - '💢 Failed to confirm candidate' - ) - return NextResponse.json( - { error: confirmCandidateResult.error }, - { status: 400 } - ) - } - - logger.info(`✅ Successfully confirmed candidate ${candidateId}`) break - case process.env.TEAM_FEE_PRICE_ID: - const teamUserId = session.metadata?.user_id ?? null - const weekendId = session.metadata?.weekend_id ?? null - logger.info(`Processing team payment for team: ${teamUserId}`) - - const weekendRosterRecord = await getWeekendRosterRecord( - teamUserId, - weekendId - ) - if (isErr(weekendRosterRecord)) { - logger.error( - weekendRosterRecord.error, - '💢 Failed to get weekend_roster record' - ) - return NextResponse.json( - { error: weekendRosterRecord.error }, - { status: 400 } - ) - } - - logger.info( - `✅ Found weekend_roster record for team member: ${teamUserId}` - ) - - const weekendRosterPaymentRecord = await recordWeekendRosterPayment( - weekendRosterRecord.data.id!, - session - ) - if (isErr(weekendRosterPaymentRecord)) { - logger.error( - weekendRosterPaymentRecord.error, - '💢 Failed to record weekend_roster_payment' - ) - return NextResponse.json( - { error: weekendRosterPaymentRecord.error }, - { status: 400 } - ) - } - - logger.info( - `✅ Successfully recorded weekend_roster_payment ${weekendRosterPaymentRecord.data.id} for weekend_roster_id ${weekendRosterRecord.data.id}` - ) - - const markTeamMemberAsPaidResult = await markTeamMemberAsPaid( - weekendRosterRecord.data.id - ) - if (isErr(markTeamMemberAsPaidResult)) { - logger.error( - markTeamMemberAsPaidResult.error, - '💢 Failed to mark team member as paid' - ) - return NextResponse.json( - { error: markTeamMemberAsPaidResult.error }, - { status: 400 } - ) - } - - logger.info(`✅ Successfully marked team member as paid`) - - const paymentAmount = session.amount_total - ? session.amount_total / 100 - : 0 - - const notifyAssistantHeadResult = await notifyAssistantHeadForTeamPayment( - teamUserId, - weekendId, - paymentAmount - ) - if (isErr(notifyAssistantHeadResult)) { - logger.error( - notifyAssistantHeadResult.error, - '💢 Failed to notify assistant head of team payment' - ) - // The payment was processed successfully, so we don't return an error here - } else { - logger.info(`✅ Successfully notified assistant head of team payment`) + } + case process.env.TEAM_FEE_PRICE_ID: { + const teamResult = await processTeamPayment(supabase, session) + if (isErr(teamResult)) { + return NextResponse.json({ error: teamResult.error }, { status: 400 }) } break + } default: logger.error( `💢 Error during webhook processing: Unknown price id: ${priceId}` @@ -213,18 +98,184 @@ export async function POST(request: NextRequest) { } } +async function processCandidatePayment( + supabase: SupabaseServerClient, + session: Stripe.Checkout.Session +): Promise { + const candidateId = session.metadata?.candidate_id ?? null + + logger.info(`Processing candidate payment for candidate: ${candidateId}`) + + if (!candidateId) { + logger.error('💢 Candidate ID is null for checkout session') + return err('💢 Candidate ID is null') + } + + const candidateIsAwaitingPaymentResult = await candidateIsAwaitingPayment( + supabase, + candidateId + ) + if (isErr(candidateIsAwaitingPaymentResult)) { + logger.error( + candidateIsAwaitingPaymentResult.error, + '💢 Candidate is not awaiting payment' + ) + return err(candidateIsAwaitingPaymentResult.error) + } + + logger.info( + `✅ Found candidate tied to payment and eligible for processing` + ) + + const recordCandidatePaymentResult = await recordCandidatePayment( + supabase, + candidateId, + session + ) + if (isErr(recordCandidatePaymentResult)) { + logger.error( + recordCandidatePaymentResult.error, + '💢 Failed to record candidate payment' + ) + return err(recordCandidatePaymentResult.error) + } + + const candidatePaymentRecord = recordCandidatePaymentResult.data + if (candidatePaymentRecord.created) { + logger.info( + `✅ Successfully recorded candidate_payment ${candidatePaymentRecord.record.id} for candidate id ${candidateId}` + ) + } else { + logger.info( + `🔁 Candidate payment for intent ${session.payment_intent} already recorded` + ) + } + + const confirmCandidateResult = await confirmCandidate(supabase, candidateId) + if (isErr(confirmCandidateResult)) { + logger.error( + confirmCandidateResult.error, + '💢 Failed to confirm candidate' + ) + return err(confirmCandidateResult.error) + } + + logger.info(`✅ Successfully confirmed candidate ${candidateId}`) + + return ok(true) +} + +async function processTeamPayment( + supabase: SupabaseServerClient, + session: Stripe.Checkout.Session +): Promise { + const teamUserId = session.metadata?.user_id ?? null + const weekendId = session.metadata?.weekend_id ?? null + + logger.info(`Processing team payment for team: ${teamUserId}`) + + if (!teamUserId || !weekendId) { + logger.error( + JSON.stringify({ teamUserId, weekendId }), + '💢 Missing team payment metadata' + ) + return err('💢 Missing team payment metadata') + } + + const weekendRosterRecord = await getWeekendRosterRecord(teamUserId, weekendId) + if (isErr(weekendRosterRecord)) { + logger.error( + weekendRosterRecord.error, + '💢 Failed to get weekend_roster record' + ) + return err(weekendRosterRecord.error) + } + + logger.info( + `✅ Found weekend_roster record for team member: ${teamUserId}` + ) + + const weekendRosterPaymentRecordResult = await recordWeekendRosterPayment( + supabase, + weekendRosterRecord.data.id!, + session + ) + if (isErr(weekendRosterPaymentRecordResult)) { + logger.error( + weekendRosterPaymentRecordResult.error, + '💢 Failed to record weekend_roster_payment' + ) + return err(weekendRosterPaymentRecordResult.error) + } + + const weekendRosterPaymentRecord = weekendRosterPaymentRecordResult.data + if (weekendRosterPaymentRecord.created) { + logger.info( + `✅ Successfully recorded weekend_roster_payment ${weekendRosterPaymentRecord.record.id} for weekend_roster_id ${weekendRosterRecord.data.id}` + ) + } else { + logger.info( + `🔁 Weekend roster payment for intent ${session.payment_intent} already recorded` + ) + } + + if (weekendRosterRecord.data.status !== 'paid') { + const markTeamMemberAsPaidResult = await markTeamMemberAsPaid( + supabase, + weekendRosterRecord.data.id + ) + if (isErr(markTeamMemberAsPaidResult)) { + logger.error( + markTeamMemberAsPaidResult.error, + '💢 Failed to mark team member as paid' + ) + return err(markTeamMemberAsPaidResult.error) + } + + logger.info(`✅ Successfully marked team member as paid`) + } else { + logger.info( + `🔁 Weekend roster already marked as paid for roster id ${weekendRosterRecord.data.id}` + ) + } + + const paymentAmount = session.amount_total ? session.amount_total / 100 : 0 + + if (weekendRosterPaymentRecord.created) { + const notifyAssistantHeadResult = await notifyAssistantHeadForTeamPayment( + teamUserId, + weekendId, + paymentAmount + ) + if (isErr(notifyAssistantHeadResult)) { + logger.error( + notifyAssistantHeadResult.error, + '💢 Failed to notify assistant head of team payment' + ) + // The payment was processed successfully, so we don't return an error here + } else { + logger.info(`✅ Successfully notified assistant head of team payment`) + } + } else { + logger.info( + `🔁 Skipping assistant head notification; payment already processed` + ) + } + + return ok(true) +} + /** * Checks if a candidate is in the awaiting_payment status */ async function candidateIsAwaitingPayment( + supabase: SupabaseServerClient, candidateId: string | null ): Promise> { if (!candidateId) { return err('💢 Candidate ID is null') } - const supabase = await createClient() - // Verify the candidate exists and is in awaiting_payment status const { data: candidate, error: fetchError } = await supabase .from('candidates') @@ -235,11 +286,17 @@ async function candidateIsAwaitingPayment( if (fetchError) { return err(fetchError.message) } - if (!candidate) { return err(`💢 Candidate not found with id: ${candidateId}`) } + if (candidate.status === 'confirmed') { + logger.info( + `🔁 Candidate ${candidateId} already confirmed; treating webhook as idempotent` + ) + return ok(true) + } + if (candidate.status !== 'awaiting_payment') { return err('💢 Candidate not in awaiting_payment status') } @@ -251,10 +308,33 @@ async function candidateIsAwaitingPayment( * Records a candidate payment in the candidate_payments table */ async function recordCandidatePayment( + supabase: SupabaseServerClient, candidateId: string, session: Stripe.Checkout.Session -): Promise>> { - const supabase = await createClient() +): Promise< + Result< + string, + { + record: Tables<'candidate_payments'> + created: boolean + } + > +> { + const paymentIntentId = session.payment_intent as string + + const { data: existingPayment, error: existingPaymentError } = await supabase + .from('candidate_payments') + .select('*') + .eq('payment_intent_id', paymentIntentId) + .maybeSingle() + + if (existingPaymentError) { + return err(existingPaymentError.message) + } + + if (existingPayment) { + return ok({ record: existingPayment, created: false }) + } const { data: paymentRecord, error: paymentRecordError } = await supabase .from('candidate_payments') @@ -268,6 +348,26 @@ async function recordCandidatePayment( .single() if (paymentRecordError) { + if (paymentRecordError.code === '23505') { + logger.info( + `🔁 Candidate payment for intent ${paymentIntentId} already exists (unique constraint)` + ) + const { data: duplicatePayment, error: duplicateFetchError } = await supabase + .from('candidate_payments') + .select('*') + .eq('payment_intent_id', paymentIntentId) + .maybeSingle() + + if (duplicateFetchError) { + return err(duplicateFetchError.message) + } + + if (duplicatePayment) { + return ok({ record: duplicatePayment, created: false }) + } + + return err('💢 Payment already exists but could not be retrieved') + } return err(paymentRecordError.message) } @@ -275,17 +375,16 @@ async function recordCandidatePayment( return err('💢 Failed to record payment') } - return ok(paymentRecord) + return ok({ record: paymentRecord, created: true }) } /** * Confirms a candidate by updating their status to confirmed */ async function confirmCandidate( + supabase: SupabaseServerClient, candidateId: string ): Promise> { - const supabase = await createClient() - // Update candidate status to confirmed const { error: updateError } = await supabase .from('candidates') @@ -302,10 +401,33 @@ async function confirmCandidate( } async function recordWeekendRosterPayment( + supabase: SupabaseServerClient, weekendRosterRecordId: string, session: Stripe.Checkout.Session -): Promise>> { - const supabase = await createClient() +): Promise< + Result< + string, + { + record: Tables<'weekend_roster_payments'> + created: boolean + } + > +> { + const paymentIntentId = session.payment_intent as string + + const { data: existingPayment, error: existingPaymentError } = await supabase + .from('weekend_roster_payments') + .select('*') + .eq('payment_intent_id', paymentIntentId) + .maybeSingle() + + if (existingPaymentError) { + return err(existingPaymentError.message) + } + + if (existingPayment) { + return ok({ record: existingPayment, created: false }) + } const { data: weekendRosterPaymentRecord, error: paymentRecordError } = await supabase @@ -321,17 +443,36 @@ async function recordWeekendRosterPayment( .single() if (paymentRecordError) { + if (paymentRecordError.code === '23505') { + logger.info( + `🔁 Weekend roster payment for intent ${paymentIntentId} already exists (unique constraint)` + ) + const { data: duplicatePayment, error: duplicateFetchError } = await supabase + .from('weekend_roster_payments') + .select('*') + .eq('payment_intent_id', paymentIntentId) + .maybeSingle() + + if (duplicateFetchError) { + return err(duplicateFetchError.message) + } + + if (duplicatePayment) { + return ok({ record: duplicatePayment, created: false }) + } + + return err('💢 Weekend roster payment already exists but could not be retrieved') + } return err(paymentRecordError.message) } - return ok(weekendRosterPaymentRecord) + return ok({ record: weekendRosterPaymentRecord, created: true }) } async function markTeamMemberAsPaid( + supabase: SupabaseServerClient, weekendRosterId: string ): Promise> { - const supabase = await createClient() - const { error: updateError } = await supabase .from('weekend_roster') .update({ status: 'paid' }) From 3ed648c1b8c4aa984a39f514b414c4ae2926a143 Mon Sep 17 00:00:00 2001 From: nicholas-fierro Date: Wed, 17 Sep 2025 23:12:53 -0500 Subject: [PATCH 2/2] dollarAmount helper --- .../api/webhooks/complete-checkout/route.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/(public)/api/webhooks/complete-checkout/route.ts b/app/(public)/api/webhooks/complete-checkout/route.ts index c487e9a6..3f630090 100644 --- a/app/(public)/api/webhooks/complete-checkout/route.ts +++ b/app/(public)/api/webhooks/complete-checkout/route.ts @@ -239,7 +239,7 @@ async function processTeamPayment( ) } - const paymentAmount = session.amount_total ? session.amount_total / 100 : 0 + const paymentAmount = toDollarAmount(session.amount_total) ?? 0 if (weekendRosterPaymentRecord.created) { const notifyAssistantHeadResult = await notifyAssistantHeadForTeamPayment( @@ -340,7 +340,7 @@ async function recordCandidatePayment( .from('candidate_payments') .insert({ candidate_id: candidateId, - payment_amount: session.amount_total ? session.amount_total / 100 : null, // Convert from cents + payment_amount: toDollarAmount(session.amount_total), payment_owner: session.metadata?.payment_owner ?? 'unknown', payment_intent_id: session.payment_intent as string, // This type assertion is safe because we check payment_intent in the webhook route }) @@ -434,9 +434,7 @@ async function recordWeekendRosterPayment( .from('weekend_roster_payments') .insert({ weekend_roster_id: weekendRosterRecordId, - payment_amount: session.amount_total - ? session.amount_total / 100 - : null, + payment_amount: toDollarAmount(session.amount_total), payment_intent_id: session.payment_intent as string, }) .select() @@ -486,3 +484,11 @@ async function markTeamMemberAsPaid( return ok(true) } + +function toDollarAmount(amount: number | null | undefined): number | null { + if (typeof amount !== 'number') { + return null + } + + return amount / 100 +}