chris 84167c1ae8 Fix checkout NOT_FOUND: retry as ad-hoc when catalog IDs are stale
- When Square returns NOT_FOUND (stale catalogObjectId in cache), retry
  the order without catalog IDs so checkout still succeeds; refresh cache
  async so the next order picks up correct IDs
- Admin error email now includes full order details (items, options,
  colors, delivery address) so manual invoicing is possible
- Mailto fallback link now shows per-item options and colors
- Bouquet grouping: pass bouquetGroupId through checkout payload so
  admin/alert emails group BYO bouquet items under a labeled header
- EmailLineItem type updated to carry bouquetGroupId for notify.ts grouping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 08:39:02 -04:00

541 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
import { getHoursConfig } from '@/lib/hours'
import { getStoreStatus } from '@/lib/store-status'
interface LineItem {
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
bouquetGroupId?: 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) {
const storeStatus = await getStoreStatus()
if (storeStatus.closed) {
return NextResponse.json(
{ error: storeStatus.message || 'The store is temporarily closed. Please check back soon.' },
{ status: 503 }
)
}
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 })
}
if (deliveryCents !== undefined && (!Number.isInteger(deliveryCents) || deliveryCents < 0)) {
return NextResponse.json({ error: 'Invalid delivery charge' }, { status: 400 })
}
if (customerFirstName && (typeof customerFirstName !== 'string' || customerFirstName.length > 100 || /[\r\n\x00-\x1f]/.test(customerFirstName))) {
return NextResponse.json({ error: 'Invalid first name' }, { status: 400 })
}
if (customerLastName && (typeof customerLastName !== 'string' || customerLastName.length > 100 || /[\r\n\x00-\x1f]/.test(customerLastName))) {
return NextResponse.json({ error: 'Invalid last name' }, { status: 400 })
}
if (deliveryAddress && !deliverySlotISO) {
return NextResponse.json({ error: 'A delivery time is required when scheduling a delivery.' }, { 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] // calendar event duration (internal)
const windowMin = getHoursConfig().deliveryWindowMinutes // customer-facing window
const toIsoDuration = (m: number) =>
`PT${Math.floor(m / 60) > 0 ? `${Math.floor(m / 60)}H` : ''}${m % 60 > 0 ? `${m % 60}M` : ''}`
const windowDurationStr = deliverySlotISO ? toIsoDuration(windowMin) : 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'
// Build Square line items — withCatalogIds=false strips all catalogObjectIds for a fallback retry
const buildOrderLineItems = (withCatalogIds: boolean) => lineItems.map((li) => ({
catalogObjectId: (withCatalogIds && !isSandbox) ? li.catalogItemId : undefined,
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: (withCatalogIds && !isSandbox) ? m.catalogObjectId : undefined,
name: m.name,
basePriceMoney: { amount: BigInt(0), currency: 'USD' },
}))
: undefined,
}))
const orderBase = {
note,
customerId,
idempotencyKey,
serviceCharge: deliveryCents
? { name: 'Delivery', amountCents: deliveryCents, taxable: false }
: 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,
}
// Try with catalog IDs first; on NOT_FOUND (stale cache) retry as ad-hoc and refresh cache
let order: Awaited<ReturnType<typeof createSquareOrder>>
try {
order = await createSquareOrder({ ...orderBase, lineItems: buildOrderLineItems(true) })
} catch (firstErr: unknown) {
const isNotFound = (firstErr as { errors?: Array<{ code?: string }> })?.errors?.[0]?.code === 'NOT_FOUND'
if (!isNotFound) throw firstErr
const badId = (firstErr as { errors?: Array<{ detail?: string }> })?.errors?.[0]?.detail ?? ''
console.warn('[checkout] NOT_FOUND for catalog ID — retrying as ad-hoc order:', badId)
// Refresh catalog cache asynchronously so next order uses fresh IDs
void import('@/lib/catalog-cache').then(({ refreshCatalog }) =>
refreshCatalog()
.then(() => console.log('[checkout] Catalog cache refreshed after NOT_FOUND'))
.catch(() => {})
)
order = await createSquareOrder({ ...orderBase, lineItems: buildOrderLineItems(false) })
}
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
buyerEmailAddress: customerEmail,
})
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() + windowMin * 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<string, string> = {
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')
const itemLines = lineItems.map((li) => {
const price = `$${(li.priceCents / 100).toFixed(2)}`
const parts = [` ${li.quantity}× ${li.name}${price}`]
if (li.modifiers?.length) parts.push(` Options: ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) parts.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) parts.push(` Note: ${li.note}`)
return parts.join('\n')
}).join('\n')
const fulfillmentStr = deliveryAddress
? `Delivery to ${deliveryAddress}${deliverySlotISO ? ` at ${new Date(deliverySlotISO).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })}` : ''}`
: pickupSlotISO ? `Pickup at ${new Date(pickupSlotISO).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })}` : 'Unknown'
await sendAdminErrorAlert({
subject: 'Checkout error',
message: [
err instanceof Error ? err.message : String(err),
'',
`Customer: ${customerName ?? '(unknown)'} <${customerEmail ?? '(none)'}> ${customerPhone ?? ''}`,
`${fulfillmentStr}`,
'',
'Order:',
itemLines,
].join('\n'),
context: { code: code || '(none)' },
})
} catch { /* best effort */ }
})()
}
return NextResponse.json({ error: userMessage }, { status: 500 })
}
}