@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.
- package/dist/admin/components/AdminPageHeader.d.ts.map +1 -1
- package/dist/admin/components/AdminPageHeader.js +9 -2
- package/dist/admin/components/AdminPageHeader.js.map +1 -1
- package/dist/admin/components/AdminSelect.d.ts +11 -0
- package/dist/admin/components/AdminSelect.d.ts.map +1 -1
- package/dist/admin/components/AdminSelect.js +60 -4
- package/dist/admin/components/AdminSelect.js.map +1 -1
- package/dist/admin/components/AdminUserMenu.d.ts.map +1 -1
- package/dist/admin/components/AdminUserMenu.js +2 -1
- package/dist/admin/components/AdminUserMenu.js.map +1 -1
- package/dist/admin/components/CustomerDetails.d.ts.map +1 -1
- package/dist/admin/components/CustomerDetails.js +2 -1
- package/dist/admin/components/CustomerDetails.js.map +1 -1
- package/dist/admin/components/CustomerTable.d.ts.map +1 -1
- package/dist/admin/components/CustomerTable.js +1 -1
- package/dist/admin/components/CustomerTable.js.map +1 -1
- package/dist/admin/components/EmailsManager.d.ts.map +1 -1
- package/dist/admin/components/EmailsManager.js +2 -1
- package/dist/admin/components/EmailsManager.js.map +1 -1
- package/dist/admin/components/LowStockAlert.d.ts.map +1 -1
- package/dist/admin/components/LowStockAlert.js +3 -2
- package/dist/admin/components/LowStockAlert.js.map +1 -1
- package/dist/admin/components/OrderDetails.d.ts.map +1 -1
- package/dist/admin/components/OrderDetails.js +2 -1
- package/dist/admin/components/OrderDetails.js.map +1 -1
- package/dist/admin/components/OrderTable.d.ts.map +1 -1
- package/dist/admin/components/OrderTable.js +1 -1
- package/dist/admin/components/OrderTable.js.map +1 -1
- package/dist/admin/components/ProductTable.d.ts.map +1 -1
- package/dist/admin/components/ProductTable.js +4 -3
- package/dist/admin/components/ProductTable.js.map +1 -1
- package/dist/admin/components/RecentOrders.d.ts.map +1 -1
- package/dist/admin/components/RecentOrders.js +2 -1
- package/dist/admin/components/RecentOrders.js.map +1 -1
- package/dist/admin/components/SEOPreview.d.ts +2 -8
- package/dist/admin/components/SEOPreview.d.ts.map +1 -1
- package/dist/admin/components/SEOPreview.js +32 -5
- package/dist/admin/components/SEOPreview.js.map +1 -1
- package/dist/admin/components/SecondaryMetricsRow.d.ts.map +1 -1
- package/dist/admin/components/SecondaryMetricsRow.js +3 -2
- package/dist/admin/components/SecondaryMetricsRow.js.map +1 -1
- package/dist/admin/components/StatsCards.d.ts.map +1 -1
- package/dist/admin/components/StatsCards.js +2 -1
- package/dist/admin/components/StatsCards.js.map +1 -1
- package/dist/admin/components/UsersTable.d.ts.map +1 -1
- package/dist/admin/components/UsersTable.js +1 -1
- package/dist/admin/components/UsersTable.js.map +1 -1
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +6 -1
- package/dist/admin/index.js.map +1 -1
- package/dist/admin/styles/admin-theme.css +20 -0
- package/dist/cart/CartProvider.d.ts.map +1 -1
- package/dist/cart/CartProvider.js +31 -8
- package/dist/cart/CartProvider.js.map +1 -1
- package/dist/cart/store.d.ts +24 -4
- package/dist/cart/store.d.ts.map +1 -1
- package/dist/cart/store.js +24 -4
- package/dist/cart/store.js.map +1 -1
- package/dist/checkout/api/manual.d.ts.map +1 -1
- package/dist/checkout/api/manual.js +9 -4
- package/dist/checkout/api/manual.js.map +1 -1
- package/dist/checkout/components/CheckoutFlow.d.ts.map +1 -1
- package/dist/checkout/components/CheckoutFlow.js +19 -6
- package/dist/checkout/components/CheckoutFlow.js.map +1 -1
- package/dist/checkout/components/CheckoutSuccess.js +1 -1
- package/dist/checkout/components/CheckoutSuccess.js.map +1 -1
- package/dist/checkout/components/OrderTimeline.d.ts +5 -1
- package/dist/checkout/components/OrderTimeline.d.ts.map +1 -1
- package/dist/checkout/components/OrderTimeline.js +18 -5
- package/dist/checkout/components/OrderTimeline.js.map +1 -1
- package/dist/checkout/components/ShippingForm.d.ts +1 -1
- package/dist/checkout/components/ShippingForm.d.ts.map +1 -1
- package/dist/checkout/components/ShippingForm.js +15 -11
- package/dist/checkout/components/ShippingForm.js.map +1 -1
- package/dist/checkout/components/ShippingOptions.js +1 -1
- package/dist/checkout/components/ShippingOptions.js.map +1 -1
- package/dist/checkout/server/create-checkout-session.d.ts.map +1 -1
- package/dist/checkout/server/create-checkout-session.js +213 -219
- package/dist/checkout/server/create-checkout-session.js.map +1 -1
- package/dist/checkout/server/pricing.d.ts +16 -0
- package/dist/checkout/server/pricing.d.ts.map +1 -1
- package/dist/checkout/server/pricing.js +16 -0
- package/dist/checkout/server/pricing.js.map +1 -1
- package/dist/checkout/types.d.ts +15 -0
- package/dist/checkout/types.d.ts.map +1 -1
- package/dist/core/db/index.d.ts +2 -2
- package/dist/core/db/index.d.ts.map +1 -1
- package/dist/core/db/index.js +4 -0
- package/dist/core/db/index.js.map +1 -1
- package/dist/core/db/queries.d.ts +35 -35
- package/dist/core/db/queries.d.ts.map +1 -1
- package/dist/core/db/queries.js +24 -0
- package/dist/core/db/queries.js.map +1 -1
- package/dist/core/db/schema.d.ts +5 -5
- package/dist/core/i18n/messages/en.d.ts.map +1 -1
- package/dist/core/i18n/messages/en.js +2 -0
- package/dist/core/i18n/messages/en.js.map +1 -1
- package/dist/core/i18n/messages/fr.d.ts.map +1 -1
- package/dist/core/i18n/messages/fr.js +2 -0
- package/dist/core/i18n/messages/fr.js.map +1 -1
- package/dist/core/server/index.d.ts +2 -2
- package/dist/core/server/index.d.ts.map +1 -1
- package/dist/core/server/index.js +4 -0
- package/dist/core/server/index.js.map +1 -1
- package/docs/changelog/epoch-06-mobile-integrity.md +89 -2
- package/package.json +1 -1
- package/templates/store-template/app/account/orders/[id]/invoice/page.tsx +3 -1
- package/templates/store-template/app/account/orders/[id]/page.tsx +1 -0
- package/templates/store-template/app/account/orders/page.tsx +10 -1
- package/templates/store-template/app/cart/page.tsx +8 -4
- package/templates/store-template/app/checkout/page.tsx +9 -4
- 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,
|
|
9
|
-
import {
|
|
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
|
-
//
|
|
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 QUOTE — one engine, for every payment path
|
|
188
182
|
// ==========================================================================
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
-
//
|
|
321
|
-
|
|
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,
|
|
357
|
-
// instead of showing shipping_options for
|
|
358
|
-
//
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
537
|
+
// CANONICAL TAX — the pricing core's formula, computed ONCE
|
|
538
538
|
// ==========================================================================
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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}% - ${
|
|
574
|
+
description: `${taxRate}% - ${taxZoneName || 'Sales Tax'}`,
|
|
588
575
|
},
|
|
589
|
-
unit_amount:
|
|
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
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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 -
|
|
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:
|
|
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.
|