Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
// Publishes the compiled configuration to Shopify (brief §11.1, §11.5, §12):
|
|
// ONE automatic app discount per shop (function "bundle-discount") carrying $app:configuration and
|
|
// $app:function-vars, plus the app-data metafield bundle_offers.storefront read by the theme extension.
|
|
import db from "../db.server";
|
|
import { compile, settingsOf, type CompileResult } from "./compile";
|
|
import { gql, ensureShop, type AdminLike } from "./shop.server";
|
|
|
|
export const FUNCTION_HANDLE = "bundle-discount";
|
|
export const DISCOUNT_TITLE = "Bundle Offers";
|
|
export const STOREFRONT_NAMESPACE = "bundle_offers";
|
|
|
|
const CREATE = `#graphql
|
|
mutation BundleOffersCreateDiscount($d: DiscountAutomaticAppInput!) {
|
|
discountAutomaticAppCreate(automaticAppDiscount: $d) {
|
|
automaticAppDiscount { discountId }
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
const UPDATE = `#graphql
|
|
mutation BundleOffersUpdateDiscount($id: ID!, $d: DiscountAutomaticAppInput!) {
|
|
discountAutomaticAppUpdate(id: $id, automaticAppDiscount: $d) {
|
|
automaticAppDiscount { discountId }
|
|
userErrors { field message }
|
|
}
|
|
}`;
|
|
|
|
const NODE = `#graphql
|
|
query BundleOffersDiscountNode($id: ID!) {
|
|
discountNode(id: $id) { id discount { ... on DiscountAutomaticApp { status } } }
|
|
}`;
|
|
|
|
const SET_METAFIELDS = `#graphql
|
|
mutation BundleOffersSetMetafields($m: [MetafieldsSetInput!]!) {
|
|
metafieldsSet(metafields: $m) { userErrors { field message code } }
|
|
}`;
|
|
|
|
const DELETE_DISCOUNT = `#graphql
|
|
mutation BundleOffersDeleteDiscount($id: ID!) {
|
|
discountAutomaticDelete(id: $id) { deletedAutomaticDiscountId userErrors { field message } }
|
|
}`;
|
|
|
|
const DELETE_METAFIELDS = `#graphql
|
|
mutation BundleOffersDeleteMetafields($m: [MetafieldIdentifierInput!]!) {
|
|
metafieldsDelete(metafields: $m) { userErrors { field message } }
|
|
}`;
|
|
|
|
function combinesWith(settingsRaw: unknown) {
|
|
const s = settingsOf(settingsRaw);
|
|
return { productDiscounts: s.combineProduct, orderDiscounts: s.combineOrder, shippingDiscounts: s.combineShipping };
|
|
}
|
|
|
|
function userErrorText(errors: { message: string }[] | undefined) {
|
|
return errors?.length ? errors.map((e) => e.message).join("; ") : null;
|
|
}
|
|
|
|
async function loadOffers(shopId: string) {
|
|
return db.offer.findMany({ where: { shopId }, include: { lines: { orderBy: { sequence: "asc" } }, scopes: true } });
|
|
}
|
|
|
|
export async function compileShop(shopId: string, now = new Date()): Promise<CompileResult> {
|
|
const shop = await db.shop.findUniqueOrThrow({ where: { id: shopId } });
|
|
const offers = await loadOffers(shopId);
|
|
return compile(
|
|
{ planCode: shop.planCode, currency: shop.currency, timezone: shop.timezone, settings: shop.settings, monthOrders: currentMonthOrders(shop) },
|
|
offers.map((o) => ({ ...o, lines: o.lines, scopes: o.scopes })),
|
|
now,
|
|
);
|
|
}
|
|
|
|
export function monthKey(d = new Date()) {
|
|
return d.toISOString().slice(0, 7);
|
|
}
|
|
|
|
export function currentMonthOrders(shop: { monthKey: string | null; monthOrders: number }) {
|
|
return shop.monthKey === monthKey() ? shop.monthOrders : 0;
|
|
}
|
|
|
|
/** Creates the shop's single automatic discount if it is missing; returns its id. */
|
|
async function ensureDiscount(admin: AdminLike, shop: { id: string; discountNodeId: string | null; settings: unknown }, metafields: object[]) {
|
|
if (shop.discountNodeId) {
|
|
const node = await gql(admin, NODE, { id: shop.discountNodeId });
|
|
if (node.discountNode) return { id: shop.discountNodeId, created: false };
|
|
}
|
|
const data = await gql(admin, CREATE, {
|
|
d: {
|
|
title: DISCOUNT_TITLE,
|
|
functionHandle: FUNCTION_HANDLE,
|
|
discountClasses: ["PRODUCT"],
|
|
startsAt: new Date().toISOString(),
|
|
endsAt: null,
|
|
combinesWith: combinesWith(shop.settings),
|
|
metafields,
|
|
},
|
|
});
|
|
const err = userErrorText(data.discountAutomaticAppCreate.userErrors);
|
|
if (err) throw new Error(`Could not create the Bundle Offers discount: ${err}`);
|
|
const id = data.discountAutomaticAppCreate.automaticAppDiscount.discountId as string;
|
|
await db.shop.update({ where: { id: shop.id }, data: { discountNodeId: id } });
|
|
return { id, created: true };
|
|
}
|
|
|
|
export interface PublishOutcome {
|
|
ok: boolean;
|
|
error: string | null;
|
|
result: CompileResult;
|
|
}
|
|
|
|
/** Compiles and pushes everything to Shopify. Safe to call any time (idempotent). */
|
|
export async function publishShop(admin: AdminLike, domain: string, now = new Date()): Promise<PublishOutcome> {
|
|
const shop = await ensureShop(admin, domain);
|
|
const result = await compileShop(shop.id, now);
|
|
|
|
if (result.error) {
|
|
await db.shop.update({ where: { id: shop.id }, data: { publishError: result.error, nextSyncAt: result.nextSyncAt } });
|
|
return { ok: false, error: result.error, result };
|
|
}
|
|
|
|
const configMetafields = [
|
|
{ namespace: "$app", key: "configuration", type: "json", value: JSON.stringify(result.config) },
|
|
{ namespace: "$app", key: "function-vars", type: "json", value: JSON.stringify(result.vars) },
|
|
];
|
|
try {
|
|
const discount = await ensureDiscount(admin, shop, configMetafields);
|
|
const metafields: object[] = [
|
|
{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront", type: "json", value: JSON.stringify(result.storefront) },
|
|
];
|
|
if (!discount.created) metafields.push(...configMetafields.map((m) => ({ ...m, ownerId: discount.id })));
|
|
const set = await gql(admin, SET_METAFIELDS, { m: metafields });
|
|
const err = userErrorText(set.metafieldsSet.userErrors);
|
|
if (err) throw new Error(err);
|
|
|
|
await db.shop.update({
|
|
where: { id: shop.id },
|
|
data: {
|
|
publishError: null,
|
|
lastPublishedAt: now,
|
|
nextSyncAt: result.nextSyncAt,
|
|
configBytes: result.configBytes,
|
|
storefrontBytes: result.storefrontBytes,
|
|
},
|
|
});
|
|
return { ok: true, error: null, result };
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e.message : String(e);
|
|
// retry in a minute
|
|
await db.shop.update({ where: { id: shop.id }, data: { publishError: error, nextSyncAt: new Date(Date.now() + 60_000) } });
|
|
return { ok: false, error, result };
|
|
}
|
|
}
|
|
|
|
/** Applies the combine settings to the discount node. */
|
|
export async function updateCombinations(admin: AdminLike, domain: string) {
|
|
const shop = await ensureShop(admin, domain);
|
|
if (!shop.discountNodeId) return publishShop(admin, domain);
|
|
const data = await gql(admin, UPDATE, { id: shop.discountNodeId, d: { combinesWith: combinesWith(shop.settings) } });
|
|
const err = userErrorText(data.discountAutomaticAppUpdate.userErrors);
|
|
if (err) throw new Error(err);
|
|
return publishShop(admin, domain);
|
|
}
|
|
|
|
/** Removes the discount and the storefront metafield (uninstall from inside the app, or plan cleanup). */
|
|
export async function removeShopifyData(admin: AdminLike, shop: { discountNodeId: string | null; appInstallationId: string | null }) {
|
|
if (shop.discountNodeId) await gql(admin, DELETE_DISCOUNT, { id: shop.discountNodeId }).catch(() => null);
|
|
if (shop.appInstallationId) {
|
|
await gql(admin, DELETE_METAFIELDS, {
|
|
m: [{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront" }],
|
|
}).catch(() => null);
|
|
}
|
|
}
|