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>
This commit is contained in:
chris 2026-06-30 08:39:02 -04:00
parent 821bfac00a
commit 84167c1ae8
4 changed files with 132 additions and 58 deletions

View File

@ -5,13 +5,14 @@ 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 }>
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
bouquetGroupId?: string
}
interface CheckoutBody {
@ -210,30 +211,32 @@ export async function POST(req: NextRequest) {
const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production'
const order = await createSquareOrder({
// 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,
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
? {
@ -252,7 +255,25 @@ export async function POST(req: NextRequest) {
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')
@ -486,10 +507,29 @@ export async function POST(req: NextRequest) {
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),
context: { code: code || '(none)', customerEmail, customerName },
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 */ }
})()

View File

@ -287,13 +287,14 @@ export default function CartDrawer() {
]
}
return [{
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
catalogItemId: e.selectedVariationId ?? e.product.id,
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
name: e.product.name,
quantity: e.quantity,
priceCents: entryUnitPrice(e),
catalogItemId: e.selectedVariationId ?? e.product.id,
colors: e.selectedColors.length ? e.selectedColors : undefined,
note: e.notes || undefined,
bouquetGroupId: e.bouquetGroupId || undefined,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
const ml = e.product.modifiers?.find((m) => m.id === listId)
if (!ml) return []
return optIds.map((optId) => ({

View File

@ -43,13 +43,14 @@ interface TokenResult {
export interface CheckoutPayload {
lineItems: Array<{
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
bouquetGroupId?: string
}>
selectedColors: string[]
deliverySlotISO?: string
@ -80,9 +81,15 @@ const SDK_URL =
: 'https://sandbox.web.squarecdn.com/v1/square.js'
function buildMailtoLink(payload: CheckoutPayload): string {
const { fmt: fmtCents } = { fmt: (c: number) => `$${(c / 100).toFixed(2)}` }
const fmtCents = (c: number) => `$${(c / 100).toFixed(2)}`
const items = payload.lineItems
.map((li) => `${li.quantity}× ${li.name}${fmtCents(li.priceCents * li.quantity)}${li.note ? ` (${li.note})` : ''}`)
.map((li) => {
const lines = [`${li.quantity}× ${li.name}${fmtCents(li.priceCents * li.quantity)}`]
if (li.modifiers?.length) lines.push(` Options: ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) lines.push(` Note: ${li.note}`)
return lines.join('\n')
})
.join('\n')
const fulfillment = payload.deliverySlotISO
? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}`

View File

@ -42,23 +42,49 @@ function fmtWindow(startISO: string, endISO: string): string {
// ── Shared types ───────────────────────────────────────────────────────────────
export interface EmailLineItem {
name: string
quantity: number
priceCents: number
colors?: string[]
note?: string
modifiers?: Array<{ name: string }>
name: string
quantity: number
priceCents: number
colors?: string[]
note?: string
modifiers?: Array<{ name: string }>
bouquetGroupId?: string
}
function formatOneItem(li: EmailLineItem, indent = ''): string {
const price = `$${(li.priceCents / 100).toFixed(2)}`
const lines = [`${indent}${li.quantity}× ${li.name}${price}`]
if (li.modifiers?.length) lines.push(`${indent} Options: ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) lines.push(`${indent} Colors: ${li.colors.join(', ')}`)
if (li.note) lines.push(`${indent} Note: ${li.note}`)
return lines.join('\n')
}
function formatLineItems(lineItems: EmailLineItem[]): string {
return lineItems.map((li) => {
const price = `$${(li.priceCents / 100).toFixed(2)}`
const lines = [`${li.quantity}× ${li.name}${price}`]
if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) lines.push(` Note: ${li.note}`)
return lines.join('\n')
}).join('\n')
// Separate bouquet groups from standalone items
const groups = new Map<string, EmailLineItem[]>()
const standalone: EmailLineItem[] = []
for (const li of lineItems) {
if (li.bouquetGroupId) {
const g = groups.get(li.bouquetGroupId) ?? []
g.push(li)
groups.set(li.bouquetGroupId, g)
} else {
standalone.push(li)
}
}
const parts: string[] = standalone.map((li) => formatOneItem(li))
let bouquetNum = 1
for (const items of Array.from(groups.values())) {
const subtotal = items.reduce((s: number, li: EmailLineItem) => s + li.priceCents * li.quantity, 0)
parts.push(`── BYO Bouquet #${bouquetNum} ($${(subtotal / 100).toFixed(2)} subtotal, ${items.length} item${items.length !== 1 ? 's' : ''}) ──`)
for (const li of items) parts.push(formatOneItem(li, ' '))
bouquetNum++
}
return parts.join('\n')
}
function getTransporter() {