104 lines
4.7 KiB
TypeScript
104 lines
4.7 KiB
TypeScript
/**
|
|
* 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<string, unknown> }) => Promise<Response> };
|
|
|
|
async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>): Promise<T> {
|
|
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<string> {
|
|
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: "<p>Added automatically for scheduled delivery and pickup.</p>",
|
|
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<string | null> {
|
|
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);
|
|
}
|