@base44/app-plugin-commerce 0.1.6 → 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 +68 -46
- 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 +60 -39
- 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 -166
- 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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* commerce/admin-reports — on-demand report aggregation over orders.
|
|
3
3
|
* Fine to ~10k orders; larger stores should materialize stats
|
|
4
|
-
* (see skills/commerce/references/limits-and-performance.md).
|
|
4
|
+
* (see .agents/skills/commerce/references/limits-and-performance.md).
|
|
5
5
|
*
|
|
6
6
|
* Actions: summary | sales | top-sellers | stock |
|
|
7
7
|
* orders-totals | products-totals | customers-totals | coupons-totals |
|
|
8
|
-
* reviews-totals | categories-totals |
|
|
8
|
+
* reviews-totals | categories-totals | ribbons-totals | attributes-totals
|
|
9
9
|
*/
|
|
10
10
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
11
11
|
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
@@ -35,7 +35,7 @@ Deno.serve(async (req) => {
|
|
|
35
35
|
case "coupons-totals": return ok(await groupCount(sr.entities["commerce.Coupon"], "discount_type"));
|
|
36
36
|
case "reviews-totals": return ok(await groupCount(sr.entities["commerce.ProductReview"], "status"));
|
|
37
37
|
case "categories-totals": return ok(await termTotals(sr.entities["commerce.ProductCategory"]));
|
|
38
|
-
case "
|
|
38
|
+
case "ribbons-totals": return ok(await termTotals(sr.entities["commerce.ProductRibbon"]));
|
|
39
39
|
case "attributes-totals": return ok(await attributesTotals(sr));
|
|
40
40
|
default:
|
|
41
41
|
return fail(400, `Unknown action: ${action}`, "unknown_action");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* commerce/admin-tools — maintenance and diagnostics (mini "system status").
|
|
3
3
|
*
|
|
4
|
-
* Actions: status |
|
|
4
|
+
* Actions: status | admin-email-recipients |
|
|
5
5
|
* recount-terms | recount-coupon-usage | recalculate-customer-stats-all |
|
|
6
6
|
* prune-webhook-deliveries | clear-abandoned-carts |
|
|
7
7
|
* regenerate-download-permissions
|
|
@@ -10,7 +10,6 @@ import { createClientFromRequest } from "npm:@base44/sdk";
|
|
|
10
10
|
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
11
11
|
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
12
12
|
import { adminUserEmails } from "../../../shared/commerce/emails.ts";
|
|
13
|
-
import { onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
|
|
14
13
|
import { round2 } from "../../../shared/commerce/money.ts";
|
|
15
14
|
import { grantDownloadPermissions, revokeDownloadPermissions } from "../../../shared/commerce/orders.ts";
|
|
16
15
|
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
@@ -28,7 +27,6 @@ Deno.serve(async (req) => {
|
|
|
28
27
|
|
|
29
28
|
switch (action) {
|
|
30
29
|
case "status": return ok(await status(sr));
|
|
31
|
-
case "payment-connector-status": return ok(await paymentConnectorStatus(sr, payload));
|
|
32
30
|
case "admin-email-recipients": return ok(await adminEmailRecipients(sr));
|
|
33
31
|
case "recount-terms": return ok(await recountTerms(sr));
|
|
34
32
|
case "recount-coupon-usage": return ok(await recountCouponUsage(sr));
|
|
@@ -70,34 +68,12 @@ async function adminEmailRecipients(
|
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
/**
|
|
74
|
-
* Is a gateway's Base44 connector actually connected?
|
|
75
|
-
*
|
|
76
|
-
* The admin UI calls this instead of hardcoding a "not wired" notice, so the
|
|
77
|
-
* warning disappears on its own once the connector is connected. Connectors are
|
|
78
|
-
* service-role only, which is why this needs a round trip rather than a client
|
|
79
|
-
* check. Any failure (SDK without `connectors`, connector never set up) is
|
|
80
|
-
* reported as `connected: false` — the conservative answer.
|
|
81
|
-
*/
|
|
82
|
-
/**
|
|
83
|
-
* Is an online payment provider usable right now? Delegates to the payment
|
|
84
|
-
* utility, so this answers for whichever provider is wired — the admin UI shows
|
|
85
|
-
* "no payment provider connected" rather than naming one.
|
|
86
|
-
*/
|
|
87
|
-
async function paymentConnectorStatus(sr: any, _payload: any): Promise<any> {
|
|
88
|
-
const status = await onlinePaymentStatus(sr);
|
|
89
|
-
return {
|
|
90
|
-
...status,
|
|
91
|
-
connector: status.provider, // kept for callers written against the old shape
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
71
|
/** Entity counts (capped at 1000 — shown as "1000+"), settings sanity, seeded flag. */
|
|
96
72
|
async function status(sr: any): Promise<any> {
|
|
97
73
|
const entities = [
|
|
98
|
-
"commerce.Product", "commerce.ProductVariation", "commerce.ProductCategory", "commerce.
|
|
74
|
+
"commerce.Product", "commerce.ProductVariation", "commerce.ProductCategory", "commerce.ProductRibbon", "commerce.ProductAttribute",
|
|
99
75
|
"commerce.Order", "commerce.OrderRefund", "commerce.Coupon", "commerce.Customer", "commerce.ProductReview",
|
|
100
|
-
"commerce.
|
|
76
|
+
"commerce.ShippingTaxLocation", "commerce.PaymentGateway", "commerce.Webhook", "commerce.Cart",
|
|
101
77
|
];
|
|
102
78
|
const counts: Record<string, string | number> = {};
|
|
103
79
|
for (const name of entities) {
|
|
@@ -119,23 +95,21 @@ async function status(sr: any): Promise<any> {
|
|
|
119
95
|
// counts is keyed by the namespaced entity name — `counts.PaymentGateway`
|
|
120
96
|
// is undefined, which silently reported both checks as false.
|
|
121
97
|
has_payment_gateways: (counts["commerce.PaymentGateway"] as number) > 0,
|
|
122
|
-
|
|
98
|
+
has_default_location: (counts["commerce.ShippingTaxLocation"] as number) > 0,
|
|
123
99
|
},
|
|
124
100
|
};
|
|
125
101
|
}
|
|
126
102
|
|
|
127
|
-
/** Rebuild category/
|
|
103
|
+
/** Rebuild category/ribbon/attribute-term `count` fields from products. */
|
|
128
104
|
async function recountTerms(sr: any): Promise<any> {
|
|
129
105
|
const products = await scanAll(sr.entities["commerce.Product"]);
|
|
130
106
|
const catCounts: Record<string, number> = {};
|
|
131
|
-
const
|
|
132
|
-
const classCounts: Record<string, number> = {};
|
|
107
|
+
const ribbonCounts: Record<string, number> = {};
|
|
133
108
|
const termCounts: Record<string, number> = {}; // `${attribute_id}::${option}`
|
|
134
109
|
|
|
135
110
|
for (const p of products) {
|
|
136
111
|
for (const c of p.category_ids ?? []) catCounts[c] = (catCounts[c] ?? 0) + 1;
|
|
137
|
-
for (const t of p.
|
|
138
|
-
if (p.shipping_class_id) classCounts[p.shipping_class_id] = (classCounts[p.shipping_class_id] ?? 0) + 1;
|
|
112
|
+
for (const t of p.ribbon_ids ?? []) ribbonCounts[t] = (ribbonCounts[t] ?? 0) + 1;
|
|
139
113
|
for (const a of p.attributes ?? []) {
|
|
140
114
|
if (!a.attribute_id) continue;
|
|
141
115
|
for (const opt of a.options ?? []) {
|
|
@@ -158,8 +132,7 @@ async function recountTerms(sr: any): Promise<any> {
|
|
|
158
132
|
};
|
|
159
133
|
|
|
160
134
|
const categories = await updateCounts("commerce.ProductCategory", catCounts);
|
|
161
|
-
const
|
|
162
|
-
const classes = await updateCounts("commerce.ShippingClass", classCounts);
|
|
135
|
+
const ribbons = await updateCounts("commerce.ProductRibbon", ribbonCounts);
|
|
163
136
|
|
|
164
137
|
let terms = 0;
|
|
165
138
|
for (const term of await scanAll(sr.entities["commerce.ProductAttributeTerm"])) {
|
|
@@ -169,7 +142,7 @@ async function recountTerms(sr: any): Promise<any> {
|
|
|
169
142
|
terms++;
|
|
170
143
|
}
|
|
171
144
|
}
|
|
172
|
-
return { updated: { categories,
|
|
145
|
+
return { updated: { categories, ribbons, attribute_terms: terms } };
|
|
173
146
|
}
|
|
174
147
|
|
|
175
148
|
/** Rebuild coupon usage_count/used_by from orders where usage was counted. */
|
|
@@ -1,131 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* commerce/payment-webhook —
|
|
2
|
+
* commerce/payment-webhook — file 2 of 2 to implement when wiring a payment
|
|
3
|
+
* provider. File 1 is `base44/shared/commerce/card-payment.ts`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* arrives second does
|
|
5
|
+
* This is the provider's server-to-server callback — the confirmation path
|
|
6
|
+
* that covers the buyer who pays and closes the tab (the other path is the
|
|
7
|
+
* customer returning to the storefront, which calls `commerce/payments`
|
|
8
|
+
* `complete-return`; both are idempotent, whichever arrives second does
|
|
9
|
+
* nothing).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Implement ONLY `parseWebhook` below. Everything after it is premade: the
|
|
12
|
+
* order lookup, the order_key check, the idempotent confirmation and the
|
|
13
|
+
* order progression (stock, emails, download permissions, store webhooks).
|
|
12
14
|
*
|
|
13
|
-
*
|
|
15
|
+
* Unlike every other function here this takes the provider's **raw body**
|
|
16
|
+
* rather than an `{action, ...}` envelope, because webhook signatures are
|
|
17
|
+
* computed over those exact bytes.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
* `STRIPE_WEBHOOK_SECRET`): the signature is verified here and the event's
|
|
17
|
-
* "paid" is then trusted directly — one fewer round trip.
|
|
18
|
-
* 2. **No secret available** — which is the case on Base44, where a function's
|
|
19
|
-
* environment is fixed (`BASE44_APP_ID`, `STRIPE_PUBLISHABLE_KEY`,
|
|
20
|
-
* `STRIPE_SECRET_KEY`) and apps cannot add their own: the body is treated as
|
|
21
|
-
* nothing more than a *nudge naming an order*. Whether money arrived is then
|
|
22
|
-
* asked of the provider directly, over our own authenticated API call.
|
|
23
|
-
*
|
|
24
|
-
* Either way the event must carry a matching `order_key`, and any session it
|
|
25
|
-
* names is checked to have been opened for that order — so a real payment for
|
|
26
|
-
* one order can't be pointed at another, and a forged webhook achieves nothing
|
|
27
|
-
* beyond making us re-check an order.
|
|
28
|
-
*
|
|
29
|
-
* So this endpoint is useful with or without a secret, and safe either way.
|
|
30
|
-
*
|
|
31
|
-
* Setup: register this function's URL with the provider (Stripe: events
|
|
32
|
-
* `checkout.session.completed`, `payment_intent.succeeded`). On the hosted Base44
|
|
33
|
-
* platform, also configure the app's Stripe webhook secret in the app settings —
|
|
34
|
-
* verified behavior: a request carrying a `stripe-signature` header is rejected
|
|
35
|
-
* by the platform *before* any function runs unless that is set.
|
|
36
|
-
*
|
|
37
|
-
* Provider specifics (signature scheme, event shapes) live in
|
|
38
|
-
* shared/commerce/payments.ts and its adapter — nothing here is Stripe-only.
|
|
19
|
+
* Until implemented, every request answers 400 `webhook_not_implemented`.
|
|
39
20
|
*/
|
|
40
21
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
41
22
|
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
42
|
-
import {
|
|
43
|
-
confirmOnlinePayment,
|
|
44
|
-
parsePaymentWebhook,
|
|
45
|
-
verifyPaymentWebhook,
|
|
46
|
-
} from "../../../shared/commerce/payments.ts";
|
|
23
|
+
import { confirmCardPayment } from "../../../shared/commerce/payments.ts";
|
|
47
24
|
|
|
48
25
|
/** Providers retry on non-2xx; answer 200 for anything we deliberately ignore. */
|
|
49
26
|
const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
|
|
50
27
|
const fail = (status: number, error: string, code: string) =>
|
|
51
28
|
Response.json({ success: false, error, code }, { status });
|
|
52
29
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
30
|
+
interface CardWebhookEvent {
|
|
31
|
+
/** The order the event is about — read it from the event's metadata. */
|
|
32
|
+
order_id: string;
|
|
33
|
+
/** Must match the order's key; attach both to the payment when creating it. */
|
|
34
|
+
order_key: string;
|
|
35
|
+
/**
|
|
36
|
+
* Set true ONLY when you verified the request signature AND the event says
|
|
37
|
+
* the payment succeeded. When false, the event is treated as a nudge naming
|
|
38
|
+
* an order, and whether money arrived is asked of the provider through
|
|
39
|
+
* `checkCardPaymentPaid` — useful when no signing secret is available.
|
|
40
|
+
*/
|
|
41
|
+
paid: boolean;
|
|
42
|
+
/** The provider's payment id, if the event carries one. */
|
|
43
|
+
reference?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ← IMPLEMENT THIS for your provider: verify the signature (over the raw
|
|
48
|
+
* `payload` bytes) and pull out the order reference. Return null for events
|
|
49
|
+
* that aren't about a payment for one of this store's orders.
|
|
50
|
+
*/
|
|
51
|
+
async function parseWebhook(_req: Request, _payload: string): Promise<CardWebhookEvent | null> {
|
|
52
|
+
return null;
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
Deno.serve(async (req: Request) => {
|
|
64
56
|
try {
|
|
65
57
|
const payload = await req.text();
|
|
66
|
-
const header = req.headers.get("stripe-signature") ?? req.headers.get("x-payment-signature") ?? "";
|
|
67
|
-
const secret = webhookSecret();
|
|
68
58
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let signatureVerified = false;
|
|
73
|
-
if (secret) {
|
|
74
|
-
signatureVerified = await verifyPaymentWebhook({ payload, header, secret });
|
|
75
|
-
if (!signatureVerified) return fail(400, "Invalid webhook signature.", "invalid_signature");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let body: any;
|
|
79
|
-
try {
|
|
80
|
-
body = JSON.parse(payload);
|
|
81
|
-
} catch {
|
|
82
|
-
return fail(400, "Malformed webhook body.", "invalid_body");
|
|
83
|
-
}
|
|
84
|
-
const event = parsePaymentWebhook(body);
|
|
85
|
-
if (!event.order_id) return ok({ ignored: true, type: event.type, reason: "no_order_reference" });
|
|
86
|
-
// An unsigned event claiming "paid" proves nothing, but it is still worth
|
|
87
|
-
// re-checking the order it names; a signed one that doesn't claim paid isn't.
|
|
88
|
-
if (signatureVerified && !event.paid) {
|
|
89
|
-
return ok({ ignored: true, type: event.type, reason: "not_a_payment_success" });
|
|
59
|
+
const event = await parseWebhook(req, payload);
|
|
60
|
+
if (!event) {
|
|
61
|
+
return fail(400, "This store's payment webhook is not implemented.", "webhook_not_implemented");
|
|
90
62
|
}
|
|
63
|
+
if (!event.order_id) return ok({ ignored: true, reason: "no_order_reference" });
|
|
91
64
|
|
|
92
65
|
const base44 = createClientFromRequest(req);
|
|
93
66
|
const sr = base44.asServiceRole;
|
|
94
67
|
|
|
95
68
|
let order: any = null;
|
|
96
69
|
try { order = await sr.entities["commerce.Order"].get(event.order_id); } catch { order = null; }
|
|
97
|
-
//
|
|
70
|
+
// order_key is required, not merely checked when offered — it is what stops
|
|
71
|
+
// a forged event (or a payment for a cheap order) being pointed at another.
|
|
98
72
|
if (!order || !event.order_key || order.order_key !== event.order_key) {
|
|
99
73
|
console.error(`commerce/payment-webhook: no matching order for ${event.order_id}`);
|
|
100
74
|
return ok({ ignored: true, reason: "order_not_found" });
|
|
101
75
|
}
|
|
102
76
|
|
|
103
77
|
const settings = await getSettings(sr);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
trustedPaid: signatureVerified,
|
|
111
|
-
settings,
|
|
112
|
-
actor: "payment-webhook",
|
|
113
|
-
});
|
|
114
|
-
} catch (e) {
|
|
115
|
-
// Forged or misrouted: answer 200 so the provider stops retrying.
|
|
116
|
-
if ((e as any)?.code === "session_order_mismatch") {
|
|
117
|
-
console.error(`commerce/payment-webhook: session ${event.session_id} is not for order ${order.id}`);
|
|
118
|
-
return ok({ ignored: true, reason: "session_order_mismatch" });
|
|
119
|
-
}
|
|
120
|
-
throw e;
|
|
121
|
-
}
|
|
78
|
+
const result = await confirmCardPayment(sr, order, {
|
|
79
|
+
reference: event.reference,
|
|
80
|
+
trustedPaid: event.paid,
|
|
81
|
+
settings,
|
|
82
|
+
actor: "payment-webhook",
|
|
83
|
+
});
|
|
122
84
|
|
|
123
85
|
return ok({
|
|
124
86
|
order_id: order.id,
|
|
125
87
|
paid: result.paid,
|
|
126
88
|
already_confirmed: result.already_confirmed,
|
|
127
89
|
status: result.order.status,
|
|
128
|
-
verified_signature: signatureVerified,
|
|
129
90
|
});
|
|
130
91
|
} catch (e) {
|
|
131
92
|
// 500 makes the provider retry, which is what we want for a transient failure.
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* commerce/payments — online payment for an order
|
|
2
|
+
* commerce/payments — online payment for an order.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The provider specifics live in the two implementable files (see
|
|
5
|
+
* `.agents/skills/commerce/references/online-payments.md`); this function is
|
|
6
|
+
* the premade API the storefront and the admin use.
|
|
6
7
|
*
|
|
7
8
|
* Actions:
|
|
8
|
-
* status — is an online payment provider connected? (admin)
|
|
9
9
|
* create-link — hosted payment page for an unpaid order: the storefront's
|
|
10
10
|
* redirect target and the admin's "payment link" (admin, or the
|
|
11
11
|
* customer holding the order_key). Works whatever the order's
|
|
12
12
|
* current payment method is — asking for a link is the decision
|
|
13
|
-
* to collect
|
|
13
|
+
* to collect by card, so the order is switched onto the card
|
|
14
14
|
* gateway (noted in the order log). Refused only when the store
|
|
15
|
-
* has
|
|
15
|
+
* has the card option switched off.
|
|
16
16
|
* complete-return — the `/order-received` flow in one call: confirm the payment,
|
|
17
17
|
* progress the order, and return the render-ready result plus a
|
|
18
18
|
* fresh payment link while unpaid. This is what the storefront's
|
|
@@ -23,25 +23,19 @@
|
|
|
23
23
|
* Access: `order_id` + `order_key` authorizes the customer who owns the order
|
|
24
24
|
* (the same bearer rule as commerce/storefront-account); an admin may act on any
|
|
25
25
|
* order without a key. Entity access is service-role throughout.
|
|
26
|
-
* Authorization is not confirmation: a `session_id` passed to verify/
|
|
27
|
-
* complete-return must be one the provider opened for this order
|
|
28
|
-
* (409 `session_order_mismatch`).
|
|
29
26
|
*/
|
|
30
27
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
31
28
|
import { HttpError, getCallerUser, isAdmin } from "../../../shared/commerce/auth.ts";
|
|
32
29
|
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
33
30
|
import { serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
|
|
34
31
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
CARD_GATEWAY_SLUG,
|
|
33
|
+
confirmCardPayment,
|
|
34
|
+
isCardGateway,
|
|
38
35
|
isOrderPaid,
|
|
39
|
-
onlinePaymentStatus,
|
|
40
|
-
orderMeta,
|
|
41
36
|
paymentReturnState,
|
|
42
37
|
resolveReturnUrls,
|
|
43
|
-
|
|
44
|
-
startOnlinePayment,
|
|
38
|
+
startCardPayment,
|
|
45
39
|
} from "../../../shared/commerce/payments.ts";
|
|
46
40
|
|
|
47
41
|
function ok(data: unknown, status = 200): Response {
|
|
@@ -71,33 +65,28 @@ Deno.serve(async (req: Request) => {
|
|
|
71
65
|
const { action, ...payload } = await req.json();
|
|
72
66
|
|
|
73
67
|
switch (action) {
|
|
74
|
-
case "status": {
|
|
75
|
-
if (!admin) throw new HttpError(403, "Admin access required.", "forbidden");
|
|
76
|
-
return ok(await onlinePaymentStatus(sr));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
68
|
case "create-link": {
|
|
80
69
|
const order = await authorizeOrder(sr, payload, admin);
|
|
81
70
|
if (isOrderPaid(order)) throw new HttpError(409, "This order is already paid.", "already_paid");
|
|
82
71
|
|
|
83
72
|
// Asking for a payment link *is* the decision to collect this order
|
|
84
|
-
//
|
|
73
|
+
// by card, so it works whatever the order's current method is — an admin
|
|
85
74
|
// order starts with none at all, and switching from a manual method is a
|
|
86
75
|
// normal thing to want ("just send them a card link"). The one hard
|
|
87
|
-
// requirement is that the store offers
|
|
76
|
+
// requirement is that the store offers card payment: an operator who
|
|
88
77
|
// turned the card gateway off has declined it, and this must respect that.
|
|
89
|
-
const gateway = await
|
|
78
|
+
const gateway = await cardGateway(sr);
|
|
90
79
|
if (!gateway || gateway.enabled === false) {
|
|
91
80
|
throw new HttpError(
|
|
92
81
|
400,
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
{ gateway_slug:
|
|
82
|
+
"Card payment is switched off for this store.",
|
|
83
|
+
"card_payments_disabled",
|
|
84
|
+
{ gateway_slug: CARD_GATEWAY_SLUG },
|
|
96
85
|
);
|
|
97
86
|
}
|
|
98
87
|
// Record the switch, or the order would claim it's cash-on-delivery while
|
|
99
88
|
// the customer pays by card — and refunds key off `payment_method`.
|
|
100
|
-
if (!
|
|
89
|
+
if (!isCardGateway(order.payment_method)) {
|
|
101
90
|
const previous = order.payment_method_title || order.payment_method || "none";
|
|
102
91
|
await sr.entities["commerce.Order"].update(order.id, {
|
|
103
92
|
payment_method: gateway.slug,
|
|
@@ -122,7 +111,7 @@ Deno.serve(async (req: Request) => {
|
|
|
122
111
|
returnUrl: payload.return_url,
|
|
123
112
|
returnPath: settings.general?.order_received_path,
|
|
124
113
|
});
|
|
125
|
-
const link = await
|
|
114
|
+
const link = await startCardPayment(sr, order, {
|
|
126
115
|
successUrl,
|
|
127
116
|
cancelUrl,
|
|
128
117
|
customerEmail: order.billing?.email || undefined,
|
|
@@ -132,7 +121,7 @@ Deno.serve(async (req: Request) => {
|
|
|
132
121
|
|
|
133
122
|
/**
|
|
134
123
|
* The whole payment-return flow in one call, for the `/order-received`
|
|
135
|
-
* page a storefront has to implement: confirm
|
|
124
|
+
* page a storefront has to implement: confirm the payment, move the
|
|
136
125
|
* order on if the money landed, and hand back everything the page needs to
|
|
137
126
|
* render — including a fresh payment link when it is still unpaid, so
|
|
138
127
|
* "Pay now" needs no second round trip.
|
|
@@ -144,18 +133,17 @@ Deno.serve(async (req: Request) => {
|
|
|
144
133
|
case "complete-return": {
|
|
145
134
|
const order = await authorizeOrder(sr, payload, admin);
|
|
146
135
|
const settings = await getSettings(sr);
|
|
147
|
-
const result = await
|
|
148
|
-
sessionId: payload.session_id || orderMeta(order, SESSION_META_KEY),
|
|
136
|
+
const result = await confirmCardPayment(sr, order, {
|
|
149
137
|
settings,
|
|
150
138
|
actor: admin ? (user?.email ?? "admin") : "customer-return",
|
|
151
139
|
});
|
|
152
140
|
const state = paymentReturnState({ paid: result.paid, outcome: payload.payment });
|
|
153
141
|
|
|
154
|
-
// Offer a way to pay again while unpaid — best-effort: an
|
|
142
|
+
// Offer a way to pay again while unpaid — best-effort: an unimplemented
|
|
155
143
|
// provider or a store that switched card payment off must not break the
|
|
156
144
|
// page, it just means no "Pay now" button.
|
|
157
|
-
let paymentLink: { url: string;
|
|
158
|
-
if (state !== "paid" && payload.with_payment_link !== false &&
|
|
145
|
+
let paymentLink: { url: string; reference: string } | null = null;
|
|
146
|
+
if (state !== "paid" && payload.with_payment_link !== false && isCardGateway(result.order.payment_method)) {
|
|
159
147
|
try {
|
|
160
148
|
const { successUrl, cancelUrl } = resolveReturnUrls({
|
|
161
149
|
order: result.order,
|
|
@@ -165,17 +153,33 @@ Deno.serve(async (req: Request) => {
|
|
|
165
153
|
returnUrl: payload.return_url,
|
|
166
154
|
returnPath: settings.general?.order_received_path,
|
|
167
155
|
});
|
|
168
|
-
|
|
156
|
+
paymentLink = await startCardPayment(sr, result.order, {
|
|
169
157
|
successUrl,
|
|
170
158
|
cancelUrl,
|
|
171
159
|
customerEmail: result.order.billing?.email || undefined,
|
|
172
160
|
});
|
|
173
|
-
paymentLink = { url: link.url, session_id: link.session_id };
|
|
174
161
|
} catch (e) {
|
|
175
162
|
console.error("complete-return: could not mint a payment link:", e);
|
|
176
163
|
}
|
|
177
164
|
}
|
|
178
165
|
|
|
166
|
+
// A manual order still needs its payment instructions after the
|
|
167
|
+
// customer lands back here — the place-order response that carried
|
|
168
|
+
// them is long gone, so the return page gets them again.
|
|
169
|
+
let paymentInstructions: any = null;
|
|
170
|
+
if (state !== "paid" && result.order.payment_method && !isCardGateway(result.order.payment_method)) {
|
|
171
|
+
const gw = (await sr.entities["commerce.PaymentGateway"].filter(
|
|
172
|
+
{ slug: result.order.payment_method }, undefined, 1,
|
|
173
|
+
))?.[0];
|
|
174
|
+
if (gw) {
|
|
175
|
+
paymentInstructions = {
|
|
176
|
+
type: gw.slug === "offline" ? "offline" : "manual",
|
|
177
|
+
description: gw.description ?? "",
|
|
178
|
+
account_details: gw.settings?.account_details ?? [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
179
183
|
return ok({
|
|
180
184
|
state, // paid | cancelled | unpaid
|
|
181
185
|
paid: result.paid,
|
|
@@ -183,14 +187,14 @@ Deno.serve(async (req: Request) => {
|
|
|
183
187
|
status: result.order.status,
|
|
184
188
|
order: admin ? result.order : serializeOrderForCustomer(result.order),
|
|
185
189
|
payment_link: paymentLink,
|
|
190
|
+
payment_instructions: paymentInstructions,
|
|
186
191
|
});
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
case "verify": {
|
|
190
195
|
const order = await authorizeOrder(sr, payload, admin);
|
|
191
196
|
const settings = await getSettings(sr);
|
|
192
|
-
const result = await
|
|
193
|
-
sessionId: payload.session_id || orderMeta(order, SESSION_META_KEY),
|
|
197
|
+
const result = await confirmCardPayment(sr, order, {
|
|
194
198
|
settings,
|
|
195
199
|
actor: admin ? (user?.email ?? "admin") : "customer-return",
|
|
196
200
|
});
|
|
@@ -210,10 +214,10 @@ Deno.serve(async (req: Request) => {
|
|
|
210
214
|
}
|
|
211
215
|
});
|
|
212
216
|
|
|
213
|
-
/** The `commerce.PaymentGateway` record
|
|
214
|
-
async function
|
|
217
|
+
/** The `commerce.PaymentGateway` record for the card option, if any. */
|
|
218
|
+
async function cardGateway(sr: any): Promise<any | null> {
|
|
215
219
|
const hits = (await sr.entities["commerce.PaymentGateway"].filter(
|
|
216
|
-
{ slug:
|
|
220
|
+
{ slug: CARD_GATEWAY_SLUG },
|
|
217
221
|
undefined,
|
|
218
222
|
1,
|
|
219
223
|
)) ?? [];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* commerce/seed-store default data: settings groups, payment gateways
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* commerce/seed-store default data: settings groups, payment gateways and the
|
|
3
|
+
* fallback Shipping & Tax Location (seeded only when the caller passes no
|
|
4
|
+
* `locations` of their own). All idempotent — the seeder checks for
|
|
5
|
+
* existing records (by group_id / slug / name) before creating.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
/** Per-type email config keys. reset_password/new_account are handled by Base44 auth. */
|
|
@@ -9,6 +10,10 @@ const EMAIL_TYPE_DEFAULTS: Record<string, any> = {
|
|
|
9
10
|
new_order: { enabled: true, subject: "", heading: "", recipient: "" },
|
|
10
11
|
cancelled_order: { enabled: true, subject: "", heading: "", recipient: "" },
|
|
11
12
|
failed_order: { enabled: true, subject: "", heading: "", recipient: "" },
|
|
13
|
+
// Stock alerts sit in the same Settings → Emails list; subject/body are
|
|
14
|
+
// generated, so only the switch and recipient override are configurable.
|
|
15
|
+
low_stock: { enabled: true, recipient: "" },
|
|
16
|
+
out_of_stock: { enabled: true, recipient: "" },
|
|
12
17
|
on_hold_order: { enabled: true, subject: "", heading: "" },
|
|
13
18
|
processing_order: { enabled: true, subject: "", heading: "" },
|
|
14
19
|
completed_order: { enabled: true, subject: "", heading: "" },
|
|
@@ -26,21 +31,18 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
|
|
|
26
31
|
values: {
|
|
27
32
|
// Storefront route customers return to after paying; a mismatch is a 404.
|
|
28
33
|
order_received_path: "/order-received",
|
|
34
|
+
// A value, not a format — prices are rendered with Intl.NumberFormat.
|
|
29
35
|
currency: "USD",
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
decimal_sep: ".",
|
|
33
|
-
num_decimals: 2,
|
|
36
|
+
weight_unit: "kg", // kg | g | lbs | oz
|
|
37
|
+
dimension_unit: "cm", // m | cm | mm | in | yd
|
|
34
38
|
},
|
|
35
39
|
},
|
|
36
40
|
{
|
|
37
41
|
group_id: "products",
|
|
38
42
|
values: {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
only_verified_reviews: false,
|
|
43
|
-
review_rating_required: true,
|
|
43
|
+
// The one review switch the server enforces; every other review policy
|
|
44
|
+
// (login-gated, verified-only, required rating) belongs to the storefront
|
|
45
|
+
// UI — see .agents/skills/commerce/references/reviews.md.
|
|
44
46
|
auto_approve_reviews: false,
|
|
45
47
|
},
|
|
46
48
|
},
|
|
@@ -49,9 +51,6 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
|
|
|
49
51
|
values: {
|
|
50
52
|
manage_stock: true,
|
|
51
53
|
hold_stock_minutes: 60,
|
|
52
|
-
notify_low_stock: true,
|
|
53
|
-
notify_out_of_stock: true,
|
|
54
|
-
notification_recipient: "",
|
|
55
54
|
low_stock_threshold: 2,
|
|
56
55
|
out_of_stock_threshold: 0,
|
|
57
56
|
hide_out_of_stock: false,
|
|
@@ -62,7 +61,6 @@ export const SETTINGS_DEFAULTS: Array<{ group_id: string; values: Record<string,
|
|
|
62
61
|
values: {
|
|
63
62
|
prices_include_tax: false,
|
|
64
63
|
tax_based_on: "shipping", // shipping | billing
|
|
65
|
-
shipping_tax_class: "inherit", // inherit | standard | <class slug>
|
|
66
64
|
display_prices_shop: "excl", // incl | excl
|
|
67
65
|
display_prices_cart: "excl",
|
|
68
66
|
},
|
|
@@ -98,39 +96,34 @@ export const GATEWAY_DEFAULTS = [
|
|
|
98
96
|
settings: { account_details: [] as any[] },
|
|
99
97
|
},
|
|
100
98
|
{
|
|
101
|
-
slug: "
|
|
99
|
+
slug: "card",
|
|
102
100
|
title: "Credit card",
|
|
103
101
|
description: "Pay securely by credit card.",
|
|
104
|
-
// Enabled
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// here cannot strand a shopper on an unusable option.
|
|
102
|
+
// Enabled means offered: the storefront lists every enabled gateway. Until
|
|
103
|
+
// shared/commerce/card-payment.ts is implemented, picking this at checkout
|
|
104
|
+
// answers 503 no_card_payment_provider — switch it off to hide it instead.
|
|
108
105
|
enabled: true,
|
|
109
106
|
order: 1,
|
|
110
|
-
method_title: "
|
|
111
|
-
method_description:
|
|
112
|
-
|
|
113
|
-
settings: { connector: "stripe" },
|
|
107
|
+
method_title: "Credit card",
|
|
108
|
+
method_description: "Card payment on a provider-hosted page.",
|
|
109
|
+
settings: {},
|
|
114
110
|
},
|
|
115
111
|
];
|
|
116
112
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
export const REST_OF_WORLD_ZONE = {
|
|
113
|
+
/**
|
|
114
|
+
* Fallback Shipping & Tax Location so a fresh store can sell anywhere: one
|
|
115
|
+
* free shipping rate, the default 'Products' tax group with no rates (no tax),
|
|
116
|
+
* no shipping tax. Admins refine it in Settings → Shipping & Tax.
|
|
117
|
+
*/
|
|
118
|
+
export const REST_OF_WORLD_LOCATION = {
|
|
124
119
|
name: "Rest of the world",
|
|
125
120
|
order: 999,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
order: 0,
|
|
135
|
-
settings: { cost: 0, tax_status: "taxable", class_costs: [], no_class_cost: 0, calculation_type: "class" },
|
|
121
|
+
regions: [] as any[],
|
|
122
|
+
shipping_rates: [
|
|
123
|
+
{ id: "rest-of-world-standard", name: "Standard delivery", cost: 0, free_over: null },
|
|
124
|
+
],
|
|
125
|
+
tax_groups: [
|
|
126
|
+
{ name: "Products", rates: [] as any[] },
|
|
127
|
+
],
|
|
128
|
+
shipping_tax: null,
|
|
136
129
|
};
|