@base44/app-plugin-commerce 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -1,176 +1,34 @@
1
- # Online payments
1
+ # Online (card) payments
2
2
 
3
- **Card payments are implemented and shipped.** Hosted payment page, payment links for existing orders, two confirmation paths, and refunds through the provider — all wired to **Stripe** out of the box. There is **no charge flow to write**: the store starts taking card payments as soon as a payment provider is connected.
3
+ The store ships with a **Credit card** checkout option (`commerce.PaymentGateway` slug `card`) and everything around it already built — order creation, totals, stock holds, the two idempotent confirmation paths (customer return + provider webhook), payment links for unpaid orders, refund records, emails, and the admin's payment panel.
4
4
 
5
- - `base44/shared/commerce/payments.ts` — the **provider-neutral payment utility** every caller uses, plus the one-line `ACTIVE_PROVIDER` binding.
6
- - `base44/shared/commerce/stripe.ts` — the Stripe adapter (the only file that speaks Stripe).
7
- - `base44/functions/commerce/payments/` — `status` · `create-link` · `complete-return` · `verify`.
8
- - `base44/functions/commerce/payment-webhook/` — the provider's signed callback.
9
- - `commerce/storefront-checkout` `place-order` returns a `checkout_url`; `commerce/admin-refunds` refunds through the provider; the admin order page has a **Payment** panel.
5
+ What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal, Adyen, a local PSP…) means implementing **exactly two files** — nothing else changes, no entity is touched, no caller or UI needs editing:
10
6
 
11
- ---
7
+ | # | File | Implement |
8
+ |---|------|-----------|
9
+ | 1 | `base44/shared/commerce/card-payment.ts` | `createCardPayment` (make a hosted payment page for `order.total`, return `{url, reference}`), `checkCardPaymentPaid` (ask the provider whether `reference` was paid), and optionally `refundCardPayment` |
10
+ | 2 | `base44/functions/commerce/payment-webhook/entry.ts` | `parseWebhook` (verify the provider's signature over the raw body, return `{order_id, order_key, paid, reference?}`) |
12
11
 
13
- ## 1. Connecting a provider
12
+ Until file 1 is implemented, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods); the admin can also just switch the card option off in Settings → Payments to hide it. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions, and the operator moves it on once the money arrives — those need no code at all, and the admin can add more of them in Settings → Payments.
14
13
 
15
- **Connect the Stripe integration for the app** — early, ideally at the beginning of the implementation, so it is already connected by the time the checkout exists and a test order can prove the whole path. The platform holds the keys and injects them into backend functions as **`STRIPE_SECRET_KEY`** / **`STRIPE_PUBLISHABLE_KEY`**, which is where `stripe.ts` reads the credential from — never put keys in the store's data, in code, or in a settings field.
14
+ ## How the premade flow works
16
15
 
17
- **Connecting to an already-deployed store? Redeploy the backend functions.** The key is injected **at deploy time**, so `commerce/*` functions deployed before the integration was connected keep the environment they started with and the store keeps reporting *no payment provider connected* even though Stripe is set up correctly. Redeploy them all (anything under `base44/shared/commerce/` is bundled into every function, so touching it redeploys the whole set) and allow up to a minute for the ~60s status cache.
16
+ 1. **Checkout** (`commerce/storefront-checkout` `place-order` with the `card` gateway): the order is created `pending`, `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` is called, the returned `reference` is stored on the order (`_payment_reference` meta), and the customer is redirected to `url`. The return URLs already carry `order_id`, `order_key` and `payment=success|cancel`.
17
+ 2. **Confirmation — two idempotent paths**, whichever runs second is a no-op:
18
+ - **Customer return**: the `/order-received` page calls `commerce/payments` `complete-return`, which runs `checkCardPaymentPaid(sr, order, reference)` and, when true, moves the order to `processing` (stock/email/webhook side-effects fire from the transition).
19
+ - **Webhook**: register `commerce/payment-webhook`'s URL with the provider. Your `parseWebhook` verifies the signature and names the order; `paid: true` from a **verified** event is trusted directly, `paid: false` makes the premade code re-check via `checkCardPaymentPaid`. The event's `order_key` must match the order — attach `order.id` and `order.order_key` to the payment's metadata in `createCardPayment` so the event can carry them back.
20
+ 3. **Payment links**: `commerce/payments` `create-link` mints a fresh page for any unpaid order via the same `createCardPayment` — used by the admin's payment-link button and the order-received page's "Pay now".
21
+ 4. **Refunds**: `commerce/admin-refunds` with `refund_payment: true` calls `refundCardPayment` **before** writing the local record (a failed provider refund writes nothing). While unimplemented it answers `501 card_refund_not_implemented` — record the refund without `refund_payment` and return the money from the provider's own dashboard.
18
22
 
19
- That's it — the `Credit card` gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken in the meantime. To *stop* taking cards, switch the gateway off in Payments settings — disconnecting the provider alone can leave a still-working key in the functions' environment.
23
+ ## Implementation rules
20
24
 
21
- ## 2. How it works
25
+ - **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
26
+ - **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. In `parseWebhook`, set `paid: true` only after verifying the request signature over the raw body bytes.
27
+ - **Amounts**: `order.total` is in display units (e.g. `12.34`) with `order.currency`; convert to the provider's minor units yourself if it needs them.
28
+ - The shared helpers in `base44/shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
22
29
 
23
- ```
24
- place-order (gateway = card)
25
- └─ order created `pending` → payment: { status: "requires_payment", checkout_url }
26
- └─ storefront redirects the customer to checkout_url (provider-hosted page)
27
- ├─ customer returns → commerce/payments `verify` ─┐ both idempotent,
28
- └─ provider calls → commerce/payment-webhook ─┘ first one wins
29
- └─ order → `processing`, date_paid set, reference stored, emails + webhooks fire
30
- ```
30
+ ## Storefront requirements (unchanged by any of this)
31
31
 
32
- Card data never touches this app — the customer pays on the provider's page.
33
-
34
- **Return URLs — and the route your page actually lives at.** Providers require *absolute* URLs. Resolution, most specific first:
35
-
36
- 1. `success_url` / `cancel_url` — a full page URL per outcome;
37
- 2. `return_url` — **your return page**, used for both outcomes (`payment=success|cancel` distinguishes them). A bare origin (`https://shop`, no path) is instead treated as a base, since there's no page in it;
38
- 3. the origin the request came from (the storefront that called this) + the store's **Payment return path** (`settings.general.order_received_path`).
39
-
40
- Every result carries `order_id`, `order_key` and `payment`, **including URLs you supplied** — a `success_url` without them can't verify anything. Existing query parameters survive. With nothing to build from, the call fails `400 return_url_required` rather than letting the provider answer "Not a valid URL".
41
-
42
- **Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they resolve to the app's own origin plus the store's configured path.
43
-
44
- ### MANDATORY: the payment return page (`/order-received`)
45
-
46
- **Payment links do not work until you build this page.** Every link comes back to it — checkout, the admin's *Create payment link*, any "pay now" in an email — so without the route a customer pays and lands on a **404**, at the worst possible moment. And because confirming the payment is what moves the order to `processing`, a missing page also means **paid orders sit unpaid forever**.
47
-
48
- The template ships **no storefront UI**, so the page is yours to build. **If you don't call it `/order-received`, set your route in Settings → General → *Payment return path*** (`settings.general.order_received_path`) — that's what payment links are built from, and a mismatch is a 404 for a paying customer. All the logic is one backend call:
49
-
50
- ```js
51
- // GET /order-received?order_id=…&order_key=…&payment=success|cancel
52
- const params = new URLSearchParams(window.location.search);
53
- const res = await base44.functions.invoke("commerce/payments", {
54
- action: "complete-return",
55
- order_id: params.get("order_id"),
56
- order_key: params.get("order_key"),
57
- payment: params.get("payment"), // only a hint — the server decides
58
- return_url: window.location.origin, // so "pay again" can come back here
59
- });
60
- const { state, order, payment_link } = res.data.data;
61
- ```
62
-
63
- `complete-return` confirms with the provider, progresses the order when the money landed, and answers with everything the page needs:
64
-
65
- | Field | Meaning |
66
- |---|---|
67
- | `state` | **`paid`** → thank the customer, show the order · **`cancelled`** → they backed out, order saved and unpaid · **`unpaid`** → no payment recorded yet (closed the tab, slow bank, gave up) |
68
- | `paid`, `already_confirmed`, `status` | the confirmed payment state and the order's new status |
69
- | `order` | customer-safe order (order number, totals, status) to render |
70
- | `payment_link` | `{ url, session_id }` while unpaid — wire it to a **Pay now** button; `null` when the store can't take card payment |
71
-
72
- Rules for whatever you build:
73
-
74
- - **Call `complete-return` on every visit.** It is idempotent, so refreshes and double-taps are safe, and it is what marks the order paid.
75
- - **Never treat the URL as proof.** `?payment=success` is editable by anyone; `state` comes from the provider, and a hand-edited URL can never produce `paid`.
76
- - **Handle all three states**, not just the happy one — `unpaid` is common (bank delays, closed tabs) and needs a *Check again* plus *Pay now*, not an error.
77
- - **Don't gate it behind login.** Guests pay too; `order_key` in the link is the credential.
78
-
79
- **Storefront wiring** (the only integration work):
80
-
81
- ```js
82
- const res = await inv("commerce/storefront-checkout", {
83
- action: "place-order", cart_token, payment_method: "stripe", billing, shipping,
84
- success_url: `${origin}/order-received?order_id=…&order_key=…`, // optional; a sensible default is used
85
- cancel_url: `${origin}/checkout`,
86
- });
87
- if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
88
-
89
- // …and on the success page, confirm before showing "paid":
90
- const { paid, status } = await inv("commerce/payments", { action: "verify", order_id, order_key });
91
- ```
92
-
93
- Never mark an order paid from a URL parameter — `verify` asks the provider and is the only honest answer. It is safe to call repeatedly.
94
-
95
- **Resuming a payment**: `commerce/payments` `create-link` returns a fresh hosted-page URL for any unpaid order (`{ order_id, order_key }` for a customer, or admin auth). That's what powers "pay now" links in the admin and in order-received emails.
96
-
97
- **Re-issuing after an edit is safe.** Each session is created with an idempotency key derived from the request itself, so an unchanged request (a double-click, a retry) reuses its session while an edited order gets a new one — keying on the order's total alone would break here, since editing an order without moving the total changes the request but not the key. And the previous session is **expired** before the new one is minted, so an old link can't be paid at the total the order used to have.
98
-
99
- ## 3. Payment links from the admin
100
-
101
- The order page's **Payment** panel shows the method, total, paid state and payment reference, and for **any unpaid order** — with a provider connected — offers:
102
-
103
- - **Create payment link** — the provider-hosted page for exactly that order's total; copy it to the customer.
104
- - **Check payment** — re-asks the provider and moves the order on if the money arrived.
105
-
106
- **The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was Offline payment)…`). That switch matters beyond tidiness: refunds key off `payment_method`, so an order paid by card must say so or it can't be refunded through the provider. The panel warns before it happens.
107
-
108
- The one refusal is a store that has **turned online payment off** — `400 online_payments_disabled` — because that's the operator declining it, not a missing setup.
109
-
110
- Both actions are disabled with *"No payment provider connected"* when there's no provider — the capability stays visible so the operator can see what connecting unlocks.
111
-
112
- ## 4. The webhook (recommended)
113
-
114
- `commerce/payment-webhook` is the second confirmation path: it catches buyers who pay and close the tab before returning.
115
-
116
- 1. Register the function's URL with the provider (Stripe: *Developers → Webhooks*, events `checkout.session.completed` and `payment_intent.succeeded`).
117
- 2. Put the signing secret in the **`PAYMENT_WEBHOOK_SECRET`** environment variable (`STRIPE_WEBHOOK_SECRET` also works).
118
- 3. **On the hosted Base44 platform, also configure the app's Stripe webhook secret in the app settings.** Verified behavior: a request carrying a `stripe-signature` header is gated by the platform *before* any function runs — without that app-level secret every Stripe webhook is answered `400 "No Stripe webhook secret configured for this app"` and never reaches `commerce/payment-webhook`. Use the same secret in both places.
119
-
120
- **The payload is never believed on its own**, which is what makes this safe with or without a secret:
121
-
122
- - **With a signing secret**, the signature is verified here (provider scheme, timestamp tolerance so a captured request can't be replayed) and the event's "paid" is then trusted — one fewer round trip.
123
- - **Without one** — the case on Base44, where a function's environment is fixed (`BASE44_APP_ID`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`) and apps can't add their own — the body counts only as *a nudge naming an order*. Whether money arrived is then asked of the provider over our own authenticated API call. A forged webhook achieves nothing beyond making the store re-check an order; it can never mark one paid.
124
-
125
- Either way the event must carry the `order_key` matching the order it names, and any session id in it is fetched and checked to have been opened for *that* order (the provider's own copy of `order_id`/`order_key`, put there at session creation). So a genuine payment for one order can never be replayed against another — a €5 order settled for real does not confirm a €500 one. A mismatch answers `409 session_order_mismatch` (and is ignored, with a 200, on the webhook so the provider stops retrying).
126
-
127
- So the webhook is worth registering either way. It remains a **resilience upgrade, not a prerequisite**: the return page confirms payments on its own.
128
-
129
- ## 5. Refunds
130
-
131
- `commerce/admin-refunds` `create` with `refund_payment: true` sends the money back through the provider, then records the refund — **in that order**, so a failed provider refund never leaves a phantom refund behind. The response carries `gateway_refund: { provider, id, status, amount }` and the refund record's `refunded_payment` is true only when the provider really refunded. An order that wasn't paid online rejects `refund_payment` with `400 no_online_payment`: that money moved outside the store and has to go back the same way.
132
-
133
- The admin's refund panel offers *"Send the money back through the payment provider"* only when the order was actually paid online and a provider is connected.
134
-
135
- ## 6. Using a different provider
136
-
137
- The payment utility exists for this. **Write an adapter and change one line:**
138
-
139
- ```ts
140
- // base44/shared/commerce/payments.ts
141
- const paypalAdapter: PaymentAdapter = { id: "paypal", label: "PayPal", gatewaySlug: "paypal", /* … */ };
142
- export const ACTIVE_PROVIDER: PaymentAdapter = paypalAdapter; // ← was stripeAdapter
143
- ```
144
-
145
- `PaymentAdapter` is the whole contract: `connection`, `createSession`, `retrieveSession`, `refund`, `verifyWebhook`, `parseWebhookEvent`. Implement those against the provider's API (model `stripe.ts`, which is the reference implementation — including how it finds its credential: an injected secret first, a host connector second) and **nothing else changes** — checkout, the payment/webhook functions, refunds, the admin panel and the storefront gateway filter all go through the utility.
146
-
147
- Then check the remaining touchpoints, all of them data or copy rather than logic:
148
-
149
- - **The gateway record** (`commerce.PaymentGateway`, slug `stripe` by default): either keep the slug and just change `title`/`method_title`, or create a record whose slug matches your adapter's `gatewaySlug` and disable the old one. `ACTIVE_PROVIDER.gatewaySlug` is what makes a gateway "the online one".
150
- - **`grep -rn "stripe" base44/ src/`** — after swapping the binding, every remaining hit should be the adapter, the seeded gateway record, or the `stripe-signature` header fallback in the webhook. If it's anywhere else, that's a leak worth fixing.
151
- - **The webhook secret** env var stays `PAYMENT_WEBHOOK_SECRET`, and your `verifyWebhook` decides how to check it.
152
-
153
- Keeping several providers live at once (card *and* PayPal, say) means going beyond a single `ACTIVE_PROVIDER`: give each its own gateway slug and resolve the adapter per order's `payment_method`. The utility is the place to add that map — callers won't need to change.
154
-
155
- ## 7. The storefront must render payment options from settings — never a hardcoded list
156
-
157
- The available payment methods are **data**, owned by the admin (Payments settings → `commerce.PaymentGateway`). A storefront that hardcodes "Card / Bank transfer / COD" will silently disagree with the store the moment an admin toggles a gateway, reorders them, or edits a title.
158
-
159
- Rules for any checkout UI you build:
160
-
161
- - **Fetch, don't assume.** `commerce/storefront-catalog` → `get-store-info` returns `payment_gateways`, filtered to `enabled: true`, sorted by the admin's `order`, and with any online gateway **dropped while no provider is connected** — so what you render is always payable. Each entry is `{ slug, title, description, online }`.
162
- - **Use the admin's copy.** Show `title`/`description` as the customer-facing labels instead of your own strings, and key your logic off `slug` (`online: true` marks the one that redirects to a hosted page).
163
- - **Render the zero-state honestly.** An empty `payment_gateways` means checkout cannot complete; say so rather than showing a dead button.
164
- - **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate a gateway's own rules. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
165
- - **Re-fetch rather than cache hard.** An admin can enable, disable or reorder gateways at any time; a long-lived cached list is how a storefront drifts out of sync with the store.
166
- - **Show `payment_instructions` from the response** for the `offline` flow rather than hardcoding bank details in the UI.
167
-
168
- ## 8. Never hardcode payment availability
169
-
170
- Whether the store can take a card is a **live fact about the connector**, not a constant. Everything derives it:
171
-
172
- - backend — `onlinePaymentStatus(sr)` from the payment utility. It doesn't just look for a key, it **verifies** it with the provider (cheapest authenticated call, answer cached ~60s): a credential that has been disconnected, rotated or revoked lingers in a function's environment until the next deploy, so presence alone would advertise card payment the store can no longer take. The same deploy-time injection is why a *newly* connected provider stays invisible until the functions are redeployed (§1). Expect up to a minute for a change to show. A *rejected* credential means not connected; a network blip does **not** flip a working store to "no payments" while a good answer is still cached;
173
- - admin — the `usePaymentProvider()` hook (`commerce/admin-tools` → `payment-connector-status`);
174
- - storefront — the filtered `payment_gateways` list.
175
-
176
- So do **not** ship a permanent "payments not set up" banner: it disappears by itself the moment a provider is connected. And never surface a provider name in a failure state — *"No payment provider connected"* is what an operator needs to read.
32
+ - Redirect to `payment.checkout_url` when `place-order` returns `payment.status === "requires_payment"`.
33
+ - **Build the `/order-received` page** (or set your route in Settings → General → *Payment return path*): call `commerce/payments` `complete-return` with the return query params and render its `state` (`paid | cancelled | unpaid`, with `payment_link` for "Pay now" while unpaid). Without this page a paying customer lands on a 404 and the order is never marked paid.
34
+ - Handle `503 no_card_payment_provider` from `place-order` by telling the customer card payment is unavailable and offering the other methods.
@@ -4,8 +4,8 @@ What to show for a product, and **which view can show it**. Both storefront surf
4
4
 
5
5
  | View | Call | Returns |
6
6
  |---|---|---|
7
- | Listing / grid / search results / tag & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
8
- | Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells }` |
7
+ | Listing / grid / search results / ribbon & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
8
+ | Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
9
9
 
10
10
  Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). Variant selection mechanics (axes, resolving a selection to a variation, unavailable combinations): [`storefront-product-page.md`](./storefront-product-page.md).
11
11
 
@@ -15,7 +15,7 @@ You choose what belongs in each view — but you can only render what the call r
15
15
 
16
16
  ## 1. Field availability
17
17
 
18
- A listing **row** is the product record itself (minus paywalled fields), plus resolved `tags`. `get-product` adds everything that needs a second read.
18
+ A listing **row** is the product record itself (minus paywalled fields), plus resolved `ribbons`. `get-product` adds everything that needs a second read.
19
19
 
20
20
  | Data | `list-products` row | `get-product` | Notes |
21
21
  |---|---|---|---|
@@ -25,25 +25,25 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
25
25
  | `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
26
26
  | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
27
27
  | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
28
- | **`tags`** (resolved `{id, name}`) | ✅ | ✅ | `get-product` returns the full tag records; rows carry the short form |
29
- | `tag_ids`, `category_ids` | ✅ | ✅ | Raw ids |
28
+ | **`ribbons`** (resolved `{id, name}`) | ✅ | ✅ | `get-product` returns the full ribbon records; rows carry the short form |
29
+ | `ribbon_ids`, `category_ids` | ✅ | ✅ | Raw ids |
30
30
  | **`categories`** (resolved) | ❌ *ids only* | ✅ | See §5 to add |
31
31
  | **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | The reason a product with variants can't be fully priced from a row |
32
32
  | **`reviews`** (paged items + `average_rating`/`rating_count`) | ❌ | ✅ | Row still has the aggregate numbers |
33
33
  | **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
34
34
  | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** exposed publicly — paywalled. Gated by `commerce/storefront-account` `get-download` |
35
35
 
36
- ## 2. Tags — render them in **both** views
36
+ ## 2. Ribbons — render them in **both** views
37
37
 
38
- Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Generated storefronts routinely omit tags entirely. Don't.
38
+ Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
39
39
 
40
- - **Listing rows carry resolved `tags`** (`{id, name}`), so a card can render them with no extra call. One or two chips per card is useful ("New", "Bundle"); more is noise. Hide the block when the array is empty — never a dangling "Tags:" label.
41
- - **On the product page**, render `product.tags` (the top-level `tags` array) as chips near the metadata, visually lighter than the category breadcrumb.
42
- - **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label. A tag has no slug, so key the URL on its id (`/shop?tag=<tag_id>`) so the page is shareable and survives reload. Use `list-tags` for a name to label it with.
43
- - **Offer tags as a listing filter** from `list-tags`, which hides tags no published product carries and gives a `count` for labels like "Gift (12)". `tag_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
44
- - **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as the heading.
45
- - Don't put tags in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a tag.
46
- - Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not tags and not attributes. Render them as a spec table on the product page.
40
+ - **Listing rows carry resolved `ribbons`** (`{id, name}`), so a card can render them with no extra call. One or two per card is useful (a corner label like "Best Seller"); more is noise. Hide the block when the array is empty — never a dangling "Ribbons:" label.
41
+ - **On the product page**, render `product.ribbons` (the top-level `ribbons` array) as labels near the metadata, visually lighter than the category breadcrumb.
42
+ - **Every ribbon can link to a filtered listing** — `list-products` with `ribbon_id` — never a dead label. A ribbon has no slug, so key the URL on its id (`/shop?ribbon=<ribbon_id>`) so the page is shareable and survives reload. Use `list-ribbons` for a name to label it with.
43
+ - **Offer ribbons as a listing filter** from `list-ribbons`, which hides ribbons no published product carries and gives a `count` for labels like "Gift (12)". `ribbon_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
44
+ - **Ribbon landing page:** `list-products` filtered by `ribbon_id`, with the ribbon's `name` as the heading.
45
+ - Don't put ribbons in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a ribbon.
46
+ - Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not ribbons and not attributes. Render them as a spec table on the product page.
47
47
 
48
48
  ## 3. Product shapes in each view
49
49
 
@@ -66,9 +66,9 @@ A listing row has no `variations`, so a card still cannot compute the full range
66
66
 
67
67
  ## 4. Sensible defaults per view
68
68
 
69
- **Card:** image, name, price (or range), sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two tags. Link the whole card to the product page.
69
+ **Card:** image, name, price (or range), sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page.
70
70
 
71
- **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, tags, reviews, then upsells/cross-sells.
71
+ **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells.
72
72
 
73
73
  ## 5. Adding a `get-product`-only field to the list view
74
74
 
@@ -77,10 +77,10 @@ If a customer asks for something the row doesn't carry — resolved categories a
77
77
  `list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
78
78
 
79
79
  ```ts
80
- const pageItems = await withTags(sr, products.slice(start, start + perPage).map(publicProduct));
80
+ const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
81
81
  ```
82
82
 
83
- `withTags` reads the taxonomy **once per request** and maps ids on the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to the fields the UI renders; a listing payload is served on every browse.
83
+ `withRibbons` reads the taxonomy **once per request** and maps ids on the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to the fields the UI renders; a listing payload is served on every browse.
84
84
 
85
85
  What not to do:
86
86
 
@@ -6,15 +6,21 @@ What ships, with nothing to write on the backend:
6
6
 
7
7
  - `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
8
8
  - `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
9
- - `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a **signed-in** customer — the email comes from the session, never the payload;
10
- - `commerce/storefront-account` `my-reviews` lists a customer's own;
9
+ - `commerce/storefront-catalog` `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. For a signed-in caller the session email always wins (the payload cannot impersonate); a guest supplies `email`. `verified` is derived from that email's order history;
10
+ - `commerce/storefront-account` `my-reviews` lists a signed-in customer's own;
11
11
  - moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
12
12
 
13
- So the work is UI: stars on cards and the product page, a reviews list, and a submit form. Behaviors to respect rather than fight:
13
+ **The one server-side switch is auto-approval** (`products.auto_approve_reviews`, the toggle on the admin's Reviews screen): off (the default) holds every new review as `hold` for moderation; on publishes immediately. Tell the submitter their review is awaiting approval rather than showing it as live.
14
14
 
15
- - anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
16
- - a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
17
- - `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
18
- - the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
15
+ ## Policies are yours to enforce in the UI
19
16
 
20
- Shapes and error codes: [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
17
+ The server deliberately ships the **most open default — anyone can review by email**. Anything stricter is storefront policy, implemented where you build the form:
18
+
19
+ - **Login-gated reviews** — render the form only for a signed-in session (`base44.auth.me()`), and omit `email` from the payload: the session email is used automatically.
20
+ - **Verified buyers only** — before showing the form to a signed-in customer, check their history: `commerce/storefront-account` `my-orders` and look for a `processing`/`completed` order containing the product. (For guests there is nothing to check against — pair this policy with login-gating.) The `verified` flag on every stored review tells moderators and readers which reviews came from buyers either way — an honest middle ground is to accept all reviews and render a "Verified purchase" badge from it.
21
+ - **Required rating** — `rating` is optional server-side (0–5 when present); make the stars mandatory in the form before submitting if the store wants no unrated reviews.
22
+ - **Disabling reviews store-wide** — simply build no review UI. There is no server switch to keep in sync.
23
+
24
+ If a policy must hold even against handcrafted API calls (not just your UI), enforce it in a backend function of your own that wraps `submit-review` — the same pattern as any custom storefront action.
25
+
26
+ Shapes and error codes (`400 email_required|review_incomplete|invalid_rating`): [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
@@ -80,4 +80,4 @@ Handle `400 variation_required` (empty `variation_id` on a product with attribut
80
80
  - [ ] Initial state from `defaultSelection`; selection mirrored into the URL.
81
81
  - [ ] **Add to cart** gated on `view.purchasable`, sending `view.addToCart`.
82
82
  - [ ] A product with empty `axes[].options` shows as unavailable — no empty selector groups, no "Select a undefined".
83
- - [ ] Tags, ratings, upsells and the rest per [`product-render.md`](./product-render.md).
83
+ - [ ] Ribbons, ratings, upsells and the rest per [`product-render.md`](./product-render.md).
@@ -4,8 +4,8 @@ React admin UI for the Base44 commerce template. Copy this
4
4
  folder into a Base44 app built on the default template (Vite + React +
5
5
  Tailwind + shadcn/ui + React Router) to get a full store back office.
6
6
 
7
- > Install docs: [`skills/commerce/installation-guidelines.md`](../../../skills/commerce/installation-guidelines.md) · mounting & role setup: [`skills/commerce/post-installation.md`](../../../skills/commerce/post-installation.md) · architecture & operations: the commerce skill, [`skills/commerce/SKILL.md`](../../../skills/commerce/SKILL.md)
8
- > API references: [`skills/commerce/docs/api-admin.md`](../../../skills/commerce/docs/api-admin.md), [`skills/commerce/docs/api-storefront.md`](../../../skills/commerce/docs/api-storefront.md)
7
+ > Install docs: [`.agents/skills/commerce/installation-guidelines.md`](../../../.agents/skills/commerce/installation-guidelines.md) · mounting & role setup: [`.agents/skills/commerce/post-installation.md`](../../../.agents/skills/commerce/post-installation.md) · architecture & operations: the commerce skill, [`.agents/skills/commerce/SKILL.md`](../../../.agents/skills/commerce/SKILL.md)
8
+ > API references: [`.agents/skills/commerce/docs/api-admin.md`](../../../.agents/skills/commerce/docs/api-admin.md), [`.agents/skills/commerce/docs/api-storefront.md`](../../../.agents/skills/commerce/docs/api-storefront.md)
9
9
 
10
10
  ## Mounting
11
11
 
@@ -39,7 +39,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
39
39
  sample data when the store has no products yet. If `commerce/seed-store` was
40
40
  already run during installation — including when an agent generated a real
41
41
  catalog — the store counts as ready and this screen never shows; see
42
- `skills/commerce/post-installation.md` §2.
42
+ `.agents/skills/commerce/post-installation.md` §2.
43
43
 
44
44
  ## External touchpoints
45
45
 
@@ -77,8 +77,7 @@ routes.jsx Route table + <AdminRoutes/>
77
77
  layout/ AdminLayout, Sidebar, Topbar, AuthGuard (admin-role gate), AccessDenied
78
78
  bot/ StoreAdminBot (chat panel over the commerce/StoreAdmin agent), Markdown (GFM renderer)
79
79
  context/ SettingsContext (store settings + first-run seeding), BasePathContext
80
- hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce,
81
- usePaymentProvider (is an online payment provider connected?)
80
+ hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce
82
81
  lib/ api (function calls), constants, format, geo-data, order/product utils
83
82
  components/ DataTable, SearchSelect, MoneyInput, DateRangePicker, AddressForm, …
84
83
  pages/ All admin pages (orders, products, coupons, customers, reports, settings, webhooks)
@@ -16,7 +16,7 @@ import { useAdminHref } from "../context/BasePathContext";
16
16
  */
17
17
  /**
18
18
  * `admin:` links are the agent's way to send the operator to a page it cannot
19
- * act on itself (`[Settings → Tax](admin:settings/tax)`). Resolved through
19
+ * act on itself (`[Settings → Shipping & Tax](admin:settings/shipping-tax)`). Resolved through
20
20
  * useAdminHref so they follow the actual mount point, and routed in-app rather
21
21
  * than opening a tab. Everything else stays an external link.
22
22
  */
@@ -3,7 +3,9 @@ import { useSettings } from "../context/SettingsContext";
3
3
  import { CURRENCIES } from "../lib/constants";
4
4
 
5
5
  /**
6
- * Currency formatter driven by the `general` settings group.
6
+ * Currency formatter driven by `general.currency`. Formatting itself is
7
+ * localization's job — Intl.NumberFormat renders the symbol, separators and
8
+ * decimals for the browser locale; there are no format settings.
7
9
  * Returns { format(n), symbol, code, decimals }.
8
10
  */
9
11
  export default function useMoney() {
@@ -13,13 +15,17 @@ export default function useMoney() {
13
15
  const get = settings?.get || (() => undefined);
14
16
  const code = get("general", "currency", "USD");
15
17
  const currency = CURRENCIES.find((cc) => cc.code === code);
18
+ let formatter = null;
19
+ try {
20
+ formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: code });
21
+ } catch {
22
+ formatter = null; // unknown code — fall back below
23
+ }
16
24
  return {
17
25
  code,
18
26
  symbol: currency?.symbol || code,
19
- position: get("general", "currency_position", "left"),
20
- thousandSep: get("general", "thousand_sep", ","),
21
- decimalSep: get("general", "decimal_sep", "."),
22
- decimals: get("general", "num_decimals", currency?.decimals ?? 2),
27
+ decimals: currency?.decimals ?? 2,
28
+ formatter,
23
29
  };
24
30
  }, [settings]);
25
31
 
@@ -27,23 +33,8 @@ export default function useMoney() {
27
33
  (n) => {
28
34
  const num = Number(n);
29
35
  const val = isNaN(num) ? 0 : num;
30
- const negative = val < 0;
31
- const fixed = Math.abs(val).toFixed(cfg.decimals);
32
- const [intPart, decPart] = fixed.split(".");
33
- const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, cfg.thousandSep);
34
- const amount = decPart ? grouped + cfg.decimalSep + decPart : grouped;
35
- const sign = negative ? "-" : "";
36
- switch (cfg.position) {
37
- case "right":
38
- return `${sign}${amount}${cfg.symbol}`;
39
- case "left_space":
40
- return `${sign}${cfg.symbol} ${amount}`;
41
- case "right_space":
42
- return `${sign}${amount} ${cfg.symbol}`;
43
- case "left":
44
- default:
45
- return `${sign}${cfg.symbol}${amount}`;
46
- }
36
+ if (cfg.formatter) return cfg.formatter.format(val);
37
+ return `${cfg.symbol} ${val.toFixed(cfg.decimals)}`;
47
38
  },
48
39
  [cfg]
49
40
  );
@@ -24,7 +24,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
24
24
 
25
25
  /**
26
26
  * Blocks the admin UI unless the caller is an authenticated user with
27
- * role === "admin". Do NOT weaken this check — see skills/commerce/post-installation.md.
27
+ * role === "admin". Do NOT weaken this check — see .agents/skills/commerce/post-installation.md.
28
28
  * (Server-side RLS + requireAdmin() in functions enforce this independently.)
29
29
  *
30
30
  * Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
@@ -17,13 +17,6 @@ export const PRODUCT_STATUSES = [
17
17
  { value: "publish", label: "Published", color: "bg-green-100 text-green-800 border-green-200" },
18
18
  ];
19
19
 
20
- export const CATALOG_VISIBILITIES = [
21
- { value: "visible", label: "Shop and search results" },
22
- { value: "catalog", label: "Shop only" },
23
- { value: "search", label: "Search results only" },
24
- { value: "hidden", label: "Hidden" },
25
- ];
26
-
27
20
  export const STOCK_STATUSES = [
28
21
  { value: "instock", label: "In stock", color: "bg-green-100 text-green-800 border-green-200" },
29
22
  { value: "outofstock", label: "Out of stock", color: "bg-red-100 text-red-800 border-red-200" },
@@ -38,7 +31,6 @@ export const BACKORDER_OPTIONS = [
38
31
 
39
32
  export const TAX_STATUSES = [
40
33
  { value: "taxable", label: "Taxable" },
41
- { value: "shipping", label: "Shipping only" },
42
34
  { value: "none", label: "None" },
43
35
  ];
44
36
 
@@ -68,20 +60,6 @@ export const WEBHOOK_TOPICS = [
68
60
  "coupon.created", "coupon.updated", "coupon.deleted",
69
61
  ];
70
62
 
71
- export const SHIPPING_METHOD_TYPES = [
72
- { value: "flat_rate", label: "Flat rate" },
73
- { value: "free_shipping", label: "Free shipping" },
74
- { value: "local_pickup", label: "Local pickup" },
75
- ];
76
-
77
- export const FREE_SHIPPING_REQUIRES = [
78
- { value: "", label: "No requirement" },
79
- { value: "coupon", label: "A valid free shipping coupon" },
80
- { value: "min_amount", label: "A minimum order amount" },
81
- { value: "either", label: "A minimum order amount OR a coupon" },
82
- { value: "both", label: "A minimum order amount AND a coupon" },
83
- ];
84
-
85
63
  /**
86
64
  * The 11 transactional emails.
87
65
  * `recipient: "admin"` emails expose a Recipient override in the Emails settings.
@@ -91,6 +69,8 @@ export const EMAIL_TYPES = [
91
69
  { id: "new_order", label: "New order", recipient: "admin" },
92
70
  { id: "cancelled_order", label: "Cancelled order", recipient: "admin" },
93
71
  { id: "failed_order", label: "Failed order", recipient: "admin" },
72
+ { id: "low_stock", label: "Low stock", recipient: "admin", stock: true },
73
+ { id: "out_of_stock", label: "Out of stock", recipient: "admin", stock: true },
94
74
  { id: "on_hold_order", label: "Order on-hold", recipient: "customer" },
95
75
  { id: "processing_order", label: "Processing order", recipient: "customer" },
96
76
  { id: "completed_order", label: "Completed order", recipient: "customer" },
@@ -135,13 +115,6 @@ export const CURRENCIES = [
135
115
  { code: "VND", name: "Vietnamese Dong", symbol: "₫", decimals: 0 },
136
116
  ];
137
117
 
138
- export const CURRENCY_POSITIONS = [
139
- { value: "left", label: "Left ($99.99)" },
140
- { value: "right", label: "Right (99.99$)" },
141
- { value: "left_space", label: "Left with space ($ 99.99)" },
142
- { value: "right_space", label: "Right with space (99.99 $)" },
143
- ];
144
-
145
118
  export const WEIGHT_UNITS = ["kg", "g", "lbs", "oz"];
146
119
  export const DIMENSION_UNITS = ["cm", "m", "mm", "in", "yd"];
147
120
 
@@ -98,7 +98,7 @@ export function buildOrderPatch(draft, before) {
98
98
  patch.fees = (draft.fee_lines || []).map((f) => ({
99
99
  name: f.name,
100
100
  amount: f.total,
101
- tax_class: f.tax_class,
101
+ tax_group: f.tax_group,
102
102
  tax_status: f.tax_status,
103
103
  }));
104
104
  }