@rovela-ai/sdk 0.19.5 → 0.19.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/admin/components/AdminPageHeader.d.ts.map +1 -1
  2. package/dist/admin/components/AdminPageHeader.js +9 -2
  3. package/dist/admin/components/AdminPageHeader.js.map +1 -1
  4. package/dist/admin/components/AdminSelect.d.ts +11 -0
  5. package/dist/admin/components/AdminSelect.d.ts.map +1 -1
  6. package/dist/admin/components/AdminSelect.js +60 -4
  7. package/dist/admin/components/AdminSelect.js.map +1 -1
  8. package/dist/admin/components/AdminUserMenu.d.ts.map +1 -1
  9. package/dist/admin/components/AdminUserMenu.js +2 -1
  10. package/dist/admin/components/AdminUserMenu.js.map +1 -1
  11. package/dist/admin/components/CustomerDetails.d.ts.map +1 -1
  12. package/dist/admin/components/CustomerDetails.js +2 -1
  13. package/dist/admin/components/CustomerDetails.js.map +1 -1
  14. package/dist/admin/components/CustomerTable.d.ts.map +1 -1
  15. package/dist/admin/components/CustomerTable.js +1 -1
  16. package/dist/admin/components/CustomerTable.js.map +1 -1
  17. package/dist/admin/components/EmailsManager.d.ts.map +1 -1
  18. package/dist/admin/components/EmailsManager.js +2 -1
  19. package/dist/admin/components/EmailsManager.js.map +1 -1
  20. package/dist/admin/components/LowStockAlert.d.ts.map +1 -1
  21. package/dist/admin/components/LowStockAlert.js +3 -2
  22. package/dist/admin/components/LowStockAlert.js.map +1 -1
  23. package/dist/admin/components/OrderDetails.d.ts.map +1 -1
  24. package/dist/admin/components/OrderDetails.js +2 -1
  25. package/dist/admin/components/OrderDetails.js.map +1 -1
  26. package/dist/admin/components/OrderTable.d.ts.map +1 -1
  27. package/dist/admin/components/OrderTable.js +1 -1
  28. package/dist/admin/components/OrderTable.js.map +1 -1
  29. package/dist/admin/components/ProductTable.d.ts.map +1 -1
  30. package/dist/admin/components/ProductTable.js +4 -3
  31. package/dist/admin/components/ProductTable.js.map +1 -1
  32. package/dist/admin/components/RecentOrders.d.ts.map +1 -1
  33. package/dist/admin/components/RecentOrders.js +2 -1
  34. package/dist/admin/components/RecentOrders.js.map +1 -1
  35. package/dist/admin/components/SEOPreview.d.ts +2 -8
  36. package/dist/admin/components/SEOPreview.d.ts.map +1 -1
  37. package/dist/admin/components/SEOPreview.js +32 -5
  38. package/dist/admin/components/SEOPreview.js.map +1 -1
  39. package/dist/admin/components/SecondaryMetricsRow.d.ts.map +1 -1
  40. package/dist/admin/components/SecondaryMetricsRow.js +3 -2
  41. package/dist/admin/components/SecondaryMetricsRow.js.map +1 -1
  42. package/dist/admin/components/StatsCards.d.ts.map +1 -1
  43. package/dist/admin/components/StatsCards.js +2 -1
  44. package/dist/admin/components/StatsCards.js.map +1 -1
  45. package/dist/admin/components/UsersTable.d.ts.map +1 -1
  46. package/dist/admin/components/UsersTable.js +1 -1
  47. package/dist/admin/components/UsersTable.js.map +1 -1
  48. package/dist/admin/index.d.ts +2 -2
  49. package/dist/admin/index.d.ts.map +1 -1
  50. package/dist/admin/index.js +6 -1
  51. package/dist/admin/index.js.map +1 -1
  52. package/dist/admin/styles/admin-theme.css +20 -0
  53. package/dist/cart/CartProvider.d.ts.map +1 -1
  54. package/dist/cart/CartProvider.js +31 -8
  55. package/dist/cart/CartProvider.js.map +1 -1
  56. package/dist/cart/store.d.ts +24 -4
  57. package/dist/cart/store.d.ts.map +1 -1
  58. package/dist/cart/store.js +24 -4
  59. package/dist/cart/store.js.map +1 -1
  60. package/dist/checkout/api/manual.d.ts.map +1 -1
  61. package/dist/checkout/api/manual.js +9 -4
  62. package/dist/checkout/api/manual.js.map +1 -1
  63. package/dist/checkout/components/CheckoutFlow.d.ts.map +1 -1
  64. package/dist/checkout/components/CheckoutFlow.js +19 -6
  65. package/dist/checkout/components/CheckoutFlow.js.map +1 -1
  66. package/dist/checkout/components/CheckoutSuccess.js +1 -1
  67. package/dist/checkout/components/CheckoutSuccess.js.map +1 -1
  68. package/dist/checkout/components/OrderTimeline.d.ts +5 -1
  69. package/dist/checkout/components/OrderTimeline.d.ts.map +1 -1
  70. package/dist/checkout/components/OrderTimeline.js +18 -5
  71. package/dist/checkout/components/OrderTimeline.js.map +1 -1
  72. package/dist/checkout/components/ShippingForm.d.ts +1 -1
  73. package/dist/checkout/components/ShippingForm.d.ts.map +1 -1
  74. package/dist/checkout/components/ShippingForm.js +15 -11
  75. package/dist/checkout/components/ShippingForm.js.map +1 -1
  76. package/dist/checkout/components/ShippingOptions.js +1 -1
  77. package/dist/checkout/components/ShippingOptions.js.map +1 -1
  78. package/dist/checkout/server/create-checkout-session.d.ts.map +1 -1
  79. package/dist/checkout/server/create-checkout-session.js +213 -219
  80. package/dist/checkout/server/create-checkout-session.js.map +1 -1
  81. package/dist/checkout/server/pricing.d.ts +16 -0
  82. package/dist/checkout/server/pricing.d.ts.map +1 -1
  83. package/dist/checkout/server/pricing.js +16 -0
  84. package/dist/checkout/server/pricing.js.map +1 -1
  85. package/dist/checkout/types.d.ts +15 -0
  86. package/dist/checkout/types.d.ts.map +1 -1
  87. package/dist/core/db/index.d.ts +2 -2
  88. package/dist/core/db/index.d.ts.map +1 -1
  89. package/dist/core/db/index.js +4 -0
  90. package/dist/core/db/index.js.map +1 -1
  91. package/dist/core/db/queries.d.ts +35 -35
  92. package/dist/core/db/queries.d.ts.map +1 -1
  93. package/dist/core/db/queries.js +24 -0
  94. package/dist/core/db/queries.js.map +1 -1
  95. package/dist/core/db/schema.d.ts +5 -5
  96. package/dist/core/i18n/messages/en.d.ts.map +1 -1
  97. package/dist/core/i18n/messages/en.js +2 -0
  98. package/dist/core/i18n/messages/en.js.map +1 -1
  99. package/dist/core/i18n/messages/fr.d.ts.map +1 -1
  100. package/dist/core/i18n/messages/fr.js +2 -0
  101. package/dist/core/i18n/messages/fr.js.map +1 -1
  102. package/dist/core/server/index.d.ts +2 -2
  103. package/dist/core/server/index.d.ts.map +1 -1
  104. package/dist/core/server/index.js +4 -0
  105. package/dist/core/server/index.js.map +1 -1
  106. package/docs/changelog/epoch-06-mobile-integrity.md +89 -2
  107. package/package.json +1 -1
  108. package/templates/store-template/app/account/orders/[id]/invoice/page.tsx +3 -1
  109. package/templates/store-template/app/account/orders/[id]/page.tsx +1 -0
  110. package/templates/store-template/app/account/orders/page.tsx +10 -1
  111. package/templates/store-template/app/cart/page.tsx +8 -4
  112. package/templates/store-template/app/checkout/page.tsx +9 -4
  113. package/templates/store-template/package.json +2 -2
@@ -5,9 +5,8 @@
5
5
  * The payment is processed ON the connected account, not the platform.
6
6
  */
7
7
  import { getStripe, getConnectedAccountId, calculateApplicationFee, getStoreCurrency, getAllowedShippingCountries, } from '../stripe/client';
8
- import { findSettings, findActiveShippingZones, findTaxRateForAddress, } from '../../core/db/queries';
9
- import { repriceItems, resolveShippingSelection, PricingError } from './pricing';
10
- import { evaluateDiscounts } from './discounts';
8
+ import { findSettings, findActiveShippingZones, } from '../../core/db/queries';
9
+ import { buildQuote, CHECKOUT_PRICING_MODE } from './pricing';
11
10
  // =============================================================================
12
11
  // Shipping Helpers
13
12
  // =============================================================================
@@ -179,98 +178,71 @@ export async function createCheckoutSession(request, options = {}) {
179
178
  throw new Error('Success and cancel URLs are required');
180
179
  }
181
180
  // ==========================================================================
182
- // SERVER RE-PRICE (pricing core) the client's prices are NOT the source
183
- // of truth for money. Every line is re-priced from the catalog in 'floor'
184
- // mode: a client price ABOVE the catalog price is accepted (configurator /
185
- // add-on surcharges priced client-side); a client price BELOW it rejects
186
- // the checkout (PricingError → 4xx in the route). The Stripe hosted page
187
- // now only DISPLAYS and COLLECTS the amount we computed.
181
+ // THE QUOTEone engine, for every payment path
188
182
  // ==========================================================================
189
- const repriced = await repriceItems(request.items, 'floor');
190
- const items = repriced.lines;
191
- // Subtotal from SERVER-priced lines; application fee follows it.
192
- const subtotal = repriced.subtotalCents;
193
- // Server-validate the pre-selected shipping (manual zone rates are
194
- // re-derived from the rate id the server's price wins; Shippo quotes are
195
- // sanity-bounded). Replaces the client-claimed price everywhere below.
196
- let selectedShipping = request.selectedShipping;
197
- const isPickup = selectedShipping?.source === 'pickup';
198
- let pickupLocationId = null;
199
- if (selectedShipping && isShippingEnabled && collectShippingAddress) {
200
- if (selectedShipping.source !== 'shippo' && !request.shippingAddress?.country) {
201
- // Manual rates can't be re-derived without a destination country
202
- // bound the claimed price like a Shippo quote instead of trusting it.
203
- const claimed = Number(selectedShipping.price);
204
- if (!Number.isFinite(claimed) || claimed < 0 || claimed > 1_000_000) {
205
- throw new Error('Invalid shipping selection');
206
- }
207
- }
208
- else {
209
- const resolved = await resolveShippingSelection({
210
- selection: selectedShipping,
211
- country: request.shippingAddress?.country ?? '',
212
- subtotalCents: subtotal,
213
- groupId: request.customerGroupId ?? null,
214
- });
215
- selectedShipping = {
216
- ...selectedShipping,
217
- price: resolved.shippingCents,
218
- name: selectedShipping.name,
219
- };
220
- // Click & collect: remember the validated location for the webhook.
221
- pickupLocationId = resolved.pickupLocationId ?? null;
222
- }
223
- }
224
- // ==========================================================================
225
- // NATIVE DISCOUNTS (pricing core, step 2) — evaluated in OUR infrastructure
226
- // before Stripe sees anything. The applied amount reaches the hosted page
227
- // as an ephemeral one-off Stripe coupon (display + collection only). An
228
- // entered code that fails validation rejects the checkout (the UI
229
- // pre-validates via /api/checkout/discount, so this is a stale/tampered
230
- // submission).
231
- // ==========================================================================
232
- const discountEvaluation = await evaluateDiscounts({
233
- lines: items,
234
- subtotalCents: subtotal,
235
- categoryByProductId: repriced.categoryByProductId,
236
- code: request.discountCode ?? null,
183
+ // §55: `buildQuote` is the ONLY money source; processors collect, never
184
+ // compute. That was not true. This function used to reimplement the whole
185
+ // pipeline inline its own repricing, its own discount evaluation, its own
186
+ // shipping resolution, its own tax — while the manual (wire transfer) path
187
+ // called `buildQuote`. Two engines, and the drift between them was not
188
+ // theoretical: click & collect fell through into Stripe's shipping flow and
189
+ // charged a collecting shopper for delivery; the tax line was taken on a
190
+ // different base than the tax the order recorded; and the pickup tax base came
191
+ // from whatever address the client sent rather than from the location.
192
+ //
193
+ // From here down, this file is an ADAPTER: Quote → Stripe session. It formats.
194
+ // It does not compute money. The manual path is the same adapter shape:
195
+ // Quote order. One input, one engine, one output, two processors.
196
+ //
197
+ // The one honest concession to a processor: if the caller has NOT resolved
198
+ // shipping, the shopper picks it on Stripe's own page — so Stripe, not us,
199
+ // determines the final total, and the quote cannot be authoritative over it.
200
+ // That is inherent (you cannot be payment-agnostic and let the processor choose
201
+ // shipping), not a second engine: we still quote the goods, the discounts and
202
+ // the tax through `buildQuote`, and simply don't claim the total.
203
+ const shippingResolved = !!request.selectedShipping && shouldCollectShipping;
204
+ const isAuthoritative = shippingResolved || !shouldCollectShipping;
205
+ const quote = await buildQuote({
206
+ items: request.items,
207
+ shippingAddress: request.shippingAddress,
208
+ selectedShipping: shippingResolved ? request.selectedShipping : null,
209
+ pricingMode: CHECKOUT_PRICING_MODE,
210
+ discountCode: request.discountCode ?? null,
237
211
  customerId: request.customerId ?? null,
238
212
  customerGroupId: request.customerGroupId ?? null,
239
213
  customerEmail: request.customerEmail ?? null,
214
+ }, {
215
+ // `shouldCollectShipping` folds in the caller's `collectShippingAddress`
216
+ // option, which the settings row knows nothing about; and when shipping is
217
+ // still unresolved we tell the quote to skip it rather than demand an
218
+ // address the shopper has not been asked for yet.
219
+ settings: storeSettings
220
+ ? { ...storeSettings, shippingEnabled: shippingResolved }
221
+ : null,
240
222
  });
241
- if (request.discountCode && discountEvaluation.codeError) {
242
- throw new PricingError(discountEvaluation.codeError.message);
243
- }
244
- const discountCents = Math.min(discountEvaluation.discountCents, subtotal);
245
- const appliedDiscounts = discountEvaluation.applied;
246
- // A free-shipping discount zeroes the (already validated) shipping price
247
- // while keeping the chosen method — the shipping line renders at 0.
248
- if (discountEvaluation.freeShipping && selectedShipping && selectedShipping.price > 0) {
249
- selectedShipping = { ...selectedShipping, price: 0 };
250
- }
223
+ // Everything below reads the quote. A PricingError (bad code, stale price,
224
+ // invalid shipping selection) propagated out of buildQuote and became a 4xx in
225
+ // the route — never a silent re-price behind the shopper's back.
226
+ const items = quote.lines;
227
+ const subtotal = quote.subtotalCents;
228
+ const discountCents = quote.discountCents;
229
+ const appliedDiscounts = quote.appliedDiscounts;
230
+ const taxIncludedInPrices = quote.taxIncludedInPrices;
231
+ const taxRate = quote.taxRate;
232
+ const taxZoneId = quote.taxZoneId ?? '';
233
+ const taxZoneName = quote.taxZoneName ?? '';
234
+ // The quote's shipping price is the SERVER's: manual rates re-derived from the
235
+ // rate id, Shippo quotes bounded, free-shipping discounts applied, pickup = 0.
236
+ // The client's claimed price never survives.
237
+ const selectedShipping = request.selectedShipping
238
+ ? { ...request.selectedShipping, price: quote.shippingCents }
239
+ : undefined;
240
+ const isPickup = selectedShipping?.source === 'pickup';
241
+ // Resolved from the LOCATION server-side, not from whatever address the client
242
+ // happened to send along with the pickup.
243
+ const pickupLocationId = quote.pickupLocationId;
251
244
  // Application fee follows the DISCOUNTED goods value.
252
245
  const appFee = applicationFeeAmount ?? calculateApplicationFee(subtotal - discountCents);
253
- // ==========================================================================
254
- // Zone-Based Tax Calculation
255
- // ==========================================================================
256
- // Tax is calculated based on the customer's shipping address using tax zones.
257
- // The admin configures zones with country/state coverage and tax rates.
258
- //
259
- // taxIncludedInPrices setting determines how tax is handled:
260
- // - true (default): Prices include tax → extract and show separately
261
- // - false: Prices exclude tax → add tax on top
262
- //
263
- // Tax can only be calculated when we have a shipping address (pre-checkout flow).
264
- // If no address is provided, tax = 0 (address will be collected in webhook).
265
- // Get tax inclusion setting from store settings
266
- const taxIncludedInPrices = storeSettings?.taxIncludedInPrices ?? true;
267
- // Find matching tax zone from shipping address
268
- let taxMatch = null;
269
- let taxRate = 0;
270
- if (request.shippingAddress) {
271
- taxMatch = await findTaxRateForAddress(request.shippingAddress.country, request.shippingAddress.state, request.customerGroupId ?? null);
272
- taxRate = taxMatch?.taxRate ?? 0;
273
- }
274
246
  // Calculate divisor for tax extraction (only when prices include tax)
275
247
  const taxDivisor = (taxIncludedInPrices && taxRate > 0) ? (1 + taxRate / 100) : 1;
276
248
  // Determine tax handling mode
@@ -293,7 +265,12 @@ export async function createCheckoutSession(request, options = {}) {
293
265
  currency,
294
266
  product_data: {
295
267
  name: item.name,
296
- description: item.description,
268
+ // Stripe REJECTS an empty-string description ("cannot be unset") and
269
+ // 500s the whole session. A caller that legitimately has nothing to
270
+ // say about a line (e.g. a configurator line whose attributes are
271
+ // all internal) must be able to send '' / null without breaking
272
+ // checkout — so normalize any blank value away entirely.
273
+ description: item.description?.trim() ? item.description : undefined,
297
274
  images: safeImage ? [safeImage] : undefined,
298
275
  // Per-item identifiers live on the line item's product metadata —
299
276
  // a per-line 500-char bag that cannot overflow from cart size.
@@ -317,8 +294,11 @@ export async function createCheckoutSession(request, options = {}) {
317
294
  quantity: item.quantity,
318
295
  };
319
296
  });
320
- // Track original totals for tax calculation (will include shipping if added)
321
- let originalTotal = subtotal; // Will be updated if shipping is added
297
+ // (There used to be an `originalTotal` accumulator here, feeding a tax
298
+ // computation taken BEFORE the discount. Tax is now taken once, on the
299
+ // discounted base — see CANONICAL TAX below — so the accumulator is gone.
300
+ // Do not reintroduce it: a second tax basis is how the receipt and the
301
+ // recorded order drifted apart in the first place.)
322
302
  // Build session parameters
323
303
  const sessionParams = {
324
304
  mode: 'payment',
@@ -351,53 +331,69 @@ export async function createCheckoutSession(request, options = {}) {
351
331
  sessionParams.customer_email = request.customerEmail;
352
332
  }
353
333
  // ==========================================================================
354
- // Pre-Selected Shipping (from pre-checkout flow)
334
+ // Pre-Selected Shipping (from pre-checkout flow) — INCLUDING click & collect
355
335
  // ==========================================================================
356
- // When selectedShipping is provided, we add shipping as a line item
357
- // instead of showing shipping_options for customer to choose from.
358
- // This ensures the pre-checkout flow selection is locked in.
336
+ // When selectedShipping is provided, the money is settled server-side: we add
337
+ // shipping as a fixed line item instead of showing shipping_options for the
338
+ // customer to choose from, so the pre-checkout selection is locked in.
359
339
  // (`selectedShipping` was server-validated above — its price is ours.)
360
- if (selectedShipping && shouldCollectShipping && !isPickup) {
361
- // Apply same tax extraction logic to shipping (if shipping is tax-inclusive)
362
- const shippingDisplayPrice = shouldExtractTax
363
- ? Math.round(selectedShipping.price / taxDivisor)
364
- : selectedShipping.price;
365
- // Track original shipping price for tax calculation
366
- originalTotal += selectedShipping.price;
367
- // Add shipping as a line item (fixed, not selectable)
368
- lineItems.push({
369
- price_data: {
370
- currency,
371
- product_data: {
372
- name: `Shipping - ${selectedShipping.carrierName || 'Standard'} ${selectedShipping.name}`,
373
- description: selectedShipping.description || selectedShipping.estimatedDays || undefined,
374
- metadata: {
375
- shippingOptionId: selectedShipping.id,
376
- shippingSource: selectedShipping.source,
377
- carrierName: selectedShipping.carrierName || '',
378
- carrierCode: selectedShipping.carrierCode || '',
379
- carrierId: selectedShipping.carrierId || '',
340
+ //
341
+ // PICKUP BELONGS HERE. It is a pre-selected shipping option at price 0, not a
342
+ // separate world. The guard used to read `&& !isPickup`, which did two silent
343
+ // things: it dropped every pickup order into the "let Stripe collect shipping"
344
+ // branch below — so Stripe was handed shipping_options and auto-charged the
345
+ // first rate, and a shopper coming to COLLECT their order paid for delivery,
346
+ // while `rovelaTotals` still stamped shipping = 0 (the webhook then recorded a
347
+ // different total than Stripe collected) and it made the `if (isPickup )`
348
+ // block inside this branch unreachable by construction, so `rovelaPickup` was
349
+ // never set and no order was ever flagged as a pickup. Stripe-based click &
350
+ // collect had therefore never worked on any store.
351
+ if (selectedShipping && shouldCollectShipping) {
352
+ if (!isPickup) {
353
+ // Apply same tax extraction logic to shipping (if shipping is tax-inclusive)
354
+ const shippingDisplayPrice = shouldExtractTax
355
+ ? Math.round(selectedShipping.price / taxDivisor)
356
+ : selectedShipping.price;
357
+ // Add shipping as a line item (fixed, not selectable)
358
+ lineItems.push({
359
+ price_data: {
360
+ currency,
361
+ product_data: {
362
+ name: `Shipping - ${selectedShipping.carrierName || 'Standard'} ${selectedShipping.name}`,
363
+ description: selectedShipping.description || selectedShipping.estimatedDays || undefined,
364
+ metadata: {
365
+ shippingOptionId: selectedShipping.id,
366
+ shippingSource: selectedShipping.source,
367
+ carrierName: selectedShipping.carrierName || '',
368
+ carrierCode: selectedShipping.carrierCode || '',
369
+ carrierId: selectedShipping.carrierId || '',
370
+ },
380
371
  },
372
+ unit_amount: shippingDisplayPrice,
381
373
  },
382
- unit_amount: shippingDisplayPrice,
383
- },
384
- quantity: 1,
385
- });
386
- // Store selected shipping in session metadata for order creation
387
- sessionParams.metadata = {
388
- ...sessionParams.metadata,
389
- selectedShippingId: selectedShipping.id,
390
- selectedShippingName: `${selectedShipping.carrierName || 'Standard'} - ${selectedShipping.name}`,
391
- selectedShippingPrice: String(selectedShipping.price),
392
- selectedShippingSource: selectedShipping.source,
393
- // Store carrier identifier - use carrierId for manual shipping, carrierCode for Shippo
394
- ...((selectedShipping.carrierId || selectedShipping.carrierCode) && {
395
- selectedShippingCarrierId: selectedShipping.carrierId || selectedShipping.carrierCode || '',
396
- }),
397
- };
398
- // Click & collect: selection metadata for the webhook (the shipping-line
399
- // metadata block above is skipped for pickup — price is always 0).
400
- if (isPickup && selectedShipping) {
374
+ quantity: 1,
375
+ });
376
+ // Store selected shipping in session metadata for order creation
377
+ sessionParams.metadata = {
378
+ ...sessionParams.metadata,
379
+ selectedShippingId: selectedShipping.id,
380
+ selectedShippingName: `${selectedShipping.carrierName || 'Standard'} - ${selectedShipping.name}`,
381
+ selectedShippingPrice: String(selectedShipping.price),
382
+ selectedShippingSource: selectedShipping.source,
383
+ // Store carrier identifier - use carrierId for manual shipping, carrierCode for Shippo
384
+ ...((selectedShipping.carrierId || selectedShipping.carrierCode) && {
385
+ selectedShippingCarrierId: selectedShipping.carrierId || selectedShipping.carrierCode || '',
386
+ }),
387
+ };
388
+ }
389
+ else {
390
+ // Click & collect. NO shipping line item (the price is always 0) and NO
391
+ // shipping_options the shopper is collecting in store. `originalTotal`
392
+ // is deliberately not incremented: there is nothing to tax as shipping.
393
+ //
394
+ // `rovelaPickup` is the load-bearing key: the webhook reads it and stamps
395
+ // orders.pickup_location_id, which drives the pickup lifecycle labels and
396
+ // the order-ready-for-pickup email (§59).
401
397
  sessionParams.metadata = {
402
398
  ...sessionParams.metadata,
403
399
  selectedShippingId: selectedShipping.id,
@@ -408,7 +404,9 @@ export async function createCheckoutSession(request, options = {}) {
408
404
  ...(request.locale ? { rovelaLocale: String(request.locale).slice(0, 5) } : {}),
409
405
  };
410
406
  }
411
- // If shipping address is pre-collected, store in metadata for webhook order creation
407
+ // If shipping address is pre-collected, store in metadata for webhook order
408
+ // creation. For pickup this carries the LOCATION's address, which is the
409
+ // order's shippingAddress snapshot (§59).
412
410
  if (request.shippingAddress) {
413
411
  const addr = request.shippingAddress;
414
412
  sessionParams.metadata = {
@@ -447,8 +445,10 @@ export async function createCheckoutSession(request, options = {}) {
447
445
  };
448
446
  }
449
447
  // Set shipping_address_collection if no pre-collected address
450
- // (need to collect for order fulfillment)
451
- if (!request.shippingAddress) {
448
+ // (need to collect for order fulfillment). NEVER for pickup — asking a
449
+ // shopper who is collecting in store for a delivery address is nonsense,
450
+ // and Stripe would then also expect to ship somewhere.
451
+ if (!request.shippingAddress && !isPickup) {
452
452
  const shippingCountries = await getShippingCountriesFromSettings();
453
453
  sessionParams.shipping_address_collection = {
454
454
  allowed_countries: shippingCountries,
@@ -534,109 +534,85 @@ export async function createCheckoutSession(request, options = {}) {
534
534
  sessionParams.allow_promotion_codes = true;
535
535
  }
536
536
  // ==========================================================================
537
- // Add Tax Line Item (Zone-Based)
537
+ // CANONICAL TAX the pricing core's formula, computed ONCE
538
538
  // ==========================================================================
539
- // Tax handling depends on the taxIncludedInPrices setting:
540
- //
541
- // TAX-INCLUSIVE (shouldExtractTax):
542
- // - Product prices already include tax
543
- // - Line items sent as pre-tax amounts (divided by taxDivisor)
544
- // - Tax line item = original total - sum of pre-tax line items
539
+ // These ARE the pricing core's numbers — not a re-derivation of them. The tax
540
+ // the shopper sees on Stripe, the tax the order records, and the tax a wire
541
+ // transfer for the same cart records are now literally the same value, because
542
+ // there is only one place that computes it.
543
+ const quoteShippingCents = quote.shippingCents;
544
+ const canonicalTaxCents = quote.taxCents;
545
+ const quoteTotalCents = quote.totalCents;
546
+ // The tax LINE. Inclusive stores show goods pre-tax (the line items were
547
+ // already divided by taxDivisor above) with tax broken out; exclusive stores
548
+ // show goods as-is with tax added.
545
549
  //
546
- // TAX-EXCLUSIVE (shouldAddTax):
547
- // - Product prices are pre-tax
548
- // - Line items sent as-is (no division)
549
- // - Tax line item = original total × (taxRate / 100)
550
- if (shouldExtractTax) {
551
- // TAX-INCLUSIVE: Extract tax from prices
552
- const preTaxTotal = lineItems.reduce((sum, item) => sum + (item.price_data?.unit_amount || 0) * (item.quantity || 1), 0);
553
- // Tax is the difference between original inclusive total and pre-tax total
554
- const extractedTaxAmount = originalTotal - preTaxTotal;
555
- if (extractedTaxAmount > 0) {
556
- lineItems.push({
557
- price_data: {
558
- currency,
559
- product_data: {
560
- name: 'Tax',
561
- description: `${taxRate}% - ${taxMatch?.zoneName || 'Sales Tax'}`,
562
- },
563
- unit_amount: extractedTaxAmount,
564
- },
565
- quantity: 1,
566
- });
567
- // Store tax info in session metadata for order creation
568
- sessionParams.metadata = {
569
- ...sessionParams.metadata,
570
- taxZoneId: taxMatch?.zoneId || '',
571
- taxZoneName: taxMatch?.zoneName || '',
572
- taxRate: String(taxRate),
573
- taxAmount: String(extractedTaxAmount),
574
- taxIncludedInPrices: 'true',
575
- };
576
- }
577
- }
578
- else if (shouldAddTax) {
579
- // TAX-EXCLUSIVE: Add tax on top of prices
580
- const taxAmount = Math.round(originalTotal * (taxRate / 100));
581
- if (taxAmount > 0) {
550
+ // The amount is the quote's tax — with ONE adjustment. In an inclusive store
551
+ // each line is divided and rounded to the cent, so the line sum can land a
552
+ // cent or two off the quote. When there is a discount, the coupon below
553
+ // absorbs that (it is `lineSum quoteTotal` by construction). When there is
554
+ // NOT, there is no coupon to absorb it — and we must not mint one just for a
555
+ // rounding remainder, because Stripe forbids `discounts` together with
556
+ // `allow_promotion_codes` and it would silently strip a store's promo-code
557
+ // field. So the tax line itself takes the remainder: the charged total lands
558
+ // exactly on the quote, and the displayed tax differs from the recorded tax by
559
+ // at most a cent. `rovelaTotals` still records the canonical figure, so the
560
+ // order and the wire-transfer order still agree exactly.
561
+ if ((shouldExtractTax || shouldAddTax) && canonicalTaxCents > 0) {
562
+ const preTaxLineSum = lineItems.reduce((sum, li) => sum +
563
+ ((li.price_data?.unit_amount || 0) *
564
+ (li.quantity || 1)), 0);
565
+ const taxLineCents = discountCents === 0 && isAuthoritative
566
+ ? quoteTotalCents - preTaxLineSum
567
+ : canonicalTaxCents;
568
+ if (taxLineCents > 0) {
582
569
  lineItems.push({
583
570
  price_data: {
584
571
  currency,
585
572
  product_data: {
586
573
  name: 'Tax',
587
- description: `${taxRate}% - ${taxMatch?.zoneName || 'Sales Tax'}`,
574
+ description: `${taxRate}% - ${taxZoneName || 'Sales Tax'}`,
588
575
  },
589
- unit_amount: taxAmount,
576
+ unit_amount: taxLineCents,
590
577
  },
591
578
  quantity: 1,
592
579
  });
593
- // Store tax info in session metadata for order creation
594
- sessionParams.metadata = {
595
- ...sessionParams.metadata,
596
- taxZoneId: taxMatch?.zoneId || '',
597
- taxZoneName: taxMatch?.zoneName || '',
598
- taxRate: String(taxRate),
599
- taxAmount: String(taxAmount),
600
- taxIncludedInPrices: 'false',
601
- };
602
580
  }
581
+ // Store tax info in session metadata for order creation. This records the
582
+ // CANONICAL tax, not the rounding-adjusted display line.
583
+ sessionParams.metadata = {
584
+ ...sessionParams.metadata,
585
+ taxZoneId,
586
+ taxZoneName,
587
+ taxRate: String(taxRate),
588
+ taxAmount: String(canonicalTaxCents),
589
+ taxIncludedInPrices: taxIncludedInPrices ? 'true' : 'false',
590
+ };
603
591
  }
604
592
  // ==========================================================================
605
593
  // Native discount → ephemeral Stripe coupon + authoritative-totals stamp
606
594
  // ==========================================================================
607
- // When the session's money is fully deterministic server-side (pre-selected
608
- // shipping, or a no-shipping store), stamp OUR computed totals on the
609
- // session. The webhook records the order from THESE numbers Stripe's
610
- // amounts become a cross-check, not the source of truth. The standard flow
611
- // (shipping chosen on the hosted page) stays on Stripe-derived amounts.
612
- const quoteShippingCents = selectedShipping && shouldCollectShipping ? selectedShipping.price : 0;
613
- const isDeterministicQuote = (selectedShipping && shouldCollectShipping) || !shouldCollectShipping;
614
- if (isDeterministicQuote) {
615
- // Canonical (recorded) tax: computed on the DISCOUNTED base the
616
- // session's tax line was computed pre-discount, so re-derive here with
617
- // the same rate. Inclusive stores: display-only extraction.
618
- const discountedBase = subtotal - discountCents + quoteShippingCents;
619
- const canonicalTaxCents = taxRate > 0
620
- ? taxIncludedInPrices
621
- ? Math.round(discountedBase - discountedBase / (1 + taxRate / 100))
622
- : Math.round(discountedBase * (taxRate / 100))
623
- : 0;
624
- const quoteTotal = taxIncludedInPrices
625
- ? discountedBase
626
- : discountedBase + canonicalTaxCents;
627
- // The hosted page shows our discount as a one-off coupon whose amount is
628
- // EXACTLY lineSum − quoteTotal, so the charged total equals the quote by
629
- // construction (this also absorbs the tax-on-discount adjustment in
630
- // tax-exclusive stores).
631
- if (discountCents > 0) {
595
+ // The discount reaches Stripe as a one-off coupon of EXACTLY
596
+ // `lineSum quoteTotal`, so the amount Stripe charges equals our quote BY
597
+ // CONSTRUCTION (with the tax line taken on the discounted base, this resolves
598
+ // to the discount itself an honest "Discount −$X", not a fudge factor).
599
+ //
600
+ // The coupon is emitted on BOTH modes. It used to sit inside the
601
+ // deterministic-only branch, which meant that on the Stripe-collects-shipping
602
+ // flow a native discount was evaluated, charged to nobody, and silently
603
+ // dropped: the shopper entered a valid code and paid full price. Nothing
604
+ // reached that flow, so nobody noticed. It is fixed here rather than left as a
605
+ // trap for the first store that does.
606
+ {
607
+ {
632
608
  const lineSum = lineItems.reduce((sum, li) => sum +
633
609
  ((li.price_data?.unit_amount || 0) *
634
610
  (li.quantity || 1)), 0);
635
- const couponAmountOff = lineSum - quoteTotal;
611
+ const couponAmountOff = lineSum - quoteTotalCents;
636
612
  if (couponAmountOff > 0) {
637
613
  const couponLabel = appliedDiscounts.find((d) => d.code)?.code ||
638
614
  appliedDiscounts[0]?.title ||
639
- 'Discount';
615
+ (discountCents > 0 ? 'Discount' : 'Rounding adjustment');
640
616
  const coupon = await stripe.coupons.create({
641
617
  amount_off: couponAmountOff,
642
618
  currency,
@@ -648,6 +624,24 @@ export async function createCheckoutSession(request, options = {}) {
648
624
  delete sessionParams.allow_promotion_codes;
649
625
  }
650
626
  }
627
+ }
628
+ // ==========================================================================
629
+ // AUTHORITATIVE TOTALS — we claim the total only when we actually know it
630
+ // ==========================================================================
631
+ // The quote covers the whole bill (shipping was resolved server-side, or the
632
+ // store doesn't ship), so stamp it. The webhook records the order from THESE
633
+ // numbers and Stripe's amounts become a cross-check, not the source of truth —
634
+ // which is what makes a card order and a wire-transfer order for the same cart
635
+ // record identical money.
636
+ //
637
+ // When shipping is still being chosen on Stripe's page we do NOT stamp them:
638
+ // Stripe adds the rate, so Stripe determines the total and it is not ours to
639
+ // claim. The webhook falls back to Stripe's amounts there (and to Stripe's
640
+ // discount total, since the coupon above was still applied — the shopper does
641
+ // get their discount now). The one thing that path does not do is increment a
642
+ // native discount's usage count; no store reaches it, and inventing a
643
+ // redemption path for a flow nobody uses is not worth the surface area.
644
+ if (isAuthoritative) {
651
645
  sessionParams.metadata = {
652
646
  ...sessionParams.metadata,
653
647
  rovelaTotals: JSON.stringify({
@@ -656,7 +650,7 @@ export async function createCheckoutSession(request, options = {}) {
656
650
  discount: discountCents,
657
651
  shipping: quoteShippingCents,
658
652
  tax: canonicalTaxCents,
659
- total: quoteTotal,
653
+ total: quoteTotalCents,
660
654
  }),
661
655
  // Applied-discount snapshot for webhook redemption recording. Compact
662
656
  // (id + code + net amount); metadata values cap at 500 chars.