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:
parent
821bfac00a
commit
84167c1ae8
@ -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 */ }
|
||||
})()
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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 ?? ''}`
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user