metatrondelivery/app/services/checkout-snapshot.server.ts
metatroncubeswdev a2c78d703f
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and
closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md).

Core (code + unit tests, 156 green):
- Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget
  now sends variantIds so inventory-based location exclusion actually runs.
- Live slot re-validation at checkout: new checkout-snapshot.server.ts writes
  a shop-metafield capacity snapshot; validation-slot's evaluateCheckout
  rejects a complete selection that has since filled / blacked out / closed /
  hit the daily cap / left the schedule. Refreshed on order webhooks and
  slot/blackout/location/enforcement edits.
- Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) +
  enforcementTag, new app.settings.tsx admin page, honoured via the snapshot.
- Per-day order cap: Location.dailyOrderCap threaded through getAvailability
  (dailyCap + consumedPerDate); admin field on the location screen.
- Product-rule slot blocking: ProductRule.blockedStartMins, unioned in
  resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest;
  admin field on the product rules screen.
- Product-page placement: product-availability.liquid block + widget
  data-mode="preview" (read-only earliest-date line).
- Second locale: datetime-widget fr.json / fr.schema.json.
- Migration 20260904120000_review_gaps (apply with prisma migrate deploy).

New Functions (source + unit tests; need `shopify app deploy` to ship):
- extensions/payment-customization: cart.payment-methods.transform.run — hides
  cash-on-delivery / pay-in-store gateways on SHIPPING orders.
- extensions/checkout-datetime/src: restored from a gitignored dist-only state
  — Plus native picker + Thank you / Order status confirmation blocks, all
  calling the existing checkout.scheduling.* routes (one capacity pool).
  tsconfig ships checkJs:false pending reconciliation with live checkout types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:31:02 -04:00

242 lines
10 KiB
TypeScript

import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import db from "../db.server";
import { getAvailability } from "./scheduling.server";
import type { AdminGraphQLClient } from "./zones.server";
// Denormalized capacity + enforcement snapshot that the Cart/Checkout
// Validation Function reads from a shop metafield. Shopify Functions can't
// query this app's database, so anything the Function needs to enforce
// "the slot has since become invalid" (study §5.2) or "only enforce for
// tagged products" (study §3.5) has to be pushed into a metafield it can
// read directly. This module builds and writes that metafield; it's
// refreshed whenever bookings or scheduling config change (order webhooks,
// slot/blackout admin edits) and on a periodic worker sweep so the rolling
// horizon keeps advancing.
export const SNAPSHOT_NAMESPACE = "delivery_datetime";
export const SNAPSHOT_KEY = "checkout_snapshot";
const HORIZON_DAYS = 30;
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
// A fixed instant far in the past, so getAvailability's cutoff/lead-time
// filters never hide a slot here — the snapshot captures *capacity and
// calendar* state (blackout, closed override, daily cap, bookings), not the
// time-of-day cutoff, which is inherently second-sensitive and already
// covered by the hold TTL and the widget.
const EPOCH = DateTime.fromISO("1970-01-01T00:00:00Z");
export interface CheckoutSnapshot {
version: 1;
generatedAt: string;
/** Slots whose date is after this (UTC calendar date) aren't covered — the Function stays permissive past the horizon. */
horizonDate: string;
enforcement: { mode: "all" | "tagged" | "off" };
/** Product GIDs that trigger enforcement when mode = "tagged". Ignored otherwise. */
schedulableProductIds: string[];
/** `${locationId}|${METHOD}|${YYYY-MM-DD}|${startMin}` -> remaining capacity. */
slots: Record<string, number>;
/** `${locationId}|${METHOD}|${YYYY-MM-DD}` -> true when that day has no bookable slot (blackout / closed / daily cap / no template). */
closedDates: Record<string, true>;
}
export function slotSnapshotKey(locationId: string, method: Method, date: string, startMin: number): string {
return `${locationId}|${method}|${date}|${startMin}`;
}
export function closedDateKey(locationId: string, method: Method, date: string): string {
return `${locationId}|${method}|${date}`;
}
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* Builds the capacity/calendar half of the snapshot from the database.
* Enforcement config and tagged-product resolution are layered on by
* writeCheckoutSnapshot (they need the Admin API / are cheap to add there).
*/
export async function buildCheckoutSnapshot(shopDomain: string): Promise<Omit<CheckoutSnapshot, "enforcement" | "schedulableProductIds">> {
const nowUtc = DateTime.utc();
const startDate = nowUtc.toISODate()!;
const endDate = nowUtc.plus({ days: HORIZON_DAYS }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const locations = await db.location.findMany({ where: { shopDomain, active: true } });
const slots: Record<string, number> = {};
const closedDates: Record<string, true> = {};
for (const location of locations) {
const [templates, overrides, blackouts, bookings] = await Promise.all([
db.slotTemplate.findMany({ where: { shopDomain, locationId: location.id } }),
db.slotOverride.findMany({
where: { shopDomain, locationId: location.id, date: { gte: rangeStart, lte: rangeEnd } },
}),
db.blackoutDate.findMany({
where: { shopDomain, date: { gte: rangeStart, lte: rangeEnd }, OR: [{ locationId: location.id }, { locationId: null }] },
}),
db.booking.findMany({
where: {
shopDomain,
locationId: location.id,
status: { in: ["confirmed", "fulfilled"] },
slotStart: { gte: rangeStart, lte: rangeEnd },
},
select: { slotStart: true, method: true },
}),
]);
// Per-date booking tally (all methods) drives the daily cap; per-slot
// tally is method-specific.
const consumedPerDate = new Map<string, number>();
const consumedBySlot = new Map<string, Map<string, number>>(); // method -> (date|startMin -> n)
for (const booking of bookings) {
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
const isoDate = local.toISODate()!;
consumedPerDate.set(isoDate, (consumedPerDate.get(isoDate) ?? 0) + 1);
const perMethod = consumedBySlot.get(booking.method) ?? new Map<string, number>();
const key = `${isoDate}|${local.hour * 60 + local.minute}`;
perMethod.set(key, (perMethod.get(key) ?? 0) + 1);
consumedBySlot.set(booking.method, perMethod);
}
for (const method of METHODS) {
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
slotTemplates: templates
.filter((t) => t.method === method)
.map((t) => ({
weekday: t.weekday,
startMin: t.startMin,
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: null, // deliberately ignored here — see EPOCH note
leadTimeMin: 0,
transitMinDays: t.transitMinDays,
transitMaxDays: t.transitMaxDays,
})),
overrides: overrides
.filter((o) => o.method === method)
.map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity })),
blackoutDates: blackouts
.filter((b) => b.method === null || b.method === method)
.map((b) => ({ date: toIsoDate(b.date) })),
now: EPOCH,
consumed: consumedBySlot.get(method) ?? new Map(),
dailyCap: location.dailyOrderCap,
consumedPerDate,
});
// Walk every calendar day in the horizon: present -> record each slot's
// remaining capacity; absent -> the day is closed for this method.
let cursor = DateTime.fromISO(startDate, { zone: "utc" });
const last = DateTime.fromISO(endDate, { zone: "utc" });
for (; cursor <= last; cursor = cursor.plus({ days: 1 })) {
const date = cursor.toISODate()!;
const daySlots = availability[date];
if (!daySlots || daySlots.length === 0) {
closedDates[closedDateKey(location.id, method, date)] = true;
continue;
}
for (const slot of daySlots) {
slots[slotSnapshotKey(location.id, method, date, slot.startMin)] = slot.remainingCapacity;
}
}
}
}
return { version: 1, generatedAt: nowUtc.toISO()!, horizonDate: endDate, slots, closedDates };
}
/** Resolves the Product GIDs carrying `tag` via the Admin API (paged). */
async function resolveTaggedProductIds(admin: AdminGraphQLClient, tag: string): Promise<string[]> {
const ids: string[] = [];
let cursor: string | null = null;
// Cap the sweep so a huge catalog can't stall a webhook — 2000 tagged
// products is already an extreme case for this feature.
for (let page = 0; page < 8; page += 1) {
const response: Response = await admin.graphql(
`#graphql
query TaggedProducts($query: String!, $after: String) {
products(first: 250, query: $query, after: $after) {
nodes { id }
pageInfo { hasNextPage endCursor }
}
}`,
{ variables: { query: `tag:'${tag.replace(/'/g, "")}'`, after: cursor } },
);
const body = (await response.json()) as {
data?: { products: { nodes: Array<{ id: string }>; pageInfo: { hasNextPage: boolean; endCursor: string | null } } };
};
const products = body.data?.products;
if (!products) break;
for (const node of products.nodes) ids.push(node.id);
if (!products.pageInfo.hasNextPage) break;
cursor = products.pageInfo.endCursor;
}
return ids;
}
/**
* Builds the snapshot and writes it to the shop metafield the Validation
* Function reads. Safe to call from a webhook handler — failures are logged,
* not thrown, so a metafield hiccup never fails order processing.
*/
export async function writeCheckoutSnapshot(admin: AdminGraphQLClient, shopDomain: string): Promise<void> {
try {
const shop = await db.shop.findUnique({
where: { shopDomain },
select: { enforcementMode: true, enforcementTag: true },
});
const mode = (shop?.enforcementMode as CheckoutSnapshot["enforcement"]["mode"]) ?? "all";
const base = await buildCheckoutSnapshot(shopDomain);
const schedulableProductIds =
mode === "tagged" && shop?.enforcementTag ? await resolveTaggedProductIds(admin, shop.enforcementTag) : [];
const snapshot: CheckoutSnapshot = { ...base, enforcement: { mode }, schedulableProductIds };
const response = await admin.graphql(
`#graphql
mutation WriteCheckoutSnapshot($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
userErrors { field message }
}
}`,
{
variables: {
metafields: [
{
ownerId: await currentShopGid(admin),
namespace: SNAPSHOT_NAMESPACE,
key: SNAPSHOT_KEY,
type: "json",
value: JSON.stringify(snapshot),
},
],
},
},
);
const body = (await response.json()) as { data?: { metafieldsSet?: { userErrors: Array<{ message: string }> } } };
const errors = body.data?.metafieldsSet?.userErrors ?? [];
if (errors.length > 0) {
console.error("writeCheckoutSnapshot userErrors", errors);
}
} catch (error) {
console.error("writeCheckoutSnapshot failed", error);
}
}
async function currentShopGid(admin: AdminGraphQLClient): Promise<string> {
const response = await admin.graphql(`#graphql
query CurrentShopId { shop { id } }`);
const body = (await response.json()) as { data?: { shop?: { id: string } } };
return body.data?.shop?.id ?? "";
}