@base44/app-plugin-commerce 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +84 -31
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +76 -41
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -191
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +4 -5
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
|
@@ -69,8 +69,8 @@ export function eligibleLines(coupon: any, lines: CouponLineInput[]): CouponLine
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* The
|
|
73
|
-
* Error codes: not_found | expired | usage_limit_reached |
|
|
72
|
+
* The 10 ordered validation checks. Pass `coupon = null` for a lookup miss.
|
|
73
|
+
* Error codes: not_found | disabled | expired | usage_limit_reached |
|
|
74
74
|
* usage_limit_per_user_reached | individual_use | min_amount_not_met |
|
|
75
75
|
* max_amount_exceeded | not_applicable | sale_items_excluded | email_restricted
|
|
76
76
|
*/
|
|
@@ -78,17 +78,23 @@ export function validateCoupon(coupon: any | null, ctx: CouponValidationCtx): Co
|
|
|
78
78
|
// 1. exists
|
|
79
79
|
if (!coupon) return { valid: false, code: "not_found", error: "Coupon does not exist." };
|
|
80
80
|
|
|
81
|
-
// 2.
|
|
81
|
+
// 2. enabled — a disabled coupon is unusable everywhere: apply-coupon rejects
|
|
82
|
+
// it, and carts already holding it drop it on the next re-price.
|
|
83
|
+
if (coupon.enabled === false) {
|
|
84
|
+
return { valid: false, code: "disabled", error: "This coupon is disabled." };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. expiry
|
|
82
88
|
if (coupon.date_expires && new Date(coupon.date_expires).getTime() < Date.now()) {
|
|
83
89
|
return { valid: false, code: "expired", error: "This coupon has expired." };
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
//
|
|
92
|
+
// 4. global usage limit
|
|
87
93
|
if (coupon.usage_limit != null && (coupon.usage_count ?? 0) >= coupon.usage_limit) {
|
|
88
94
|
return { valid: false, code: "usage_limit_reached", error: "Coupon usage limit has been reached." };
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
//
|
|
97
|
+
// 5. per-user usage limit (usages recorded in used_by, one entry per use)
|
|
92
98
|
if (coupon.usage_limit_per_user != null && ctx.customerEmail) {
|
|
93
99
|
const uses = (coupon.used_by || []).filter(
|
|
94
100
|
(e: string) => (e || "").toLowerCase() === ctx.customerEmail!.toLowerCase(),
|
|
@@ -98,7 +104,7 @@ export function validateCoupon(coupon: any | null, ctx: CouponValidationCtx): Co
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
//
|
|
107
|
+
// 6. individual use — both directions
|
|
102
108
|
const others = (ctx.appliedCoupons || []).filter((c) => c.code !== coupon.code);
|
|
103
109
|
if (coupon.individual_use && others.length) {
|
|
104
110
|
return { valid: false, code: "individual_use", error: "This coupon cannot be used in conjunction with other coupons." };
|
|
@@ -107,7 +113,7 @@ export function validateCoupon(coupon: any | null, ctx: CouponValidationCtx): Co
|
|
|
107
113
|
return { valid: false, code: "individual_use", error: "An applied coupon does not allow other coupons." };
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
//
|
|
116
|
+
// 7. min / max spend (vs items subtotal, ex tax)
|
|
111
117
|
if (coupon.minimum_amount != null && coupon.minimum_amount > 0 && ctx.itemsSubtotal < coupon.minimum_amount) {
|
|
112
118
|
return { valid: false, code: "min_amount_not_met", error: `The minimum spend for this coupon is ${coupon.minimum_amount}.` };
|
|
113
119
|
}
|
|
@@ -115,7 +121,7 @@ export function validateCoupon(coupon: any | null, ctx: CouponValidationCtx): Co
|
|
|
115
121
|
return { valid: false, code: "max_amount_exceeded", error: `The maximum spend for this coupon is ${coupon.maximum_amount}.` };
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
//
|
|
124
|
+
// 8. product/category restrictions must leave something to discount
|
|
119
125
|
const restricted =
|
|
120
126
|
(coupon.product_ids || []).length || (coupon.excluded_product_ids || []).length ||
|
|
121
127
|
(coupon.product_category_ids || []).length || (coupon.excluded_product_category_ids || []).length;
|
|
@@ -124,12 +130,12 @@ export function validateCoupon(coupon: any | null, ctx: CouponValidationCtx): Co
|
|
|
124
130
|
return { valid: false, code: "not_applicable", error: "Sorry, this coupon is not applicable to your cart contents." };
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
//
|
|
133
|
+
// 9. exclude_sale_items must still leave eligible lines
|
|
128
134
|
if (coupon.exclude_sale_items && withoutSaleFilter.length && !eligibleLines(coupon, ctx.lines).length) {
|
|
129
135
|
return { valid: false, code: "sale_items_excluded", error: "Sorry, this coupon is not valid for sale items." };
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
//
|
|
138
|
+
// 10. email restrictions
|
|
133
139
|
if ((coupon.email_restrictions || []).length) {
|
|
134
140
|
if (!ctx.customerEmail || !emailMatchesRestriction(ctx.customerEmail, coupon.email_restrictions)) {
|
|
135
141
|
return { valid: false, code: "email_restricted", error: "This coupon is not valid for your email address." };
|
|
@@ -150,25 +150,35 @@ export async function sendOrderEmail(
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* Low/out-of-stock notifications to the
|
|
153
|
+
* Low/out-of-stock notifications to the store operator
|
|
154
154
|
* (kind: "low_stock" | "out_of_stock" | "backorder").
|
|
155
|
+
*
|
|
156
|
+
* Configured per type in the `emails` group like every other email in
|
|
157
|
+
* Settings → Emails (`emails.low_stock.enabled`, `emails.low_stock.recipient`).
|
|
155
158
|
*/
|
|
156
159
|
export async function sendStockEmail(sr: any, kind: string, product: any, opts: { settings?: Record<string, any> } = {}): Promise<void> {
|
|
157
160
|
try {
|
|
158
161
|
const settings = opts.settings ?? (await getSettings(sr, "inventory", "emails", "general"));
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
const em = settings.emails ?? {};
|
|
163
|
+
const kindCfg = em[kind] ?? {};
|
|
164
|
+
if (kindCfg.enabled === false) return;
|
|
165
|
+
|
|
166
|
+
// Same chain as admin order emails; comma-separated lists reach every
|
|
167
|
+
// address, not just the first.
|
|
168
|
+
const configured = [
|
|
169
|
+
toAddresses(kindCfg.recipient),
|
|
170
|
+
toAddresses(em.admin_recipients),
|
|
171
|
+
].find((list) => list.length);
|
|
172
|
+
const recipients = dedupeByAddress(
|
|
173
|
+
(configured ?? (await adminUserEmails(sr))).map((to) => ({ to, target: "admin" as const })),
|
|
174
|
+
);
|
|
175
|
+
if (!recipients.length) {
|
|
166
176
|
await logEmail(sr, {
|
|
167
177
|
type: kind,
|
|
168
178
|
recipient: "",
|
|
169
179
|
target: "admin",
|
|
170
180
|
success: false,
|
|
171
|
-
error: "no recipient — no
|
|
181
|
+
error: "no recipient — no stock notification recipient, no admin recipients, and no app user has the admin role",
|
|
172
182
|
});
|
|
173
183
|
return;
|
|
174
184
|
}
|
|
@@ -182,15 +192,17 @@ export async function sendStockEmail(sr: any, kind: string, product: any, opts:
|
|
|
182
192
|
const subject = `${storeName ? `[${storeName}] ` : ""}Product ${labels[kind] ?? kind}: ${product.name}`;
|
|
183
193
|
const body = `<p><strong>${product.name}</strong> (${product.sku || "no SKU"}) ${labels[kind] ?? kind}.</p>
|
|
184
194
|
<p>Remaining stock: ${product.stock_quantity ?? "n/a"}</p>`;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
for (const { to } of recipients) {
|
|
196
|
+
try {
|
|
197
|
+
await sr.integrations.Core.SendEmail({ to, subject, body, from_name: storeName || undefined });
|
|
198
|
+
await logEmail(sr, { type: kind, recipient: to, target: "admin", subject, success: true });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
await logEmail(sr, {
|
|
201
|
+
type: kind, recipient: to, target: "admin", subject,
|
|
202
|
+
success: false, error: String((e as Error)?.message ?? e),
|
|
203
|
+
});
|
|
204
|
+
console.error(`sendStockEmail(${kind}) to ${to} failed:`, e);
|
|
205
|
+
}
|
|
194
206
|
}
|
|
195
207
|
} catch (e) {
|
|
196
208
|
console.error(`sendStockEmail(${kind}) failed:`, e);
|
|
@@ -35,32 +35,19 @@ export function distributeProportionally(total: number, weights: number[]): numb
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Format an amount
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* Format an amount in the store currency (`general.currency`). Formatting is
|
|
39
|
+
* localization's job, not configuration: `Intl.NumberFormat` renders the
|
|
40
|
+
* symbol, separators and decimal count for the currency — there are no
|
|
41
|
+
* position/separator settings to keep in sync.
|
|
41
42
|
*/
|
|
42
43
|
export function formatMoney(amount: number, general: Record<string, unknown> = {}): string {
|
|
43
44
|
const code = String(general.currency ?? "USD");
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const negative = amount < 0;
|
|
52
|
-
const fixed = Math.abs(round2(amount)).toFixed(decimals);
|
|
53
|
-
const [intPart, fracPart] = fixed.split(".");
|
|
54
|
-
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep);
|
|
55
|
-
const num = fracPart ? `${grouped}${decimalSep}${fracPart}` : grouped;
|
|
56
|
-
|
|
57
|
-
let out: string;
|
|
58
|
-
switch (position) {
|
|
59
|
-
case "right": out = `${num}${symbol}`; break;
|
|
60
|
-
case "left_space": out = `${symbol} ${num}`; break;
|
|
61
|
-
case "right_space": out = `${num} ${symbol}`; break;
|
|
62
|
-
case "left":
|
|
63
|
-
default: out = `${symbol}${num}`;
|
|
45
|
+
const value = round2(Number(amount) || 0);
|
|
46
|
+
try {
|
|
47
|
+
return new Intl.NumberFormat(undefined, { style: "currency", currency: code }).format(value);
|
|
48
|
+
} catch {
|
|
49
|
+
// Unknown/custom currency code: fall back to a plain "CODE 12.34".
|
|
50
|
+
const currency = CURRENCIES.find((c) => c.code === code);
|
|
51
|
+
return `${currency?.symbol ?? code} ${value.toFixed(currency?.decimals ?? 2)}`;
|
|
64
52
|
}
|
|
65
|
-
return negative ? `-${out}` : out;
|
|
66
53
|
}
|
|
@@ -1,171 +1,34 @@
|
|
|
1
|
-
// Deploy marker: 1
|
|
2
|
-
// Bump after connecting a payment provider: env vars are injected at deploy time,
|
|
3
|
-
// and editing this file redeploys every commerce/* function.
|
|
4
|
-
// See skills/commerce/references/online-payments.md §2.
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
2
|
+
* Card payment plumbing — the premade order-side half of online payments.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* webhook and refunds all call the operations below; the admin UI talks about
|
|
11
|
-
* "an online payment provider", not a brand.
|
|
4
|
+
* The provider-specific half lives in exactly two files an agent implements
|
|
5
|
+
* when wiring a provider (see `.agents/skills/commerce/references/online-payments.md`):
|
|
12
6
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* payment
|
|
7
|
+
* 1. `shared/commerce/card-payment.ts` — create a payment page, check it was
|
|
8
|
+
* paid, refund it.
|
|
9
|
+
* 2. `functions/commerce/payment-webhook/entry.ts` — parse the provider's
|
|
10
|
+
* server-to-server event.
|
|
16
11
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Credentials always come from the host — an injected secret (Base44 puts the
|
|
23
|
-
* Stripe keys in `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` once Stripe is
|
|
24
|
-
* set up for the app) or a connector — never from the `commerce.PaymentGateway`
|
|
25
|
-
* entity and never from the client.
|
|
12
|
+
* Everything here — return URLs, storing the payment reference on the order,
|
|
13
|
+
* idempotent confirmation that moves the order to processing, refund routing —
|
|
14
|
+
* is already wired into checkout, the payments function, the webhook and admin
|
|
15
|
+
* refunds. None of it needs touching to add a provider.
|
|
26
16
|
*/
|
|
27
17
|
import { HttpError } from "./auth.ts";
|
|
28
18
|
import { round2 } from "./money.ts";
|
|
29
19
|
import { transitionOrder } from "./orders.ts";
|
|
30
|
-
import
|
|
31
|
-
|
|
32
|
-
/** A hosted-payment session: the page a customer pays on. */
|
|
33
|
-
export interface PaymentSession {
|
|
34
|
-
id: string;
|
|
35
|
-
url: string;
|
|
36
|
-
/** "paid" once the provider has the money. */
|
|
37
|
-
paid: boolean;
|
|
38
|
-
/** Provider's payment/charge id, stored on the order for refunds. */
|
|
39
|
-
payment_reference: string;
|
|
40
|
-
expires_at?: number;
|
|
41
|
-
/** The order this session was opened for, as the provider recorded it. */
|
|
42
|
-
order_id: string;
|
|
43
|
-
order_key: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface PaymentConnection {
|
|
47
|
-
connected: boolean;
|
|
48
|
-
error?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** What a provider implementation has to offer. Keep these signatures. */
|
|
52
|
-
export interface PaymentAdapter {
|
|
53
|
-
/** Machine id (e.g. "stripe"); also the connector name on hosts that use one. */
|
|
54
|
-
id: string;
|
|
55
|
-
/** Human label — the only place a provider brand belongs in the backend. */
|
|
56
|
-
label: string;
|
|
57
|
-
/** The `commerce.PaymentGateway` slug this provider backs. */
|
|
58
|
-
gatewaySlug: string;
|
|
59
|
-
connection(sr: any): Promise<PaymentConnection>;
|
|
60
|
-
createSession(sr: any, order: any, opts: {
|
|
61
|
-
successUrl: string;
|
|
62
|
-
cancelUrl: string;
|
|
63
|
-
customerEmail?: string;
|
|
64
|
-
}): Promise<PaymentSession>;
|
|
65
|
-
retrieveSession(sr: any, sessionId: string): Promise<PaymentSession>;
|
|
66
|
-
/**
|
|
67
|
-
* Invalidate a previously issued payment page, if the provider supports it, so
|
|
68
|
-
* an old link can't be paid after the order changed. Best-effort.
|
|
69
|
-
*/
|
|
70
|
-
expireSession?(sr: any, sessionId: string): Promise<boolean>;
|
|
71
|
-
refund(sr: any, opts: {
|
|
72
|
-
paymentReference: string;
|
|
73
|
-
amount: number;
|
|
74
|
-
currency: string;
|
|
75
|
-
reason?: string;
|
|
76
|
-
idempotencyKey?: string;
|
|
77
|
-
}): Promise<{ id: string; status: string; amount: number }>;
|
|
78
|
-
/** True when the raw webhook body really came from the provider. */
|
|
79
|
-
verifyWebhook(opts: { payload: string; header: string; secret: string }): Promise<boolean>;
|
|
80
|
-
/** Pull the fields the order lifecycle needs out of a webhook body. */
|
|
81
|
-
parseWebhookEvent(body: any): {
|
|
82
|
-
type: string;
|
|
83
|
-
paid: boolean;
|
|
84
|
-
order_id: string;
|
|
85
|
-
order_key: string;
|
|
86
|
-
session_id: string;
|
|
87
|
-
payment_reference: string;
|
|
88
|
-
};
|
|
89
|
-
}
|
|
20
|
+
import { checkCardPaymentPaid, createCardPayment, refundCardPayment } from "./card-payment.ts";
|
|
90
21
|
|
|
91
|
-
|
|
22
|
+
/** The one `commerce.PaymentGateway` slug that pays online; all others are manual. */
|
|
23
|
+
export const CARD_GATEWAY_SLUG = "card";
|
|
92
24
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
url: session.url,
|
|
97
|
-
paid: session.payment_status === "paid",
|
|
98
|
-
payment_reference: session.payment_intent,
|
|
99
|
-
expires_at: session.expires_at,
|
|
100
|
-
order_id: session.order_id,
|
|
101
|
-
order_key: session.order_key,
|
|
102
|
-
};
|
|
25
|
+
/** Does this gateway slug mean "pay by card on the provider's hosted page"? */
|
|
26
|
+
export function isCardGateway(slug: string): boolean {
|
|
27
|
+
return String(slug || "") === CARD_GATEWAY_SLUG;
|
|
103
28
|
}
|
|
104
29
|
|
|
105
|
-
|
|
106
|
-
id: "stripe",
|
|
107
|
-
label: "Stripe",
|
|
108
|
-
gatewaySlug: "stripe",
|
|
109
|
-
|
|
110
|
-
async connection(sr) {
|
|
111
|
-
// Verified, not just present: a disconnected or rotated key must stop
|
|
112
|
-
// reading as connected, or the store advertises card payment it can't take.
|
|
113
|
-
const check = await stripe.verifyStripeCredential(sr);
|
|
114
|
-
return { connected: check.ok, error: check.error };
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
async createSession(sr, order, opts) {
|
|
118
|
-
return toPaymentSession(await stripe.createCheckoutSession(sr, order, opts));
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
async retrieveSession(sr, sessionId) {
|
|
122
|
-
return toPaymentSession(await stripe.retrieveCheckoutSession(sr, sessionId));
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
expireSession(sr, sessionId) {
|
|
126
|
-
return stripe.expireCheckoutSession(sr, sessionId);
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
refund(sr, opts) {
|
|
130
|
-
return stripe.createStripeRefund(sr, {
|
|
131
|
-
paymentIntentId: opts.paymentReference,
|
|
132
|
-
amount: opts.amount,
|
|
133
|
-
currency: opts.currency,
|
|
134
|
-
reason: opts.reason,
|
|
135
|
-
idempotencyKey: opts.idempotencyKey,
|
|
136
|
-
});
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
verifyWebhook(opts) {
|
|
140
|
-
return stripe.verifyWebhookSignature(opts);
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
parseWebhookEvent(body) {
|
|
144
|
-
const type = String(body?.type ?? "");
|
|
145
|
-
const object = body?.data?.object ?? {};
|
|
146
|
-
const metadata = object.metadata ?? {};
|
|
147
|
-
const paid = type === "checkout.session.completed"
|
|
148
|
-
? object.payment_status === "paid"
|
|
149
|
-
: type === "payment_intent.succeeded";
|
|
150
|
-
return {
|
|
151
|
-
type,
|
|
152
|
-
paid,
|
|
153
|
-
order_id: String(metadata.order_id ?? ""),
|
|
154
|
-
order_key: String(metadata.order_key ?? ""),
|
|
155
|
-
session_id: type.startsWith("checkout.session") ? String(object.id ?? "") : "",
|
|
156
|
-
payment_reference: type.startsWith("checkout.session")
|
|
157
|
-
? String(typeof object.payment_intent === "string" ? object.payment_intent : object.payment_intent?.id ?? "")
|
|
158
|
-
: String(object.id ?? ""),
|
|
159
|
-
};
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
/** ← The one line to change when moving to another provider. */
|
|
164
|
-
export const ACTIVE_PROVIDER: PaymentAdapter = stripeAdapter;
|
|
30
|
+
// ── order bookkeeping ────────────────────────────────────────────────────────
|
|
165
31
|
|
|
166
|
-
// ── order bookkeeping (provider-neutral meta keys) ───────────────────────────
|
|
167
|
-
|
|
168
|
-
export const SESSION_META_KEY = "_payment_session_id";
|
|
169
32
|
export const REFERENCE_META_KEY = "_payment_reference";
|
|
170
33
|
|
|
171
34
|
export function orderMeta(order: any, key: string): string {
|
|
@@ -181,6 +44,11 @@ export function withOrderMeta(order: any, entries: Record<string, string>): any[
|
|
|
181
44
|
return meta;
|
|
182
45
|
}
|
|
183
46
|
|
|
47
|
+
/** Paid enough to stop asking for money? */
|
|
48
|
+
export function isOrderPaid(order: any): boolean {
|
|
49
|
+
return Boolean(order?.date_paid) || ["processing", "completed", "refunded"].includes(order?.status);
|
|
50
|
+
}
|
|
51
|
+
|
|
184
52
|
// ── return URLs ──────────────────────────────────────────────────────────────
|
|
185
53
|
|
|
186
54
|
/** An absolute http(s) URL — what every provider requires for its return URLs. */
|
|
@@ -290,7 +158,7 @@ export function resolveReturnUrls(opts: {
|
|
|
290
158
|
* confirmed payment state plus the outcome the provider claims in the URL.
|
|
291
159
|
*
|
|
292
160
|
* The URL is only ever a hint (`?payment=success` is editable by anyone), so
|
|
293
|
-
* `paid` — the answer
|
|
161
|
+
* `paid` — the confirmed answer — always wins over it.
|
|
294
162
|
*/
|
|
295
163
|
export function paymentReturnState(input: { paid: boolean; outcome?: string }):
|
|
296
164
|
"paid" | "cancelled" | "unpaid" {
|
|
@@ -298,141 +166,54 @@ export function paymentReturnState(input: { paid: boolean; outcome?: string }):
|
|
|
298
166
|
return input.outcome === "cancel" ? "cancelled" : "unpaid";
|
|
299
167
|
}
|
|
300
168
|
|
|
301
|
-
// ──
|
|
302
|
-
|
|
303
|
-
/** Does this gateway slug mean "pay online with the configured provider"? */
|
|
304
|
-
export function isOnlineGateway(slug: string): boolean {
|
|
305
|
-
return String(slug || "") === ACTIVE_PROVIDER.gatewaySlug;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Whether the store can actually take an online payment right now.
|
|
310
|
-
*
|
|
311
|
-
* A *live fact about the host's payment setup*, never a constant: surface it from
|
|
312
|
-
* here so a "no payment provider connected" state disappears by itself once the
|
|
313
|
-
* provider is set up.
|
|
314
|
-
*/
|
|
315
|
-
export async function onlinePaymentStatus(sr: any): Promise<{
|
|
316
|
-
provider: string;
|
|
317
|
-
provider_label: string;
|
|
318
|
-
gateway_slug: string;
|
|
319
|
-
connected: boolean;
|
|
320
|
-
error?: string;
|
|
321
|
-
}> {
|
|
322
|
-
const conn = await ACTIVE_PROVIDER.connection(sr);
|
|
323
|
-
return {
|
|
324
|
-
provider: ACTIVE_PROVIDER.id,
|
|
325
|
-
provider_label: ACTIVE_PROVIDER.label,
|
|
326
|
-
gateway_slug: ACTIVE_PROVIDER.gatewaySlug,
|
|
327
|
-
connected: conn.connected,
|
|
328
|
-
...(conn.error ? { error: conn.error } : {}),
|
|
329
|
-
};
|
|
330
|
-
}
|
|
169
|
+
// ── operations (premade; they call the card-payment.ts stubs) ────────────────
|
|
331
170
|
|
|
332
171
|
/**
|
|
333
|
-
* Start (or
|
|
334
|
-
* customer to. Used by checkout right after the order is created **and** by
|
|
335
|
-
* admin
|
|
336
|
-
* so both routes behave identically.
|
|
172
|
+
* Start (or restart) card payment for an order: returns the hosted page to send
|
|
173
|
+
* the customer to. Used by checkout right after the order is created **and** by
|
|
174
|
+
* the admin's "payment link" — same flow either way.
|
|
337
175
|
*
|
|
338
|
-
* The
|
|
339
|
-
*
|
|
176
|
+
* The payment reference is stored on the order so the return handler, the
|
|
177
|
+
* webhook and refunds can name the payment without trusting the client.
|
|
340
178
|
*/
|
|
341
|
-
export async function
|
|
179
|
+
export async function startCardPayment(sr: any, order: any, opts: {
|
|
342
180
|
successUrl: string;
|
|
343
181
|
cancelUrl: string;
|
|
344
182
|
customerEmail?: string;
|
|
345
|
-
}): Promise<{
|
|
183
|
+
}): Promise<{ url: string; reference: string }> {
|
|
346
184
|
if (isOrderPaid(order)) {
|
|
347
185
|
throw new HttpError(409, "This order is already paid.", "already_paid");
|
|
348
186
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// edited, an older link would otherwise still be payable at the old total.
|
|
352
|
-
const previousSession = orderMeta(order, SESSION_META_KEY);
|
|
353
|
-
if (previousSession && ACTIVE_PROVIDER.expireSession) {
|
|
354
|
-
try {
|
|
355
|
-
await ACTIVE_PROVIDER.expireSession(sr, previousSession);
|
|
356
|
-
} catch {
|
|
357
|
-
// already completed or expired — nothing to do
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const session = await ACTIVE_PROVIDER.createSession(sr, order, opts);
|
|
362
|
-
if (!session.url) {
|
|
187
|
+
const page = await createCardPayment(sr, order, opts);
|
|
188
|
+
if (!page?.url) {
|
|
363
189
|
throw new HttpError(502, "The payment provider did not return a payment page.", "payment_session_failed");
|
|
364
190
|
}
|
|
365
191
|
await sr.entities["commerce.Order"].update(order.id, {
|
|
366
|
-
meta_data: withOrderMeta(order, {
|
|
367
|
-
[SESSION_META_KEY]: session.id,
|
|
368
|
-
[REFERENCE_META_KEY]: session.payment_reference,
|
|
369
|
-
}),
|
|
192
|
+
meta_data: withOrderMeta(order, { [REFERENCE_META_KEY]: page.reference ?? "" }),
|
|
370
193
|
});
|
|
371
|
-
return {
|
|
372
|
-
provider: ACTIVE_PROVIDER.id,
|
|
373
|
-
session_id: session.id,
|
|
374
|
-
url: session.url,
|
|
375
|
-
expires_at: session.expires_at,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Paid enough to stop asking for money? */
|
|
380
|
-
export function isOrderPaid(order: any): boolean {
|
|
381
|
-
return Boolean(order?.date_paid) || ["processing", "completed", "refunded"].includes(order?.status);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Session ids reach us from places the customer controls (a return URL, a webhook
|
|
386
|
-
* body), so without this anyone could pay a cheap order of their own and replay
|
|
387
|
-
* that session against an expensive one — or someone else's.
|
|
388
|
-
*/
|
|
389
|
-
function assertSessionBelongsToOrder(session: PaymentSession, order: any): void {
|
|
390
|
-
const sameOrder = session.order_id && session.order_id === String(order.id);
|
|
391
|
-
const sameKey = !session.order_key || session.order_key === String(order.order_key ?? "");
|
|
392
|
-
if (!sameOrder || !sameKey) {
|
|
393
|
-
throw new HttpError(
|
|
394
|
-
409,
|
|
395
|
-
"That payment belongs to a different order.",
|
|
396
|
-
"session_order_mismatch",
|
|
397
|
-
);
|
|
398
|
-
}
|
|
194
|
+
return { url: page.url, reference: page.reference ?? "" };
|
|
399
195
|
}
|
|
400
196
|
|
|
401
197
|
/**
|
|
402
|
-
* Confirm a payment and move the order on — **idempotent**, so the webhook
|
|
403
|
-
* the customer's return can both run in any order and only the first one
|
|
404
|
-
* effect (no double stock reduction, coupon counting or emails).
|
|
405
|
-
*
|
|
406
|
-
* Whether money arrived is always asked of the provider; `trustedPaid` (a
|
|
407
|
-
* signature-verified event) only stands in when there is no session to fetch.
|
|
198
|
+
* Confirm a card payment and move the order on — **idempotent**, so the webhook
|
|
199
|
+
* and the customer's return can both run in any order and only the first one
|
|
200
|
+
* has an effect (no double stock reduction, coupon counting or emails).
|
|
408
201
|
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
202
|
+
* Whether money arrived is asked of the provider (`checkCardPaymentPaid`);
|
|
203
|
+
* `trustedPaid` — a signature-verified webhook event — stands in for that
|
|
204
|
+
* round trip when the webhook implementation vouches for it.
|
|
411
205
|
*/
|
|
412
|
-
export async function
|
|
413
|
-
|
|
414
|
-
paymentReference?: string;
|
|
206
|
+
export async function confirmCardPayment(sr: any, order: any, opts: {
|
|
207
|
+
reference?: string;
|
|
415
208
|
settings?: Record<string, any>;
|
|
416
209
|
actor?: string;
|
|
417
210
|
trustedPaid?: boolean;
|
|
418
211
|
} = {}): Promise<{ paid: boolean; already_confirmed: boolean; order: any }> {
|
|
419
212
|
if (isOrderPaid(order)) return { paid: true, already_confirmed: true, order };
|
|
420
213
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const storedSessionId = orderMeta(order, SESSION_META_KEY);
|
|
425
|
-
const sessionId = opts.sessionId || storedSessionId;
|
|
426
|
-
if (sessionId) {
|
|
427
|
-
const session = await ACTIVE_PROVIDER.retrieveSession(sr, sessionId);
|
|
428
|
-
// The order's own session was minted for it; one from the request must prove it.
|
|
429
|
-
if (sessionId !== storedSessionId) assertSessionBelongsToOrder(session, order);
|
|
430
|
-
// A payment intent can succeed a moment before its session reads paid.
|
|
431
|
-
paid = session.paid || !!opts.trustedPaid;
|
|
432
|
-
reference = reference || session.payment_reference;
|
|
433
|
-
} else {
|
|
434
|
-
paid = !!opts.trustedPaid;
|
|
435
|
-
}
|
|
214
|
+
const reference = opts.reference || orderMeta(order, REFERENCE_META_KEY) || String(order?.transaction_id ?? "");
|
|
215
|
+
const paid = opts.trustedPaid === true ||
|
|
216
|
+
(reference ? await checkCardPaymentPaid(sr, order, reference) : false);
|
|
436
217
|
if (!paid) return { paid: false, already_confirmed: false, order };
|
|
437
218
|
|
|
438
219
|
if (reference) {
|
|
@@ -445,7 +226,7 @@ export async function confirmOnlinePayment(sr: any, order: any, opts: {
|
|
|
445
226
|
await transitionOrder(sr, order, "processing", {
|
|
446
227
|
settings: opts.settings,
|
|
447
228
|
actor: opts.actor ?? "payment-provider",
|
|
448
|
-
note: `
|
|
229
|
+
note: `Card payment received${reference ? ` (${reference})` : ""}.`,
|
|
449
230
|
});
|
|
450
231
|
return { paid: true, already_confirmed: false, order };
|
|
451
232
|
}
|
|
@@ -453,31 +234,19 @@ export async function confirmOnlinePayment(sr: any, order: any, opts: {
|
|
|
453
234
|
/**
|
|
454
235
|
* Refund money at the provider. Called *before* the local refund record is
|
|
455
236
|
* written, so a failed provider refund never leaves a phantom refund behind.
|
|
456
|
-
* Returns null when the order has no
|
|
237
|
+
* Returns null when the order has no card payment to refund; throws
|
|
238
|
+
* 501 `card_refund_not_implemented` while `refundCardPayment` is a stub.
|
|
457
239
|
*/
|
|
458
|
-
export async function
|
|
240
|
+
export async function refundCardOrder(sr: any, order: any, opts: {
|
|
459
241
|
amount: number;
|
|
460
242
|
reason?: string;
|
|
461
|
-
|
|
462
|
-
}): Promise<{ provider: string; id: string; status: string; amount: number } | null> {
|
|
243
|
+
}): Promise<{ refund_id: string } | null> {
|
|
463
244
|
const reference = order?.transaction_id || orderMeta(order, REFERENCE_META_KEY);
|
|
464
|
-
if (!
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
paymentReference: reference,
|
|
245
|
+
if (!isCardGateway(order?.payment_method) || !reference) return null;
|
|
246
|
+
return await refundCardPayment(sr, order, {
|
|
247
|
+
reference,
|
|
468
248
|
amount: round2(opts.amount),
|
|
469
249
|
currency: String(order.currency || "USD"),
|
|
470
250
|
reason: opts.reason,
|
|
471
|
-
idempotencyKey: opts.idempotencyKey,
|
|
472
251
|
});
|
|
473
|
-
return { provider: ACTIVE_PROVIDER.id, ...refund };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** Webhook plumbing, provider-neutral. */
|
|
477
|
-
export function verifyPaymentWebhook(opts: { payload: string; header: string; secret: string }): Promise<boolean> {
|
|
478
|
-
return ACTIVE_PROVIDER.verifyWebhook(opts);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function parsePaymentWebhook(body: any) {
|
|
482
|
-
return ACTIVE_PROVIDER.parseWebhookEvent(body);
|
|
483
252
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Full-collection scan helper. Base44's filter() is exact-match with a 5k page
|
|
3
3
|
* cap and no total count, so server-side search/aggregation loops pages of 500.
|
|
4
4
|
* Keep `cap` sane — reports over very large stores should move to a
|
|
5
|
-
* materialized stats entity (see skills/commerce/references/limits-and-performance.md).
|
|
5
|
+
* materialized stats entity (see .agents/skills/commerce/references/limits-and-performance.md).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export interface ScanOpts {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* NOTE ON CONCURRENCY: Base44 has no transactions, so nextOrderNumber() is a
|
|
5
5
|
* read-max-then-increment and two simultaneous checkouts could race. The window
|
|
6
6
|
* is tiny and order ids (not numbers) are the primary key; see
|
|
7
|
-
* skills/commerce/references/limits-and-performance.md for mitigations.
|
|
7
|
+
* .agents/skills/commerce/references/limits-and-performance.md for mitigations.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const ORDER_NUMBER_START = 1001;
|