@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.
Files changed (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +84 -31
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +76 -41
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -191
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. 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
- const cart = await loadCart(sr, payload.cart_token);
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
- return ok((await priceCart(sr, cart, { customerEmail: user?.email })).view);
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-tags |
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; browse context hides
8
- * catalog_visibility hidden/search; search context hides hidden/catalog;
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, requireUser } from "../../../shared/commerce/auth.ts";
11
+ import { getCallerUser, HttpError } from "../../../shared/commerce/auth.ts";
13
12
  import { getSettings, getSetting, storefrontSafeSettings } from "../../../shared/commerce/settings.ts";
14
- import { isOnlineGateway, onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
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-tags":
51
- return ok(await listTags(sr, payload));
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.tag_id) {
131
- products = products.filter((prod: any) => (prod.tag_ids || []).includes(String(p.tag_id)));
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 withTags(sr, products.slice(start, start + perPage).map(publicProduct));
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 `tags` to listing rows. A row otherwise carries only tag_ids,
161
- * which is why cards end up with no tags at all — the shape a card needs has to
162
- * be in the row it renders. One tag read per request, not per row; kept to
163
- * {id, name} so a listing payload stays small (use list-tags for counts).
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 withTags(sr: any, rows: any[]): Promise<any[]> {
166
- if (!rows.some((r) => (r.tag_ids ?? []).length)) return rows;
167
- const all = await scanAll(sr.entities["commerce.ProductTag"], {}, "name", 2000);
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
- tags: (r.tag_ids ?? []).map((id: string) => byId.get(id)).filter(Boolean),
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" || product.catalog_visibility === "hidden") {
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, tags] = await Promise.all([
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.ProductTag"], product.tag_ids),
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
- tags,
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
- * Tags for a tag cloud / "shop by tag" nav. Entities are admin-only, so a
317
- * storefront has no other way to enumerate them — and list-products `tag_id` is
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
- * ProductTag.count: that field counts drafts and hidden products too, and drifts
322
- * until admin-tools recount-terms runs — either way a nav built on it can show
323
- * "Gift (7)" that lists nothing, or hide a tag that has stock.
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 listTags(sr: any, p: any): Promise<any> {
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.tag_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1);
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.ProductTag"], {}, "name", 2000);
343
- const tags = all
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 { tags };
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
- // An online gateway is only offerable while its provider is actually
369
- // connected — otherwise the customer would pick "Card" and pay nothing. This
370
- // is a live check, never a stored flag.
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: offerable.map((g: any) => ({
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: isOnlineGateway(g.slug),
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
- /** Signed-in only: the reviewer's email is the session's, never the payload's. */
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
- const reviewerEmail = String(user.email ?? "").trim().toLowerCase();
403
- if (!reviewerEmail) {
404
- throw new HttpError(403, "Your account has no email address to review under.", "forbidden");
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.full_name ?? "").trim();
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 (!reviewer || !review) {
411
- throw new HttpError(400, "reviewer and review are required.", "review_incomplete");
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
- taxRates: any[];
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, taxRates, zones, zoneMethods] = await Promise.all([
44
+ const [settings, locations] = await Promise.all([
47
45
  getSettings(sr),
48
- scanAll(sr.entities["commerce.TaxRate"], {}, "menu_order", 2000),
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, taxRates, zones, zoneMethods };
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
- taxRates: pricingData.taxRates,
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
- confirmOnlinePayment,
24
- isOnlineGateway,
23
+ confirmCardPayment,
24
+ isCardGateway,
25
25
  isOrderPaid,
26
- orderMeta,
27
26
  resolveReturnUrls,
28
- SESSION_META_KEY,
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
- taxRates: pricingData.taxRates,
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
- switch (gateway.slug) {
282
- // Settled outside the store, so no money has arrived yet: on-hold, and the
283
- // merchant moves it on once it has.
284
- case "offline":
285
- await transitionOrder(sr, order, "on-hold", { settings });
286
- paymentInstructions = {
287
- type: "offline",
288
- description: gateway.description ?? "Pay outside the store. Your order is confirmed once we receive payment.",
289
- account_details: gateway.settings?.account_details ?? [],
290
- };
291
- break;
292
- default:
293
- if (isOnlineGateway(gateway.slug)) {
294
- // Online payment: the order stays `pending` and the customer is sent to
295
- // the provider's hosted page. Confirmation comes back through
296
- // commerce/payments `verify` (return) and commerce/payment-webhook.
297
- const { successUrl, cancelUrl } = resolveReturnUrls({
298
- order,
299
- req,
300
- successUrl: payload.success_url,
301
- cancelUrl: payload.cancel_url,
302
- returnUrl: payload.return_url,
303
- returnPath: settings.general?.order_received_path,
304
- });
305
- const link = await startOnlinePayment(sr, order, {
306
- successUrl,
307
- cancelUrl,
308
- customerEmail: billing.email,
309
- });
310
- payment = {
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 (!isOnlineGateway(order.payment_method)) {
350
+ if (!isCardGateway(order.payment_method)) {
363
351
  throw new HttpError(
364
352
  400,
365
- "This order is not paid through the online payment provider, so its payment cannot be confirmed here. Settle it from the admin.",
366
- "not_an_online_payment",
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 confirmOnlinePayment(sr, order, {
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
+ }