@base44/app-plugin-commerce 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +12 -12
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +5 -6
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -69,8 +69,8 @@ export function eligibleLines(coupon: any, lines: CouponLineInput[]): CouponLine
69
69
  }
70
70
 
71
71
  /**
72
- * The 9 ordered validation checks. Pass `coupon = null` for a lookup miss.
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. expiry
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
- // 3. global usage limit
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
- // 4. per-user usage limit (usages recorded in used_by, one entry per use)
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
- // 5. individual use — both directions
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
- // 6. min / max spend (vs items subtotal, ex tax)
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
- // 7. product/category restrictions must leave something to discount
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
- // 8. exclude_sale_items must still leave eligible lines
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
- // 9. email restrictions
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 inventory recipient
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 inv = settings.inventory ?? {};
160
- if (kind === "low_stock" && inv.notify_low_stock === false) return;
161
- if (kind === "out_of_stock" && inv.notify_out_of_stock === false) return;
162
- const to = toAddresses(inv.notification_recipient)[0]
163
- ?? toAddresses(settings.emails?.admin_recipients)[0]
164
- ?? (await adminUserEmails(sr))[0];
165
- if (!to) {
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 inventory recipient, no admin recipients, and no app user has the admin role",
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
- try {
186
- await sr.integrations.Core.SendEmail({ to, subject, body, from_name: storeName || undefined });
187
- await logEmail(sr, { type: kind, recipient: to, target: "admin", subject, success: true });
188
- } catch (e) {
189
- await logEmail(sr, {
190
- type: kind, recipient: to, target: "admin", subject,
191
- success: false, error: String((e as Error)?.message ?? e),
192
- });
193
- throw e;
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 per the store's `general` settings values:
39
- * {currency, currency_position, thousand_sep, decimal_sep, num_decimals}.
40
- * Positions: left | right | left_space | right_space.
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 currency = CURRENCIES.find((c) => c.code === code);
45
- const symbol = currency?.symbol ?? code;
46
- const decimals = Number(general.num_decimals ?? currency?.decimals ?? 2);
47
- const thousandSep = String(general.thousand_sep ?? ",");
48
- const decimalSep = String(general.decimal_sep ?? ".");
49
- const position = String(general.currency_position ?? "left");
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
- * Online payments — the provider-neutral utility every caller uses.
2
+ * Card payment plumbing — the premade order-side half of online payments.
7
3
  *
8
- * Nothing outside this file (and the adapter it points at) knows which payment
9
- * provider the store uses. Checkout, the payment-link/verify function, the
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
- * **The wiring** is one line — `ACTIVE_PROVIDER`. It is bound to the Stripe
14
- * adapter (`stripe.ts`), which is fully implemented: hosted payment page,
15
- * payment links, return-verification, webhook signature checking and refunds.
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
- * **To use a different provider** (PayPal, Adyen, a local PSP): write an adapter
18
- * that satisfies `PaymentAdapter` and point `ACTIVE_PROVIDER` at it. That is the
19
- * whole change on the backend — no caller, no UI and no entity needs touching.
20
- * See skills/commerce/references/online-payments.md.
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 * as stripe from "./stripe.ts";
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
- // ── the Stripe adapter ───────────────────────────────────────────────────────
22
+ /** The one `commerce.PaymentGateway` slug that pays online; all others are manual. */
23
+ export const CARD_GATEWAY_SLUG = "card";
92
24
 
93
- function toPaymentSession(session: stripe.CheckoutSession): PaymentSession {
94
- return {
95
- id: session.id,
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
- const stripeAdapter: PaymentAdapter = {
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 from the provider — always wins over it.
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
- // ── provider-neutral operations ──────────────────────────────────────────────
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 resume) payment for an order: returns the hosted page to send the
334
- * customer to. Used by checkout right after the order is created **and** by the
335
- * admin to produce a payment link for an unpaid order — same session either way,
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 session id and payment reference are stored on the order so the return
339
- * handler and the webhook can confirm without trusting anything in the URL.
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 startOnlinePayment(sr: any, order: any, opts: {
179
+ export async function startCardPayment(sr: any, order: any, opts: {
342
180
  successUrl: string;
343
181
  cancelUrl: string;
344
182
  customerEmail?: string;
345
- }): Promise<{ provider: string; session_id: string; url: string; expires_at?: number }> {
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
- // Retire the previous payment page before minting a new one: after an order is
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 and
403
- * the customer's return can both run in any order and only the first one has an
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
- * With no `sessionId`, the session stored on the order is used, which is what a
410
- * "check payment status" button in the admin wants.
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 confirmOnlinePayment(sr: any, order: any, opts: {
413
- sessionId?: string;
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
- let paid = false;
422
- let reference = opts.paymentReference ?? "";
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: `Payment received via ${ACTIVE_PROVIDER.label}${reference ? ` (${reference})` : ""}.`,
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 online payment to refund.
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 refundOnlinePayment(sr: any, order: any, opts: {
240
+ export async function refundCardOrder(sr: any, order: any, opts: {
459
241
  amount: number;
460
242
  reason?: string;
461
- idempotencyKey?: string;
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 (!isOnlineGateway(order?.payment_method) || !reference) return null;
465
-
466
- const refund = await ACTIVE_PROVIDER.refund(sr, {
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;