diff --git a/.env.example b/.env.example index ecf73bd..0cd5d3a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ SHOPIFY_API_KEY= SHOPIFY_API_SECRET= SHOPIFY_APP_URL=https://your-app.example.com -SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations +SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations # PostgreSQL 14+ (row locks are required for capacity holds; SQLite is not supported) DATABASE_URL=postgresql://user:password@localhost:5432/datewise diff --git a/README.md b/README.md index 74aa65a..a56c5ec 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ app/routes/webhooks.* orders/create, cancelled, fulfilled, app/uninstalled app/jobs/worker.ts background worker extensions/datewise-widget theme app extension (cart block + app embed for drawers), en/fr extensions/checkout-guard validation function (JS) + tests +extensions/delivery-label delivery customization: chosen date/time on the checkout option (JS) + tests +extensions/payment-rules payment customization: hide pay-in-person for shipping (JS) + tests extensions/order-reschedule customer account UI extension (Preact) prisma/ schema + SQL migration prototype/ the clickable demo (runs the real engine) @@ -96,6 +98,8 @@ Then, in the dev store: | `read_customers` | verify that the customer rescheduling actually owns the order | | `read_locations`, `read_inventory` | link Shopify locations; stock-aware location choice | | `write_validations` | switch the checkout guard on and off | +| `write_delivery_customizations` | show the chosen date and time on the checkout delivery option | +| `write_payment_customizations` | hide pay-in-person methods for shipped orders | Customer names, emails, phones and addresses are **protected customer data**. Request Level 2 access in the Partner Dashboard before review. diff --git a/app/lib/guard.server.ts b/app/lib/guard.server.ts index 9eda107..7d71dfc 100644 --- a/app/lib/guard.server.ts +++ b/app/lib/guard.server.ts @@ -1,5 +1,13 @@ -/** Checkout guard (cart & checkout validation function) lookup, shared by Settings and the Today setup guide. */ +/** + * Datewise's checkout functions and their activation records, shared by Settings and the Today setup guide. + * - checkout guard → a cart & checkout validation + * - delivery label → a delivery customization + * - payment rules → a payment customization + * Functions are matched by extension handle, never by title, so one can't be mistaken for another. + */ export const GUARD_HANDLE = "datewise-checkout-guard"; +export const DELIVERY_LABEL_HANDLE = "datewise-delivery-label"; +export const PAYMENT_RULES_HANDLE = "datewise-payment-rules"; export type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; @@ -8,16 +16,83 @@ export async function gql(admin: AdminGraphql, q: string, variables?: Record< return ((await r.json()) as { data: T }).data; } +type Fn = { id: string; handle: string }; +type Activation = { id: string; enabled: boolean; shopifyFunction: { id: string } }; + export async function findGuard(admin: AdminGraphql) { - const d = await gql<{ - shopifyFunctions: { nodes: { id: string; title: string; apiType: string }[] }; - validations: { nodes: { id: string; enabled: boolean; shopifyFunction: { id: string } }[] }; - }>(admin, `#graphql - query { - shopifyFunctions(first: 50) { nodes { id title apiType } } + const d = await gql<{ shopifyFunctions: { nodes: Fn[] }; validations: { nodes: Activation[] } }>(admin, `#graphql + query DatewiseGuard { + shopifyFunctions(first: 50) { nodes { id handle } } validations(first: 25) { nodes { id enabled shopifyFunction { id } } } }`); - const fn = d.shopifyFunctions.nodes.find((f) => f.title === GUARD_HANDLE || /datewise/i.test(f.title)); + const fn = d.shopifyFunctions.nodes.find((f) => f.handle === GUARD_HANDLE); const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined; return { fn, validation }; } + +export type CustomizationKind = "delivery" | "payment"; +export type CustomizationState = { deployed: boolean; enabled: boolean; id?: string }; + +/** Separate from findGuard: needs the delivery/payment customization scopes, which may not be granted yet. */ +export async function findCustomizations(admin: AdminGraphql): Promise> { + const d = await gql<{ + shopifyFunctions: { nodes: Fn[] }; + deliveryCustomizations: { nodes: Activation[] }; + paymentCustomizations: { nodes: Activation[] }; + }>(admin, `#graphql + query DatewiseCustomizations { + shopifyFunctions(first: 50) { nodes { id handle } } + deliveryCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } } + paymentCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } } + }`); + const state = (handle: string, list: Activation[]): CustomizationState => { + const fn = d.shopifyFunctions.nodes.find((f) => f.handle === handle); + const act = fn ? list.find((c) => c.shopifyFunction.id === fn.id) : undefined; + return { deployed: !!fn, enabled: !!act?.enabled, id: act?.id }; + }; + return { + delivery: state(DELIVERY_LABEL_HANDLE, d.deliveryCustomizations.nodes), + payment: state(PAYMENT_RULES_HANDLE, d.paymentCustomizations.nodes), + }; +} + +const CUSTOMIZATION = { + delivery: { + handle: DELIVERY_LABEL_HANDLE, + title: "Datewise: show chosen date and time", + create: `#graphql + mutation DeliveryLabelCreate($input: DeliveryCustomizationInput!) { + deliveryCustomizationCreate(deliveryCustomization: $input) { deliveryCustomization { id } userErrors { message } } + }`, + update: `#graphql + mutation DeliveryLabelUpdate($id: ID!, $input: DeliveryCustomizationInput!) { + deliveryCustomizationUpdate(id: $id, deliveryCustomization: $input) { userErrors { message } } + }`, + }, + payment: { + handle: PAYMENT_RULES_HANDLE, + title: "Datewise: hide pay-in-person for shipping", + create: `#graphql + mutation PaymentRulesCreate($input: PaymentCustomizationInput!) { + paymentCustomizationCreate(paymentCustomization: $input) { paymentCustomization { id } userErrors { message } } + }`, + update: `#graphql + mutation PaymentRulesUpdate($id: ID!, $input: PaymentCustomizationInput!) { + paymentCustomizationUpdate(id: $id, paymentCustomization: $input) { userErrors { message } } + }`, + }, +} as const; + +/** Turns a customization on or off, creating its activation record the first time. Returns an error message or null. */ +export async function setCustomization(admin: AdminGraphql, kind: CustomizationKind, on: boolean): Promise { + const current = (await findCustomizations(admin))[kind]; + if (!current.deployed) return "This feature isn't deployed yet. Run `shopify app deploy`."; + const c = CUSTOMIZATION[kind]; + type Res = Record; + let res: Res; + if (current.id) res = await gql(admin, c.update, { id: current.id, input: { enabled: on } }); + else if (on) res = await gql(admin, c.create, { input: { functionHandle: c.handle, title: c.title, enabled: true } }); + else return null; + const errors = Object.values(res ?? {}).flatMap((x) => x?.userErrors ?? []); + return errors.length ? errors.map((e) => e.message).join(" ") : null; +} diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 03a61f0..1b72ca6 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -9,7 +9,7 @@ import { encrypt } from "../services/crypto.server"; import { PLAN_COPY } from "../services/plans"; import { type AppSettings, withDefaults } from "../services/settings"; -import { GUARD_HANDLE, findGuard, gql } from "../lib/guard.server"; +import { type CustomizationKind, GUARD_HANDLE, findCustomizations, findGuard, gql, setCustomization } from "../lib/guard.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const { cfg, admin, billing } = await adminContext(request); @@ -18,11 +18,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free"; if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } }); const guard = await findGuard(admin).catch(() => ({ fn: undefined, validation: undefined })); + const off = { deployed: false, enabled: false }; + const custom = await findCustomizations(admin).catch(() => ({ delivery: off, payment: off })); const s = cfg.settings; return { plan, s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } }, guard: { deployed: !!guard.fn, enabled: !!guard.validation?.enabled }, + custom: { delivery: { deployed: custom.delivery.deployed, enabled: custom.delivery.enabled }, payment: { deployed: custom.payment.deployed, enabled: custom.payment.enabled } }, }; }; @@ -68,6 +71,15 @@ export const action = async ({ request }: ActionFunctionArgs) => { return { ok: true, message: turnOn ? "Checkout guard is on" : "Checkout guard is off" }; } + if (intent === "custom:delivery" || intent === "custom:payment") { + const kind = intent.slice(7) as CustomizationKind; + const on = formBool(fd, "on"); + const error = await setCustomization(admin, kind, on).catch((e: Error) => e.message); + if (error) return { ok: false, message: error }; + const name = kind === "delivery" ? "Date and time at checkout" : "Payment rules"; + return { ok: true, message: `${name} ${on ? "on" : "off"}` }; + } + if (intent === "policy") { s.holdMinutes = Math.min(60, Math.max(3, formInt(fd, "holdMinutes") ?? 10)); s.mixedPrepPolicy = formString(fd, "mixedPrepPolicy") === "sum" ? "sum" : "longest"; @@ -152,7 +164,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; export default function Settings() { - const { plan, s, guard } = useLoaderData(); + const { plan, s, guard, custom } = useLoaderData(); const result = useActionData(); const busy = useNavigation().state !== "idle"; return ( @@ -175,6 +187,24 @@ export default function Settings() { + + + + + + + +
@@ -314,3 +344,22 @@ export default function Settings() { ); } + +function CheckoutToggle({ intent, state, title, body }: { intent: string; state: { deployed: boolean; enabled: boolean }; title: string; body: string }) { + return ( + + + + {title} + {state.enabled ? "On" : state.deployed ? "Off" : "Not deployed"} + + {body} + + + + + {state.enabled ? "Turn off" : "Turn on"} + +
+ ); +} diff --git a/extensions/delivery-label/package.json b/extensions/delivery-label/package.json new file mode 100644 index 0000000..6928871 --- /dev/null +++ b/extensions/delivery-label/package.json @@ -0,0 +1,16 @@ +{ + "name": "datewise-delivery-label", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "UNLICENSED", + "scripts": { + "shopify": "npm exec -- shopify", + "typegen": "npm exec -- shopify app function typegen", + "build": "npm exec -- shopify app function build", + "preview": "npm exec -- shopify app function run" + }, + "dependencies": { + "@shopify/shopify_function": "^2.0.1" + } +} diff --git a/extensions/delivery-label/shopify.extension.toml b/extensions/delivery-label/shopify.extension.toml new file mode 100644 index 0000000..7bd7a1b --- /dev/null +++ b/extensions/delivery-label/shopify.extension.toml @@ -0,0 +1,16 @@ +api_version = "2026-01" + +[[extensions]] +name = "Datewise delivery label" +handle = "datewise-delivery-label" +type = "function" +description = "Shows the customer's chosen pickup or delivery date and time on the matching checkout delivery option." + + [[extensions.targeting]] + target = "cart.delivery-options.transform.run" + input_query = "src/cart_delivery_options_transform_run.graphql" + export = "cart-delivery-options-transform-run" + + [extensions.build] + command = "" + path = "dist/function.wasm" diff --git a/extensions/delivery-label/src/cart_delivery_options_transform_run.graphql b/extensions/delivery-label/src/cart_delivery_options_transform_run.graphql new file mode 100644 index 0000000..babf9de --- /dev/null +++ b/extensions/delivery-label/src/cart_delivery_options_transform_run.graphql @@ -0,0 +1,19 @@ +query CartDeliveryOptionsTransformRunInput { + cart { + selection: attribute(key: "_datewise") { + value + } + deliveryGroups { + deliveryOptions { + handle + title + deliveryMethodType + } + } + } + localization { + language { + isoCode + } + } +} diff --git a/extensions/delivery-label/src/cart_delivery_options_transform_run.js b/extensions/delivery-label/src/cart_delivery_options_transform_run.js new file mode 100644 index 0000000..637b328 --- /dev/null +++ b/extensions/delivery-label/src/cart_delivery_options_transform_run.js @@ -0,0 +1,98 @@ +// @ts-check +/** + * Datewise delivery label (Delivery Customization Function, all Shopify plans). + * + * Checkout only shows the merchant's generic option names ("Local Delivery", "Pickup at Kitchener"). + * This appends the date and time the customer chose in the cart, so the order summary and the + * confirmation say exactly when, e.g. "Local Delivery — Wed, Nov 7, 10 AM–1 PM". + * + * Only options of the chosen method's type are renamed, so a store that offers both "Standard" and + * "Express" shipping keeps them distinct, and a pickup choice never relabels a shipping rate. + * Functions can't use Intl reliably, so dates are formatted here (English and French, like the widget). + */ + +/** Datewise method → checkout delivery method types it corresponds to. */ +const METHOD_TYPES = /** @type {Record} */ ({ + pickup: ["PICK_UP"], + delivery: ["LOCAL"], + shipping: ["SHIPPING"], +}); + +const WORDS = /** @type {Record} */ ({ + en: { + days: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + ships: "ships", + }, + fr: { + days: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."], + months: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."], + ships: "expédié le", + }, +}); + +/** @param {string} date YYYY-MM-DD @param {string} lang */ +export function formatDate(date, lang) { + const w = WORDS[lang] ?? WORDS.en; + const [y, m, d] = date.split("-").map(Number); + // Sakamoto's day-of-week algorithm: no Date objects needed. + const t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + const yy = m < 3 ? y - 1 : y; + const dow = (yy + Math.floor(yy / 4) - Math.floor(yy / 100) + Math.floor(yy / 400) + t[m - 1] + d) % 7; + return lang === "fr" ? `${w.days[dow]} ${d} ${w.months[m - 1]}` : `${w.days[dow]}, ${w.months[m - 1]} ${d}`; +} + +/** @param {string} hhmm @param {string} lang */ +export function formatTime(hhmm, lang) { + const [h, min] = hhmm.split(":").map(Number); + if (lang === "fr") return `${h} h${min ? String(min).padStart(2, "0") : ""}`; + const suffix = h < 12 ? "AM" : "PM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}${min ? ":" + String(min).padStart(2, "0") : ""} ${suffix}`; +} + +/** + * @param {string | null | undefined} raw the `_datewise` cart attribute + * @returns {{ method: string; date: string; start: string; end: string } | null} + */ +export function parseSelection(raw) { + if (!raw) return null; + try { + const s = JSON.parse(raw); + if (typeof s?.m !== "string" || !METHOD_TYPES[s.m]) return null; + if (typeof s.d !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(s.d)) return null; + const slot = typeof s.s === "string" ? /^(\d{2}:\d{2})-(\d{2}:\d{2})$/.exec(s.s) : null; + return { method: s.m, date: s.d, start: slot ? slot[1] : "", end: slot ? slot[2] : "" }; + } catch { + return null; + } +} + +/** + * @param {{ method: string; date: string; start: string; end: string }} sel + * @param {string} title the option's current title + * @param {string} lang + */ +export function labelFor(sel, title, lang) { + const w = WORDS[lang] ?? WORDS.en; + const date = formatDate(sel.date, lang); + if (sel.method === "shipping") return `${title} (${w.ships} ${date})`; + const time = sel.start && sel.end ? `, ${formatTime(sel.start, lang)}–${formatTime(sel.end, lang)}` : ""; + return `${title} — ${date}${time}`; +} + +/** @param {any} input */ +export function cartDeliveryOptionsTransformRun(input) { + const sel = parseSelection(input.cart?.selection?.value); + if (!sel) return { operations: [] }; + const lang = String(input.localization?.language?.isoCode ?? "EN").slice(0, 2).toLowerCase(); + const types = METHOD_TYPES[sel.method]; + const operations = (input.cart?.deliveryGroups ?? []).flatMap((/** @type {any} */ g) => + (g.deliveryOptions ?? []) + .filter((/** @type {any} */ o) => types.includes(o.deliveryMethodType)) + .map((/** @type {any} */ o) => ({ + deliveryOptionRename: { deliveryOptionHandle: o.handle, title: labelFor(sel, o.title ?? "", lang) }, + })), + ); + return { operations }; +} diff --git a/extensions/delivery-label/src/index.js b/extensions/delivery-label/src/index.js new file mode 100644 index 0000000..bde4771 --- /dev/null +++ b/extensions/delivery-label/src/index.js @@ -0,0 +1 @@ +export * from "./cart_delivery_options_transform_run"; diff --git a/extensions/delivery-label/tests/delivery-label.test.js b/extensions/delivery-label/tests/delivery-label.test.js new file mode 100644 index 0000000..cfe6a61 --- /dev/null +++ b/extensions/delivery-label/tests/delivery-label.test.js @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { cartDeliveryOptionsTransformRun as run, formatDate, formatTime } from "../src/cart_delivery_options_transform_run.js"; + +const sel = (o = {}) => JSON.stringify({ t: "tok", m: "delivery", l: "kit", d: "2026-11-07", s: "10:00-13:00", f: 0, ...o }); +const options = [ + { handle: "local", title: "Local Delivery", deliveryMethodType: "LOCAL" }, + { handle: "std", title: "Standard", deliveryMethodType: "SHIPPING" }, + { handle: "exp", title: "Express", deliveryMethodType: "SHIPPING" }, + { handle: "pick", title: "Kitchener — Guelph St", deliveryMethodType: "PICK_UP" }, +]; +const input = (raw, lang = "EN") => ({ + cart: { selection: raw == null ? null : { value: raw }, deliveryGroups: [{ deliveryOptions: options }] }, + localization: { language: { isoCode: lang } }, +}); +const renames = (r) => r.operations.map((o) => [o.deliveryOptionRename.deliveryOptionHandle, o.deliveryOptionRename.title]); + +describe("delivery label", () => { + it("formats dates without Intl, including weekday", () => { + expect(formatDate("2026-11-07", "en")).toBe("Sat, Nov 7"); + expect(formatDate("2024-02-29", "en")).toBe("Thu, Feb 29"); + expect(formatDate("2026-01-01", "fr")).toBe("jeu. 1 janv."); + }); + it("formats times in 12 h English and 24 h French", () => { + expect(formatTime("00:00", "en")).toBe("12 AM"); + expect(formatTime("12:30", "en")).toBe("12:30 PM"); + expect(formatTime("13:00", "en")).toBe("1 PM"); + expect(formatTime("09:05", "fr")).toBe("9 h05"); + }); + it("does nothing without a selection or with a malformed one", () => { + expect(run(input(null)).operations).toEqual([]); + expect(run(input("{not json")).operations).toEqual([]); + expect(run(input(sel({ m: "teleport" }))).operations).toEqual([]); + expect(run(input(sel({ d: "07/11/2026" }))).operations).toEqual([]); + }); + it("renames only local-delivery options for a delivery choice", () => { + expect(renames(run(input(sel())))).toEqual([["local", "Local Delivery — Sat, Nov 7, 10 AM–1 PM"]]); + }); + it("renames only pickup options for a pickup choice", () => { + expect(renames(run(input(sel({ m: "pickup", s: "14:00-16:00" }))))).toEqual([["pick", "Kitchener — Guelph St — Sat, Nov 7, 2 PM–4 PM"]]); + }); + it("keeps carrier names distinct and adds the ship date for shipping", () => { + expect(renames(run(input(sel({ m: "shipping" }))))).toEqual([ + ["std", "Standard (ships Sat, Nov 7)"], + ["exp", "Express (ships Sat, Nov 7)"], + ]); + }); + it("uses the buyer's language", () => { + expect(renames(run(input(sel(), "FR")))[0][1]).toBe("Local Delivery — sam. 7 nov., 10 h–13 h"); + }); + it("omits the time when the slot isn't a time range", () => { + expect(renames(run(input(sel({ s: "*" }))))[0][1]).toBe("Local Delivery — Sat, Nov 7"); + }); +}); diff --git a/extensions/payment-rules/package.json b/extensions/payment-rules/package.json new file mode 100644 index 0000000..9434b5b --- /dev/null +++ b/extensions/payment-rules/package.json @@ -0,0 +1,16 @@ +{ + "name": "datewise-payment-rules", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "UNLICENSED", + "scripts": { + "shopify": "npm exec -- shopify", + "typegen": "npm exec -- shopify app function typegen", + "build": "npm exec -- shopify app function build", + "preview": "npm exec -- shopify app function run" + }, + "dependencies": { + "@shopify/shopify_function": "^2.0.1" + } +} diff --git a/extensions/payment-rules/shopify.extension.toml b/extensions/payment-rules/shopify.extension.toml new file mode 100644 index 0000000..973e4bc --- /dev/null +++ b/extensions/payment-rules/shopify.extension.toml @@ -0,0 +1,16 @@ +api_version = "2026-01" + +[[extensions]] +name = "Datewise payment rules" +handle = "datewise-payment-rules" +type = "function" +description = "Hides cash-on-delivery and pay-in-store payment methods when the customer chose shipping." + + [[extensions.targeting]] + target = "cart.payment-methods.transform.run" + input_query = "src/cart_payment_methods_transform_run.graphql" + export = "cart-payment-methods-transform-run" + + [extensions.build] + command = "" + path = "dist/function.wasm" diff --git a/extensions/payment-rules/src/cart_payment_methods_transform_run.graphql b/extensions/payment-rules/src/cart_payment_methods_transform_run.graphql new file mode 100644 index 0000000..bc66414 --- /dev/null +++ b/extensions/payment-rules/src/cart_payment_methods_transform_run.graphql @@ -0,0 +1,11 @@ +query CartPaymentMethodsTransformRunInput { + cart { + selection: attribute(key: "_datewise") { + value + } + } + paymentMethods { + id + name + } +} diff --git a/extensions/payment-rules/src/cart_payment_methods_transform_run.js b/extensions/payment-rules/src/cart_payment_methods_transform_run.js new file mode 100644 index 0000000..d9ad038 --- /dev/null +++ b/extensions/payment-rules/src/cart_payment_methods_transform_run.js @@ -0,0 +1,31 @@ +// @ts-check +/** + * Datewise payment rules (Payment Customization Function, all Shopify plans). + * + * A shipped order leaves before anyone can pay in person, so manual "cash on delivery" / + * "pay in store" methods are hidden when the customer chose shipping. Pickup and local + * delivery keep every method, and with no selection nothing changes. + */ + +/** Common names merchants give manual pay-later / in-person methods. */ +export const PAY_IN_PERSON = /cash on delivery|\bc\.?o\.?d\.?\b|pay on (delivery|pickup|collection)|pay in (store|person)|paiement à la livraison|paiement en magasin/i; + +/** @param {string | null | undefined} raw the `_datewise` cart attribute */ +function method(raw) { + if (!raw) return null; + try { + const m = JSON.parse(raw)?.m; + return typeof m === "string" ? m : null; + } catch { + return null; + } +} + +/** @param {any} input */ +export function cartPaymentMethodsTransformRun(input) { + if (method(input.cart?.selection?.value) !== "shipping") return { operations: [] }; + const operations = (input.paymentMethods ?? []) + .filter((/** @type {any} */ p) => typeof p.name === "string" && PAY_IN_PERSON.test(p.name)) + .map((/** @type {any} */ p) => ({ paymentMethodHide: { paymentMethodId: p.id } })); + return { operations }; +} diff --git a/extensions/payment-rules/src/index.js b/extensions/payment-rules/src/index.js new file mode 100644 index 0000000..da2fe90 --- /dev/null +++ b/extensions/payment-rules/src/index.js @@ -0,0 +1 @@ +export * from "./cart_payment_methods_transform_run"; diff --git a/extensions/payment-rules/tests/payment-rules.test.js b/extensions/payment-rules/tests/payment-rules.test.js new file mode 100644 index 0000000..257035b --- /dev/null +++ b/extensions/payment-rules/tests/payment-rules.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { cartPaymentMethodsTransformRun as run } from "../src/cart_payment_methods_transform_run.js"; + +const methods = [ + { id: "card", name: "Credit card" }, + { id: "cod", name: "Cash on Delivery (COD)" }, + { id: "store", name: "Pay in store" }, + { id: "paypal", name: "PayPal" }, +]; +const input = (m) => ({ cart: { selection: m ? { value: JSON.stringify({ m, d: "2026-11-07", s: "10:00-13:00" }) } : null }, paymentMethods: methods }); +const hidden = (r) => r.operations.map((o) => o.paymentMethodHide.paymentMethodId); + +describe("payment rules", () => { + it("hides pay-in-person methods for shipping", () => expect(hidden(run(input("shipping")))).toEqual(["cod", "store"])); + it("keeps every method for pickup and local delivery", () => { + expect(hidden(run(input("pickup")))).toEqual([]); + expect(hidden(run(input("delivery")))).toEqual([]); + }); + it("changes nothing without a selection or with bad JSON", () => { + expect(hidden(run(input(null)))).toEqual([]); + expect(hidden(run({ cart: { selection: { value: "{x" } }, paymentMethods: methods }))).toEqual([]); + }); + it("doesn't hide names that merely contain the letters cod", () => { + expect(hidden(run({ ...input("shipping"), paymentMethods: [{ id: "x", name: "Codiac Pay" }] }))).toEqual([]); + }); +}); diff --git a/shopify.app.toml b/shopify.app.toml index fa7107d..91f6beb 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -13,7 +13,7 @@ include_config_on_deploy = true [access_scopes] # Each scope is used by a shipped feature — see README "Permissions". -scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations" +scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations" [auth] redirect_urls = ["https://example.com/auth/callback"]