@base44/app-plugin-commerce 0.2.3 → 0.2.4

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.
@@ -16,9 +16,9 @@
16
16
  * makes card payment visible at checkout.
17
17
  *
18
18
  * That is the whole job. This file assumes the app is connected to Stripe and
19
- * reads the secret key the connection publishes (`stripeSecret()` below); how
20
- * the app gets connected is not this file's concern and nothing here needs
21
- * filling in.
19
+ * reads the secret key that connection publishes from **Base44 secrets**
20
+ * (`secrets.get("STRIPE_SECRET_KEY")`); how the app gets connected is not this
21
+ * file's concern and nothing here needs filling in.
22
22
  *
23
23
  * NEVER patch this file (or the stub it replaces) with partial edits. A
24
24
  * find_replace that leaves the originals behind gives every commerce function a
@@ -39,9 +39,14 @@
39
39
  * `card-payment.<provider>.ts` next to the stub. `references/online-payments.md`
40
40
  * has the rules; this file is the worked model.
41
41
  */
42
- import Stripe from "npm:stripe@18";
42
+ import { secrets } from "base44:runtime";
43
43
  import { HttpError } from "./auth.ts";
44
44
 
45
+ /** Stripe's REST API, called directly — no SDK to bundle in the function. */
46
+ const STRIPE_API = "https://api.stripe.com/v1";
47
+ /** Pinned, so a Stripe API release can never change the shapes read below. */
48
+ const STRIPE_VERSION = "2025-10-29.clover";
49
+
45
50
  /** A hosted payment page for one order. */
46
51
  export interface CardPaymentPage {
47
52
  /** Where the customer goes to pay. */
@@ -52,37 +57,75 @@ export interface CardPaymentPage {
52
57
  }
53
58
 
54
59
  /**
55
- * Credentials come from backend env, never an entity and never the client. Read
56
- * lazily (per call, not at module load) so a store that has the file but not yet
57
- * the secret answers a clean 503 at checkout instead of failing to boot every
58
- * commerce function that imports it.
60
+ * Credentials come from **Base44 secrets**, never an entity, never backend
61
+ * source, never the client. Read lazily (per call, not at module load) so a
62
+ * store that has the file but not yet the secret answers a clean 503 at
63
+ * checkout instead of failing to boot every commerce function that imports it.
59
64
  */
65
+ const secret = (name: string): string => {
66
+ try {
67
+ return String(secrets.get(name) ?? "");
68
+ } catch {
69
+ return "";
70
+ }
71
+ };
72
+
60
73
  /**
61
- * Stripe's secret key, as published to the backend environment by the app's
62
- * Stripe connection. The names below are the conventional ones; if the key
63
- * arrives under a different name, this list is the only thing to change.
74
+ * Stripe's secret key, as published to the app's secrets by its Stripe
75
+ * connection. The names below are the conventional ones; if the key arrives
76
+ * under a different name, this list is the only thing to change.
64
77
  */
65
- const STRIPE_KEY_VARS = ["STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_KEY"];
78
+ const STRIPE_KEY_SECRETS = ["STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_KEY"];
66
79
 
67
- const stripeSecret = () => {
68
- for (const name of STRIPE_KEY_VARS) {
69
- const value = Deno.env.get(name);
80
+ const stripeKey = (): string => {
81
+ for (const name of STRIPE_KEY_SECRETS) {
82
+ const value = secret(name);
70
83
  if (value) return value;
71
84
  }
72
- return null;
85
+ // The client is told only that cards are unavailable — which secret is
86
+ // missing is backend configuration, and naming it to a storefront visitor
87
+ // maps out the app's secrets for them. The detail goes to the log instead.
88
+ console.error(
89
+ `Stripe is not configured — no secret key found (looked for ${STRIPE_KEY_SECRETS.join(", ")}).`,
90
+ );
91
+ throw new HttpError(
92
+ 503,
93
+ "Card payments are not available right now.",
94
+ "no_card_payment_provider",
95
+ );
73
96
  };
74
97
 
75
- const stripe = () => {
76
- const key = stripeSecret();
77
- if (!key) {
78
- throw new HttpError(
79
- 503,
80
- `Card payments are not configured no Stripe secret key in the backend environment (looked for ${STRIPE_KEY_VARS.join(", ")}).`,
81
- "no_card_payment_provider",
82
- );
98
+ /**
99
+ * The Base44 app this payment belongs to. Stamped on every payment's metadata
100
+ * as `base44_app_id`, which is how the platform attributes a Stripe payment
101
+ * back to this app — send it on every call that creates money movement.
102
+ */
103
+ const base44AppId = (): string => secret("BASE44_APP_ID") || String(Deno.env.get("BASE44_APP_ID") ?? "");
104
+
105
+ /**
106
+ * One Stripe REST call. A body makes it a POST (form-encoded, with an
107
+ * idempotency key); no body is a GET.
108
+ *
109
+ * Stripe's own error text stays in the log: it describes backend configuration
110
+ * (keys, account state, API parameters), so the caller gets a flat message.
111
+ */
112
+ async function stripeCall(path: string, body?: URLSearchParams): Promise<any> {
113
+ const headers: Record<string, string> = {
114
+ "Authorization": `Bearer ${stripeKey()}`,
115
+ "Stripe-Version": STRIPE_VERSION,
116
+ };
117
+ if (body) {
118
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
119
+ headers["Idempotency-Key"] = crypto.randomUUID();
83
120
  }
84
- return new Stripe(key);
85
- };
121
+ const res = await fetch(`${STRIPE_API}${path}`, { method: body ? "POST" : "GET", headers, body });
122
+ const data = await res.json().catch(() => null);
123
+ if (!res.ok) {
124
+ console.error(`Stripe ${path} failed (${res.status}):`, JSON.stringify(data?.error ?? {}));
125
+ throw new HttpError(502, "The payment provider could not process this request.", "payment_provider_error");
126
+ }
127
+ return data;
128
+ }
86
129
 
87
130
  // Stripe amounts are in minor units; these currencies have none, so ×100 would
88
131
  // charge a hundred times the total.
@@ -102,27 +145,34 @@ export async function createCardPayment(
102
145
  ): Promise<CardPaymentPage> {
103
146
  // order_id + order_key in the metadata is how the premade payment-webhook
104
147
  // names the order when Stripe's event arrives, and what checkCardPaymentPaid
105
- // compares against keep it on both objects (the session's own metadata is
106
- // not copied to the payment intent).
107
- const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
108
- const session = await stripe().checkout.sessions.create({
109
- mode: "payment",
110
- line_items: [{
111
- quantity: 1,
112
- price_data: {
113
- currency: String(order.currency || "USD").toLowerCase(),
114
- product_data: { name: `Order #${order.order_number}` },
115
- unit_amount: minorUnits(order.total, order.currency),
116
- },
117
- }],
118
- customer_email: opts.customerEmail || undefined,
119
- metadata,
120
- payment_intent_data: { metadata },
121
- success_url: opts.successUrl,
122
- cancel_url: opts.cancelUrl,
123
- });
124
- if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
125
- return { url: session.url, reference: session.id };
148
+ // compares against; base44_app_id attributes the payment to this app. Keep
149
+ // all of it on both objects (the session's own metadata is not copied to the
150
+ // payment intent).
151
+ const metadata: Record<string, string> = {
152
+ order_id: String(order.id),
153
+ order_key: String(order.order_key),
154
+ };
155
+ const appId = base44AppId();
156
+ if (appId) metadata.base44_app_id = appId;
157
+
158
+ const params = new URLSearchParams();
159
+ params.set("mode", "payment");
160
+ params.set("line_items[0][quantity]", "1");
161
+ params.set("line_items[0][price_data][currency]", String(order.currency || "USD").toLowerCase());
162
+ params.set("line_items[0][price_data][unit_amount]", String(minorUnits(order.total, order.currency)));
163
+ params.set("line_items[0][price_data][product_data][name]", `Order #${order.order_number}`);
164
+ if (opts.customerEmail) params.set("customer_email", opts.customerEmail);
165
+ params.set("client_reference_id", String(order.id));
166
+ params.set("success_url", opts.successUrl);
167
+ params.set("cancel_url", opts.cancelUrl);
168
+ for (const [key, value] of Object.entries(metadata)) {
169
+ params.set(`metadata[${key}]`, value);
170
+ params.set(`payment_intent_data[metadata][${key}]`, value);
171
+ }
172
+
173
+ const session = await stripeCall("/checkout/sessions", params);
174
+ if (!session?.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
175
+ return { url: session.url, reference: String(session.id) };
126
176
  }
127
177
 
128
178
  /**
@@ -131,10 +181,10 @@ export async function createCardPayment(
131
181
  * the admin's "Check payment" button.
132
182
  */
133
183
  export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
134
- const session = await stripe().checkout.sessions.retrieve(reference);
184
+ const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(reference)}`);
135
185
  // The payment must be for THIS order — stops a reference to some other
136
186
  // (genuinely paid) session being replayed against a different order.
137
- return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
187
+ return session?.payment_status === "paid" && session?.metadata?.order_id === String(order.id);
138
188
  }
139
189
 
140
190
  /**
@@ -149,15 +199,18 @@ export async function refundCardPayment(_sr: any, _order: any, opts: {
149
199
  }): Promise<{ refund_id: string }> {
150
200
  // The stored reference is the Checkout Session; the refundable object is the
151
201
  // payment intent behind it, which only exists once the session was paid.
152
- const session = await stripe().checkout.sessions.retrieve(opts.reference);
153
- if (!session.payment_intent) {
202
+ const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(opts.reference)}`);
203
+ if (!session?.payment_intent) {
154
204
  throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
155
205
  }
156
- const refund = await stripe().refunds.create({
157
- payment_intent: String(session.payment_intent),
158
- amount: minorUnits(opts.amount, opts.currency),
159
- });
160
- return { refund_id: refund.id };
206
+ const params = new URLSearchParams();
207
+ params.set("payment_intent", String(session.payment_intent));
208
+ params.set("amount", String(minorUnits(opts.amount, opts.currency)));
209
+ const appId = base44AppId();
210
+ if (appId) params.set("metadata[base44_app_id]", appId);
211
+
212
+ const refund = await stripeCall("/refunds", params);
213
+ return { refund_id: String(refund.id) };
161
214
  }
162
215
 
163
216
  /** What parseWebhook distills an event into — the premade webhook's contract. */
@@ -34,8 +34,11 @@
34
34
  * Until implemented, the Credit Card checkout option answers
35
35
  * 503 `no_card_payment_provider`.
36
36
  *
37
- * Credentials belong in backend secrets/env (e.g. Deno.env.get("..."))
38
- * never in an entity and never from the client.
37
+ * Credentials belong in Base44 secrets (`secrets.get("...")` from
38
+ * `base44:runtime`) — never in an entity, never in the code, never from the
39
+ * client. When one is missing, log which one and answer the caller with the
40
+ * flat 503 below: the storefront must not learn the names of the app's
41
+ * secrets.
39
42
  */
40
43
  import { HttpError } from "./auth.ts";
41
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -105,14 +105,24 @@ resets to page 1 and keeps the current rows on screen (`refreshing`) while the
105
105
  page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
106
106
  nested).
107
107
 
108
- Your card renders `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
108
+ Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
109
109
  objects and the array may be empty — render a placeholder, never a broken
110
110
  `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
111
111
  product sells variants — there is no product `type` flag), `on_sale`,
112
112
  `short_description`, `stock_status`, `average_rating`/`rating_count`,
113
- `ribbons`. Full field matrix:
113
+ `ribbons` and a row carries the whole product record, so `weight`,
114
+ `dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
114
115
  [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
115
116
 
117
+ That is an inventory of what you *can* show, not a card design and not a list
118
+ to render in order. An even grid of identical cards, each with the same
119
+ name/price/stars trio, is where a generated store lands by default and almost
120
+ never where this catalog belongs: give the grid a rhythm (a hero piece spanning
121
+ two columns, an editorial break between rows, a denser tile for a large
122
+ catalog), and lead each card with the one or two fields *these* products are
123
+ judged on — carat weight, focal length, edition size, ABV — read off
124
+ `meta_data` via `productSpecs(row)`, not the fields every store shows.
125
+
116
126
  **Rails** (featured row, "new in") are the same hook with a filter
117
127
  (`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
118
128
  nothing — render *nothing* then, never a heading over an empty row. Upsells
@@ -181,12 +191,24 @@ anywhere.) Build your layout from:
181
191
  - **Description** — `product.description` is HTML; render as rich text
182
192
  (`dangerouslySetInnerHTML`), `short_description` above it.
183
193
  - **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
184
- `meta_data` (Material, Care). `[]` means no section at all.
194
+ `meta_data` (Material, Care, Provenance). `[]` means no section at all.
185
195
  - **Breadcrumbs** — build from `categories`
186
196
  (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
187
197
  labels, not breadcrumbs.
188
198
 
189
- All optional — include what this store's products actually have.
199
+ All optional — include what this store's products actually have — and each is
200
+ one hook, **not one component style**. The tell of a generated product page is
201
+ that every axis is the same chip row and every modifier the same grey
202
+ label/value line. Branch on what they are: `variantAxes` gives you `axis.key` /
203
+ `axis.name`, `productSpecs` gives you `key` / `label`, so a colour axis can be
204
+ swatches in the real colours, a size axis chips with a size guide beside them,
205
+ a material axis a small sample image; a "Composition" modifier can be bars, a
206
+ "Provenance" a map pin, a "Certification" a seal, a "Weight" a figure set in
207
+ the display face. Design the two or three that carry this product's meaning,
208
+ let the rest fall back to a plain row, and don't feel obliged to keep them in
209
+ one block — a spec can sit under the gallery, beside the price, or inside the
210
+ description. The ⚑ rules above (one control per axis, unbuyable options
211
+ disabled) constrain the *behaviour* of a selector, never its form.
190
212
 
191
213
  ### Reviews — optional
192
214
 
@@ -274,7 +296,10 @@ address automatically (debounced, never on a half-typed address), derives the
274
296
  shipping and payment choices, gates the button (`canPlaceOrder` +
275
297
  `useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
276
298
  navigations — online gateway → provider redirect, everything else →
277
- `/order-received`. The address form comes from `useAddressForm(which)` as a
299
+ `/order-received`. Both are **full page loads** (`window.location.assign`),
300
+ which is why the order-received page boots from the URL alone; pass
301
+ `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
302
+ a router transition instead. The address form comes from `useAddressForm(which)` as a
278
303
  field spec (`state` collected, country options never null); the two
279
304
  store-data choices come through the headless `ShippingMethodPicker` /
280
305
  `PaymentMethodPicker`, whose render props enumerate every branch.
@@ -449,7 +474,29 @@ above the status guards.
449
474
  These budgets assume the hooks carry the logic and your markup carries only the
450
475
  design. Over budget ⇒ you are re-implementing something a hook does — an
451
476
  address spec, a quantity clamp, totals math, variant resolution, add-to-cart
452
- error recovery. Go back to the hook and delete your version.
477
+ error recovery. Go back to the hook and delete your version. Design detail is
478
+ not what pushes a page over: giving a colour axis swatches or a composition
479
+ modifier bars costs a few hundred characters, and that is what the budget is
480
+ for.
481
+
482
+ ## If you drive the storefront from a browser script
483
+
484
+ Whatever you choose to check and however you check it, two things make a
485
+ working storefront look broken under a script:
486
+
487
+ - **Filling the checkout.** Every field is a controlled React input, so writing
488
+ `el.value` changes nothing React sees. Use the harness's own fill (it
489
+ dispatches `input` + `change`) — never lift the native setter off
490
+ `HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
491
+ element it throws `Illegal invocation`, and the workaround it is reaching for
492
+ is what the fill helper already does.
493
+ - **`placeOrder` ends the page.** It navigates with `window.location.assign`
494
+ (above), so a script that placed an order loses its page context and can land
495
+ back at `/` — while the order itself was created normally. That is the hard
496
+ navigation, not a broken redirect. The confirmation is reachable at any time
497
+ from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
498
+ come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
499
+ the order either way).
453
500
 
454
501
  ## Done — forget this file
455
502
 
@@ -461,9 +508,8 @@ error recovery. Go back to the hook and delete your version.
461
508
  - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
462
509
  - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
463
510
  - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
464
- - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
511
+ - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy, and the attributes and modifiers that matter to these products are designed rather than poured into one uniform block.
465
512
  - [ ] Every page is within its budget above.
466
- - [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
467
513
 
468
514
  Record these lines in your working notes; do not re-read this file.
469
515
 
@@ -54,14 +54,18 @@ Three rules used to be prose here and are now enforced by exports — use them a
54
54
 
55
55
  - **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
56
56
  - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
57
- - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is a spec table — never a selector, never a ribbon.
57
+ - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon. How each modifier *renders* is a design decision (§3); what it can never become is a control.
58
58
 
59
- ## 3. What each view renders
59
+ ## 3. What each view *can* render
60
60
 
61
- **Card:** image, name, `price.label`, 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 like every other piece of storefront UI, the card is entirely yours to design.
61
+ Both lists below are field inventories what the data supports **not a layout and not an order**. Read them for availability, then design the surface; a store that renders exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
62
+
63
+ **Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`) that says more about *this* catalog than a star average does. Link the whole card to the product page; the card, the grid's rhythm and whether every card is even the same size are yours.
62
64
 
63
65
  **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
64
66
 
67
+ **Attributes and modifiers are individually designable.** `variantAxes` exposes `axis.key`/`axis.name` and `productSpecs` exposes `key`/`label` precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, "Composition" as bars, "Provenance" as a located line, "Certification" as a seal. One uniform chip row for every axis and one grey label/value table for every modifier is a default, not a requirement — pick the two or three that carry the product's meaning, give them real treatment, and let the remainder fall back to a plain row. The rules in §5 govern selector *behaviour* (one control per axis, unbuyable disabled), never its form, and they hold whatever the control looks like.
68
+
65
69
  ## 4. Ribbons — in **both** views
66
70
 
67
71
  Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
@@ -37,7 +37,7 @@ fs.copyFileSync(
37
37
 
38
38
  …and **enable the gateway**: `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. The `card` row is seeded off, so that call is what makes card payment visible at checkout — the usual reason a wired provider "doesn't show up".
39
39
 
40
- That is the whole of it. The file expects the app to be connected to Stripe and reads the secret key that connection publishes; **connecting the app is outside this kit and not described here.** Nothing in the file needs filling in, and no key belongs in the code. A missing key is not silent — checkout answers `503 no_card_payment_provider`.
40
+ That is the whole of it. The file expects the app to be connected to Stripe and reads the secret key that connection publishes from the app's secrets (`secrets.get("STRIPE_SECRET_KEY")`); **connecting the app is outside this kit and not described here.** Nothing in the file needs filling in, and no key belongs in the code. A missing key is not silent — checkout answers `503 no_card_payment_provider`, with the name of the missing secret in the function log only.
41
41
 
42
42
  A kit update re-copies `shared/commerce/` and restores the stub — re-run the copy after updating. A different provider ships the same way (`card-payment.<provider>.ts` beside the stub); until one does, implement the four functions against its API per the rules below, with the Stripe file as the worked model.
43
43
 
@@ -55,9 +55,10 @@ With the gateway enabled and no provider behind it, picking Credit card answers
55
55
  For a **custom** provider (the shipped files already obey all of these):
56
56
 
57
57
  - **Write `card-payment.ts` whole** — one write of the complete file, never a `find_replace` into the stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"). The fix is always the whole-file write — which is also why the shipped provider files exist.
58
- - **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no credential yet answers a clean 503 instead of failing to boot every function that imports it.
58
+ - **Credentials come from Base44 secrets** (`import { secrets } from "base44:runtime"` → `secrets.get("STRIPE_SECRET_KEY")`) — never an entity, never the code, never the client — and are read **lazily inside the call**, so a store with the file but no credential yet answers a clean 503 instead of failing to boot every function that imports it.
59
+ - **Never tell the caller what is misconfigured.** Secret names, provider error text and account state go to `console.error`; the storefront gets a flat `503 no_card_payment_provider` / `502` with no detail. A checkout page that names the missing secret hands a visitor a map of the app's configuration.
59
60
  - **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference` **and** check the payment names this order (its metadata `order_id` vs `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
60
- - **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` **and** `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
61
+ - **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` **and** `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against. Stamp the app id alongside them (`base44_app_id`, from `secrets.get("BASE44_APP_ID")`), which is how the platform attributes the payment back to this app.
61
62
  - **Amounts**: `order.total` is in display units (`12.34`) with `order.currency`; convert to the provider's minor units yourself, remembering the zero-decimal currencies.
62
63
  - The helpers in `shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
63
64
 
@@ -63,7 +63,8 @@
63
63
  * ## Helpers re-exported from `@/commerce/utils`
64
64
  * - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
65
65
  * state derived, for the variant selector you write.
66
- * - `productSpecs(product)` — `meta_data` → spec-table rows.
66
+ * - `productSpecs(product)` — `meta_data` → descriptive rows, keyed so each
67
+ * can be rendered its own way.
67
68
  */
68
69
  export {
69
70
  StorefrontProvider,
@@ -73,6 +73,17 @@ function resolvePaymentMethod(gateways, picked) {
73
73
  * result yourself (a manual-gateway result carries
74
74
  * `result.payment_instructions`).
75
75
  *
76
+ * Both navigations are **full page loads** (`window.location.assign`), not
77
+ * router transitions: the provider hop has to leave the app, and the
78
+ * order-received page is built to boot from the URL alone (`order_id` +
79
+ * `order_key`), so a reload there is correct and shareable. Consequences
80
+ * worth knowing: React state does not survive it, and a browser script
81
+ * driving checkout loses its page context at this point — the order is
82
+ * still placed, so verify by navigating fresh to
83
+ * `orderReceivedUrl(result)`. For a client-side transition instead, pass
84
+ * `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))`
85
+ * yourself.
86
+ *
76
87
  * Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
77
88
  * `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
78
89
  * `shipping_address_required`, `shipping_method_required`,
@@ -25,7 +25,8 @@
25
25
  * - `address-spec.js` — `addressFieldSpec`: the checkout address form as data,
26
26
  * with country/state options that are always arrays.
27
27
  * - `images.js` — `productImages`: images normalized to `{src, name, alt}`.
28
- * - `specs.js` — `productSpecs`: `meta_data` → spec-table rows.
28
+ * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows, keyed so each
29
+ * can be rendered its own way.
29
30
  *
30
31
  * Building the storefront in React? **Prefer `@/commerce/storefront`** — it
31
32
  * layers headless hooks on top of this module, and a hook that pre-composes
@@ -1,16 +1,22 @@
1
1
  /**
2
- * Product spec rows — the descriptive properties a product page lists in a
3
- * table (Material, Care, Fit).
2
+ * Product spec rows — the descriptive properties a product page shows
3
+ * (Material, Care, Fit, Provenance, Composition).
4
4
  *
5
5
  * These live in `product.meta_data` (the admin's *Modifiers* section) and are
6
6
  * **not** attributes and not ribbons: they describe the product, they don't
7
7
  * select a variant. Hidden keys (leading `_`) and empty values are skipped.
8
- * You render the rows yourself:
8
+ * You render the rows yourself, and `key` is there so you don't have to render
9
+ * them all the same way — a uniform list is the fallback, not the target:
9
10
  *
10
11
  * const specs = productSpecs(product);
11
12
  * {specs.length > 0 && <dl>{specs.map(s =>
12
13
  * <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>}
13
14
  *
15
+ * Branch on `s.key` to give the ones that carry this product's meaning their
16
+ * own treatment (a composition as bars, a provenance as a located line, a
17
+ * weight set in the display face) and let the rest fall through to the row
18
+ * above.
19
+ *
14
20
  * @param {object} product
15
21
  * @returns {Array<{key: string, label: string, value: string}>} `[]` when the
16
22
  * product has no visible meta_data — render nothing, not an empty section.
@@ -41,8 +41,10 @@ export function attributeKey(attribute) {
41
41
 
42
42
  /**
43
43
  * The product's variation axes: every `attributes` entry, ordered by `position`.
44
- * These become the selectors on the product page. Descriptive properties are not
45
- * attributes they are `meta_data` entries and belong in a spec table.
44
+ * These become the selectors on the product page one control per axis, in
45
+ * whatever form suits the axis (swatches for a colour, chips for a size).
46
+ * Descriptive properties are not attributes: they are `meta_data` entries, read
47
+ * with `productSpecs`, and they never select anything.
46
48
  *
47
49
  * @param {object} product
48
50
  * @returns {Array<object>} the `product.attributes` entries