@base44/app-plugin-commerce 0.1.0
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/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/storefront-account — customer self-service API.
|
|
3
|
+
*
|
|
4
|
+
* Two access modes:
|
|
5
|
+
* - authenticated (base44 auth): my-orders, my-downloads, update-my-addresses, my-reviews
|
|
6
|
+
* - order_key bearer (guest order tracking): get-order, order-notes, get-download
|
|
7
|
+
*
|
|
8
|
+
* All entity access is service-role; the caller is verified per-action
|
|
9
|
+
* (Order/Customer/DownloadPermission entities are admin-only RLS).
|
|
10
|
+
*/
|
|
11
|
+
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
12
|
+
import { HttpError, getCallerUser, requireUser } from "../../../shared/commerce/auth.ts";
|
|
13
|
+
import { serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
|
|
14
|
+
|
|
15
|
+
function ok(data: unknown, status = 200): Response {
|
|
16
|
+
return Response.json({ success: true, data }, { status });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(e: unknown): Response {
|
|
20
|
+
if (e instanceof HttpError) {
|
|
21
|
+
return Response.json({ success: false, error: e.message, code: e.code ?? "error" }, { status: e.status });
|
|
22
|
+
}
|
|
23
|
+
console.error("commerce/storefront-account error:", e);
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ success: false, error: (e as Error)?.message ?? "Internal error", code: "internal_error" },
|
|
26
|
+
{ status: 500 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function customerByEmail(sr: any, email: string): Promise<any | null> {
|
|
31
|
+
return (await sr.entities["commerce.Customer"].filter({ email: String(email).toLowerCase() }, undefined, 1))?.[0] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function orderByKey(sr: any, orderId: string, orderKey: string): Promise<any> {
|
|
35
|
+
if (!orderId || !orderKey) throw new HttpError(400, "order_id and order_key are required.", "order_key_required");
|
|
36
|
+
let order: any = null;
|
|
37
|
+
try { order = await sr.entities["commerce.Order"].get(orderId); } catch { order = null; }
|
|
38
|
+
if (!order || order.order_key !== orderKey) throw new HttpError(404, "Order not found.", "order_not_found");
|
|
39
|
+
return order;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Deno.serve(async (req: Request) => {
|
|
43
|
+
try {
|
|
44
|
+
const base44 = createClientFromRequest(req);
|
|
45
|
+
const sr = base44.asServiceRole;
|
|
46
|
+
const { action, ...payload } = await req.json().catch(() => ({}));
|
|
47
|
+
if (!action) throw new HttpError(400, "action is required.", "action_required");
|
|
48
|
+
const user = await getCallerUser(base44);
|
|
49
|
+
|
|
50
|
+
switch (action) {
|
|
51
|
+
case "my-orders": {
|
|
52
|
+
requireUser(user);
|
|
53
|
+
const page = Math.max(1, Math.floor(Number(payload.page) || 1));
|
|
54
|
+
const perPage = Math.min(50, Math.max(1, Math.floor(Number(payload.per_page) || 10)));
|
|
55
|
+
const customer = await customerByEmail(sr, user.email);
|
|
56
|
+
if (!customer) return ok({ orders: [], page, per_page: perPage, has_next: false });
|
|
57
|
+
const rows = (await sr.entities["commerce.Order"].filter(
|
|
58
|
+
{ customer_id: customer.id },
|
|
59
|
+
"-created_date",
|
|
60
|
+
perPage + 1,
|
|
61
|
+
(page - 1) * perPage,
|
|
62
|
+
)) ?? [];
|
|
63
|
+
return ok({
|
|
64
|
+
orders: rows.slice(0, perPage).map(serializeOrderForCustomer),
|
|
65
|
+
page,
|
|
66
|
+
per_page: perPage,
|
|
67
|
+
has_next: rows.length > perPage,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case "get-order": {
|
|
72
|
+
const order = await orderByKey(sr, payload.order_id, payload.order_key);
|
|
73
|
+
return ok({ order: serializeOrderForCustomer(order) });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "order-notes": {
|
|
77
|
+
const order = await orderByKey(sr, payload.order_id, payload.order_key);
|
|
78
|
+
const notes = (await sr.entities["commerce.OrderNote"].filter(
|
|
79
|
+
{ order_id: order.id, is_customer_note: true },
|
|
80
|
+
"-created_date",
|
|
81
|
+
100,
|
|
82
|
+
)) ?? [];
|
|
83
|
+
return ok({ notes: notes.map((n: any) => ({ id: n.id, note: n.note, created_date: n.created_date })) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case "my-downloads": {
|
|
87
|
+
requireUser(user);
|
|
88
|
+
const permissions = (await sr.entities["commerce.DownloadPermission"].filter(
|
|
89
|
+
{ customer_email: String(user.email).toLowerCase() },
|
|
90
|
+
"-created_date",
|
|
91
|
+
200,
|
|
92
|
+
)) ?? [];
|
|
93
|
+
return ok({ downloads: permissions.map(publicPermission) });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "get-download": {
|
|
97
|
+
const permissionId = String(payload.permission_id || "");
|
|
98
|
+
if (!permissionId) throw new HttpError(400, "permission_id is required.", "permission_required");
|
|
99
|
+
let perm: any = null;
|
|
100
|
+
try { perm = await sr.entities["commerce.DownloadPermission"].get(permissionId); } catch { perm = null; }
|
|
101
|
+
if (!perm) throw new HttpError(404, "Download not found.", "download_not_found");
|
|
102
|
+
|
|
103
|
+
// access check: authenticated owner OR order_key bearer
|
|
104
|
+
const authed = user && String(user.email).toLowerCase() === String(perm.customer_email).toLowerCase();
|
|
105
|
+
const keyed = payload.order_key && payload.order_key === perm.order_key;
|
|
106
|
+
if (!authed && !keyed) throw new HttpError(403, "You do not have access to this download.", "forbidden");
|
|
107
|
+
|
|
108
|
+
if (perm.downloads_remaining === 0) {
|
|
109
|
+
throw new HttpError(403, "Download limit reached.", "download_limit_reached");
|
|
110
|
+
}
|
|
111
|
+
if (perm.access_expires && new Date(perm.access_expires).getTime() < Date.now()) {
|
|
112
|
+
throw new HttpError(403, "Download access has expired.", "download_expired");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const patch: Record<string, any> = { download_count: (perm.download_count ?? 0) + 1 };
|
|
116
|
+
if (perm.downloads_remaining > 0) patch.downloads_remaining = perm.downloads_remaining - 1;
|
|
117
|
+
await sr.entities["commerce.DownloadPermission"].update(perm.id, patch);
|
|
118
|
+
|
|
119
|
+
// private files (non-http uri) get a short-lived signed URL
|
|
120
|
+
let url = perm.file_url ?? "";
|
|
121
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
122
|
+
const signed = await sr.integrations.Core.CreateFileSignedUrl({ file_uri: url, expires_in: 3600 });
|
|
123
|
+
url = signed?.signed_url ?? url;
|
|
124
|
+
}
|
|
125
|
+
return ok({
|
|
126
|
+
url,
|
|
127
|
+
download_name: perm.download_name,
|
|
128
|
+
downloads_remaining: patch.downloads_remaining ?? perm.downloads_remaining,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "update-my-addresses": {
|
|
133
|
+
requireUser(user);
|
|
134
|
+
const email = String(user.email).toLowerCase();
|
|
135
|
+
const existing = await customerByEmail(sr, email);
|
|
136
|
+
const patch: Record<string, any> = {};
|
|
137
|
+
if (payload.billing) patch.billing = payload.billing;
|
|
138
|
+
if (payload.shipping) patch.shipping = payload.shipping;
|
|
139
|
+
if (!Object.keys(patch).length) {
|
|
140
|
+
throw new HttpError(400, "Provide billing and/or shipping.", "nothing_to_update");
|
|
141
|
+
}
|
|
142
|
+
let customer: any;
|
|
143
|
+
if (existing) {
|
|
144
|
+
await sr.entities["commerce.Customer"].update(existing.id, patch);
|
|
145
|
+
customer = { ...existing, ...patch };
|
|
146
|
+
} else {
|
|
147
|
+
customer = await sr.entities["commerce.Customer"].create({
|
|
148
|
+
email,
|
|
149
|
+
first_name: user.full_name?.split(" ")[0] ?? "",
|
|
150
|
+
last_name: user.full_name?.split(" ").slice(1).join(" ") ?? "",
|
|
151
|
+
user_id: user.id,
|
|
152
|
+
is_guest: false,
|
|
153
|
+
...patch,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return ok({ customer: publicCustomer(customer) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "my-reviews": {
|
|
160
|
+
requireUser(user);
|
|
161
|
+
const reviews = (await sr.entities["commerce.ProductReview"].filter(
|
|
162
|
+
{ reviewer_email: String(user.email).toLowerCase() },
|
|
163
|
+
"-created_date",
|
|
164
|
+
100,
|
|
165
|
+
)) ?? [];
|
|
166
|
+
return ok({
|
|
167
|
+
reviews: reviews.map((r: any) => ({
|
|
168
|
+
id: r.id,
|
|
169
|
+
product_id: r.product_id,
|
|
170
|
+
review: r.review,
|
|
171
|
+
rating: r.rating,
|
|
172
|
+
status: r.status,
|
|
173
|
+
verified: !!r.verified,
|
|
174
|
+
created_date: r.created_date,
|
|
175
|
+
})),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
default:
|
|
180
|
+
throw new HttpError(400, `Unknown action: ${action}`, "unknown_action");
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return fail(e);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function publicPermission(p: any): any {
|
|
188
|
+
return {
|
|
189
|
+
permission_id: p.id,
|
|
190
|
+
order_id: p.order_id,
|
|
191
|
+
product_id: p.product_id,
|
|
192
|
+
download_name: p.download_name,
|
|
193
|
+
downloads_remaining: p.downloads_remaining,
|
|
194
|
+
access_expires: p.access_expires,
|
|
195
|
+
download_count: p.download_count ?? 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function publicCustomer(c: any): any {
|
|
200
|
+
return {
|
|
201
|
+
email: c.email,
|
|
202
|
+
first_name: c.first_name,
|
|
203
|
+
last_name: c.last_name,
|
|
204
|
+
billing: c.billing ?? {},
|
|
205
|
+
shipping: c.shipping ?? {},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart resolution + pricing helpers for storefront functions.
|
|
3
|
+
* NOTE: duplicated in commerce/storefront-cart/ and commerce/storefront-checkout/ — keep in sync.
|
|
4
|
+
* (Base44 functions can only share code via base44/shared/commerce/, which this
|
|
5
|
+
* template treats as engine-only; per-function helpers live beside entry.ts.)
|
|
6
|
+
*/
|
|
7
|
+
import { HttpError } from "../../../shared/commerce/auth.ts";
|
|
8
|
+
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
9
|
+
import { calculateTotals } from "../../../shared/commerce/totals.ts";
|
|
10
|
+
import { validateCoupon } from "../../../shared/commerce/coupons.ts";
|
|
11
|
+
import { checkPurchasable } from "../../../shared/commerce/stock.ts";
|
|
12
|
+
import { resolveShippingSelection } from "../../../shared/commerce/shipping.ts";
|
|
13
|
+
import { getSetting } from "../../../shared/commerce/settings.ts";
|
|
14
|
+
import { round2 } from "../../../shared/commerce/money.ts";
|
|
15
|
+
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
16
|
+
|
|
17
|
+
export const CART_TTL_MS = 48 * 60 * 60 * 1000; // 48h, refreshed on touch
|
|
18
|
+
|
|
19
|
+
export function cartExpiry(): string {
|
|
20
|
+
return new Date(Date.now() + CART_TTL_MS).toISOString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Load an active cart by its bearer token. Throws 404 (not found / expired). */
|
|
24
|
+
export async function loadCart(sr: any, cartToken: string | undefined): Promise<any> {
|
|
25
|
+
if (!cartToken) throw new HttpError(400, "cart_token is required.", "cart_token_required");
|
|
26
|
+
const cart = (await sr.entities["commerce.Cart"].filter({ cart_token: cartToken }, undefined, 1))?.[0];
|
|
27
|
+
if (!cart || cart.status !== "active") {
|
|
28
|
+
throw new HttpError(404, "Cart not found.", "cart_not_found");
|
|
29
|
+
}
|
|
30
|
+
if (cart.expires_at && new Date(cart.expires_at).getTime() < Date.now()) {
|
|
31
|
+
try { await sr.entities["commerce.Cart"].update(cart.id, { status: "abandoned" }); } catch { /* best-effort */ }
|
|
32
|
+
throw new HttpError(404, "Cart has expired.", "cart_expired");
|
|
33
|
+
}
|
|
34
|
+
return cart;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PricingData {
|
|
38
|
+
settings: Record<string, any>;
|
|
39
|
+
taxRates: any[];
|
|
40
|
+
zones: any[];
|
|
41
|
+
zoneMethods: any[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One round of catalog-config reads shared by every priced response. */
|
|
45
|
+
export async function loadPricingData(sr: any): Promise<PricingData> {
|
|
46
|
+
const [settings, taxRates, zones, zoneMethods] = await Promise.all([
|
|
47
|
+
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),
|
|
51
|
+
]);
|
|
52
|
+
return { settings, taxRates, zones, zoneMethods };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolvedItem {
|
|
56
|
+
item: any; // the stored cart item {item_key, product_id, variation_id, quantity, attributes}
|
|
57
|
+
product: any;
|
|
58
|
+
variation?: any;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve stored cart items to live products/variations. Items whose product
|
|
63
|
+
* vanished or was unpublished are reported in `removed` (they are dropped from
|
|
64
|
+
* the cart and the shopper is told).
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveItems(sr: any, cart: any): Promise<{ resolved: ResolvedItem[]; removed: any[] }> {
|
|
67
|
+
const resolved: ResolvedItem[] = [];
|
|
68
|
+
const removed: any[] = [];
|
|
69
|
+
for (const item of cart.items || []) {
|
|
70
|
+
let product: any = null;
|
|
71
|
+
let variation: any = undefined;
|
|
72
|
+
try { product = item.product_id ? await sr.entities["commerce.Product"].get(item.product_id) : null; } catch { product = null; }
|
|
73
|
+
if (!product || product.status !== "publish") {
|
|
74
|
+
removed.push({ item_key: item.item_key, product_id: item.product_id, reason: "This product is no longer available.", code: "unavailable" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (item.variation_id) {
|
|
78
|
+
try { variation = await sr.entities["commerce.ProductVariation"].get(item.variation_id); } catch { variation = undefined; }
|
|
79
|
+
if (!variation || (variation.status && variation.status !== "publish")) {
|
|
80
|
+
removed.push({ item_key: item.item_key, product_id: item.product_id, reason: "This product option is no longer available.", code: "unavailable" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
resolved.push({ item, product, variation });
|
|
85
|
+
}
|
|
86
|
+
return { resolved, removed };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Lines in the shape coupons.ts validateCoupon()/eligibleLines() expect. */
|
|
90
|
+
export function couponCtxLines(resolved: ResolvedItem[]): any[] {
|
|
91
|
+
return resolved.map(({ item, product, variation }) => {
|
|
92
|
+
const src = variation ?? product;
|
|
93
|
+
const price = Number(src?.price ?? 0);
|
|
94
|
+
return {
|
|
95
|
+
line_id: item.item_key,
|
|
96
|
+
product_id: item.product_id,
|
|
97
|
+
variation_id: item.variation_id ?? "",
|
|
98
|
+
quantity: item.quantity,
|
|
99
|
+
unit_price: price,
|
|
100
|
+
subtotal: round2(price * item.quantity),
|
|
101
|
+
discount: 0,
|
|
102
|
+
product,
|
|
103
|
+
variation,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function findCoupon(sr: any, code: string): Promise<any | null> {
|
|
109
|
+
if (!code) return null;
|
|
110
|
+
return (await sr.entities["commerce.Coupon"].filter({ code: String(code).toLowerCase().trim() }, undefined, 1))?.[0] ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PricedCart {
|
|
114
|
+
view: any; // customer-facing cart payload
|
|
115
|
+
totals: any; // full TotalsResult
|
|
116
|
+
resolved: ResolvedItem[];
|
|
117
|
+
removed: any[];
|
|
118
|
+
validCoupons: any[];
|
|
119
|
+
couponNotices: any[];
|
|
120
|
+
pricingData: PricingData;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Produce the priced view of a cart: resolve items, revalidate stored coupons
|
|
125
|
+
* (silently dropping ones that stopped validating, with notices), run the
|
|
126
|
+
* totals engine in preview mode, and enrich items for display.
|
|
127
|
+
* Persists item removals / coupon drops back onto the Cart record.
|
|
128
|
+
*/
|
|
129
|
+
export async function priceCart(sr: any, cart: any, opts: { pricingData?: PricingData; customerEmail?: string } = {}): Promise<PricedCart> {
|
|
130
|
+
const pricingData = opts.pricingData ?? (await loadPricingData(sr));
|
|
131
|
+
const { settings } = pricingData;
|
|
132
|
+
const { resolved, removed } = await resolveItems(sr, cart);
|
|
133
|
+
|
|
134
|
+
const cartPatch: Record<string, any> = {};
|
|
135
|
+
if (removed.length) {
|
|
136
|
+
const removedKeys = new Set(removed.map((r) => r.item_key));
|
|
137
|
+
cart.items = (cart.items || []).filter((i: any) => !removedKeys.has(i.item_key));
|
|
138
|
+
cartPatch.items = cart.items;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// revalidate stored coupons in application order
|
|
142
|
+
const ctxLines = couponCtxLines(resolved);
|
|
143
|
+
const itemsSubtotal = round2(ctxLines.reduce((a, l) => a + l.subtotal, 0));
|
|
144
|
+
const customerEmail = cart.customer_email || opts.customerEmail || "";
|
|
145
|
+
const validCoupons: any[] = [];
|
|
146
|
+
const keptCodes: string[] = [];
|
|
147
|
+
const couponNotices: any[] = [];
|
|
148
|
+
for (const code of cart.coupon_codes || []) {
|
|
149
|
+
const coupon = await findCoupon(sr, code);
|
|
150
|
+
const res = validateCoupon(coupon, {
|
|
151
|
+
lines: ctxLines,
|
|
152
|
+
itemsSubtotal,
|
|
153
|
+
customerEmail,
|
|
154
|
+
appliedCoupons: validCoupons,
|
|
155
|
+
});
|
|
156
|
+
if (res.valid) {
|
|
157
|
+
validCoupons.push(coupon);
|
|
158
|
+
keptCodes.push(code);
|
|
159
|
+
} else {
|
|
160
|
+
couponNotices.push({ code, error: res.error, error_code: res.code });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (keptCodes.length !== (cart.coupon_codes || []).length) {
|
|
164
|
+
cart.coupon_codes = keptCodes;
|
|
165
|
+
cartPatch.coupon_codes = keptCodes;
|
|
166
|
+
}
|
|
167
|
+
if (Object.keys(cartPatch).length) {
|
|
168
|
+
try { await sr.entities["commerce.Cart"].update(cart.id, cartPatch); } catch { /* view still correct */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const priceWith = (chosenShippingMethodId?: string) => calculateTotals({
|
|
172
|
+
items: resolved.map((r) => ({
|
|
173
|
+
product: r.product,
|
|
174
|
+
variation: r.variation,
|
|
175
|
+
quantity: r.item.quantity,
|
|
176
|
+
attributes: r.item.attributes ?? [],
|
|
177
|
+
})),
|
|
178
|
+
coupons: validCoupons,
|
|
179
|
+
shipping_address: cart.shipping_address,
|
|
180
|
+
chosenShippingMethodId: chosenShippingMethodId || undefined,
|
|
181
|
+
settings,
|
|
182
|
+
taxRates: pricingData.taxRates,
|
|
183
|
+
zones: pricingData.zones,
|
|
184
|
+
zoneMethods: pricingData.zoneMethods,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
let totals = priceWith(cart.chosen_shipping_method);
|
|
188
|
+
|
|
189
|
+
// Keep the cart's shipping choice honest: drop one that is no longer offered
|
|
190
|
+
// (the address or the cart changed) and auto-select when there is exactly one
|
|
191
|
+
// option, so a storefront that only sets an address still gets a shipping
|
|
192
|
+
// line instead of silently shipping for free. `shipping_status` on the view
|
|
193
|
+
// tells the UI whether it still has to ask the customer.
|
|
194
|
+
const shippingSelection = resolveShippingSelection({
|
|
195
|
+
needsShipping: resolved.some((r) => !(r.variation?.virtual ?? r.product.virtual)),
|
|
196
|
+
shippingEnabled: getSetting(settings, "shipping", "enable_shipping", true) !== false,
|
|
197
|
+
chosenMethodId: cart.chosen_shipping_method,
|
|
198
|
+
available: totals.available_shipping_methods,
|
|
199
|
+
});
|
|
200
|
+
if (shippingSelection.method_id !== (cart.chosen_shipping_method || "")) {
|
|
201
|
+
cart.chosen_shipping_method = shippingSelection.method_id;
|
|
202
|
+
try {
|
|
203
|
+
await sr.entities["commerce.Cart"].update(cart.id, { chosen_shipping_method: shippingSelection.method_id });
|
|
204
|
+
} catch { /* view still correct */ }
|
|
205
|
+
totals = priceWith(shippingSelection.method_id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// display items: engine lines align by index with `resolved`
|
|
209
|
+
const items = resolved.map((r, i) => {
|
|
210
|
+
const priced = totals.line_items[i] ?? {};
|
|
211
|
+
const src = r.variation ?? r.product;
|
|
212
|
+
return {
|
|
213
|
+
item_key: r.item.item_key,
|
|
214
|
+
product_id: r.item.product_id,
|
|
215
|
+
variation_id: r.item.variation_id ?? "",
|
|
216
|
+
quantity: r.item.quantity,
|
|
217
|
+
attributes: r.item.attributes ?? [],
|
|
218
|
+
name: r.product.name ?? "",
|
|
219
|
+
sku: priced.sku ?? "",
|
|
220
|
+
image: r.variation?.image?.src || r.product.images?.[0]?.src || "",
|
|
221
|
+
price: priced.price ?? Number(src?.price ?? 0),
|
|
222
|
+
subtotal: priced.subtotal ?? 0,
|
|
223
|
+
total: priced.total ?? 0,
|
|
224
|
+
total_tax: priced.total_tax ?? 0,
|
|
225
|
+
virtual: !!(r.variation?.virtual ?? r.product.virtual),
|
|
226
|
+
sold_individually: !!r.product.sold_individually,
|
|
227
|
+
purchasable: checkPurchasable(r.product, r.variation, r.item.quantity, settings),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const view = {
|
|
232
|
+
cart_token: cart.cart_token,
|
|
233
|
+
items,
|
|
234
|
+
coupon_codes: cart.coupon_codes ?? [],
|
|
235
|
+
coupons: totals.coupon_lines,
|
|
236
|
+
coupon_notices: couponNotices,
|
|
237
|
+
removed_items: removed,
|
|
238
|
+
shipping_address: cart.shipping_address ?? null,
|
|
239
|
+
chosen_shipping_method: cart.chosen_shipping_method ?? "",
|
|
240
|
+
available_shipping_methods: totals.available_shipping_methods,
|
|
241
|
+
shipping_status: shippingSelection.state,
|
|
242
|
+
totals: {
|
|
243
|
+
subtotal: totals.subtotal,
|
|
244
|
+
discount_total: totals.discount_total,
|
|
245
|
+
discount_tax: totals.discount_tax,
|
|
246
|
+
shipping_total: totals.shipping_total,
|
|
247
|
+
shipping_tax: totals.shipping_tax,
|
|
248
|
+
cart_tax: totals.cart_tax,
|
|
249
|
+
total_tax: totals.total_tax,
|
|
250
|
+
total: totals.total,
|
|
251
|
+
prices_include_tax: totals.prices_include_tax,
|
|
252
|
+
tax_lines: totals.tax_lines,
|
|
253
|
+
},
|
|
254
|
+
expires_at: cart.expires_at ?? null,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return { view, totals, resolved, removed, validCoupons, couponNotices, pricingData };
|
|
258
|
+
}
|