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
@ -12,6 +12,7 @@ interface LineItem {
|
|||||||
colors?: string[]
|
colors?: string[]
|
||||||
note?: string
|
note?: string
|
||||||
modifiers?: Array<{ catalogObjectId: string; name: string }>
|
modifiers?: Array<{ catalogObjectId: string; name: string }>
|
||||||
|
bouquetGroupId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckoutBody {
|
interface CheckoutBody {
|
||||||
@ -210,15 +211,9 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production'
|
const isSandbox = process.env.SQUARE_ENVIRONMENT !== 'production'
|
||||||
|
|
||||||
const order = await createSquareOrder({
|
// Build Square line items — withCatalogIds=false strips all catalogObjectIds for a fallback retry
|
||||||
note,
|
const buildOrderLineItems = (withCatalogIds: boolean) => lineItems.map((li) => ({
|
||||||
customerId,
|
catalogObjectId: (withCatalogIds && !isSandbox) ? li.catalogItemId : undefined,
|
||||||
idempotencyKey,
|
|
||||||
serviceCharge: deliveryCents
|
|
||||||
? { name: 'Delivery', amountCents: deliveryCents, taxable: false }
|
|
||||||
: undefined,
|
|
||||||
lineItems: lineItems.map((li) => ({
|
|
||||||
catalogObjectId: isSandbox ? undefined : li.catalogItemId,
|
|
||||||
name: li.name,
|
name: li.name,
|
||||||
quantity: String(li.quantity),
|
quantity: String(li.quantity),
|
||||||
basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' },
|
basePriceMoney: { amount: BigInt(li.priceCents), currency: 'USD' },
|
||||||
@ -228,12 +223,20 @@ export async function POST(req: NextRequest) {
|
|||||||
].filter(Boolean).join(' | ') || undefined,
|
].filter(Boolean).join(' | ') || undefined,
|
||||||
modifiers: li.modifiers?.length
|
modifiers: li.modifiers?.length
|
||||||
? li.modifiers.map((m) => ({
|
? li.modifiers.map((m) => ({
|
||||||
catalogObjectId: isSandbox ? undefined : m.catalogObjectId,
|
catalogObjectId: (withCatalogIds && !isSandbox) ? m.catalogObjectId : undefined,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
basePriceMoney: { amount: BigInt(0), currency: 'USD' },
|
basePriceMoney: { amount: BigInt(0), currency: 'USD' },
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
})),
|
}))
|
||||||
|
|
||||||
|
const orderBase = {
|
||||||
|
note,
|
||||||
|
customerId,
|
||||||
|
idempotencyKey,
|
||||||
|
serviceCharge: deliveryCents
|
||||||
|
? { name: 'Delivery', amountCents: deliveryCents, taxable: false }
|
||||||
|
: undefined,
|
||||||
fulfillment: customerName && customerPhone
|
fulfillment: customerName && customerPhone
|
||||||
? deliveryAddress
|
? deliveryAddress
|
||||||
? {
|
? {
|
||||||
@ -252,7 +255,25 @@ export async function POST(req: NextRequest) {
|
|||||||
pickupAt: pickupSlotISO,
|
pickupAt: pickupSlotISO,
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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) {
|
if (!order?.id || !order.totalMoney) {
|
||||||
throw new Error('Order creation returned no ID or total')
|
throw new Error('Order creation returned no ID or total')
|
||||||
@ -486,10 +507,29 @@ export async function POST(req: NextRequest) {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const { sendAdminErrorAlert } = await import('@/lib/notify')
|
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({
|
await sendAdminErrorAlert({
|
||||||
subject: 'Checkout error',
|
subject: 'Checkout error',
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: [
|
||||||
context: { code: code || '(none)', customerEmail, customerName },
|
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 */ }
|
} catch { /* best effort */ }
|
||||||
})()
|
})()
|
||||||
|
|||||||
@ -293,6 +293,7 @@ export default function CartDrawer() {
|
|||||||
catalogItemId: e.selectedVariationId ?? e.product.id,
|
catalogItemId: e.selectedVariationId ?? e.product.id,
|
||||||
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
||||||
note: e.notes || undefined,
|
note: e.notes || undefined,
|
||||||
|
bouquetGroupId: e.bouquetGroupId || undefined,
|
||||||
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
||||||
const ml = e.product.modifiers?.find((m) => m.id === listId)
|
const ml = e.product.modifiers?.find((m) => m.id === listId)
|
||||||
if (!ml) return []
|
if (!ml) return []
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export interface CheckoutPayload {
|
|||||||
colors?: string[]
|
colors?: string[]
|
||||||
note?: string
|
note?: string
|
||||||
modifiers?: Array<{ catalogObjectId: string; name: string }>
|
modifiers?: Array<{ catalogObjectId: string; name: string }>
|
||||||
|
bouquetGroupId?: string
|
||||||
}>
|
}>
|
||||||
selectedColors: string[]
|
selectedColors: string[]
|
||||||
deliverySlotISO?: string
|
deliverySlotISO?: string
|
||||||
@ -80,9 +81,15 @@ const SDK_URL =
|
|||||||
: 'https://sandbox.web.squarecdn.com/v1/square.js'
|
: 'https://sandbox.web.squarecdn.com/v1/square.js'
|
||||||
|
|
||||||
function buildMailtoLink(payload: CheckoutPayload): string {
|
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
|
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')
|
.join('\n')
|
||||||
const fulfillment = payload.deliverySlotISO
|
const fulfillment = payload.deliverySlotISO
|
||||||
? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}`
|
? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}`
|
||||||
|
|||||||
@ -48,17 +48,43 @@ export interface EmailLineItem {
|
|||||||
colors?: string[]
|
colors?: string[]
|
||||||
note?: string
|
note?: string
|
||||||
modifiers?: Array<{ name: 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 {
|
function formatLineItems(lineItems: EmailLineItem[]): string {
|
||||||
return lineItems.map((li) => {
|
// Separate bouquet groups from standalone items
|
||||||
const price = `$${(li.priceCents / 100).toFixed(2)}`
|
const groups = new Map<string, EmailLineItem[]>()
|
||||||
const lines = [`${li.quantity}× ${li.name} — ${price}`]
|
const standalone: EmailLineItem[] = []
|
||||||
if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`)
|
for (const li of lineItems) {
|
||||||
if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`)
|
if (li.bouquetGroupId) {
|
||||||
if (li.note) lines.push(` Note: ${li.note}`)
|
const g = groups.get(li.bouquetGroupId) ?? []
|
||||||
return lines.join('\n')
|
g.push(li)
|
||||||
}).join('\n')
|
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() {
|
function getTransporter() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user