@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.
- package/base44/functions/commerce/admin-refunds/entry.ts +4 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +5 -1
- package/base44/functions/commerce/payments/entry.ts +7 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -0
- package/base44/shared/commerce/card-payment.stripe.ts +27 -16
- package/base44/shared/commerce/card-payment.ts +18 -6
- package/base44/shared/commerce/payments.ts +8 -3
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +1 -1
- package/skills/commerce/install/02-storefront.md +3 -1
- package/skills/commerce/install/03-data.md +2 -2
- package/src/commerce/storefront/pickers.jsx +5 -0
- package/src/commerce/utils/storefront.js +9 -0
|
@@ -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
|
|
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(
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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.
|
|
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",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -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 |
|
|
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
|