@base44/app-plugin-commerce 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +84 -31
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +76 -41
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -191
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +4 -5
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* apply-coupon | remove-coupon | set-shipping-address |
|
|
9
9
|
* choose-shipping-method | totals
|
|
10
10
|
* Every mutating action returns the fresh priced cart view.
|
|
11
|
+
*
|
|
12
|
+
* `add-item` is self-healing: a missing/unknown/expired cart_token starts a
|
|
13
|
+
* fresh cart instead of failing, so storefronts must persist the cart_token
|
|
14
|
+
* from every response rather than assuming the one they sent survived.
|
|
11
15
|
*/
|
|
12
16
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
13
17
|
import { HttpError, getCallerUser } from "../../../shared/commerce/auth.ts";
|
|
@@ -141,7 +145,26 @@ Deno.serve(async (req: Request) => {
|
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
case "add-item": {
|
|
144
|
-
|
|
148
|
+
// A missing, stale or expired token must not strand the shopper: start a
|
|
149
|
+
// fresh cart and add the item to it. The response carries the new
|
|
150
|
+
// cart_token — storefronts must always persist the token from the
|
|
151
|
+
// response, not assume the one they sent survived.
|
|
152
|
+
let cart: any;
|
|
153
|
+
try {
|
|
154
|
+
cart = await loadCart(sr, payload.cart_token);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
const code = (e as any)?.code;
|
|
157
|
+
if (!["cart_token_required", "cart_not_found", "cart_expired"].includes(code)) throw e;
|
|
158
|
+
cart = await sr.entities["commerce.Cart"].create({
|
|
159
|
+
cart_token: uuid(),
|
|
160
|
+
customer_email: user?.email ?? "",
|
|
161
|
+
items: [],
|
|
162
|
+
coupon_codes: [],
|
|
163
|
+
status: "active",
|
|
164
|
+
expires_at: cartExpiry(),
|
|
165
|
+
});
|
|
166
|
+
if (user) await mergeUserCarts(sr, cart, user);
|
|
167
|
+
}
|
|
145
168
|
await addItem(sr, cart, payload);
|
|
146
169
|
return ok((await priceCart(sr, cart, { customerEmail: user?.email })).view);
|
|
147
170
|
}
|
|
@@ -214,7 +237,18 @@ Deno.serve(async (req: Request) => {
|
|
|
214
237
|
};
|
|
215
238
|
if (!address.country) throw new HttpError(400, "address.country is required.", "country_required");
|
|
216
239
|
await touch(sr, cart, { shipping_address: address });
|
|
217
|
-
|
|
240
|
+
const priced = await priceCart(sr, cart, { customerEmail: user?.email });
|
|
241
|
+
// Fail loudly the moment an unsupported address is validated — a quiet
|
|
242
|
+
// `none_available` view is how a checkout dead-ends later at place-order.
|
|
243
|
+
if (priced.view.shipping_status === "none_available") {
|
|
244
|
+
throw new HttpError(
|
|
245
|
+
400,
|
|
246
|
+
"This store does not ship to this address.",
|
|
247
|
+
"shipping_not_available",
|
|
248
|
+
{ shipping_address: address, cart: priced.view },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return ok(priced.view);
|
|
218
252
|
}
|
|
219
253
|
|
|
220
254
|
case "choose-shipping-method": {
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* commerce/storefront-catalog — public catalog browsing API.
|
|
3
3
|
*
|
|
4
|
-
* Actions: list-products | get-product | list-categories | list-
|
|
4
|
+
* Actions: list-products | get-product | list-categories | list-ribbons |
|
|
5
5
|
* list-attributes | get-store-info | submit-review
|
|
6
6
|
*
|
|
7
|
-
* Visibility rules: only status=publish products
|
|
8
|
-
*
|
|
9
|
-
* inventory.hide_out_of_stock removes out-of-stock products entirely.
|
|
7
|
+
* Visibility rules: only status=publish products (the admin's single Visible
|
|
8
|
+
* toggle); inventory.hide_out_of_stock removes out-of-stock products entirely.
|
|
10
9
|
*/
|
|
11
10
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
12
|
-
import { getCallerUser, HttpError
|
|
11
|
+
import { getCallerUser, HttpError } from "../../../shared/commerce/auth.ts";
|
|
13
12
|
import { getSettings, getSetting, storefrontSafeSettings } from "../../../shared/commerce/settings.ts";
|
|
14
|
-
import {
|
|
13
|
+
import { isCardGateway } from "../../../shared/commerce/payments.ts";
|
|
15
14
|
import { isVariable } from "../../../shared/commerce/products.ts";
|
|
16
15
|
import { recalcProductRating } from "../../../shared/commerce/reviews.ts";
|
|
17
16
|
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
@@ -47,8 +46,8 @@ Deno.serve(async (req: Request) => {
|
|
|
47
46
|
return ok(await getProduct(sr, payload));
|
|
48
47
|
case "list-categories":
|
|
49
48
|
return ok(await listCategories(sr));
|
|
50
|
-
case "list-
|
|
51
|
-
return ok(await
|
|
49
|
+
case "list-ribbons":
|
|
50
|
+
return ok(await listRibbons(sr, payload));
|
|
52
51
|
case "list-attributes":
|
|
53
52
|
return ok(await listAttributes(sr));
|
|
54
53
|
case "get-store-info":
|
|
@@ -105,15 +104,6 @@ async function listProducts(sr: any, p: any): Promise<any> {
|
|
|
105
104
|
|
|
106
105
|
let products = await scanAll(sr.entities["commerce.Product"], { status: "publish" }, "-created_date", 5000);
|
|
107
106
|
|
|
108
|
-
// visibility per context
|
|
109
|
-
products = products.filter((prod: any) => {
|
|
110
|
-
const vis = prod.catalog_visibility ?? "visible";
|
|
111
|
-
if (vis === "hidden") return false;
|
|
112
|
-
if (isSearchContext && vis === "catalog") return false;
|
|
113
|
-
if (!isSearchContext && vis === "search") return false;
|
|
114
|
-
return true;
|
|
115
|
-
});
|
|
116
|
-
|
|
117
107
|
if (hideOutOfStock || p.in_stock_only) {
|
|
118
108
|
products = products.filter((prod: any) => prod.stock_status !== "outofstock");
|
|
119
109
|
}
|
|
@@ -127,8 +117,8 @@ async function listProducts(sr: any, p: any): Promise<any> {
|
|
|
127
117
|
const catIds = await categoryWithDescendants(sr, String(p.category_id));
|
|
128
118
|
products = products.filter((prod: any) => (prod.category_ids || []).some((c: string) => catIds.has(c)));
|
|
129
119
|
}
|
|
130
|
-
if (p.
|
|
131
|
-
products = products.filter((prod: any) => (prod.
|
|
120
|
+
if (p.ribbon_id) {
|
|
121
|
+
products = products.filter((prod: any) => (prod.ribbon_ids || []).includes(String(p.ribbon_id)));
|
|
132
122
|
}
|
|
133
123
|
if (p.attribute_id && p.attribute_term) {
|
|
134
124
|
products = products.filter((prod: any) =>
|
|
@@ -147,7 +137,7 @@ async function listProducts(sr: any, p: any): Promise<any> {
|
|
|
147
137
|
products.sort(SORTS[sortKey] ?? SORTS["-created_date"]);
|
|
148
138
|
|
|
149
139
|
const start = (page - 1) * perPage;
|
|
150
|
-
const pageItems = await
|
|
140
|
+
const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
|
|
151
141
|
return {
|
|
152
142
|
products: pageItems,
|
|
153
143
|
page,
|
|
@@ -157,18 +147,19 @@ async function listProducts(sr: any, p: any): Promise<any> {
|
|
|
157
147
|
}
|
|
158
148
|
|
|
159
149
|
/**
|
|
160
|
-
* Attach resolved `
|
|
161
|
-
* which is why cards end up with no
|
|
162
|
-
* be in the row it renders. One
|
|
163
|
-
* {id, name} so a listing payload stays small (use
|
|
150
|
+
* Attach resolved `ribbons` to listing rows. A row otherwise carries only
|
|
151
|
+
* ribbon_ids, which is why cards end up with no ribbons at all — the shape a
|
|
152
|
+
* card needs has to be in the row it renders. One ribbon read per request, not
|
|
153
|
+
* per row; kept to {id, name} so a listing payload stays small (use
|
|
154
|
+
* list-ribbons for counts).
|
|
164
155
|
*/
|
|
165
|
-
async function
|
|
166
|
-
if (!rows.some((r) => (r.
|
|
167
|
-
const all = await scanAll(sr.entities["commerce.
|
|
156
|
+
async function withRibbons(sr: any, rows: any[]): Promise<any[]> {
|
|
157
|
+
if (!rows.some((r) => (r.ribbon_ids ?? []).length)) return rows;
|
|
158
|
+
const all = await scanAll(sr.entities["commerce.ProductRibbon"], {}, "name", 2000);
|
|
168
159
|
const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name }]));
|
|
169
160
|
return rows.map((r) => ({
|
|
170
161
|
...r,
|
|
171
|
-
|
|
162
|
+
ribbons: (r.ribbon_ids ?? []).map((id: string) => byId.get(id)).filter(Boolean),
|
|
172
163
|
}));
|
|
173
164
|
}
|
|
174
165
|
|
|
@@ -199,16 +190,16 @@ async function getProduct(sr: any, p: any): Promise<any> {
|
|
|
199
190
|
} else if (p.slug) {
|
|
200
191
|
product = (await sr.entities["commerce.Product"].filter({ slug: String(p.slug) }, undefined, 1))?.[0] ?? null;
|
|
201
192
|
}
|
|
202
|
-
if (!product || product.status !== "publish"
|
|
193
|
+
if (!product || product.status !== "publish") {
|
|
203
194
|
throw new HttpError(404, "Product not found.", "not_found");
|
|
204
195
|
}
|
|
205
196
|
|
|
206
|
-
const [variations, categories,
|
|
197
|
+
const [variations, categories, ribbons] = await Promise.all([
|
|
207
198
|
isVariable(product)
|
|
208
199
|
? sr.entities["commerce.ProductVariation"].filter({ product_id: product.id }, "menu_order", 500)
|
|
209
200
|
: Promise.resolve([]),
|
|
210
201
|
resolveMany(sr.entities["commerce.ProductCategory"], product.category_ids),
|
|
211
|
-
resolveMany(sr.entities["commerce.
|
|
202
|
+
resolveMany(sr.entities["commerce.ProductRibbon"], product.ribbon_ids),
|
|
212
203
|
]);
|
|
213
204
|
|
|
214
205
|
const purchasableVariations = (variations ?? []).filter(
|
|
@@ -233,7 +224,7 @@ async function getProduct(sr: any, p: any): Promise<any> {
|
|
|
233
224
|
product: publicProduct(product),
|
|
234
225
|
variations: purchasableVariations.map(publicProduct),
|
|
235
226
|
categories,
|
|
236
|
-
|
|
227
|
+
ribbons,
|
|
237
228
|
reviews: {
|
|
238
229
|
items: reviews.slice(0, reviewsPerPage).map(publicReview),
|
|
239
230
|
page: reviewsPage,
|
|
@@ -313,41 +304,37 @@ async function listCategories(sr: any): Promise<any> {
|
|
|
313
304
|
}
|
|
314
305
|
|
|
315
306
|
/**
|
|
316
|
-
*
|
|
317
|
-
* storefront has no other way to enumerate them — and list-products `
|
|
318
|
-
* useless without the ids.
|
|
307
|
+
* Ribbons for cards / a "shop by ribbon" nav. Entities are admin-only, so a
|
|
308
|
+
* storefront has no other way to enumerate them — and list-products `ribbon_id`
|
|
309
|
+
* is useless without the ids.
|
|
319
310
|
*
|
|
320
311
|
* `count` is tallied from the products this API would actually list, NOT from
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* "Gift (7)" that lists nothing, or hide a
|
|
312
|
+
* ProductRibbon.count: that field counts drafts too, and drifts until
|
|
313
|
+
* admin-tools recount-terms runs — either way a nav built on it can show
|
|
314
|
+
* "Gift (7)" that lists nothing, or hide a ribbon that has stock.
|
|
324
315
|
* `with_products_only` (default true) then drops the empty ones.
|
|
325
316
|
*/
|
|
326
|
-
async function
|
|
317
|
+
async function listRibbons(sr: any, p: any): Promise<any> {
|
|
327
318
|
const settings = await getSettings(sr, "inventory");
|
|
328
319
|
const hideOutOfStock = !!getSetting(settings, "inventory", "hide_out_of_stock", false);
|
|
329
320
|
|
|
330
321
|
const products = (await scanAll(sr.entities["commerce.Product"], { status: "publish" }, undefined, 5000))
|
|
331
|
-
.filter((prod: any) =>
|
|
332
|
-
const vis = prod.catalog_visibility ?? "visible";
|
|
333
|
-
if (vis === "hidden" || vis === "search") return false; // browse context
|
|
334
|
-
return !(hideOutOfStock && prod.stock_status === "outofstock");
|
|
335
|
-
});
|
|
322
|
+
.filter((prod: any) => !(hideOutOfStock && prod.stock_status === "outofstock"));
|
|
336
323
|
|
|
337
324
|
const counts = new Map<string, number>();
|
|
338
325
|
for (const prod of products) {
|
|
339
|
-
for (const id of prod.
|
|
326
|
+
for (const id of prod.ribbon_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
340
327
|
}
|
|
341
328
|
|
|
342
|
-
const all = await scanAll(sr.entities["commerce.
|
|
343
|
-
const
|
|
329
|
+
const all = await scanAll(sr.entities["commerce.ProductRibbon"], {}, "name", 2000);
|
|
330
|
+
const ribbons = all
|
|
344
331
|
.map((t: any) => ({
|
|
345
332
|
id: t.id,
|
|
346
333
|
name: t.name,
|
|
347
334
|
count: counts.get(t.id) ?? 0,
|
|
348
335
|
}))
|
|
349
336
|
.filter((t: any) => p?.with_products_only === false || t.count > 0);
|
|
350
|
-
return {
|
|
337
|
+
return { ribbons };
|
|
351
338
|
}
|
|
352
339
|
|
|
353
340
|
async function listAttributes(sr: any): Promise<any> {
|
|
@@ -365,18 +352,16 @@ async function listAttributes(sr: any): Promise<any> {
|
|
|
365
352
|
async function getStoreInfo(sr: any): Promise<any> {
|
|
366
353
|
const settings = await getSettings(sr);
|
|
367
354
|
const gateways = (await sr.entities["commerce.PaymentGateway"].filter({ enabled: true }, "order", 50)) ?? [];
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
const online = await onlinePaymentStatus(sr);
|
|
372
|
-
const offerable = gateways.filter((g: any) => !isOnlineGateway(g.slug) || online.connected);
|
|
355
|
+
// Every enabled gateway is returned — the admin's Payments settings are the
|
|
356
|
+
// single switch. `online: true` marks the card option (place-order answers
|
|
357
|
+
// with a payment page to redirect to); everything else settles manually.
|
|
373
358
|
return {
|
|
374
359
|
settings: storefrontSafeSettings(settings),
|
|
375
|
-
payment_gateways:
|
|
360
|
+
payment_gateways: gateways.map((g: any) => ({
|
|
376
361
|
slug: g.slug,
|
|
377
362
|
title: g.title ?? g.slug,
|
|
378
363
|
description: g.description ?? "",
|
|
379
|
-
online:
|
|
364
|
+
online: isCardGateway(g.slug),
|
|
380
365
|
})),
|
|
381
366
|
countries: COUNTRIES,
|
|
382
367
|
currencies: CURRENCIES,
|
|
@@ -385,13 +370,16 @@ async function getStoreInfo(sr: any): Promise<any> {
|
|
|
385
370
|
|
|
386
371
|
// ── reviews ──────────────────────────────────────────────────────────────────
|
|
387
372
|
|
|
388
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* Public by design: anyone can review with an email address — no login. A
|
|
375
|
+
* signed-in session supplies the email (and can't spoof another); a guest
|
|
376
|
+
* passes `email` in the payload. Stricter policies (login-gated forms,
|
|
377
|
+
* verified-buyers-only, required ratings) are the storefront's to enforce in
|
|
378
|
+
* its UI — see .agents/skills/commerce/references/reviews.md. The one server switch is
|
|
379
|
+
* auto-approval; everything else submits as `hold` for moderation.
|
|
380
|
+
*/
|
|
389
381
|
async function submitReview(sr: any, p: any, user: any): Promise<any> {
|
|
390
|
-
requireUser(user);
|
|
391
382
|
const settings = await getSettings(sr, "products");
|
|
392
|
-
if (!getSetting(settings, "products", "enable_reviews", true)) {
|
|
393
|
-
throw new HttpError(403, "Reviews are disabled for this store.", "reviews_disabled");
|
|
394
|
-
}
|
|
395
383
|
|
|
396
384
|
const productId = String(p.product_id || "");
|
|
397
385
|
let product: any = null;
|
|
@@ -399,28 +387,23 @@ async function submitReview(sr: any, p: any, user: any): Promise<any> {
|
|
|
399
387
|
if (!product || product.status !== "publish") {
|
|
400
388
|
throw new HttpError(404, "Product not found.", "not_found");
|
|
401
389
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
390
|
+
// Session email wins — a signed-in caller can never review as someone else.
|
|
391
|
+
const reviewerEmail = String(user?.email ?? p.email ?? "").trim().toLowerCase();
|
|
392
|
+
if (!reviewerEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail)) {
|
|
393
|
+
throw new HttpError(400, "A valid email address is required to review.", "email_required");
|
|
405
394
|
}
|
|
406
|
-
const reviewer = String(p.reviewer ?? user
|
|
395
|
+
const reviewer = String(p.reviewer ?? user?.full_name ?? "").trim() || reviewerEmail.split("@")[0];
|
|
407
396
|
const review = String(p.review ?? "").trim();
|
|
408
397
|
const rating = p.rating == null ? null : Math.floor(Number(p.rating));
|
|
409
398
|
|
|
410
|
-
if (!
|
|
411
|
-
throw new HttpError(400, "
|
|
412
|
-
}
|
|
413
|
-
if (getSetting(settings, "products", "review_rating_required", true) && (rating == null || rating < 1)) {
|
|
414
|
-
throw new HttpError(400, "A star rating is required.", "rating_required");
|
|
399
|
+
if (!review) {
|
|
400
|
+
throw new HttpError(400, "review is required.", "review_incomplete");
|
|
415
401
|
}
|
|
416
402
|
if (rating != null && (rating < 0 || rating > 5)) {
|
|
417
403
|
throw new HttpError(400, "Rating must be between 0 and 5.", "invalid_rating");
|
|
418
404
|
}
|
|
419
405
|
|
|
420
406
|
const verified = await hasPurchased(sr, reviewerEmail, product.id);
|
|
421
|
-
if (getSetting(settings, "products", "only_verified_reviews", false) && !verified) {
|
|
422
|
-
throw new HttpError(403, "Only customers who purchased this product can review it.", "verified_only");
|
|
423
|
-
}
|
|
424
407
|
|
|
425
408
|
const autoApprove = !!getSetting(settings, "products", "auto_approve_reviews", false);
|
|
426
409
|
const created = await sr.entities["commerce.ProductReview"].create({
|
|
@@ -36,20 +36,16 @@ export async function loadCart(sr: any, cartToken: string | undefined): Promise<
|
|
|
36
36
|
|
|
37
37
|
export interface PricingData {
|
|
38
38
|
settings: Record<string, any>;
|
|
39
|
-
|
|
40
|
-
zones: any[];
|
|
41
|
-
zoneMethods: any[];
|
|
39
|
+
locations: any[];
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/** One round of catalog-config reads shared by every priced response. */
|
|
45
43
|
export async function loadPricingData(sr: any): Promise<PricingData> {
|
|
46
|
-
const [settings,
|
|
44
|
+
const [settings, locations] = await Promise.all([
|
|
47
45
|
getSettings(sr),
|
|
48
|
-
scanAll(sr.entities["commerce.
|
|
49
|
-
scanAll(sr.entities["commerce.ShippingZone"], {}, "order", 500),
|
|
50
|
-
scanAll(sr.entities["commerce.ShippingZoneMethod"], {}, "order", 1000),
|
|
46
|
+
scanAll(sr.entities["commerce.ShippingTaxLocation"], {}, "order", 500),
|
|
51
47
|
]);
|
|
52
|
-
return { settings,
|
|
48
|
+
return { settings, locations };
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
export interface ResolvedItem {
|
|
@@ -179,9 +175,7 @@ export async function priceCart(sr: any, cart: any, opts: { pricingData?: Pricin
|
|
|
179
175
|
shipping_address: cart.shipping_address,
|
|
180
176
|
chosenShippingMethodId: chosenShippingMethodId || undefined,
|
|
181
177
|
settings,
|
|
182
|
-
|
|
183
|
-
zones: pricingData.zones,
|
|
184
|
-
zoneMethods: pricingData.zoneMethods,
|
|
178
|
+
locations: pricingData.locations,
|
|
185
179
|
});
|
|
186
180
|
|
|
187
181
|
let totals = priceWith(cart.chosen_shipping_method);
|
|
@@ -196,6 +190,7 @@ export async function priceCart(sr: any, cart: any, opts: { pricingData?: Pricin
|
|
|
196
190
|
shippingEnabled: getSetting(settings, "shipping", "enable_shipping", true) !== false,
|
|
197
191
|
chosenMethodId: cart.chosen_shipping_method,
|
|
198
192
|
available: totals.available_shipping_methods,
|
|
193
|
+
hasAddress: !!cart.shipping_address?.country,
|
|
199
194
|
});
|
|
200
195
|
if (shippingSelection.method_id !== (cart.chosen_shipping_method || "")) {
|
|
201
196
|
cart.chosen_shipping_method = shippingSelection.method_id;
|
|
@@ -20,13 +20,11 @@ import { checkPurchasable, releaseExpiredHolds } from "../../../shared/commerce/
|
|
|
20
20
|
import { transitionOrder, serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
|
|
21
21
|
import { resolveShippingSelection } from "../../../shared/commerce/shipping.ts";
|
|
22
22
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
confirmCardPayment,
|
|
24
|
+
isCardGateway,
|
|
25
25
|
isOrderPaid,
|
|
26
|
-
orderMeta,
|
|
27
26
|
resolveReturnUrls,
|
|
28
|
-
|
|
29
|
-
startOnlinePayment,
|
|
27
|
+
startCardPayment,
|
|
30
28
|
} from "../../../shared/commerce/payments.ts";
|
|
31
29
|
import { generateOrderKey, nextOrderNumber } from "../../../shared/commerce/sequence.ts";
|
|
32
30
|
import { round2 } from "../../../shared/commerce/money.ts";
|
|
@@ -154,9 +152,7 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
|
|
|
154
152
|
shipping_address: shippingAddress,
|
|
155
153
|
chosenShippingMethodId: chosenShippingMethodId || undefined,
|
|
156
154
|
settings,
|
|
157
|
-
|
|
158
|
-
zones: pricingData.zones,
|
|
159
|
-
zoneMethods: pricingData.zoneMethods,
|
|
155
|
+
locations: pricingData.locations,
|
|
160
156
|
});
|
|
161
157
|
|
|
162
158
|
// The method may be named on this call (`chosen_shipping_method`) or carried on
|
|
@@ -224,7 +220,7 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
|
|
|
224
220
|
// (4) customer upsert by billing email
|
|
225
221
|
const notices: string[] = [];
|
|
226
222
|
if (payload.create_account && !user) {
|
|
227
|
-
notices.push("account_creation_requires_login"); // see skills/commerce/references/guest-access-security.md
|
|
223
|
+
notices.push("account_creation_requires_login"); // see .agents/skills/commerce/references/guest-access-security.md
|
|
228
224
|
}
|
|
229
225
|
const customer = await upsertCustomer(sr, billing, shippingAddress, user);
|
|
230
226
|
|
|
@@ -275,50 +271,42 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
|
|
|
275
271
|
// (7) cart consumed
|
|
276
272
|
try { await sr.entities["commerce.Cart"].update(cart.id, { status: "converted" }); } catch { /* best-effort */ }
|
|
277
273
|
|
|
278
|
-
// (9) gateway routing
|
|
274
|
+
// (9) gateway routing: the card gateway sends the customer to the provider's
|
|
275
|
+
// payment page; every other gateway is manual — the money settles outside the
|
|
276
|
+
// store, so the order goes on-hold with the gateway's instructions and the
|
|
277
|
+
// merchant moves it on once paid.
|
|
279
278
|
let paymentInstructions: any = null;
|
|
280
279
|
let payment: any = null;
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
status: "requires_payment",
|
|
312
|
-
provider: link.provider,
|
|
313
|
-
checkout_url: link.url,
|
|
314
|
-
session_id: link.session_id,
|
|
315
|
-
note: "Send the customer to checkout_url; the order stays pending until the payment is confirmed.",
|
|
316
|
-
};
|
|
317
|
-
} else {
|
|
318
|
-
// custom gateway added by the store: leave pending for external wiring
|
|
319
|
-
payment = { status: "pending_external", note: `Gateway "${gateway.slug}" requires custom wiring.` };
|
|
320
|
-
}
|
|
321
|
-
break;
|
|
280
|
+
if (isCardGateway(gateway.slug)) {
|
|
281
|
+
// The order stays `pending` while the customer pays. Confirmation comes
|
|
282
|
+
// back through commerce/payments `complete-return`/`verify` (customer
|
|
283
|
+
// return) and commerce/payment-webhook.
|
|
284
|
+
const { successUrl, cancelUrl } = resolveReturnUrls({
|
|
285
|
+
order,
|
|
286
|
+
req,
|
|
287
|
+
successUrl: payload.success_url,
|
|
288
|
+
cancelUrl: payload.cancel_url,
|
|
289
|
+
returnUrl: payload.return_url,
|
|
290
|
+
returnPath: settings.general?.order_received_path,
|
|
291
|
+
});
|
|
292
|
+
const link = await startCardPayment(sr, order, {
|
|
293
|
+
successUrl,
|
|
294
|
+
cancelUrl,
|
|
295
|
+
customerEmail: billing.email,
|
|
296
|
+
});
|
|
297
|
+
payment = {
|
|
298
|
+
status: "requires_payment",
|
|
299
|
+
checkout_url: link.url,
|
|
300
|
+
reference: link.reference,
|
|
301
|
+
note: "Send the customer to checkout_url; the order stays pending until the payment is confirmed.",
|
|
302
|
+
};
|
|
303
|
+
} else {
|
|
304
|
+
await transitionOrder(sr, order, "on-hold", { settings });
|
|
305
|
+
paymentInstructions = {
|
|
306
|
+
type: gateway.slug === "offline" ? "offline" : "manual",
|
|
307
|
+
description: gateway.description ?? "Pay outside the store. Your order is confirmed once we receive payment.",
|
|
308
|
+
account_details: gateway.settings?.account_details ?? [],
|
|
309
|
+
};
|
|
322
310
|
}
|
|
323
311
|
|
|
324
312
|
// update customer aggregates for paid statuses
|
|
@@ -359,17 +347,16 @@ async function confirmPayment(sr: any, payload: any): Promise<any> {
|
|
|
359
347
|
if (!["pending", "on-hold"].includes(order.status)) {
|
|
360
348
|
throw new HttpError(409, `Order is ${order.status} and cannot be confirmed.`, "invalid_status");
|
|
361
349
|
}
|
|
362
|
-
if (!
|
|
350
|
+
if (!isCardGateway(order.payment_method)) {
|
|
363
351
|
throw new HttpError(
|
|
364
352
|
400,
|
|
365
|
-
"This order is not paid
|
|
366
|
-
"
|
|
353
|
+
"This order is not paid by card, so its payment cannot be confirmed here. Settle it from the admin.",
|
|
354
|
+
"not_a_card_payment",
|
|
367
355
|
);
|
|
368
356
|
}
|
|
369
357
|
|
|
370
358
|
const settings = await getSettings(sr);
|
|
371
|
-
const result = await
|
|
372
|
-
sessionId: payload.session_id || orderMeta(order, SESSION_META_KEY),
|
|
359
|
+
const result = await confirmCardPayment(sr, order, {
|
|
373
360
|
settings,
|
|
374
361
|
actor: "customer-return",
|
|
375
362
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card payments — file 1 of 2 to implement when wiring a payment provider
|
|
3
|
+
* (Stripe, PayPal, Adyen, a local PSP…). File 2 is the webhook:
|
|
4
|
+
* `base44/functions/commerce/payment-webhook/entry.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Everything around these three functions is already built: checkout creates
|
|
7
|
+
* the order, prices it, holds stock and routes the customer to the URL you
|
|
8
|
+
* return; the webhook and the return page confirm payment idempotently and
|
|
9
|
+
* move the order to processing (stock, emails, download permissions, webhooks
|
|
10
|
+
* all fire from there); admin refunds write the refund record and restock.
|
|
11
|
+
* Implement ONLY the provider calls below — no other file needs to change.
|
|
12
|
+
*
|
|
13
|
+
* Until implemented, the Credit Card checkout option answers
|
|
14
|
+
* 503 `no_card_payment_provider`.
|
|
15
|
+
*
|
|
16
|
+
* Credentials belong in backend secrets/env (e.g. Deno.env.get("...")) —
|
|
17
|
+
* never in an entity and never from the client.
|
|
18
|
+
*/
|
|
19
|
+
import { HttpError } from "./auth.ts";
|
|
20
|
+
|
|
21
|
+
/** A hosted payment page for one order. */
|
|
22
|
+
export interface CardPaymentPage {
|
|
23
|
+
/** Where the customer goes to pay. */
|
|
24
|
+
url: string;
|
|
25
|
+
/**
|
|
26
|
+
* The provider's id for this payment (session / intent / transaction id).
|
|
27
|
+
* Stored on the order; handed back to `checkCardPaymentPaid` and
|
|
28
|
+
* `refundCardPayment`.
|
|
29
|
+
*/
|
|
30
|
+
reference: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a payment page for `order.total` in `order.currency`.
|
|
35
|
+
*
|
|
36
|
+
* Send the customer back to `successUrl` / `cancelUrl` after they pay or give
|
|
37
|
+
* up (both already carry order_id/order_key for the return page). If your
|
|
38
|
+
* provider supports metadata, attach `order.id` and `order.order_key` so the
|
|
39
|
+
* webhook can name the order.
|
|
40
|
+
*/
|
|
41
|
+
export async function createCardPayment(
|
|
42
|
+
_sr: any,
|
|
43
|
+
_order: any,
|
|
44
|
+
_opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
45
|
+
): Promise<CardPaymentPage> {
|
|
46
|
+
throw new HttpError(
|
|
47
|
+
503,
|
|
48
|
+
"Card payments are not available yet — no card payment provider is set up for this store.",
|
|
49
|
+
"no_card_payment_provider",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Has the provider actually received the money for this payment? Ask the
|
|
55
|
+
* provider (`reference` is what `createCardPayment` returned) — never trust
|
|
56
|
+
* a claim from the client. Called by the customer-return page, the webhook's
|
|
57
|
+
* unsigned path, and the admin's "Check payment" button.
|
|
58
|
+
*/
|
|
59
|
+
export async function checkCardPaymentPaid(_sr: any, _order: any, _reference: string): Promise<boolean> {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Refund `amount` (display units, e.g. 12.34) of a payment at the provider.
|
|
65
|
+
* Return the provider's refund id, or throw an HttpError if the provider
|
|
66
|
+
* refuses. Leave unimplemented to keep refunds manual: the admin then records
|
|
67
|
+
* refunds locally and returns the money from the provider's own dashboard.
|
|
68
|
+
*/
|
|
69
|
+
export async function refundCardPayment(_sr: any, _order: any, _opts: {
|
|
70
|
+
reference: string;
|
|
71
|
+
amount: number;
|
|
72
|
+
currency: string;
|
|
73
|
+
reason?: string;
|
|
74
|
+
}): Promise<{ refund_id: string }> {
|
|
75
|
+
throw new HttpError(
|
|
76
|
+
501,
|
|
77
|
+
"Refunding through the payment provider is not implemented — record the refund without `refund_payment` and return the money from the provider's dashboard.",
|
|
78
|
+
"card_refund_not_implemented",
|
|
79
|
+
);
|
|
80
|
+
}
|