// 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 { 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 { 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); } }