@base44/app-plugin-commerce 0.8.1 → 0.8.3

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.
@@ -5,6 +5,9 @@
5
5
  * Actions: create | delete
6
6
  */
7
7
  import { createClientFromRequest } from "npm:@base44/sdk";
8
+ // The only layer that may reach the runtime: shared/ is client-bundleable,
9
+ // so the secret store is read here and handed down per call.
10
+ import { secrets } from "base44:runtime";
8
11
  import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
9
12
  import { refundCardOrder } from "../../../shared/commerce/payments.ts";
10
13
  import { round2 } from "../../../shared/commerce/money.ts";
@@ -64,6 +67,7 @@ async function create(sr: any, payload: any, actor: string): Promise<any> {
64
67
  let gatewayRefund: Awaited<ReturnType<typeof refundCardOrder>> = null;
65
68
  if (payload.refund_payment) {
66
69
  gatewayRefund = await refundCardOrder(sr, order, {
70
+ secrets,
67
71
  amount,
68
72
  reason: payload.reason,
69
73
  });
@@ -27,6 +27,9 @@
27
27
  * 400 `webhook_not_implemented`.
28
28
  */
29
29
  import { createClientFromRequest } from "npm:@base44/sdk";
30
+ // The only layer that may reach the runtime: shared/ is client-bundleable,
31
+ // so the secret store is read here and handed down per call.
32
+ import { secrets } from "base44:runtime";
30
33
  import { HttpError } from "../../../shared/commerce/auth.ts";
31
34
  import { getSettings } from "../../../shared/commerce/settings.ts";
32
35
  import { confirmCardPayment } from "../../../shared/commerce/payments.ts";
@@ -41,7 +44,7 @@ Deno.serve(async (req: Request) => {
41
44
  try {
42
45
  const payload = await req.text();
43
46
 
44
- const event = await parseWebhook(req, payload);
47
+ const event = await parseWebhook(req, payload, secrets);
45
48
  // Not about one of this store's orders — acknowledge so the provider
46
49
  // doesn't retry.
47
50
  if (!event || !event.order_id) {
@@ -62,6 +65,7 @@ Deno.serve(async (req: Request) => {
62
65
 
63
66
  const settings = await getSettings(sr);
64
67
  const result = await confirmCardPayment(sr, order, {
68
+ secrets,
65
69
  reference: event.reference,
66
70
  trustedPaid: event.paid,
67
71
  settings,
@@ -25,6 +25,9 @@
25
25
  * order without a key. Entity access is service-role throughout.
26
26
  */
27
27
  import { createClientFromRequest } from "npm:@base44/sdk";
28
+ // The only layer that may reach the runtime: shared/ is client-bundleable,
29
+ // so the secret store is read here and handed down per call.
30
+ import { secrets } from "base44:runtime";
28
31
  import { HttpError, getCallerUser, isAdmin } from "../../../shared/commerce/auth.ts";
29
32
  import { getSettings } from "../../../shared/commerce/settings.ts";
30
33
  import { serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
@@ -112,6 +115,7 @@ Deno.serve(async (req: Request) => {
112
115
  returnPath: settings.general?.order_received_path,
113
116
  });
114
117
  const link = await startCardPayment(sr, order, {
118
+ secrets,
115
119
  successUrl,
116
120
  cancelUrl,
117
121
  customerEmail: order.billing?.email || undefined,
@@ -134,6 +138,7 @@ Deno.serve(async (req: Request) => {
134
138
  const order = await authorizeOrder(sr, payload, admin);
135
139
  const settings = await getSettings(sr);
136
140
  const result = await confirmCardPayment(sr, order, {
141
+ secrets,
137
142
  settings,
138
143
  actor: admin ? (user?.email ?? "admin") : "customer-return",
139
144
  });
@@ -154,6 +159,7 @@ Deno.serve(async (req: Request) => {
154
159
  returnPath: settings.general?.order_received_path,
155
160
  });
156
161
  paymentLink = await startCardPayment(sr, result.order, {
162
+ secrets,
157
163
  successUrl,
158
164
  cancelUrl,
159
165
  customerEmail: result.order.billing?.email || undefined,
@@ -195,6 +201,7 @@ Deno.serve(async (req: Request) => {
195
201
  const order = await authorizeOrder(sr, payload, admin);
196
202
  const settings = await getSettings(sr);
197
203
  const result = await confirmCardPayment(sr, order, {
204
+ secrets,
198
205
  settings,
199
206
  actor: admin ? (user?.email ?? "admin") : "customer-return",
200
207
  });
@@ -12,6 +12,9 @@
12
12
  * Checkout is open to guests; order_key possession is the guest bearer credential.
13
13
  */
14
14
  import { createClientFromRequest } from "npm:@base44/sdk";
15
+ // The only layer that may reach the runtime: shared/ is client-bundleable,
16
+ // so the secret store is read here and handed down per call.
17
+ import { secrets } from "base44:runtime";
15
18
  import { HttpError, getCallerUser, ownsEmail } from "../../../shared/commerce/auth.ts";
16
19
  import { getSetting, getSettings } from "../../../shared/commerce/settings.ts";
17
20
  import { calculateTotals } from "../../../shared/commerce/totals.ts";
@@ -290,6 +293,7 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
290
293
  returnPath: settings.general?.order_received_path,
291
294
  });
292
295
  const link = await startCardPayment(sr, order, {
296
+ secrets,
293
297
  successUrl,
294
298
  cancelUrl,
295
299
  customerEmail: billing.email,
@@ -357,6 +361,7 @@ async function confirmPayment(sr: any, payload: any): Promise<any> {
357
361
 
358
362
  const settings = await getSettings(sr);
359
363
  const result = await confirmCardPayment(sr, order, {
364
+ secrets,
360
365
  settings,
361
366
  actor: "customer-return",
362
367
  });
@@ -39,9 +39,19 @@
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 { secrets } from "base44:runtime";
43
42
  import { HttpError } from "./auth.ts";
44
43
 
44
+ /**
45
+ * The app's secret store, handed in per call by the function layer:
46
+ * `import { secrets } from "base44:runtime"` there, never in this file —
47
+ * that specifier resolves only in the Deno function runtime, and `shared/` is
48
+ * reachable by the client bundler, so importing it here fails the storefront's
49
+ * build. `Deno.env` would leak the same runtime assumption.
50
+ */
51
+ export interface SecretStore {
52
+ get(name: string): unknown;
53
+ }
54
+
45
55
  /** Stripe's REST API, called directly — no SDK to bundle in the function. */
46
56
  const STRIPE_API = "https://api.stripe.com/v1";
47
57
  /** Pinned, so a Stripe API release can never change the shapes read below. */
@@ -62,9 +72,9 @@ export interface CardPaymentPage {
62
72
  * store that has the file but not yet the secret answers a clean 503 at
63
73
  * checkout instead of failing to boot every commerce function that imports it.
64
74
  */
65
- const secret = (name: string): string => {
75
+ const secret = (secrets: SecretStore, name: string): string => {
66
76
  try {
67
- return String(secrets.get(name) ?? "");
77
+ return String(secrets?.get(name) ?? "");
68
78
  } catch {
69
79
  return "";
70
80
  }
@@ -77,9 +87,9 @@ const secret = (name: string): string => {
77
87
  */
78
88
  const STRIPE_KEY_SECRETS = ["STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_KEY"];
79
89
 
80
- const stripeKey = (): string => {
90
+ const stripeKey = (secrets: SecretStore): string => {
81
91
  for (const name of STRIPE_KEY_SECRETS) {
82
- const value = secret(name);
92
+ const value = secret(secrets, name);
83
93
  if (value) return value;
84
94
  }
85
95
  // The client is told only that cards are unavailable — which secret is
@@ -100,7 +110,7 @@ const stripeKey = (): string => {
100
110
  * as `base44_app_id`, which is how the platform attributes a Stripe payment
101
111
  * back to this app — send it on every call that creates money movement.
102
112
  */
103
- const base44AppId = (): string => secret("BASE44_APP_ID") || String(Deno.env.get("BASE44_APP_ID") ?? "");
113
+ const base44AppId = (secrets: SecretStore): string => secret(secrets, "BASE44_APP_ID");
104
114
 
105
115
  /**
106
116
  * One Stripe REST call. A body makes it a POST (form-encoded, with an
@@ -109,9 +119,9 @@ const base44AppId = (): string => secret("BASE44_APP_ID") || String(Deno.env.get
109
119
  * Stripe's own error text stays in the log: it describes backend configuration
110
120
  * (keys, account state, API parameters), so the caller gets a flat message.
111
121
  */
112
- async function stripeCall(path: string, body?: URLSearchParams): Promise<any> {
122
+ async function stripeCall(secrets: SecretStore, path: string, body?: URLSearchParams): Promise<any> {
113
123
  const headers: Record<string, string> = {
114
- "Authorization": `Bearer ${stripeKey()}`,
124
+ "Authorization": `Bearer ${stripeKey(secrets)}`,
115
125
  "Stripe-Version": STRIPE_VERSION,
116
126
  };
117
127
  if (body) {
@@ -152,7 +162,7 @@ export async function createCardPayment(
152
162
  order_id: String(order.id),
153
163
  order_key: String(order.order_key),
154
164
  };
155
- const appId = base44AppId();
165
+ const appId = base44AppId(opts.secrets);
156
166
  if (appId) metadata.base44_app_id = appId;
157
167
 
158
168
  const params = new URLSearchParams();
@@ -170,7 +180,7 @@ export async function createCardPayment(
170
180
  params.set(`payment_intent_data[metadata][${key}]`, value);
171
181
  }
172
182
 
173
- const session = await stripeCall("/checkout/sessions", params);
183
+ const session = await stripeCall(opts.secrets, "/checkout/sessions", params);
174
184
  if (!session?.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
175
185
  return { url: session.url, reference: String(session.id) };
176
186
  }
@@ -180,8 +190,8 @@ export async function createCardPayment(
180
190
  * caller. Runs on the customer-return page, the webhook's unverified path and
181
191
  * the admin's "Check payment" button.
182
192
  */
183
- export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
184
- const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(reference)}`);
193
+ export async function checkCardPaymentPaid(_sr: any, order: any, reference: string, secrets: SecretStore): Promise<boolean> {
194
+ const session = await stripeCall(secrets, `/checkout/sessions/${encodeURIComponent(reference)}`);
185
195
  // The payment must be for THIS order — stops a reference to some other
186
196
  // (genuinely paid) session being replayed against a different order.
187
197
  return session?.payment_status === "paid" && session?.metadata?.order_id === String(order.id);
@@ -196,20 +206,21 @@ export async function refundCardPayment(_sr: any, _order: any, opts: {
196
206
  amount: number;
197
207
  currency: string;
198
208
  reason?: string;
209
+ secrets: SecretStore;
199
210
  }): Promise<{ refund_id: string }> {
200
211
  // The stored reference is the Checkout Session; the refundable object is the
201
212
  // payment intent behind it, which only exists once the session was paid.
202
- const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(opts.reference)}`);
213
+ const session = await stripeCall(opts.secrets, `/checkout/sessions/${encodeURIComponent(opts.reference)}`);
203
214
  if (!session?.payment_intent) {
204
215
  throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
205
216
  }
206
217
  const params = new URLSearchParams();
207
218
  params.set("payment_intent", String(session.payment_intent));
208
219
  params.set("amount", String(minorUnits(opts.amount, opts.currency)));
209
- const appId = base44AppId();
220
+ const appId = base44AppId(opts.secrets);
210
221
  if (appId) params.set("metadata[base44_app_id]", appId);
211
222
 
212
- const refund = await stripeCall("/refunds", params);
223
+ const refund = await stripeCall(opts.secrets, "/refunds", params);
213
224
  return { refund_id: String(refund.id) };
214
225
  }
215
226
 
@@ -242,7 +253,7 @@ export interface CardWebhookEvent {
242
253
  * null for anything that isn't a payment event for one of this store's orders;
243
254
  * the premade function answers 200 so Stripe stops retrying.
244
255
  */
245
- export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
256
+ export async function parseWebhook(_req: Request, payload: string, secrets: SecretStore): Promise<CardWebhookEvent | null> {
246
257
  let event: any;
247
258
  try { event = JSON.parse(payload); } catch { return null; }
248
259
  const metadata = event?.data?.object?.metadata;
@@ -34,14 +34,25 @@
34
34
  * Until implemented, the Credit Card checkout option answers
35
35
  * 503 `no_card_payment_provider`.
36
36
  *
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
37
+ * Credentials belong in Base44 secrets but this file never reaches for them
38
+ * itself. Every function below is handed a `SecretStore` by the function that
39
+ * called it, the one layer that may `import { secrets } from "base44:runtime"`.
40
+ * Neither that import nor `Deno.env` belongs here: `shared/` is reachable by
41
+ * the client bundler, so either one fails the storefront's build and pins this
42
+ * file to a single runtime. Never an entity, never the code, never the client. When one is missing, log which one and answer the caller with the
40
43
  * flat 503 below: the storefront must not learn the names of the app's
41
44
  * secrets.
42
45
  */
43
46
  import { HttpError } from "./auth.ts";
44
47
 
48
+ /**
49
+ * The app's secret store, handed in per call by the function layer:
50
+ * `import { secrets } from "base44:runtime"` there, never here.
51
+ */
52
+ export interface SecretStore {
53
+ get(name: string): unknown;
54
+ }
55
+
45
56
  /** A hosted payment page for one order. */
46
57
  export interface CardPaymentPage {
47
58
  /** Where the customer goes to pay. */
@@ -65,7 +76,7 @@ export interface CardPaymentPage {
65
76
  export async function createCardPayment(
66
77
  _sr: any,
67
78
  _order: any,
68
- _opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
79
+ _opts: { successUrl: string; cancelUrl: string; customerEmail?: string; secrets: SecretStore },
69
80
  ): Promise<CardPaymentPage> {
70
81
  throw new HttpError(
71
82
  503,
@@ -80,7 +91,7 @@ export async function createCardPayment(
80
91
  * a claim from the client. Called by the customer-return page, the webhook's
81
92
  * unsigned path, and the admin's "Check payment" button.
82
93
  */
83
- export async function checkCardPaymentPaid(_sr: any, _order: any, _reference: string): Promise<boolean> {
94
+ export async function checkCardPaymentPaid(_sr: any, _order: any, _reference: string, _secrets: SecretStore): Promise<boolean> {
84
95
  return false;
85
96
  }
86
97
 
@@ -95,6 +106,7 @@ export async function refundCardPayment(_sr: any, _order: any, _opts: {
95
106
  amount: number;
96
107
  currency: string;
97
108
  reason?: string;
109
+ secrets: SecretStore;
98
110
  }): Promise<{ refund_id: string }> {
99
111
  throw new HttpError(
100
112
  501,
@@ -138,7 +150,7 @@ export interface CardWebhookEvent {
138
150
  * aren't about a payment for one of this store's orders (answered 200 so the
139
151
  * provider doesn't retry).
140
152
  */
141
- export async function parseWebhook(_req: Request, _payload: string): Promise<CardWebhookEvent | null> {
153
+ export async function parseWebhook(_req: Request, _payload: string, _secrets: SecretStore): Promise<CardWebhookEvent | null> {
142
154
  throw new HttpError(
143
155
  400,
144
156
  "This store's payment webhook is not implemented.",
@@ -19,7 +19,7 @@
19
19
  import { HttpError } from "./auth.ts";
20
20
  import { round2 } from "./money.ts";
21
21
  import { transitionOrder } from "./orders.ts";
22
- import { checkCardPaymentPaid, createCardPayment, refundCardPayment } from "./card-payment.ts";
22
+ import { checkCardPaymentPaid, createCardPayment, refundCardPayment, type SecretStore } from "./card-payment.ts";
23
23
 
24
24
  /** The one `commerce.PaymentGateway` slug that pays online; all others are manual. */
25
25
  export const CARD_GATEWAY_SLUG = "card";
@@ -182,6 +182,8 @@ export async function startCardPayment(sr: any, order: any, opts: {
182
182
  successUrl: string;
183
183
  cancelUrl: string;
184
184
  customerEmail?: string;
185
+ // Handed down from the function entry — shared/ may not read secrets itself.
186
+ secrets: SecretStore;
185
187
  }): Promise<{ url: string; reference: string }> {
186
188
  if (isOrderPaid(order)) {
187
189
  throw new HttpError(409, "This order is already paid.", "already_paid");
@@ -210,12 +212,13 @@ export async function confirmCardPayment(sr: any, order: any, opts: {
210
212
  settings?: Record<string, any>;
211
213
  actor?: string;
212
214
  trustedPaid?: boolean;
213
- } = {}): Promise<{ paid: boolean; already_confirmed: boolean; order: any }> {
215
+ secrets: SecretStore;
216
+ }): Promise<{ paid: boolean; already_confirmed: boolean; order: any }> {
214
217
  if (isOrderPaid(order)) return { paid: true, already_confirmed: true, order };
215
218
 
216
219
  const reference = opts.reference || orderMeta(order, REFERENCE_META_KEY) || String(order?.transaction_id ?? "");
217
220
  const paid = opts.trustedPaid === true ||
218
- (reference ? await checkCardPaymentPaid(sr, order, reference) : false);
221
+ (reference ? await checkCardPaymentPaid(sr, order, reference, opts.secrets) : false);
219
222
  if (!paid) return { paid: false, already_confirmed: false, order };
220
223
 
221
224
  if (reference) {
@@ -242,10 +245,12 @@ export async function confirmCardPayment(sr: any, order: any, opts: {
242
245
  export async function refundCardOrder(sr: any, order: any, opts: {
243
246
  amount: number;
244
247
  reason?: string;
248
+ secrets: SecretStore;
245
249
  }): Promise<{ refund_id: string } | null> {
246
250
  const reference = order?.transaction_id || orderMeta(order, REFERENCE_META_KEY);
247
251
  if (!isCardGateway(order?.payment_method) || !reference) return null;
248
252
  return await refundCardPayment(sr, order, {
253
+ secrets: opts.secrets,
249
254
  reference,
250
255
  amount: round2(opts.amount),
251
256
  currency: String(order.currency || "USD"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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",
@@ -123,7 +123,7 @@ batch (above).
123
123
  | Topic | Open when | Size |
124
124
  |---|---|---|
125
125
  | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 6K |
126
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 37K |
126
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 38K |
127
127
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
128
128
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
129
129
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
@@ -25,6 +25,8 @@ One split decides everything here: **the logic is premade, the UI never is.** Th
25
25
 
26
26
  **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
27
27
 
28
+ ⚑ **Only some of what lives there is importable.** Hooks and helpers (`useProduct`, `useCart`, `productImages`, …) are named exports; a few operations exist **only on the client** `useStorefront()` returns — `submitReview`, `getProductReviews`, `listProducts`, `getProduct`, `applyCoupon`, `chooseShippingMethod`, `completeReturn`. Importing one by name throws `does not provide an export named …` and blanks the whole app: `const c = useStorefront(); await c.submitReview(…)`.
29
+
28
30
  ## Setup — once
29
31
 
30
32
  Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
@@ -317,7 +319,7 @@ function CheckoutForm() {
317
319
 
318
320
  **`<AddressFields>` is the one shipped component — use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
319
321
 
320
- It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
322
+ ⚑ **Pass `classes.control`** — an unstyled `<input>` still reads as deliberate; a `<select>` does not, so skipping this one prop leaves the checkout looking broken on exactly one field. It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
321
323
 
322
324
  ⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
323
325
 
@@ -5,7 +5,7 @@ skip_when: "commerce/seed-store has already returned success for this store and
5
5
  forget_when: "The seed response is received and recorded (slugs + warnings), and the checklist at the bottom passes."
6
6
  carry_forward:
7
7
  - "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
8
- - "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer."
8
+ - "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer. Shipping the Stripe file is not a Stripe connection; only the user can authorise that."
9
9
  - "Turning card payments on or off later is one more seed call: { payment_methods: [\"offline\", \"card\"] }."
10
10
  - "Seed-time `locations` is THE shipping path; patching commerce.ShippingTaxLocation is the day-2 route."
11
11
  ---
@@ -131,7 +131,7 @@ Online card payments are **off by default**: the seeded store takes the manual `
131
131
  | Paid another way (transfer, COD, invoice, pickup) | nothing to do — the default is exactly this |
132
132
  | Payments not mentioned | leave the default, **state it at handover** |
133
133
 
134
- *Cards only:* **if it is Stripe the code is already written** — copy `base44/shared/commerce/card-payment.stripe.ts` over `base44/shared/commerce/card-payment.ts` **whole** (`fs.copyFileSync`; a partial edit breaks every function's deploy), then enable via `payment_methods`. Nothing needs filling in. Any other provider: [`../references/online-payments.md`](../references/online-payments.md).
134
+ *Cards only:* **if it is Stripe the code is already written** — copy `base44/shared/commerce/card-payment.stripe.ts` over `base44/shared/commerce/card-payment.ts` **whole** (`fs.copyFileSync`; a partial edit breaks every function's deploy), then enable via `payment_methods`. Nothing needs filling in. ⚑ **That is the code, not a connection** — the app is not connected to Stripe until the user authorises it and its secret key reaches the app's secrets, which only they can do. Say the card path is *ready* and ask them to connect Stripe; never report it as connected, and never ask for a key in chat. Any other provider: [`../references/online-payments.md`](../references/online-payments.md).
135
135
 
136
136
  ## Done — forget this file
137
137
 
@@ -125,6 +125,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
125
125
  * value the selected slug ("" while none)
126
126
  * select (slug) => void
127
127
  * selected the selected gateway entry, or null
128
+ * mustChoose more than one gateway → render them as a picker
128
129
  * single exactly one gateway — already selected
129
130
  * hint { code: "none_available", severity, serverMessage } | null —
130
131
  * no gateways at all: say checkout is unavailable, in your words
@@ -160,6 +161,10 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
160
161
  value,
161
162
  select,
162
163
  selected: decorated.find((g) => g.selected) ?? null,
164
+ // Symmetrical with ShippingMethodPicker: a checkout that gates its radios
165
+ // on `mustChoose` renders NOTHING when the field is missing, and a payment
166
+ // picker that renders nothing is a checkout no one can complete.
167
+ mustChoose: gateways.length > 1,
163
168
  single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
164
169
  hint,
165
170
  });
@@ -65,6 +65,15 @@ export function storefrontErrorMessage(e) {
65
65
  }
66
66
 
67
67
  export function createStorefront(base44, { storageKey = "cart_token", storage } = {}) {
68
+ // Fail at construction, not four calls later. Without this the first symptom
69
+ // is "Cannot read properties of undefined (reading 'functions')" thrown from
70
+ // inside a cart call, which reads as a kit bug rather than a missing prop.
71
+ if (!base44 || typeof base44.functions?.invoke !== "function") {
72
+ throw new Error(
73
+ "StorefrontProvider requires the base44 client: <StorefrontProvider base44={base44}> " +
74
+ '(import { base44 } from "@/api/base44Client").',
75
+ );
76
+ }
68
77
  const bag = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
69
78
 
70
79
  // Every function returns the envelope { success, data }; with the SDK the