import { NextRequest, NextResponse } from 'next/server' import { createHash } from 'crypto' import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery' interface LineItem { name: string quantity: number priceCents: number catalogItemId?: string colors?: string[] note?: string modifiers?: Array<{ catalogObjectId: string; name: string }> } interface CheckoutBody { lineItems: LineItem[] selectedColors: string[] deliverySlotISO?: string driveMinutes?: number deliveryAddress?: string deliveryTier?: string deliveryNotes?: string deliveryCents?: number pickupSlotISO?: string sourceId: string idempotencyKey?: string customerFirstName?: string customerLastName?: string customerEmail?: string customerPhone?: string } export async function POST(req: NextRequest) { if (!process.env.SQUARE_ACCESS_TOKEN) { return NextResponse.json( { error: 'Square is not configured on this server.' }, { status: 500 } ) } let body: CheckoutBody try { body = (await req.json()) as CheckoutBody } catch { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } // ── Input validation ──────────────────────────────────────────────────────── const { lineItems, selectedColors, deliverySlotISO, driveMinutes, deliveryAddress, deliveryTier, deliveryNotes, deliveryCents, pickupSlotISO, sourceId, idempotencyKey, customerFirstName, customerLastName, customerEmail, customerPhone, } = body if (!Array.isArray(lineItems) || lineItems.length === 0) { return NextResponse.json({ error: 'Cart is empty' }, { status: 400 }) } for (const li of lineItems) { if (typeof li.name !== 'string' || li.name.length > 255) { return NextResponse.json({ error: 'Invalid item name' }, { status: 400 }) } if (!Number.isInteger(li.quantity) || li.quantity < 1 || li.quantity > 999) { return NextResponse.json({ error: 'Invalid item quantity' }, { status: 400 }) } if (!Number.isInteger(li.priceCents) || li.priceCents < 0) { return NextResponse.json({ error: 'Invalid item price' }, { status: 400 }) } } if (typeof sourceId !== 'string' || !sourceId) { return NextResponse.json({ error: 'Missing payment source' }, { status: 400 }) } if (customerEmail && (typeof customerEmail !== 'string' || customerEmail.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customerEmail))) { return NextResponse.json({ error: 'Invalid email address' }, { status: 400 }) } if (deliveryAddress && typeof deliveryAddress === 'string' && deliveryAddress.length > 500) { return NextResponse.json({ error: 'Delivery address too long' }, { status: 400 }) } if (deliveryNotes && typeof deliveryNotes === 'string' && deliveryNotes.length > 1000) { return NextResponse.json({ error: 'Delivery notes too long' }, { status: 400 }) } const customerName = [customerFirstName, customerLastName].filter(Boolean).join(' ') || undefined const resolvedTier: DeliveryTier = (deliveryTier === 'dropoff' || deliveryTier === 'classic' || deliveryTier === 'organic') ? deliveryTier : inferTier(lineItems.map((l) => l.name)) const arrivalISO = deliverySlotISO const jobMin = JOB_MINUTES[resolvedTier] const windowDurationStr = deliverySlotISO ? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}` : undefined const fmtSlot = (iso: string) => new Date(iso).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' }) const noteParts = [ selectedColors.length > 0 ? `Colors: ${selectedColors.join(', ')}` : null, deliverySlotISO ? `Delivery: ${fmtSlot(deliverySlotISO)}` : null, pickupSlotISO ? `Pickup: ${fmtSlot(pickupSlotISO)}` : null, deliveryAddress ? `Address: ${deliveryAddress}` : null, deliveryNotes ? `Instructions: ${deliveryNotes}` : null, ].filter(Boolean) const note = noteParts.join(' | ') // ── Slot pre-check ────────────────────────────────────────────────────────── // If an idempotency key is present this may be a retry of an already-completed // order. The calendar event already exists from the first attempt so the slot // will appear taken — we must not block here. Fall through to Square to check // for idempotency replay. let slotConflict = false if (deliverySlotISO && driveMinutes != null && process.env.CALDAV_URL) { try { const { getBusyDates } = await import('@/lib/caldav') const { getAvailableSlots } = await import('@/lib/slots') const slotDate = deliverySlotISO.slice(0, 10) const dayStart = new Date(`${slotDate}T00:00:00Z`) const dayEnd = new Date(`${slotDate}T23:59:59Z`) const freshBusy = await getBusyDates(dayStart, dayEnd) const stillFree = (await getAvailableSlots(slotDate, driveMinutes, resolvedTier, freshBusy)) .some((s) => s.startISO === deliverySlotISO) if (!stillFree) { if (!idempotencyKey) { return NextResponse.json( { error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' }, { status: 409 } ) } slotConflict = true console.warn('[checkout] Slot appears taken on retry — checking Square for idempotency replay:', idempotencyKey) } } catch (slotErr) { console.error('[checkout] Slot pre-check failed, proceeding without guard:', slotErr) } } // ── Inventory check (skip on likely replay) ───────────────────────────────── if (!slotConflict) { try { const { getInventoryCounts } = await import('@/lib/square') const variationIds = lineItems .filter((li) => li.catalogItemId) .map((li) => li.catalogItemId as string) if (variationIds.length) { const counts = await getInventoryCounts(variationIds) for (const li of lineItems) { if (!li.catalogItemId) continue const stock = counts.get(li.catalogItemId) if (stock !== undefined && stock < li.quantity) { return NextResponse.json( { error: `"${li.name}" ${stock === 0 ? 'is sold out' : `only has ${stock} left`}. Please update your cart.` }, { status: 409 } ) } } } } catch (invErr) { console.error('[checkout] Inventory pre-check failed, proceeding:', invErr) } } try { const { createSquareOrder, createSquarePayment, completeSquarePayment, cancelSquarePayment, retrieveSquareOrder, upsertSquareCustomer, } = await import('@/lib/square') // ── Customer upsert (skip on likely replay) ───────────────────────────── let customerId: string | undefined if (customerEmail && customerPhone && !slotConflict) { try { customerId = await upsertSquareCustomer({ givenName: customerFirstName?.trim() ?? '', familyName: customerLastName?.trim() ?? '', emailAddress: customerEmail, phoneNumber: customerPhone, }) } catch (custErr) { console.error('[checkout] Failed to upsert customer:', custErr) } } const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production' const order = await createSquareOrder({ note, customerId, idempotencyKey, serviceCharge: deliveryCents ? { name: 'Delivery', amountCents: deliveryCents, taxable: false } : undefined, lineItems: lineItems.map((li) => ({ catalogObjectId: isSandbox ? undefined : li.catalogItemId, name: li.name, quantity: String(li.quantity), basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' }, note: [ li.colors?.length ? `Colors: ${li.colors.join(', ')}` : null, li.note || null, ].filter(Boolean).join(' | ') || undefined, modifiers: li.modifiers?.length ? li.modifiers.map((m) => ({ catalogObjectId: isSandbox ? undefined : m.catalogObjectId, name: m.name, basePriceMoney: { amount: BigInt(0), currency: 'USD' }, })) : undefined, })), fulfillment: customerName && customerPhone ? deliveryAddress ? { type: 'delivery' as const, recipientName: customerName, recipientPhone: customerPhone, addressLine1: deliveryAddress, deliverAt: arrivalISO, deliveryWindowDuration: windowDurationStr, note: deliveryNotes, } : { type: 'pickup' as const, recipientName: customerName, recipientPhone: customerPhone, pickupAt: pickupSlotISO, } : undefined, }) if (!order?.id || !order.totalMoney) { throw new Error('Order creation returned no ID or total') } const shortRef = order.id!.slice(-6).toUpperCase() const itemsSummary = lineItems.map((l) => `${l.quantity}× ${l.name}`).join(', ') const totalCents = order.totalMoney!.amount! // ── Idempotency replay: order already fully completed ─────────────────── // The entire flow ran on a previous attempt — payment captured, calendar // written. The response just never reached the client. if (order.state === 'COMPLETED') { console.log('[checkout] Idempotency replay — order already completed:', order.id) return NextResponse.json({ success: true, orderId: order.id, shortRef, paymentId: undefined, }) } // ── Genuine slot conflict on retry ────────────────────────────────────── // The slot is taken AND Square created a brand-new OPEN order (not a replay // of a prior pre-auth). Someone else genuinely booked the slot. // Check the full order for existing tenders to confirm this is truly new. if (slotConflict) { const fullOrder = await retrieveSquareOrder(order.id!) const hasExistingPayment = (fullOrder?.tenders?.length ?? 0) > 0 if (!hasExistingPayment) { console.warn('[checkout] Genuine slot conflict on retry — no prior tender found:', order.id) return NextResponse.json( { error: 'That delivery time was just booked by someone else. Please go back and choose a different slot.' }, { status: 409 } ) } // Prior tender exists — fall through to re-attempt calendar write + capture console.log('[checkout] Retry with existing tender — completing pre-auth:', order.id) } // ── Payment idempotency key ───────────────────────────────────────────── // Derived from the nonce (sourceId) so the key is unique per card tokenization. // Square nonces are single-use — tying the key to the nonce means: // • Same nonce on auto-retry → same key → Square idempotency replay (safe) // • New nonce on manual retry → new key → fresh payment attempt (no mismatch error) const paymentIdempotencyKey = createHash('sha256').update(sourceId).digest('hex').slice(0, 45) // ── Pre-authorize card (hold funds, do not capture yet) ───────────────── // We capture only AFTER the calendar write succeeds. If the calendar fails, // we void the hold — the customer is never charged without a confirmed booking. const payment = await createSquarePayment({ sourceId: sourceId, orderId: order.id!, amountMoney: { amount: order.totalMoney.amount!, currency: 'USD' }, note, idempotencyKey: paymentIdempotencyKey, autocomplete: false, // hold only — capture after calendar write }) if (!payment?.id) throw new Error('Pre-authorization returned no payment ID') // ── Write to calendar (awaited) ───────────────────────────────────────── // This is no longer fire-and-forget. If it fails, we void the hold. // Calendar writes are idempotent (orderId-based UID) so retries are safe. const calendarWriteError = await (async () => { try { if (deliverySlotISO && driveMinutes != null && deliveryAddress && process.env.CALDAV_URL) { const { createDeliveryEvent } = await import('@/lib/caldav') await createDeliveryEvent({ startTime: new Date(deliverySlotISO), tier: resolvedTier, driveMinutes, address: deliveryAddress, lineItems, colors: selectedColors, customerName: customerName ?? 'Customer', customerPhone: customerPhone ?? '', notes: deliveryNotes, orderId: order.id!, }) } else if (pickupSlotISO && process.env.CALDAV_URL) { const { createPickupEvent } = await import('@/lib/caldav') await createPickupEvent({ startTime: new Date(pickupSlotISO), lineItems, colors: selectedColors, customerName: customerName ?? 'Customer', customerPhone: customerPhone ?? '', notes: deliveryNotes, orderId: order.id!, }) } return null // success } catch (err) { return err } })() // ── Calendar failed → void the hold, never charge the customer ────────── if (calendarWriteError) { console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', { orderId: order.id, paymentId: payment.id, error: calendarWriteError, }) void (async () => { try { const { sendAdminErrorAlert } = await import('@/lib/notify') await sendAdminErrorAlert({ subject: 'Calendar write failed — order not booked', message: `Calendar write failed for order ${order.id}. Pre-auth ${payment.id} is being voided. Customer: ${customerName} (${customerEmail}).`, context: { orderId: order.id, paymentId: payment.id, error: String(calendarWriteError) }, }) } catch { /* best effort */ } })() try { await cancelSquarePayment(payment.id!) console.log('[checkout] Pre-auth voided successfully:', payment.id) } catch (cancelErr) { // Cancellation failed — this is a critical state: pre-auth exists but // calendar is not written. Alert immediately so it can be manually voided. console.error('[checkout] CRITICAL: failed to void pre-auth after calendar failure — MANUAL ACTION REQUIRED:', { orderId: order.id, paymentId: payment.id, cancelErr, }) try { const { sendSlotConflictAlert } = await import('@/lib/notify') await sendSlotConflictAlert({ shortRef, orderId: order.id!, customerName: customerName ?? 'Unknown', customerPhone: customerPhone ?? '', slotISO: (deliverySlotISO ?? pickupSlotISO)!, address: deliveryAddress ?? 'PICKUP', items: `URGENT: pre-auth ${payment.id!} could not be voided after calendar failure. Manual void required. Items: ${itemsSummary}`, }) } catch { /* best effort */ } } return NextResponse.json( { error: 'Our booking system is temporarily unavailable. Your card has not been charged — please try again in a few minutes or contact us.' }, { status: 503 } ) } // ── Calendar written — now capture the payment ────────────────────────── const captured = await completeSquarePayment(payment.id!) if (!captured) throw new Error('Payment capture returned no result') // ── Fire-and-forget: emails only (calendar already written above) ──────── const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0) const slotEndISO = deliverySlotISO ? new Date(new Date(deliverySlotISO).getTime() + jobMin * 60_000).toISOString() : undefined void (async () => { try { const { sendNewOrderAlert } = await import('@/lib/notify') await sendNewOrderAlert({ shortRef, orderId: order.id!, customerName: customerName ?? 'Unknown', customerPhone: customerPhone ?? '', customerEmail: customerEmail ?? '', fulfillment: deliveryAddress ? 'delivery' : 'pickup', slotISO: (deliverySlotISO ?? pickupSlotISO)!, slotEndISO, address: deliveryAddress, lineItems, colors: selectedColors, subtotalCents, deliveryCents: deliveryCents ?? undefined, totalCents, }) } catch (err) { console.error('[checkout] Failed to send new order alert:', err) } if (customerEmail && (deliverySlotISO ?? pickupSlotISO)) { try { const { sendOrderConfirmationEmail } = await import('@/lib/notify') await sendOrderConfirmationEmail({ shortRef, orderId: order.id!, customerName: customerName ?? 'Customer', customerEmail, fulfillment: deliveryAddress ? 'delivery' : 'pickup', slotISO: (deliverySlotISO ?? pickupSlotISO)!, slotEndISO, address: deliveryAddress, lineItems, colors: selectedColors, subtotalCents, deliveryCents: deliveryCents ?? undefined, totalCents, }) } catch (err) { console.error('[checkout] Failed to send order confirmation email:', err) } } })() return NextResponse.json({ success: true, orderId: order.id, shortRef, paymentId: captured.id, }) } catch (err: unknown) { console.error('[checkout] Error:', err) const squareErrors = (err as { errors?: Array<{ code?: string; detail?: string; category?: string }> })?.errors const code = squareErrors?.[0]?.code ?? '' const CARD_MESSAGES: Record = { CARD_DECLINED: 'Your card was declined. Please try a different card or contact your bank.', CARD_DECLINED_CALL_ISSUER: 'Your bank declined this charge. Please call the number on the back of your card to authorise it, then try again.', CARD_DECLINED_VERIFICATION_REQUIRED: 'Your bank requires additional verification. Please contact them, then try again.', CARD_EXPIRED: 'Your card has expired. Please use a different card.', CARD_NOT_SUPPORTED: 'This card type is not accepted. Please try a Visa, Mastercard, Discover, or Amex.', CVV_FAILURE: 'The security code (CVV) you entered is incorrect. Please double-check and try again.', ADDRESS_VERIFICATION_FAILURE: 'The billing zip code did not match your card. Please check and try again.', INSUFFICIENT_FUNDS: 'Your card has insufficient funds. Please use a different card.', INVALID_CARD: 'Your card details could not be verified. Please check your number and try again.', INVALID_EXPIRATION: 'The expiration date you entered is invalid. Please check and try again.', NONCE_USED: 'Your payment session expired — please tap "Place Order" once more to try again.', INVALID_NONCE: 'Your payment session expired — please tap "Place Order" once more to try again.', IDEMPOTENCY_KEY_REUSED: 'A duplicate request was detected. If you were charged, please contact us — otherwise tap "Place Order" again.', GENERIC_DECLINE: 'Your card was declined. Please try a different card or contact your bank.', } const userMessage = CARD_MESSAGES[code] ?? 'Something went wrong with your payment. Please try again or contact us for help.' // Email admin for unexpected server errors (not card declines the customer can self-resolve) if (!CARD_MESSAGES[code]) { void (async () => { try { const { sendAdminErrorAlert } = await import('@/lib/notify') await sendAdminErrorAlert({ subject: 'Checkout error', message: err instanceof Error ? err.message : String(err), context: { code: code || '(none)', customerEmail, customerName }, }) } catch { /* best effort */ } })() } return NextResponse.json({ error: userMessage }, { status: 500 }) } }