/** * Charging delivery fees and peak surcharges on every Shopify plan. * * Carrier-calculated rates need Advanced/Plus (or the paid add-on), so instead the * app manages one hidden "Delivery & handling" product tagged `datewise-fee`, with a * variant per fee amount. The widget puts exactly one fee line in the cart that * matches the quote; the checkout validation function and the order webhook both * re-check it. */ import prisma from "../db.server"; import { FEE_TAG } from "./catalog.server"; type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; async function gql(admin: AdminGraphql, q: string, variables?: Record): Promise { const r = await admin.graphql(q, { variables }); const j = (await r.json()) as { data: T; errors?: unknown }; if (j.errors) throw new Error(`Admin API: ${JSON.stringify(j.errors)}`); return j.data; } const label = (cents: number) => (cents / 100).toFixed(2); const numericId = (gid: string) => gid.split("/").pop()!; export async function ensureFeeProduct(admin: AdminGraphql, shopId: string): Promise { const shop = await prisma.shop.findUniqueOrThrow({ where: { id: shopId } }); if (shop.feeProductId) { const d = await gql<{ product: { id: string } | null }>(admin, `#graphql query($id: ID!) { product(id: $id) { id } }`, { id: shop.feeProductId }); if (d.product) return d.product.id; } const created = await gql<{ productCreate: { product: { id: string } | null; userErrors: { message: string }[] } }>( admin, `#graphql mutation DatewiseFeeProduct($product: ProductCreateInput!) { productCreate(product: $product) { product { id } userErrors { message } } }`, { product: { title: "Delivery & handling", handle: "datewise-delivery-fee", tags: [FEE_TAG], status: "ACTIVE", productType: "Service", descriptionHtml: "

Added automatically for scheduled delivery and pickup.

", productOptions: [{ name: "Fee", values: [{ name: label(0) }] }], seo: { title: "Delivery & handling" }, }, }, ); const id = created.productCreate.product?.id; if (!id) throw new Error(created.productCreate.userErrors.map((e) => e.message).join("; ")); // Hide from storefront search/collections. await gql(admin, `#graphql mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, { m: [{ ownerId: id, namespace: "seo", key: "hidden", type: "number_integer", value: "1" }], }); // Must be on the Online Store channel to be added to carts. const pubs = await gql<{ publications: { nodes: { id: string; catalog: { title: string } | null }[] } }>(admin, `#graphql query { publications(first: 20) { nodes { id catalog { title } } } }`); const online = pubs.publications.nodes.find((p) => /online store/i.test(p.catalog?.title ?? "")); if (online) await gql(admin, `#graphql mutation($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { message } } }`, { id, input: [{ publicationId: online.id }], }); await prisma.shop.update({ where: { id: shopId }, data: { feeProductId: id } }); return id; } /** Returns the numeric variant id for a fee amount, creating the variant when needed. */ export async function ensureFeeVariant(admin: AdminGraphql, shopId: string, cents: number): Promise { if (cents <= 0) return null; const productId = await ensureFeeProduct(admin, shopId); const d = await gql<{ product: { variants: { nodes: { id: string; price: string }[] } } }>(admin, `#graphql query($id: ID!) { product(id: $id) { variants(first: 250) { nodes { id price } } } }`, { id: productId }); const found = d.product.variants.nodes.find((v) => Math.round(Number(v.price) * 100) === cents); if (found) return numericId(found.id); const c = await gql<{ productVariantsBulkCreate: { productVariants: { id: string }[]; userErrors: { message: string }[] } }>( admin, `#graphql mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkCreate(productId: $productId, variants: $variants) { productVariants { id } userErrors { message } } }`, { productId, variants: [ { price: label(cents), optionValues: [{ optionName: "Fee", name: label(cents) }], inventoryPolicy: "CONTINUE", inventoryItem: { tracked: false, requiresShipping: false }, }, ], }, ); const v = c.productVariantsBulkCreate.productVariants[0]; if (!v) throw new Error(c.productVariantsBulkCreate.userErrors.map((e) => e.message).join("; ")); return numericId(v.id); }