feat: delivery-label and payment-rules checkout functions with Settings toggles

- delivery-label renames only options matching the chosen method (en/fr dates)
- payment-rules hides COD / pay-in-store for shipped orders
- functions matched by handle (fixes guard lookup matching any 'datewise' title)
- scopes: write_delivery_customizations, write_payment_customizations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-13 21:21:55 -04:00
parent 67b86cf411
commit 85ed1a8f0e
17 changed files with 444 additions and 12 deletions

View File

@ -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

View File

@ -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.

View File

@ -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<string, unknown> }) => Promise<Response> };
@ -8,16 +16,83 @@ export async function gql<T>(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<Record<CustomizationKind, CustomizationState>> {
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<string | null> {
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<string, { userErrors: { message: string }[] }>;
let res: Res;
if (current.id) res = await gql<Res>(admin, c.update, { id: current.id, input: { enabled: on } });
else if (on) res = await gql<Res>(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;
}

View File

@ -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<typeof loader>();
const { plan, s, guard, custom } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
return (
@ -175,6 +187,24 @@ export default function Settings() {
</s-stack>
</s-section>
<s-section heading="Checkout display">
<s-stack gap="base">
<CheckoutToggle
intent="custom:delivery"
state={custom.delivery}
title="Show the chosen date and time at checkout"
body="Adds the customer's date and time to the matching delivery option, for example “Local Delivery — Sat, Nov 7, 10 AM–1 PM”, so it appears in the order summary and confirmation."
/>
<s-divider />
<CheckoutToggle
intent="custom:payment"
state={custom.payment}
title="Hide pay-in-person methods for shipped orders"
body="Hides manual methods such as Cash on Delivery or Pay in store when the customer chose shipping. Pickup and local delivery keep every method."
/>
</s-stack>
</s-section>
<s-section heading="Booking policies">
<Form method="post">
<input type="hidden" name="intent" value="policy" />
@ -314,3 +344,22 @@ export default function Settings() {
</s-page>
);
}
function CheckoutToggle({ intent, state, title, body }: { intent: string; state: { deployed: boolean; enabled: boolean }; title: string; body: string }) {
return (
<s-grid gridTemplateColumns="1fr auto" gap="base" alignItems="center">
<s-stack gap="small-200">
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-text type="strong">{title}</s-text>
<s-badge tone={state.enabled ? "success" : undefined}>{state.enabled ? "On" : state.deployed ? "Off" : "Not deployed"}</s-badge>
</s-stack>
<s-paragraph color="subdued">{body}</s-paragraph>
</s-stack>
<Form method="post">
<input type="hidden" name="intent" value={intent} />
<input type="hidden" name="on" value={state.enabled ? "false" : "true"} />
<s-button type="submit" disabled={!state.deployed}>{state.enabled ? "Turn off" : "Turn on"}</s-button>
</Form>
</s-grid>
);
}

View File

@ -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"
}
}

View File

@ -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"

View File

@ -0,0 +1,19 @@
query CartDeliveryOptionsTransformRunInput {
cart {
selection: attribute(key: "_datewise") {
value
}
deliveryGroups {
deliveryOptions {
handle
title
deliveryMethodType
}
}
}
localization {
language {
isoCode
}
}
}

View File

@ -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<string, string[]>} */ ({
pickup: ["PICK_UP"],
delivery: ["LOCAL"],
shipping: ["SHIPPING"],
});
const WORDS = /** @type {Record<string, { days: string[]; months: string[]; ships: string }>} */ ({
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 };
}

View File

@ -0,0 +1 @@
export * from "./cart_delivery_options_transform_run";

View File

@ -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");
});
});

View File

@ -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"
}
}

View File

@ -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"

View File

@ -0,0 +1,11 @@
query CartPaymentMethodsTransformRunInput {
cart {
selection: attribute(key: "_datewise") {
value
}
}
paymentMethods {
id
name
}
}

View File

@ -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 };
}

View File

@ -0,0 +1 @@
export * from "./cart_payment_methods_transform_run";

View File

@ -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([]);
});
});

View File

@ -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"]