@base44/app-plugin-commerce 0.8.2 → 0.8.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.
- package/README.md +9 -3
- 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 -20
- package/base44/shared/commerce/card-payment.ts +18 -8
- package/base44/shared/commerce/payments.ts +8 -3
- package/package.json +1 -1
- package/scripts/install.js +4 -2
- package/skills/commerce/SKILL.md +1 -1
- package/skills/commerce/install/01-install.md +24 -8
- package/skills/commerce/install/02-storefront.md +1 -1
- package/src/commerce/admin/README.md +14 -6
- package/src/commerce/admin/index.jsx +30 -6
- package/src/commerce/admin/lib/paths.js +3 -2
- package/src/commerce/admin/routes.jsx +14 -1
package/README.md
CHANGED
|
@@ -85,12 +85,18 @@ From your existing Base44 app:
|
|
|
85
85
|
npx npq install <only the missing names> # npq audits the package before npm installs it
|
|
86
86
|
```
|
|
87
87
|
See [`src/commerce/admin/README.md`](./src/commerce/admin/README.md) for the exact shadcn component list.
|
|
88
|
-
5. **Mount the admin
|
|
88
|
+
5. **Mount the admin** as a layout route in your app's `src/App.jsx` — Base44 discovers an app's pages by reading the literal `<Route>` JSX in that file, so the screens that should be listed as pages are declared there and the rest run off a splat handled by the kit's own router:
|
|
89
89
|
```jsx
|
|
90
|
-
import AdminApp from "@/commerce/admin";
|
|
90
|
+
import AdminApp, { AdminRoutes } from "@/commerce/admin";
|
|
91
91
|
// inside your <Routes>:
|
|
92
|
-
<Route path="/store-admin
|
|
92
|
+
<Route path="/store-admin" element={<AdminApp />}>
|
|
93
|
+
<Route index element={<Dashboard />} />
|
|
94
|
+
<Route path="orders" element={<OrdersList />} />
|
|
95
|
+
{/* …products, customers, coupons, reports… */}
|
|
96
|
+
<Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
|
|
97
|
+
</Route>
|
|
93
98
|
```
|
|
99
|
+
Name the section they group under in `base44/ui.jsonc` (app-owned — edit in place): `{ "version": 1, "sections": [{ "path": "/store-admin/*", "name": "Store Management" }] }`
|
|
94
100
|
6. **Grant yourself the `admin` role** (Base44 dashboard → users, or `users.inviteUser(email, "admin")`). The admin UI refuses non-admins.
|
|
95
101
|
7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, the payment gateway rows (`offline` enabled, `card` off — enable it only with a provider wired) and — unless you pass your own `locations` — a fallback Shipping & Tax Location, plus the catalog: pass `products` (whole products with attributes — variants, categories, ribbons and taxonomy are created internally) or `with_sample_data: true` for the generic demo. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. Worked example: [`skills/commerce/install/03-data.md`](./skills/commerce/install/03-data.md); the full payload contract: [`skills/commerce/docs/api-admin.md`](./skills/commerce/docs/api-admin.md). Shipping zones are part of the same call — `locations` takes `continents: ["EU"]` and `rest_of_world: true`, so "€20 in Europe, €100 worldwide" is six lines.
|
|
96
102
|
|
|
@@ -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
|
});
|
|
@@ -41,6 +41,17 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { HttpError } from "./auth.ts";
|
|
43
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
|
+
|
|
44
55
|
/** Stripe's REST API, called directly — no SDK to bundle in the function. */
|
|
45
56
|
const STRIPE_API = "https://api.stripe.com/v1";
|
|
46
57
|
/** Pinned, so a Stripe API release can never change the shapes read below. */
|
|
@@ -61,14 +72,9 @@ export interface CardPaymentPage {
|
|
|
61
72
|
* store that has the file but not yet the secret answers a clean 503 at
|
|
62
73
|
* checkout instead of failing to boot every commerce function that imports it.
|
|
63
74
|
*/
|
|
64
|
-
const secret = (name: string): string => {
|
|
65
|
-
// Deno.env, never `secrets` from "base44:runtime": that specifier resolves
|
|
66
|
-
// only in the Deno function runtime, and this file lives under shared/ where
|
|
67
|
-
// a client bundler can reach it — the static import fails the Vite build of
|
|
68
|
-
// the whole storefront. Base44 publishes app secrets into the function
|
|
69
|
-
// environment, so Deno.env.get reads the same values.
|
|
75
|
+
const secret = (secrets: SecretStore, name: string): string => {
|
|
70
76
|
try {
|
|
71
|
-
return String(
|
|
77
|
+
return String(secrets?.get(name) ?? "");
|
|
72
78
|
} catch {
|
|
73
79
|
return "";
|
|
74
80
|
}
|
|
@@ -81,9 +87,9 @@ const secret = (name: string): string => {
|
|
|
81
87
|
*/
|
|
82
88
|
const STRIPE_KEY_SECRETS = ["STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_KEY"];
|
|
83
89
|
|
|
84
|
-
const stripeKey = (): string => {
|
|
90
|
+
const stripeKey = (secrets: SecretStore): string => {
|
|
85
91
|
for (const name of STRIPE_KEY_SECRETS) {
|
|
86
|
-
const value = secret(name);
|
|
92
|
+
const value = secret(secrets, name);
|
|
87
93
|
if (value) return value;
|
|
88
94
|
}
|
|
89
95
|
// The client is told only that cards are unavailable — which secret is
|
|
@@ -104,7 +110,7 @@ const stripeKey = (): string => {
|
|
|
104
110
|
* as `base44_app_id`, which is how the platform attributes a Stripe payment
|
|
105
111
|
* back to this app — send it on every call that creates money movement.
|
|
106
112
|
*/
|
|
107
|
-
const base44AppId = (): string => secret("BASE44_APP_ID");
|
|
113
|
+
const base44AppId = (secrets: SecretStore): string => secret(secrets, "BASE44_APP_ID");
|
|
108
114
|
|
|
109
115
|
/**
|
|
110
116
|
* One Stripe REST call. A body makes it a POST (form-encoded, with an
|
|
@@ -113,9 +119,9 @@ const base44AppId = (): string => secret("BASE44_APP_ID");
|
|
|
113
119
|
* Stripe's own error text stays in the log: it describes backend configuration
|
|
114
120
|
* (keys, account state, API parameters), so the caller gets a flat message.
|
|
115
121
|
*/
|
|
116
|
-
async function stripeCall(path: string, body?: URLSearchParams): Promise<any> {
|
|
122
|
+
async function stripeCall(secrets: SecretStore, path: string, body?: URLSearchParams): Promise<any> {
|
|
117
123
|
const headers: Record<string, string> = {
|
|
118
|
-
"Authorization": `Bearer ${stripeKey()}`,
|
|
124
|
+
"Authorization": `Bearer ${stripeKey(secrets)}`,
|
|
119
125
|
"Stripe-Version": STRIPE_VERSION,
|
|
120
126
|
};
|
|
121
127
|
if (body) {
|
|
@@ -156,7 +162,7 @@ export async function createCardPayment(
|
|
|
156
162
|
order_id: String(order.id),
|
|
157
163
|
order_key: String(order.order_key),
|
|
158
164
|
};
|
|
159
|
-
const appId = base44AppId();
|
|
165
|
+
const appId = base44AppId(opts.secrets);
|
|
160
166
|
if (appId) metadata.base44_app_id = appId;
|
|
161
167
|
|
|
162
168
|
const params = new URLSearchParams();
|
|
@@ -174,7 +180,7 @@ export async function createCardPayment(
|
|
|
174
180
|
params.set(`payment_intent_data[metadata][${key}]`, value);
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
const session = await stripeCall("/checkout/sessions", params);
|
|
183
|
+
const session = await stripeCall(opts.secrets, "/checkout/sessions", params);
|
|
178
184
|
if (!session?.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
179
185
|
return { url: session.url, reference: String(session.id) };
|
|
180
186
|
}
|
|
@@ -184,8 +190,8 @@ export async function createCardPayment(
|
|
|
184
190
|
* caller. Runs on the customer-return page, the webhook's unverified path and
|
|
185
191
|
* the admin's "Check payment" button.
|
|
186
192
|
*/
|
|
187
|
-
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
188
|
-
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)}`);
|
|
189
195
|
// The payment must be for THIS order — stops a reference to some other
|
|
190
196
|
// (genuinely paid) session being replayed against a different order.
|
|
191
197
|
return session?.payment_status === "paid" && session?.metadata?.order_id === String(order.id);
|
|
@@ -200,20 +206,21 @@ export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
|
200
206
|
amount: number;
|
|
201
207
|
currency: string;
|
|
202
208
|
reason?: string;
|
|
209
|
+
secrets: SecretStore;
|
|
203
210
|
}): Promise<{ refund_id: string }> {
|
|
204
211
|
// The stored reference is the Checkout Session; the refundable object is the
|
|
205
212
|
// payment intent behind it, which only exists once the session was paid.
|
|
206
|
-
const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(opts.reference)}`);
|
|
213
|
+
const session = await stripeCall(opts.secrets, `/checkout/sessions/${encodeURIComponent(opts.reference)}`);
|
|
207
214
|
if (!session?.payment_intent) {
|
|
208
215
|
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
209
216
|
}
|
|
210
217
|
const params = new URLSearchParams();
|
|
211
218
|
params.set("payment_intent", String(session.payment_intent));
|
|
212
219
|
params.set("amount", String(minorUnits(opts.amount, opts.currency)));
|
|
213
|
-
const appId = base44AppId();
|
|
220
|
+
const appId = base44AppId(opts.secrets);
|
|
214
221
|
if (appId) params.set("metadata[base44_app_id]", appId);
|
|
215
222
|
|
|
216
|
-
const refund = await stripeCall("/refunds", params);
|
|
223
|
+
const refund = await stripeCall(opts.secrets, "/refunds", params);
|
|
217
224
|
return { refund_id: String(refund.id) };
|
|
218
225
|
}
|
|
219
226
|
|
|
@@ -246,7 +253,7 @@ export interface CardWebhookEvent {
|
|
|
246
253
|
* null for anything that isn't a payment event for one of this store's orders;
|
|
247
254
|
* the premade function answers 200 so Stripe stops retrying.
|
|
248
255
|
*/
|
|
249
|
-
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> {
|
|
250
257
|
let event: any;
|
|
251
258
|
try { event = JSON.parse(payload); } catch { return null; }
|
|
252
259
|
const metadata = event?.data?.object?.metadata;
|
|
@@ -34,16 +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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* the client
|
|
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
|
|
42
43
|
* flat 503 below: the storefront must not learn the names of the app's
|
|
43
44
|
* secrets.
|
|
44
45
|
*/
|
|
45
46
|
import { HttpError } from "./auth.ts";
|
|
46
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
|
+
|
|
47
56
|
/** A hosted payment page for one order. */
|
|
48
57
|
export interface CardPaymentPage {
|
|
49
58
|
/** Where the customer goes to pay. */
|
|
@@ -67,7 +76,7 @@ export interface CardPaymentPage {
|
|
|
67
76
|
export async function createCardPayment(
|
|
68
77
|
_sr: any,
|
|
69
78
|
_order: any,
|
|
70
|
-
_opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
79
|
+
_opts: { successUrl: string; cancelUrl: string; customerEmail?: string; secrets: SecretStore },
|
|
71
80
|
): Promise<CardPaymentPage> {
|
|
72
81
|
throw new HttpError(
|
|
73
82
|
503,
|
|
@@ -82,7 +91,7 @@ export async function createCardPayment(
|
|
|
82
91
|
* a claim from the client. Called by the customer-return page, the webhook's
|
|
83
92
|
* unsigned path, and the admin's "Check payment" button.
|
|
84
93
|
*/
|
|
85
|
-
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> {
|
|
86
95
|
return false;
|
|
87
96
|
}
|
|
88
97
|
|
|
@@ -97,6 +106,7 @@ export async function refundCardPayment(_sr: any, _order: any, _opts: {
|
|
|
97
106
|
amount: number;
|
|
98
107
|
currency: string;
|
|
99
108
|
reason?: string;
|
|
109
|
+
secrets: SecretStore;
|
|
100
110
|
}): Promise<{ refund_id: string }> {
|
|
101
111
|
throw new HttpError(
|
|
102
112
|
501,
|
|
@@ -140,7 +150,7 @@ export interface CardWebhookEvent {
|
|
|
140
150
|
* aren't about a payment for one of this store's orders (answered 200 so the
|
|
141
151
|
* provider doesn't retry).
|
|
142
152
|
*/
|
|
143
|
-
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> {
|
|
144
154
|
throw new HttpError(
|
|
145
155
|
400,
|
|
146
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.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",
|
package/scripts/install.js
CHANGED
|
@@ -217,8 +217,10 @@
|
|
|
217
217
|
"\n" +
|
|
218
218
|
" 1. No deps to add: sonner, recharts and react-markdown ship with the default\n" +
|
|
219
219
|
" Base44 template — check package.json and npm i only what is truly missing\n" +
|
|
220
|
-
' 2. Mount the admin
|
|
221
|
-
"
|
|
220
|
+
' 2. Mount the admin in src/App.jsx: <Route path="/store-admin" element={<AdminApp />}>\n' +
|
|
221
|
+
" with the main screens as literal child <Route>s (that file is what the platform\n" +
|
|
222
|
+
' discovers pages from) and <Route path="*" element={<AdminRoutes />} /> for the\n' +
|
|
223
|
+
" rest, plus the mandatory /order-received route (useOrderReturn + your markup)\n" +
|
|
222
224
|
" 3. Seed the store — one commerce/seed-store call (store_name required) takes the\n" +
|
|
223
225
|
" catalog, currency, shipping locations and payment methods\n" +
|
|
224
226
|
" 4. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -122,7 +122,7 @@ batch (above).
|
|
|
122
122
|
|
|
123
123
|
| Topic | Open when | Size |
|
|
124
124
|
|---|---|---|
|
|
125
|
-
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 |
|
|
125
|
+
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 8K |
|
|
126
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 |
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: install/01
|
|
3
3
|
read_when: "The commerce kit's files were just copied into the app, or you are installing it now."
|
|
4
|
-
skip_when: "
|
|
4
|
+
skip_when: "src/App.jsx already declares the admin's screens as literal <Route> JSX under a /store-admin layout route, and / routes somewhere real."
|
|
5
5
|
forget_when: "The checklist at the bottom of this file passes (admin mounts, / routes somewhere real, /order-received exists)."
|
|
6
6
|
carry_forward:
|
|
7
|
+
- "The admin's six picker-visible routes are literal <Route> JSX in src/App.jsx (the platform discovers pages by reading that file — an array or a .map() discovers nothing); the rest run off its <Route path=\"*\" element={<AdminRoutes />} />."
|
|
7
8
|
- "Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them."
|
|
8
9
|
- "/order-received must exist as a route: every payment link returns there, and confirming is what marks an order paid."
|
|
9
10
|
- "The storefront header shows a visible \"Store manager\" link to /store-admin when the signed-in user's role is admin, and nothing for everyone else."
|
|
@@ -41,18 +42,33 @@ Image generation is the slowest step and nothing depends on it until seed time;
|
|
|
41
42
|
|
|
42
43
|
The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
|
|
43
44
|
|
|
44
|
-
## Mount the admin
|
|
45
|
+
## Mount the admin
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
import AdminApp from "@/commerce/admin";
|
|
48
|
-
import { Navigate } from "react-router-dom";
|
|
47
|
+
The admin mounts as a **layout route in the app's own `src/App.jsx`**: the six screens the store owner opens from the builder are declared there as literal `<Route>` JSX, and everything deeper goes to `<AdminRoutes />` on a splat. The platform discovers an app's pages by reading that file — a screen declared anywhere else is unreachable from the page picker.
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
```jsx
|
|
50
|
+
import AdminApp, { AdminRoutes } from "@/commerce/admin";
|
|
51
|
+
import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/OrdersList,
|
|
52
|
+
// products/ProductsList, customers/CustomersList, coupons/CouponsList, reports/Reports
|
|
53
|
+
|
|
54
|
+
{/* Literal JSX — the platform reads this file, it never runs it. Do not refactor into a map. */}
|
|
55
|
+
<Route path="/store-admin" element={<AdminApp />}>
|
|
56
|
+
<Route index element={<Dashboard />} />
|
|
57
|
+
<Route path="orders" element={<OrdersList />} />
|
|
58
|
+
<Route path="products" element={<ProductsList />} />
|
|
59
|
+
<Route path="customers" element={<CustomersList />} />
|
|
60
|
+
<Route path="coupons" element={<CouponsList />} />
|
|
61
|
+
<Route path="reports" element={<Reports />} />
|
|
62
|
+
<Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
|
|
63
|
+
</Route>
|
|
51
64
|
<Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
|
|
52
65
|
<Route path="/order-received" element={<OrderReceived />} /> {/* mandatory — see below */}
|
|
53
66
|
```
|
|
54
67
|
|
|
55
|
-
- **The
|
|
68
|
+
- **Those seven lines, as they are.** The splat is what keeps the app's listed pages to six instead of 26: `path="*"` is skipped, so the editors, settings tabs and webhook screens stay navigable without appearing there, and `<AdminRoutes />` still serves the admin's own 404.
|
|
69
|
+
- **Don't add `settings` to the list** — it is a tabbed layout around a nested route, so it only renders correctly from the splat.
|
|
70
|
+
- **Elsewhere than `/store-admin`**: change the layout route's path and pass the prefix — `<AdminApp basePath="/backoffice" />`; the children are unchanged.
|
|
71
|
+
- **Name the group** in `base44/ui.jsonc` — app-owned, so edit it in place, keep any other keys, never recreate a deleted one: `{ "version": 1, "sections": [{ "path": "/store-admin/*", "name": "Store Management" }] }`
|
|
56
72
|
- **Give `/` something** — a blank app has no `/` route, and "page not found" at the app's own URL reads like a broken install.
|
|
57
73
|
- **Link the admin from the storefront header** — otherwise the merchant has no way in but typing the URL. Resolve the signed-in user once (`base44.auth.me()`, rejection/no session = not an admin, never blocking the page) and render a plainly visible "Store manager" link to `/store-admin` in the header when `role === "admin"` — and nothing at all for everyone else.
|
|
58
74
|
- **`/order-received` is mandatory**, even offline-only: every payment link returns there, and confirming is what marks an order paid — without it a paying customer hits a 404 and the order stays unpaid. The page is one hook, `useOrderReturn()` ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General (`general.order_received_path`).
|
|
@@ -69,7 +85,7 @@ Storefront functions are public on purpose (per-action verification, above). To
|
|
|
69
85
|
|
|
70
86
|
## Done — forget this file
|
|
71
87
|
|
|
72
|
-
- [ ] `/store-admin
|
|
88
|
+
- [ ] The `/store-admin` layout route is in `src/App.jsx` with its six literal `<Route>` screens and the `path="*"` → `<AdminRoutes />` splat; `base44/ui.jsonc` names the section; the three enforcement layers untouched.
|
|
73
89
|
- [ ] `/` routes somewhere real; `/order-received` is a route.
|
|
74
90
|
- [ ] The storefront header shows a visible `/store-admin` link to signed-in admins, and to nobody else.
|
|
75
91
|
- [ ] Anonymous function invocation is allowed in the app's settings.
|
|
@@ -50,7 +50,7 @@ import AdminApp from "@/commerce/admin";
|
|
|
50
50
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
51
51
|
{/* /bag, /checkout, and /order-received — which is mandatory */}
|
|
52
52
|
</Route>
|
|
53
|
-
<Route path="/store-admin
|
|
53
|
+
<Route path="/store-admin" element={<AdminApp />}>…</Route> {/* own chrome, outside the provider */}
|
|
54
54
|
</Routes>
|
|
55
55
|
</BrowserRouter>
|
|
56
56
|
|
|
@@ -25,12 +25,20 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
25
25
|
npx npq install <only the missing names> # npq audits the package before npm installs it
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
4. Mount the app
|
|
28
|
+
4. Mount it as a layout route in the app's own `src/App.jsx` — that is the file
|
|
29
|
+
Base44 discovers an app's pages from, so the screens that should be listed as
|
|
30
|
+
pages are declared there literally, and the rest go to `<AdminRoutes />` on a
|
|
31
|
+
splat. The full step is the skill's `install/01-install.md`.
|
|
29
32
|
|
|
30
33
|
```jsx
|
|
31
|
-
import AdminApp from "@/commerce/admin";
|
|
32
|
-
|
|
33
|
-
<Route path="/store-admin
|
|
34
|
+
import AdminApp, { AdminRoutes } from "@/commerce/admin";
|
|
35
|
+
|
|
36
|
+
<Route path="/store-admin" element={<AdminApp />}>
|
|
37
|
+
<Route index element={<Dashboard />} />
|
|
38
|
+
<Route path="orders" element={<OrdersList />} />
|
|
39
|
+
{/* …products, customers, coupons, reports… */}
|
|
40
|
+
<Route path="*" element={<AdminRoutes />} />
|
|
41
|
+
</Route>
|
|
34
42
|
// mounted elsewhere? → <AdminApp basePath="/backoffice" />
|
|
35
43
|
```
|
|
36
44
|
|
|
@@ -75,8 +83,8 @@ npx shadcn@latest add <component>
|
|
|
75
83
|
## Layout of this folder
|
|
76
84
|
|
|
77
85
|
```
|
|
78
|
-
index.jsx AdminApp: providers → auth guard → layout →
|
|
79
|
-
routes.jsx Route table + <AdminRoutes/>
|
|
86
|
+
index.jsx AdminApp: providers → auth guard → layout → <Outlet/>
|
|
87
|
+
routes.jsx Route table + <AdminRoutes/> (the splat handler App.jsx delegates to)
|
|
80
88
|
layout/ AdminLayout, Sidebar, Topbar, AuthGuard (admin-role gate), AccessDenied
|
|
81
89
|
bot/ StoreAdminBot (chat panel over the commerce/StoreAdmin agent), Markdown (GFM renderer)
|
|
82
90
|
context/ SettingsContext (store settings + first-run seeding), BasePathContext
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { useOutlet } from "react-router-dom";
|
|
2
3
|
import { Toaster } from "sonner";
|
|
3
4
|
import AuthGuard from "./layout/AuthGuard";
|
|
4
5
|
import AdminLayout from "./layout/AdminLayout";
|
|
@@ -6,27 +7,50 @@ import AdminRoutes from "./routes";
|
|
|
6
7
|
import { SettingsProvider } from "./context/SettingsContext";
|
|
7
8
|
import { BasePathProvider } from "./context/BasePathContext";
|
|
8
9
|
|
|
10
|
+
export { default as AdminRoutes } from "./routes";
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* The store admin application.
|
|
11
14
|
*
|
|
12
|
-
* Mount
|
|
13
|
-
*
|
|
15
|
+
* Mount it as a *layout route* in the app's own `src/App.jsx`: declare the few
|
|
16
|
+
* screens that should be listed among the app's pages there, as literal `<Route>`
|
|
17
|
+
* JSX, and hand everything deeper to `<AdminRoutes />` on a splat. Base44
|
|
18
|
+
* discovers an app's pages by reading that file rather than running it, so a
|
|
19
|
+
* route declared anywhere else is reachable by URL but never listed — and a
|
|
20
|
+
* splat is skipped by discovery, which is what keeps the editors and the
|
|
21
|
+
* settings tabs off the list while leaving them navigable.
|
|
22
|
+
*
|
|
23
|
+
* <Route path="/store-admin" element={<AdminApp />}>
|
|
24
|
+
* <Route index element={<Dashboard />} />
|
|
25
|
+
* <Route path="orders" element={<OrdersList />} />
|
|
26
|
+
* …
|
|
27
|
+
* <Route path="*" element={<AdminRoutes />} />
|
|
28
|
+
* </Route>
|
|
14
29
|
*
|
|
15
30
|
* If mounted somewhere other than /store-admin, pass the prefix:
|
|
16
|
-
* <Route path="/backoffice
|
|
31
|
+
* <Route path="/backoffice" element={<AdminApp basePath="/backoffice" />}>
|
|
32
|
+
*
|
|
33
|
+
* The providers belong here, above the outlet, and must not be repeated per
|
|
34
|
+
* route: SettingsProvider fetches commerce.StoreSettings on mount, so wrapping
|
|
35
|
+
* each route would cost a round trip and a remounted sidebar on every admin
|
|
36
|
+
* navigation.
|
|
17
37
|
*
|
|
18
38
|
* Requires an authenticated user with role "admin" (enforced by AuthGuard,
|
|
19
39
|
* and independently by entity RLS + requireAdmin() in backend functions).
|
|
20
40
|
*/
|
|
21
41
|
export default function AdminApp({ basePath = "/store-admin" }) {
|
|
42
|
+
// Installs from before the routes moved into App.jsx mount the whole admin
|
|
43
|
+
// behind one splat route (`<Route path="/store-admin/*" element={<AdminApp />} />`)
|
|
44
|
+
// and declare no children, so nothing ever fills the outlet. Fall back to the
|
|
45
|
+
// kit's own router there rather than drawing an empty page area under the
|
|
46
|
+
// sidebar; those apps keep working until an agent rewrites their App.jsx.
|
|
47
|
+
const outlet = useOutlet();
|
|
22
48
|
return (
|
|
23
49
|
<BasePathProvider value={basePath}>
|
|
24
50
|
<Toaster richColors position="top-right" />
|
|
25
51
|
<AuthGuard>
|
|
26
52
|
<SettingsProvider>
|
|
27
|
-
<AdminLayout>
|
|
28
|
-
<AdminRoutes />
|
|
29
|
-
</AdminLayout>
|
|
53
|
+
<AdminLayout>{outlet ?? <AdminRoutes />}</AdminLayout>
|
|
30
54
|
</SettingsProvider>
|
|
31
55
|
</AuthGuard>
|
|
32
56
|
</BasePathProvider>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mount-path handling for the admin app.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Installs from before the routes moved into App.jsx mount the admin with a splat
|
|
5
|
+
* route — `<Route path="/store-admin/*">` — and both halves of that pattern leak
|
|
6
|
+
* into places they shouldn't: the `basePath` prop
|
|
6
7
|
* gets the pattern pasted in verbatim, and the literal URL `/store-admin/*` gets
|
|
7
8
|
* opened (pattern copied into the address bar, or a nav link built from the
|
|
8
9
|
* route table). Neither is a real page, so both are normalized here rather than
|
|
@@ -92,7 +92,20 @@ function UnmatchedRoute() {
|
|
|
92
92
|
return isMountPatternPath(params["*"]) ? <Navigate to={href()} replace /> : <NotFound />;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Every admin route, matched relative to the mount point.
|
|
97
|
+
*
|
|
98
|
+
* The app's `src/App.jsx` declares the handful of screens that should be listed
|
|
99
|
+
* among the app's pages — discovery only sees literal `<Route>` JSX in that file —
|
|
100
|
+
* and sends the rest here on a splat: `<Route path="*" element={<AdminRoutes />} />`.
|
|
101
|
+
* A descendant `<Routes>` needs exactly that trailing splat to resolve against.
|
|
102
|
+
* The overlap is deliberate: a path declared in App.jsx wins there, and the same
|
|
103
|
+
* entry here keeps this table complete for anything App.jsx does not name —
|
|
104
|
+
* `orders/:id` and the settings tabs among them.
|
|
105
|
+
*
|
|
106
|
+
* With no splat and no children at all — an install predating the move into
|
|
107
|
+
* App.jsx — `index.jsx` renders this component directly instead.
|
|
108
|
+
*/
|
|
96
109
|
export default function AdminRoutes() {
|
|
97
110
|
return (
|
|
98
111
|
<Routes>
|