- Fix Chrome Rose Gold hex (#B76E79 → #C17F87) so it no longer conflicts with Classic Rose Gold; image still used for display - ScrollToTop hides when cart drawer is open and uses z-index 98 (below the drawer); uses drawerOpen from CartContext - Search now switches to All tab automatically so results span every item, not just the active category - Add sendAdminErrorAlert() to notify.ts; checkout route emails admin@beachpartyballoons.com on unexpected server errors and on critical calendar-write failures; card decline errors are not forwarded (customers can self-resolve those) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
477 lines
21 KiB
TypeScript
477 lines
21 KiB
TypeScript
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<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')
|
||
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 })
|
||
}
|
||
}
|