/** * Order webhooks → bookings. * * orders/create confirm the hold (idempotent), verify the fee that was paid, * tag the order and write a booking metafield, plan reminders. * orders/cancelled release capacity (per policy) and offer it to the waitlist. * orders/fulfilled mark fulfilled. * * Orders that reach us without a selection (Buy now, accelerated checkouts that * skipped the cart, draft orders) go to the review queue when scheduling is required. */ import prisma from "../db.server"; import { capsFor } from "../engine/capacity"; import { evaluateRules } from "../engine/rules"; import { zonedToUtc } from "../engine/time"; import { loadShopConfig } from "./config.server"; import { features } from "./plans"; import { scheduleReminders } from "./reminders.server"; import { ReservationService } from "./reservations.server"; import { SELECTION_ATTR, SelectionAttrSchema } from "./settings"; import { offerFreedPlace } from "./waitlist.server"; const reservations = new ReservationService(prisma); type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; export interface OrderPayload { id: number; admin_graphql_api_id: string; name: string; email?: string | null; phone?: string | null; note_attributes?: { name: string; value: string }[]; customer?: { first_name?: string | null; last_name?: string | null; email?: string | null; phone?: string | null } | null; shipping_address?: { address1?: string; address2?: string; city?: string; zip?: string; latitude?: number | null; longitude?: number | null; phone?: string | null; first_name?: string; last_name?: string } | null; line_items: { title: string; quantity: number; price: string; product_id: number | null; variant_id: number | null; requires_shipping?: boolean }[]; source_name?: string; } /** Webhook de-duplication by Shopify event id (falls back to topic+order). */ export async function firstDelivery(eventId: string, topic: string, shop: string): Promise { try { await prisma.processedWebhook.create({ data: { id: eventId, topic, shop } }); return true; } catch { return false; } } export async function handleOrderCreate(domain: string, order: OrderPayload, admin?: AdminGraphql) { const cfg = await loadShopConfig(domain, { fresh: true }); const feeProductNumeric = cfg.shop.feeProductId?.split("/").pop(); const feeLines = order.line_items.filter((l) => feeProductNumeric && String(l.product_id) === feeProductNumeric); const goods = order.line_items.filter((l) => !feeLines.includes(l)); const paidFee = feeLines.reduce((s, l) => s + Math.round(Number(l.price) * 100) * l.quantity, 0); const raw = order.note_attributes?.find((a) => a.name === SELECTION_ATTR)?.value; const parsed = raw ? SelectionAttrSchema.safeParse(safeJson(raw)) : null; const attr = parsed?.success ? parsed.data : null; const customerName = [order.customer?.first_name ?? order.shipping_address?.first_name, order.customer?.last_name ?? order.shipping_address?.last_name].filter(Boolean).join(" ") || undefined; const sa = order.shipping_address; const details = { orderName: order.name, timezone: cfg.shop.timezone, customerName, customerEmail: order.email ?? order.customer?.email ?? undefined, customerPhone: order.phone ?? sa?.phone ?? order.customer?.phone ?? undefined, address: sa ? { line1: [sa.address1, sa.address2].filter(Boolean).join(", "), city: sa.city, postal: sa.zip, lat: sa.latitude ?? undefined, lng: sa.longitude ?? undefined } : undefined, items: goods.map((l) => ({ title: l.title, quantity: l.quantity })), fee: paidFee, configVersion: attr?.v ?? cfg.version, snapshot: { attr: raw ?? null, source: order.source_name ?? null, paidFee }, }; const orderId = order.admin_graphql_api_id; if (!attr) { // Does this order need a time at all? const cons = evaluateRules({ items: goods.map((l) => ({ productId: `gid://shopify/Product/${l.product_id}`, variantId: `gid://shopify/ProductVariant/${l.variant_id}`, title: l.title, quantity: l.quantity, price: 0 })) }, cfg.rules); const needs = goods.length > 0 && (cfg.settings.requireScheduling || cons.requiresScheduling); if (!needs) return { skipped: true }; const r = await reservations.confirm(cfg.shop.id, orderId, null, null, { dayCap: 0, slotCap: 0 }, new Date(), details); await tagOrder(admin, orderId, ["Datewise: needs time"]); return r; } const loc = cfg.locations.find((l) => l.id === attr.l); const sel = { locationId: attr.l, method: attr.m, date: attr.d, slot: attr.s }; const caps = loc ? capsFor(loc, attr.m, attr.d, attr.s, features(cfg.shop.plan).peakDays ? cfg.peakDays : []) : { dayCap: 0, slotCap: 0 }; const [start, end] = attr.s.split("-"); const hold = await prisma.hold.findUnique({ where: { token: attr.t } }); const quoted = hold?.fee ?? attr.f; const peak = cfg.peakDays.find((p) => p.dates.includes(attr.d) && (!p.locationIds?.length || p.locationIds.includes(attr.l))); const r = await reservations.confirm(cfg.shop.id, orderId, attr.t, loc ? sel : null, caps, new Date(), { ...details, startUtc: new Date(zonedToUtc(attr.d, start, cfg.shop.timezone)), endUtc: new Date(zonedToUtc(attr.d, end, cfg.shop.timezone)), peakDayId: peak?.id ?? null, snapshot: { ...details.snapshot, quotedFee: quoted, location: loc?.name ?? null }, }); if (r.duplicate) return r; if (r.ok && paidFee !== quoted) { // Keep the place (the customer did book it) but flag the money difference for staff. await prisma.booking.update({ where: { id: r.bookingId }, data: { reviewReason: `Fee paid ${(paidFee / 100).toFixed(2)} differs from quote ${(quoted / 100).toFixed(2)}` }, }); } const label = cfg.settings.widget.labels[attr.m]; await tagOrder(admin, orderId, r.ok ? [`Datewise`, `${label}: ${attr.d}`] : ["Datewise: needs review"]); await writeBookingMetafield(admin, orderId, { method: attr.m, location: loc?.name, date: attr.d, start, end, status: r.ok ? "confirmed" : "needs_review" }); if (r.ok) await scheduleReminders(r.bookingId); return r; } export async function handleOrderCancelled(domain: string, order: { admin_graphql_api_id: string }) { const cfg = await loadShopConfig(domain); if (!cfg.settings.releaseOnCancel) { const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id } } }); if (b) await prisma.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } }); return { freed: null }; } const r = await reservations.cancel(cfg.shop.id, order.admin_graphql_api_id); if (r.freed) await offerFreedPlace(domain, r.freed); return r; } export async function handleOrderFulfilled(domain: string, order: { admin_graphql_api_id: string }) { const cfg = await loadShopConfig(domain); await prisma.booking.updateMany({ where: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id, status: "confirmed" }, data: { status: "fulfilled" }, }); } async function tagOrder(admin: AdminGraphql | undefined, id: string, tags: string[]) { if (!admin) return; await admin .graphql(`#graphql mutation($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { message } } }`, { variables: { id, tags } }) .catch((e) => console.error("tagsAdd failed", e)); } async function writeBookingMetafield(admin: AdminGraphql | undefined, ownerId: string, value: object) { if (!admin) return; await admin .graphql(`#graphql mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, { variables: { m: [{ ownerId, namespace: "datewise", key: "booking", type: "json", value: JSON.stringify(value) }] }, }) .catch((e) => console.error("metafieldsSet failed", e)); } function safeJson(s: string): unknown { try { return JSON.parse(s); } catch { return null; } }