@stacksjs/commerce 0.70.55 → 0.70.57
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/package.json +10 -7
- package/src/carts/destroy.ts +0 -54
- package/src/carts/fetch.ts +0 -20
- package/src/carts/index.ts +0 -13
- package/src/carts/store.ts +0 -75
- package/src/carts/update.ts +0 -78
- package/src/coupons/destroy.ts +0 -49
- package/src/coupons/fetch.ts +0 -510
- package/src/coupons/index.ts +0 -30
- package/src/coupons/store.ts +0 -46
- package/src/coupons/update.ts +0 -147
- package/src/customers/destroy.ts +0 -62
- package/src/customers/fetch.ts +0 -20
- package/src/customers/index.ts +0 -17
- package/src/customers/store.ts +0 -45
- package/src/customers/update.ts +0 -44
- package/src/devices/destroy.ts +0 -54
- package/src/devices/export.ts +0 -98
- package/src/devices/fetch.ts +0 -125
- package/src/devices/index.ts +0 -31
- package/src/devices/store.ts +0 -82
- package/src/devices/update.ts +0 -125
- package/src/errors/destroy.ts +0 -54
- package/src/errors/fetch.ts +0 -197
- package/src/errors/index.ts +0 -28
- package/src/errors/update.ts +0 -116
- package/src/gift-cards/destroy.ts +0 -87
- package/src/gift-cards/fetch.ts +0 -431
- package/src/gift-cards/index.ts +0 -26
- package/src/gift-cards/store.ts +0 -56
- package/src/gift-cards/update.ts +0 -102
- package/src/index.ts +0 -78
- package/src/orders/destroy.ts +0 -111
- package/src/orders/events.ts +0 -121
- package/src/orders/export.ts +0 -158
- package/src/orders/fetch.ts +0 -379
- package/src/orders/guards.ts +0 -261
- package/src/orders/index.ts +0 -91
- package/src/orders/place-order.ts +0 -274
- package/src/orders/store.ts +0 -51
- package/src/orders/totals.ts +0 -190
- package/src/orders/update.ts +0 -164
- package/src/orders/webhook.ts +0 -312
- package/src/payments/destroy.ts +0 -56
- package/src/payments/fetch.ts +0 -288
- package/src/payments/index.ts +0 -16
- package/src/payments/store.ts +0 -63
- package/src/payments/update.ts +0 -52
- package/src/product.ts +0 -0
- package/src/products/categories/destroy.ts +0 -103
- package/src/products/categories/fetch.ts +0 -285
- package/src/products/categories/index.ts +0 -36
- package/src/products/categories/store.ts +0 -87
- package/src/products/categories/update.ts +0 -251
- package/src/products/index.ts +0 -35
- package/src/products/items/destroy.ts +0 -54
- package/src/products/items/fetch.ts +0 -282
- package/src/products/items/index.ts +0 -26
- package/src/products/items/store.ts +0 -84
- package/src/products/items/update.ts +0 -324
- package/src/products/manufacturers/destroy.ts +0 -56
- package/src/products/manufacturers/fetch.ts +0 -117
- package/src/products/manufacturers/index.ts +0 -24
- package/src/products/manufacturers/store.ts +0 -82
- package/src/products/manufacturers/update.ts +0 -113
- package/src/products/reviews/destroy.ts +0 -56
- package/src/products/reviews/fetch.ts +0 -63
- package/src/products/reviews/index.ts +0 -25
- package/src/products/reviews/store.ts +0 -46
- package/src/products/reviews/update.ts +0 -82
- package/src/products/units/destroy.ts +0 -56
- package/src/products/units/fetch.ts +0 -20
- package/src/products/units/index.ts +0 -13
- package/src/products/units/store.ts +0 -162
- package/src/products/units/update.ts +0 -159
- package/src/products/variants/destroy.ts +0 -56
- package/src/products/variants/fetch.ts +0 -20
- package/src/products/variants/index.ts +0 -21
- package/src/products/variants/store.ts +0 -136
- package/src/products/variants/update.ts +0 -104
- package/src/receipts/destroy.ts +0 -54
- package/src/receipts/export.ts +0 -0
- package/src/receipts/fetch.ts +0 -222
- package/src/receipts/index.ts +0 -24
- package/src/receipts/printer.ts +0 -69
- package/src/receipts/store.ts +0 -69
- package/src/receipts/update.ts +0 -124
- package/src/shippings/delivery-routes/destroy.ts +0 -54
- package/src/shippings/delivery-routes/fetch.ts +0 -42
- package/src/shippings/delivery-routes/index.ts +0 -26
- package/src/shippings/delivery-routes/store.ts +0 -69
- package/src/shippings/delivery-routes/update.ts +0 -120
- package/src/shippings/digital-deliveries/destroy.ts +0 -111
- package/src/shippings/digital-deliveries/fetch.ts +0 -20
- package/src/shippings/digital-deliveries/index.ts +0 -26
- package/src/shippings/digital-deliveries/store.ts +0 -76
- package/src/shippings/digital-deliveries/update.ts +0 -129
- package/src/shippings/drivers/destroy.ts +0 -78
- package/src/shippings/drivers/fetch.ts +0 -20
- package/src/shippings/drivers/index.ts +0 -24
- package/src/shippings/drivers/store.ts +0 -69
- package/src/shippings/drivers/update.ts +0 -117
- package/src/shippings/index.ts +0 -39
- package/src/shippings/license-keys/destroy.ts +0 -111
- package/src/shippings/license-keys/fetch.ts +0 -20
- package/src/shippings/license-keys/index.ts +0 -26
- package/src/shippings/license-keys/store.ts +0 -77
- package/src/shippings/license-keys/update.ts +0 -117
- package/src/shippings/shipping-methods/destroy.ts +0 -111
- package/src/shippings/shipping-methods/fetch.ts +0 -62
- package/src/shippings/shipping-methods/index.ts +0 -28
- package/src/shippings/shipping-methods/store.ts +0 -123
- package/src/shippings/shipping-methods/update.ts +0 -120
- package/src/shippings/shipping-rates/destroy.ts +0 -102
- package/src/shippings/shipping-rates/fetch.ts +0 -320
- package/src/shippings/shipping-rates/index.ts +0 -36
- package/src/shippings/shipping-rates/store.ts +0 -102
- package/src/shippings/shipping-rates/update.ts +0 -139
- package/src/shippings/shipping-zones/destroy.ts +0 -111
- package/src/shippings/shipping-zones/fetch.ts +0 -96
- package/src/shippings/shipping-zones/index.ts +0 -26
- package/src/shippings/shipping-zones/store.ts +0 -74
- package/src/shippings/shipping-zones/update.ts +0 -156
- package/src/tax/destroy.ts +0 -54
- package/src/tax/fetch.ts +0 -20
- package/src/tax/index.ts +0 -24
- package/src/tax/store.ts +0 -69
- package/src/tax/update.ts +0 -119
- package/src/tests/coupons.test.ts +0 -90
- package/src/tests/customers.test.ts +0 -39
- package/src/tests/device.test.ts +0 -111
- package/src/tests/digitals.test.ts +0 -31
- package/src/tests/drivers.test.ts +0 -25
- package/src/tests/gift-cards.test.ts +0 -36
- package/src/tests/license.test.ts +0 -31
- package/src/tests/manufacturers.test.ts +0 -80
- package/src/tests/orders.test.ts +0 -25
- package/src/tests/payments.test.ts +0 -75
- package/src/tests/rates.test.ts +0 -367
- package/src/tests/receipts.test.ts +0 -287
- package/src/tests/restaurant.test.ts +0 -522
- package/src/tests/reviews.test.ts +0 -165
- package/src/tests/routes.test.ts +0 -51
- package/src/tests/setup.ts +0 -149
- package/src/tests/shipping.test.ts +0 -88
- package/src/tests/tax.test.ts +0 -25
- package/src/tests/units.test.ts +0 -110
- package/src/tests/variants.test.ts +0 -133
- package/src/tests/waitlist.test.ts +0 -303
- package/src/tests/zones.test.ts +0 -86
- package/src/types.ts +0 -312
- package/src/utils/typed-stats.ts +0 -67
- package/src/waitlists/index.ts +0 -19
- package/src/waitlists/products/destroy.ts +0 -65
- package/src/waitlists/products/export.ts +0 -113
- package/src/waitlists/products/fetch.ts +0 -362
- package/src/waitlists/products/index.ts +0 -33
- package/src/waitlists/products/store.ts +0 -112
- package/src/waitlists/products/update.ts +0 -125
- package/src/waitlists/restaurant/destroy.ts +0 -65
- package/src/waitlists/restaurant/export.ts +0 -107
- package/src/waitlists/restaurant/fetch.ts +0 -538
- package/src/waitlists/restaurant/index.ts +0 -43
- package/src/waitlists/restaurant/store.ts +0 -109
- package/src/waitlists/restaurant/update.ts +0 -198
- /package/dist/{src/coupons → coupons}/destroy.d.ts +0 -0
- /package/dist/{src/coupons → coupons}/fetch.d.ts +0 -0
- /package/dist/{src/coupons → coupons}/index.d.ts +0 -0
- /package/dist/{src/coupons → coupons}/store.d.ts +0 -0
- /package/dist/{src/coupons → coupons}/update.d.ts +0 -0
- /package/dist/{src/customers → customers}/destroy.d.ts +0 -0
- /package/dist/{src/customers → customers}/fetch.d.ts +0 -0
- /package/dist/{src/customers → customers}/index.d.ts +0 -0
- /package/dist/{src/customers → customers}/store.d.ts +0 -0
- /package/dist/{src/customers → customers}/update.d.ts +0 -0
- /package/dist/{src/devices → devices}/destroy.d.ts +0 -0
- /package/dist/{src/devices → devices}/export.d.ts +0 -0
- /package/dist/{src/devices → devices}/fetch.d.ts +0 -0
- /package/dist/{src/devices → devices}/index.d.ts +0 -0
- /package/dist/{src/devices → devices}/store.d.ts +0 -0
- /package/dist/{src/devices → devices}/update.d.ts +0 -0
- /package/dist/{src/errors → errors}/destroy.d.ts +0 -0
- /package/dist/{src/errors → errors}/fetch.d.ts +0 -0
- /package/dist/{src/errors → errors}/index.d.ts +0 -0
- /package/dist/{src/errors → errors}/update.d.ts +0 -0
- /package/dist/{src/gift-cards → gift-cards}/destroy.d.ts +0 -0
- /package/dist/{src/gift-cards → gift-cards}/fetch.d.ts +0 -0
- /package/dist/{src/gift-cards → gift-cards}/index.d.ts +0 -0
- /package/dist/{src/gift-cards → gift-cards}/store.d.ts +0 -0
- /package/dist/{src/gift-cards → gift-cards}/update.d.ts +0 -0
- /package/dist/{src/index.d.ts → index.d.ts} +0 -0
- /package/dist/{src/orders → orders}/destroy.d.ts +0 -0
- /package/dist/{src/orders → orders}/events.d.ts +0 -0
- /package/dist/{src/orders → orders}/export.d.ts +0 -0
- /package/dist/{src/orders → orders}/fetch.d.ts +0 -0
- /package/dist/{src/orders → orders}/guards.d.ts +0 -0
- /package/dist/{src/orders → orders}/index.d.ts +0 -0
- /package/dist/{src/orders → orders}/place-order.d.ts +0 -0
- /package/dist/{src/orders → orders}/store.d.ts +0 -0
- /package/dist/{src/orders → orders}/totals.d.ts +0 -0
- /package/dist/{src/orders → orders}/update.d.ts +0 -0
- /package/dist/{src/orders → orders}/webhook.d.ts +0 -0
- /package/dist/{src/payments → payments}/destroy.d.ts +0 -0
- /package/dist/{src/payments → payments}/fetch.d.ts +0 -0
- /package/dist/{src/payments → payments}/index.d.ts +0 -0
- /package/dist/{src/payments → payments}/store.d.ts +0 -0
- /package/dist/{src/payments → payments}/update.d.ts +0 -0
- /package/dist/{src/products → products}/categories/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/categories/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/categories/index.d.ts +0 -0
- /package/dist/{src/products → products}/categories/store.d.ts +0 -0
- /package/dist/{src/products → products}/categories/update.d.ts +0 -0
- /package/dist/{src/products → products}/index.d.ts +0 -0
- /package/dist/{src/products → products}/items/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/items/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/items/index.d.ts +0 -0
- /package/dist/{src/products → products}/items/store.d.ts +0 -0
- /package/dist/{src/products → products}/items/update.d.ts +0 -0
- /package/dist/{src/products → products}/manufacturers/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/manufacturers/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/manufacturers/index.d.ts +0 -0
- /package/dist/{src/products → products}/manufacturers/store.d.ts +0 -0
- /package/dist/{src/products → products}/manufacturers/update.d.ts +0 -0
- /package/dist/{src/products → products}/reviews/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/reviews/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/reviews/index.d.ts +0 -0
- /package/dist/{src/products → products}/reviews/store.d.ts +0 -0
- /package/dist/{src/products → products}/reviews/update.d.ts +0 -0
- /package/dist/{src/products → products}/units/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/units/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/units/index.d.ts +0 -0
- /package/dist/{src/products → products}/units/store.d.ts +0 -0
- /package/dist/{src/products → products}/units/update.d.ts +0 -0
- /package/dist/{src/products → products}/variants/destroy.d.ts +0 -0
- /package/dist/{src/products → products}/variants/fetch.d.ts +0 -0
- /package/dist/{src/products → products}/variants/index.d.ts +0 -0
- /package/dist/{src/products → products}/variants/store.d.ts +0 -0
- /package/dist/{src/products → products}/variants/update.d.ts +0 -0
- /package/dist/{src/receipts → receipts}/destroy.d.ts +0 -0
- /package/dist/{src/receipts → receipts}/fetch.d.ts +0 -0
- /package/dist/{src/receipts → receipts}/index.d.ts +0 -0
- /package/dist/{src/receipts → receipts}/store.d.ts +0 -0
- /package/dist/{src/receipts → receipts}/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/delivery-routes/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/delivery-routes/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/delivery-routes/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/delivery-routes/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/delivery-routes/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/digital-deliveries/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/digital-deliveries/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/digital-deliveries/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/digital-deliveries/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/digital-deliveries/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/drivers/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/drivers/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/drivers/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/drivers/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/drivers/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/license-keys/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/license-keys/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/license-keys/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/license-keys/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/license-keys/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-methods/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-methods/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-methods/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-methods/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-methods/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-rates/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-rates/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-rates/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-rates/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-rates/update.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-zones/destroy.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-zones/fetch.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-zones/index.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-zones/store.d.ts +0 -0
- /package/dist/{src/shippings → shippings}/shipping-zones/update.d.ts +0 -0
- /package/dist/{src/tax → tax}/destroy.d.ts +0 -0
- /package/dist/{src/tax → tax}/fetch.d.ts +0 -0
- /package/dist/{src/tax → tax}/index.d.ts +0 -0
- /package/dist/{src/tax → tax}/store.d.ts +0 -0
- /package/dist/{src/tax → tax}/update.d.ts +0 -0
- /package/dist/{src/types.d.ts → types.d.ts} +0 -0
- /package/dist/{src/utils → utils}/typed-stats.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/index.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/products/destroy.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/products/fetch.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/products/index.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/products/store.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/products/update.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/restaurant/destroy.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/restaurant/fetch.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/restaurant/index.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/restaurant/store.d.ts +0 -0
- /package/dist/{src/waitlists → waitlists}/restaurant/update.d.ts +0 -0
package/src/orders/index.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
bulkDestroy,
|
|
3
|
-
bulkSoftDelete,
|
|
4
|
-
destroy,
|
|
5
|
-
softDelete,
|
|
6
|
-
} from './destroy'
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
downloadOrders,
|
|
10
|
-
exportOrders,
|
|
11
|
-
storeOrdersExport,
|
|
12
|
-
} from './export'
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
calculateOrderMetrics,
|
|
16
|
-
compareOrdersByPeriod,
|
|
17
|
-
fetchAll,
|
|
18
|
-
fetchById,
|
|
19
|
-
fetchDailyOrderTrends,
|
|
20
|
-
fetchStats,
|
|
21
|
-
} from './fetch'
|
|
22
|
-
|
|
23
|
-
export {
|
|
24
|
-
store,
|
|
25
|
-
} from './store'
|
|
26
|
-
|
|
27
|
-
// Atomic order placement (stacksjs/stacks#1879 Co-1).
|
|
28
|
-
// Wraps order + payment + inventory decrement in a single
|
|
29
|
-
// transaction so any failure rolls back the rest.
|
|
30
|
-
export { placeOrder } from './place-order'
|
|
31
|
-
export type { PlaceOrderInput, PlaceOrderResult } from './place-order'
|
|
32
|
-
|
|
33
|
-
// Event bus integration + status-transition state machine
|
|
34
|
-
// (stacksjs/stacks#1879 Co-18, Co-4). Emit helpers fire
|
|
35
|
-
// order:created / order:paid / order:shipped / order:delivered /
|
|
36
|
-
// order:cancelled / order:refunded for downstream subscribers;
|
|
37
|
-
// `canTransition` rejects illegal state changes.
|
|
38
|
-
export {
|
|
39
|
-
canTransition,
|
|
40
|
-
emitForStatus,
|
|
41
|
-
emitOrderCancelled,
|
|
42
|
-
emitOrderCreated,
|
|
43
|
-
emitOrderDelivered,
|
|
44
|
-
emitOrderPaid,
|
|
45
|
-
emitOrderRefunded,
|
|
46
|
-
emitOrderShipped,
|
|
47
|
-
} from './events'
|
|
48
|
-
export type { OrderStatus } from './events'
|
|
49
|
-
|
|
50
|
-
export {
|
|
51
|
-
update,
|
|
52
|
-
updateDeliveryInfo,
|
|
53
|
-
updateStatus,
|
|
54
|
-
} from './update'
|
|
55
|
-
|
|
56
|
-
// Stripe webhook handlers (stacksjs/stacks#1879 Co-17). Call
|
|
57
|
-
// registerCommerceWebhookHandlers() once at boot; the payments
|
|
58
|
-
// package's processWebhook() verifies signatures + dispatches.
|
|
59
|
-
export {
|
|
60
|
-
handleChargeRefunded,
|
|
61
|
-
handlePaymentIntentFailed,
|
|
62
|
-
handlePaymentIntentSucceeded,
|
|
63
|
-
registerCommerceWebhookHandlers,
|
|
64
|
-
} from './webhook'
|
|
65
|
-
|
|
66
|
-
// Server-side cart-to-order total recompute (stacksjs/stacks#1879 Co-13).
|
|
67
|
-
// Apps pre-flight the cart through this before placeOrder to detect
|
|
68
|
-
// client-supplied drift and reject "price changed" cases.
|
|
69
|
-
export { recomputeOrderTotals } from './totals'
|
|
70
|
-
export type {
|
|
71
|
-
RecomputeLineItem,
|
|
72
|
-
RecomputeOrderInput,
|
|
73
|
-
RecomputeOrderResult,
|
|
74
|
-
} from './totals'
|
|
75
|
-
|
|
76
|
-
// Cart / order policy guards (stacksjs/stacks#1879 Co-7, Co-14, Co-15).
|
|
77
|
-
// Pre-flight the cart through these before placeOrder; abandoned-
|
|
78
|
-
// cart cleanup runs as a scheduled job.
|
|
79
|
-
export {
|
|
80
|
-
cleanupAbandonedCarts,
|
|
81
|
-
validateCartQuantities,
|
|
82
|
-
validateCouponStacking,
|
|
83
|
-
validateQuantityBounds,
|
|
84
|
-
} from './guards'
|
|
85
|
-
export type {
|
|
86
|
-
CleanupAbandonedCartsOptions,
|
|
87
|
-
CleanupAbandonedCartsResult,
|
|
88
|
-
CouponStackingPolicy,
|
|
89
|
-
CouponStackingResult,
|
|
90
|
-
QuantityBoundsResult,
|
|
91
|
-
} from './guards'
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Atomic order placement (stacksjs/stacks#1879 Co-1).
|
|
3
|
-
*
|
|
4
|
-
* Background: order creation, payment recording, and inventory
|
|
5
|
-
* decrement happened as three separate writes. A failure between
|
|
6
|
-
* steps left the system in a split-brain state:
|
|
7
|
-
* - Order created but no payment row → reconciliation churn
|
|
8
|
-
* - Payment created but no inventory decrement → over-sale
|
|
9
|
-
* - Inventory decremented but order rolled back → phantom stock loss
|
|
10
|
-
*
|
|
11
|
-
* Fix: wrap all three writes in a single `db.transaction`. Either
|
|
12
|
-
* everything commits or nothing does. The Stripe API call (a
|
|
13
|
-
* network round-trip, not a DB write) stays OUTSIDE the transaction
|
|
14
|
-
* to keep the transaction window short — apps that need
|
|
15
|
-
* Stripe-side atomicity already get it via #1876 X-1's idempotency
|
|
16
|
-
* keys (a retry of the local DB after a successful Stripe call
|
|
17
|
-
* returns the same Stripe object).
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```ts
|
|
21
|
-
* const result = await placeOrder({
|
|
22
|
-
* order: { customer_id: user.id, total_amount: 12345, ... },
|
|
23
|
-
* payment: { transaction_id: stripeIntent.id, amount: 12345, ... },
|
|
24
|
-
* inventory: [
|
|
25
|
-
* { id: cart.itemA, delta: -2 },
|
|
26
|
-
* { id: cart.itemB, delta: -1 },
|
|
27
|
-
* ],
|
|
28
|
-
* })
|
|
29
|
-
* if (!result.ok) {
|
|
30
|
-
* // Stock changed between cart view and checkout, or payment
|
|
31
|
-
* // duplicated — caller can show a useful error.
|
|
32
|
-
* throw new HttpError(409, result.reason)
|
|
33
|
-
* }
|
|
34
|
-
* await sendOrderConfirmation(result.order)
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { randomUUIDv7 } from 'bun'
|
|
39
|
-
import { db } from '@stacksjs/database'
|
|
40
|
-
import { formatDate } from '@stacksjs/orm'
|
|
41
|
-
import { sql } from '@stacksjs/database'
|
|
42
|
-
import { emitOrderCreated, emitOrderPaid } from './events'
|
|
43
|
-
|
|
44
|
-
type OrderJsonResponse = ModelRow<typeof Order>
|
|
45
|
-
type NewOrder = NewModelData<typeof Order>
|
|
46
|
-
type PaymentJsonResponse = ModelRow<typeof Payment>
|
|
47
|
-
type NewPayment = NewModelData<typeof Payment>
|
|
48
|
-
|
|
49
|
-
export interface PlaceOrderInput {
|
|
50
|
-
/** Order row fields. UUID / created_at / updated_at are filled in. */
|
|
51
|
-
order: NewOrder
|
|
52
|
-
/** Optional payment row to record alongside the order. */
|
|
53
|
-
payment?: NewPayment
|
|
54
|
-
/** Optional inventory decrements to apply (each delta is signed). */
|
|
55
|
-
inventory?: ReadonlyArray<{ id: number, delta: number }>
|
|
56
|
-
/**
|
|
57
|
-
* Optional caller-supplied idempotency key (stacksjs/stacks#1879
|
|
58
|
-
* Co-3). When set, the order is recorded against this key in a
|
|
59
|
-
* dedup side-table; a retry with the same key returns the
|
|
60
|
-
* original order id instead of creating a duplicate. Use this
|
|
61
|
-
* when a user might double-click "place order" (network blip,
|
|
62
|
-
* impatient retry, mobile background-sync race). UUID per
|
|
63
|
-
* logical checkout attempt is typical; server-side `userId +
|
|
64
|
-
* cartId` also works.
|
|
65
|
-
*/
|
|
66
|
-
idempotencyKey?: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Discriminated result. `ok: true` means the order, payment, and
|
|
71
|
-
* inventory all committed; `ok: false` carries a `reason` so the
|
|
72
|
-
* caller can surface a specific error to the user.
|
|
73
|
-
*/
|
|
74
|
-
export type PlaceOrderResult =
|
|
75
|
-
| { ok: true, order: OrderJsonResponse, payment?: PaymentJsonResponse }
|
|
76
|
-
| { ok: false, reason: 'out-of-stock' | 'duplicate-payment' | 'duplicate-idempotency-key' | 'unknown', failedAt?: string, error?: unknown }
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Look up an existing order by idempotency key — returns the
|
|
80
|
-
* original order when this key has been seen before, null when it
|
|
81
|
-
* hasn't. Degrades to "always null" with a startup warn when the
|
|
82
|
-
* `order_idempotency` dedup table isn't migrated yet.
|
|
83
|
-
*/
|
|
84
|
-
let warnedAboutMissingOrderIdempotencyTable = false
|
|
85
|
-
async function findOrderByIdempotencyKey(key: string): Promise<OrderJsonResponse | null> {
|
|
86
|
-
try {
|
|
87
|
-
const row = await (db as any)
|
|
88
|
-
.selectFrom('order_idempotency')
|
|
89
|
-
.where('idempotency_key', '=', key)
|
|
90
|
-
.selectAll()
|
|
91
|
-
.executeTakeFirst()
|
|
92
|
-
if (!row) return null
|
|
93
|
-
const order = await (db as any)
|
|
94
|
-
.selectFrom('orders')
|
|
95
|
-
.where('id', '=', row.order_id)
|
|
96
|
-
.selectAll()
|
|
97
|
-
.executeTakeFirst()
|
|
98
|
-
return (order ?? null) as OrderJsonResponse | null
|
|
99
|
-
}
|
|
100
|
-
catch (err: any) {
|
|
101
|
-
if (err?.message?.includes('no such table') || err?.message?.includes("doesn't exist")) {
|
|
102
|
-
if (!warnedAboutMissingOrderIdempotencyTable) {
|
|
103
|
-
warnedAboutMissingOrderIdempotencyTable = true
|
|
104
|
-
// eslint-disable-next-line no-console
|
|
105
|
-
console.warn('[commerce/orders] order_idempotency table missing — idempotency keys are accepted but NOT enforced. Run migrations to enable dedup.')
|
|
106
|
-
}
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
throw err
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function placeOrder(input: PlaceOrderInput): Promise<PlaceOrderResult> {
|
|
114
|
-
// Idempotency-key short-circuit (stacksjs/stacks#1879 Co-3).
|
|
115
|
-
// Before doing any work, check the dedup side-table — a retry
|
|
116
|
-
// with the same key returns the original order without
|
|
117
|
-
// re-running the transaction. Insert-into-dedup happens AFTER
|
|
118
|
-
// the order successfully commits, so a partial failure (e.g.,
|
|
119
|
-
// the original placeOrder threw mid-transaction) doesn't
|
|
120
|
-
// permanently lock the key.
|
|
121
|
-
if (input.idempotencyKey) {
|
|
122
|
-
const existing = await findOrderByIdempotencyKey(input.idempotencyKey)
|
|
123
|
-
if (existing)
|
|
124
|
-
return { ok: true, order: existing }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const result = await db.transaction().execute(async (trx: any) => {
|
|
129
|
-
const now = formatDate(new Date())
|
|
130
|
-
|
|
131
|
-
// 1. Insert the order row.
|
|
132
|
-
const orderData = {
|
|
133
|
-
...input.order,
|
|
134
|
-
status: input.order.status || 'PENDING',
|
|
135
|
-
uuid: randomUUIDv7(),
|
|
136
|
-
created_at: now,
|
|
137
|
-
updated_at: now,
|
|
138
|
-
}
|
|
139
|
-
const orderInsert: any = await trx
|
|
140
|
-
.insertInto('orders')
|
|
141
|
-
.values(orderData as NewOrder)
|
|
142
|
-
.executeTakeFirst()
|
|
143
|
-
|
|
144
|
-
const orderId = Number(orderInsert?.insertId) || Number(orderInsert?.numInsertedOrUpdatedRows)
|
|
145
|
-
if (!orderId)
|
|
146
|
-
throw Object.assign(new Error('order insert returned no id'), { __placeFail: true, failedAt: 'order', reason: 'unknown' })
|
|
147
|
-
|
|
148
|
-
// 2. Insert the payment row if provided. The transaction_id
|
|
149
|
-
// unique constraint catches duplicate Stripe webhook deliveries
|
|
150
|
-
// (the pre-fix code's Co-19 backstop) — we surface it as a
|
|
151
|
-
// structured result instead of a generic SQL error.
|
|
152
|
-
let paymentRow: PaymentJsonResponse | undefined
|
|
153
|
-
if (input.payment) {
|
|
154
|
-
try {
|
|
155
|
-
const paymentInsert: any = await trx
|
|
156
|
-
.insertInto('payments')
|
|
157
|
-
.values({
|
|
158
|
-
...input.payment,
|
|
159
|
-
order_id: orderId,
|
|
160
|
-
created_at: now,
|
|
161
|
-
updated_at: now,
|
|
162
|
-
} as NewPayment)
|
|
163
|
-
.executeTakeFirst()
|
|
164
|
-
const paymentId = Number(paymentInsert?.insertId) || Number(paymentInsert?.numInsertedOrUpdatedRows)
|
|
165
|
-
if (paymentId)
|
|
166
|
-
paymentRow = await trx.selectFrom('payments').where('id', '=', paymentId).selectAll().executeTakeFirst() as PaymentJsonResponse | undefined
|
|
167
|
-
}
|
|
168
|
-
catch (err: any) {
|
|
169
|
-
if (err?.message?.includes('UNIQUE constraint') || err?.message?.includes('Duplicate entry'))
|
|
170
|
-
throw Object.assign(new Error('duplicate payment transaction_id'), { __placeFail: true, failedAt: 'payment', reason: 'duplicate-payment' })
|
|
171
|
-
throw Object.assign(err, { __placeFail: true, failedAt: 'payment', reason: 'unknown' })
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 3. Decrement inventory atomically. Same conditional UPDATE
|
|
176
|
-
// shape as adjustInventoryMany — `inventory_count + delta >=
|
|
177
|
-
// 0` enforces the precondition in the WHERE clause so two
|
|
178
|
-
// concurrent placeOrders for the same last-in-stock item don't
|
|
179
|
-
// both succeed.
|
|
180
|
-
if (input.inventory && input.inventory.length > 0) {
|
|
181
|
-
for (let i = 0; i < input.inventory.length; i++) {
|
|
182
|
-
const { id, delta } = input.inventory[i]!
|
|
183
|
-
if (!Number.isFinite(delta) || delta === 0)
|
|
184
|
-
throw Object.assign(new Error('inventory delta must be a non-zero finite number'), { __placeFail: true, failedAt: 'inventory', reason: 'unknown' })
|
|
185
|
-
|
|
186
|
-
const adjResult: any = await trx
|
|
187
|
-
.updateTable('products')
|
|
188
|
-
.set({
|
|
189
|
-
inventory_count: sql`inventory_count + ${delta}`,
|
|
190
|
-
updated_at: now,
|
|
191
|
-
})
|
|
192
|
-
.where('id', '=', id)
|
|
193
|
-
.where(sql`inventory_count + ${delta}`, '>=', 0)
|
|
194
|
-
.execute()
|
|
195
|
-
|
|
196
|
-
const affected = Number(
|
|
197
|
-
adjResult?.numUpdatedRows
|
|
198
|
-
?? adjResult?.[0]?.numUpdatedRows
|
|
199
|
-
?? adjResult?.numAffectedRows
|
|
200
|
-
?? 0,
|
|
201
|
-
)
|
|
202
|
-
if (!affected)
|
|
203
|
-
throw Object.assign(new Error(`inventory adjust failed for product ${id}`), { __placeFail: true, failedAt: 'inventory', reason: 'out-of-stock' })
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// 4. Record the idempotency key inside the transaction so a
|
|
208
|
-
// concurrent retry of the same key (the classic double-click
|
|
209
|
-
// scenario) hits the unique constraint and rolls back here
|
|
210
|
-
// instead of creating a second order. Skipped silently when
|
|
211
|
-
// the table isn't migrated yet — same opt-in pattern as the
|
|
212
|
-
// Stripe webhook dedup.
|
|
213
|
-
if (input.idempotencyKey) {
|
|
214
|
-
try {
|
|
215
|
-
await trx
|
|
216
|
-
.insertInto('order_idempotency')
|
|
217
|
-
.values({
|
|
218
|
-
idempotency_key: input.idempotencyKey,
|
|
219
|
-
order_id: orderId,
|
|
220
|
-
created_at: now,
|
|
221
|
-
})
|
|
222
|
-
.execute()
|
|
223
|
-
}
|
|
224
|
-
catch (err: any) {
|
|
225
|
-
// Unique-constraint collision = concurrent retry won the
|
|
226
|
-
// race; abort this transaction so the caller falls back
|
|
227
|
-
// to the existing order via the pre-transaction lookup
|
|
228
|
-
// on the next attempt (or right now if they re-call).
|
|
229
|
-
if (err?.message?.includes('UNIQUE constraint') || err?.message?.includes('Duplicate entry'))
|
|
230
|
-
throw Object.assign(new Error('idempotency-key collision'), { __placeFail: true, failedAt: 'idempotency', reason: 'duplicate-idempotency-key' })
|
|
231
|
-
// Missing table: degrade silently (the pre-transaction
|
|
232
|
-
// lookup already warned).
|
|
233
|
-
if (!(err?.message?.includes('no such table') || err?.message?.includes("doesn't exist")))
|
|
234
|
-
throw err
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 5. Refetch the order so callers get the post-write canonical row.
|
|
239
|
-
const order = await trx.selectFrom('orders').where('id', '=', orderId).selectAll().executeTakeFirst() as OrderJsonResponse | undefined
|
|
240
|
-
if (!order)
|
|
241
|
-
throw Object.assign(new Error('order disappeared mid-transaction'), { __placeFail: true, failedAt: 'order', reason: 'unknown' })
|
|
242
|
-
|
|
243
|
-
return { order, payment: paymentRow }
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
// Fire `order:created` AFTER the transaction commits (#1879
|
|
247
|
-
// Co-18). If a payment row also landed, also emit `order:paid`
|
|
248
|
-
// — the placeOrder path is the typical "checkout completed
|
|
249
|
-
// successfully" entry point. Both fire-and-forget; emission
|
|
250
|
-
// failures never undo the write.
|
|
251
|
-
void emitOrderCreated(result.order as unknown as Record<string, unknown>)
|
|
252
|
-
if (result.payment) {
|
|
253
|
-
void emitOrderPaid(
|
|
254
|
-
result.order as unknown as Record<string, unknown>,
|
|
255
|
-
result.payment as unknown as Record<string, unknown>,
|
|
256
|
-
)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return { ok: true, order: result.order, payment: result.payment }
|
|
260
|
-
}
|
|
261
|
-
catch (err: any) {
|
|
262
|
-
if (err?.__placeFail) {
|
|
263
|
-
return {
|
|
264
|
-
ok: false,
|
|
265
|
-
reason: err.reason as PlaceOrderResult['reason'] extends infer R ? R : never,
|
|
266
|
-
failedAt: err.failedAt,
|
|
267
|
-
error: err,
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
// Genuinely unexpected error — surface up so the global error
|
|
271
|
-
// handler can log it. Transaction already rolled back.
|
|
272
|
-
throw err
|
|
273
|
-
}
|
|
274
|
-
}
|
package/src/orders/store.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// Import dependencies
|
|
2
|
-
import { randomUUIDv7 } from 'bun'
|
|
3
|
-
import { db } from '@stacksjs/database'
|
|
4
|
-
import { formatDate } from '@stacksjs/orm'
|
|
5
|
-
type OrderJsonResponse = ModelRow<typeof Order>
|
|
6
|
-
type NewOrder = NewModelData<typeof Order>
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Create a new order
|
|
10
|
-
*
|
|
11
|
-
* @param data The order data to store
|
|
12
|
-
* @returns The newly created order record
|
|
13
|
-
*/
|
|
14
|
-
export async function store(data: NewOrder): Promise<OrderJsonResponse | undefined> {
|
|
15
|
-
const orderData = {
|
|
16
|
-
...data,
|
|
17
|
-
status: data.status || 'PENDING',
|
|
18
|
-
uuid: randomUUIDv7(),
|
|
19
|
-
created_at: formatDate(new Date()),
|
|
20
|
-
updated_at: formatDate(new Date()),
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
// Insert the order record
|
|
25
|
-
const createdOrder = await db
|
|
26
|
-
.insertInto('orders')
|
|
27
|
-
.values(orderData as NewOrder)
|
|
28
|
-
.executeTakeFirst()
|
|
29
|
-
|
|
30
|
-
const insertId = Number(createdOrder?.insertId) || Number(createdOrder?.numInsertedOrUpdatedRows)
|
|
31
|
-
|
|
32
|
-
// If insert was successful, retrieve the newly created order
|
|
33
|
-
if (insertId) {
|
|
34
|
-
const order = await db
|
|
35
|
-
.selectFrom('orders')
|
|
36
|
-
.where('id', '=', insertId)
|
|
37
|
-
.selectAll()
|
|
38
|
-
.executeTakeFirst()
|
|
39
|
-
|
|
40
|
-
return order as OrderJsonResponse | undefined
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return undefined
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (error instanceof Error)
|
|
47
|
-
throw new Error(`Failed to create order: ${error.message}`)
|
|
48
|
-
|
|
49
|
-
throw error
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/orders/totals.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side order-total recomputation (stacksjs/stacks#1879 Co-13).
|
|
3
|
-
*
|
|
4
|
-
* Background: the pre-fix `placeOrder` path trusted whatever
|
|
5
|
-
* `total_amount` the client supplied on the order row. A client
|
|
6
|
-
* could submit `total_amount: 0.01` and the order would persist
|
|
7
|
-
* at that amount (Stripe-side might catch it if the payment
|
|
8
|
-
* intent's amount field disagrees, but the LOCAL order record
|
|
9
|
-
* was already wrong). Worse: a benign client whose cart total
|
|
10
|
-
* drifted (price change between cart view and checkout) would
|
|
11
|
-
* silently charge the wrong amount with no signal.
|
|
12
|
-
*
|
|
13
|
-
* This module ships a `recomputeOrderTotals(input)` helper that
|
|
14
|
-
* derives subtotal + tax + shipping + discount from the line
|
|
15
|
-
* items + current product prices + current tax rates + current
|
|
16
|
-
* shipping rates, returns the canonical totals, and (optionally)
|
|
17
|
-
* compares against a client-supplied total to flag drift.
|
|
18
|
-
*
|
|
19
|
-
* Caller pattern:
|
|
20
|
-
* 1. Build the line-items array from the cart (server-side)
|
|
21
|
-
* 2. Call `recomputeOrderTotals({ items, ... })` to get canonical numbers
|
|
22
|
-
* 3. If `result.diffCents > MAX_DRIFT_CENTS`, reject with "price changed"
|
|
23
|
-
* 4. Use `result.total` as the authoritative order.total_amount
|
|
24
|
-
*
|
|
25
|
-
* Currency: all amounts are integer cents (or your currency's smallest
|
|
26
|
-
* unit). Float arithmetic on money is a known footgun (#1879 Co-12);
|
|
27
|
-
* cents avoid the rounding-error class entirely.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { db } from '@stacksjs/database'
|
|
31
|
-
|
|
32
|
-
export interface RecomputeLineItem {
|
|
33
|
-
/** Product ID — used to look up the current canonical unit price. */
|
|
34
|
-
productId: number
|
|
35
|
-
/** Quantity ordered. */
|
|
36
|
-
quantity: number
|
|
37
|
-
/**
|
|
38
|
-
* Optional override unit price in cents. When set, used INSTEAD
|
|
39
|
-
* of the product table lookup — for grandfathered prices, admin
|
|
40
|
-
* discounts, or B2B contract pricing. Document the override in
|
|
41
|
-
* the order metadata so the audit trail is traceable.
|
|
42
|
-
*/
|
|
43
|
-
unitPriceCents?: number
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface RecomputeOrderInput {
|
|
47
|
-
/** Server-built line items. Quantities and product IDs only — prices
|
|
48
|
-
* default to the products table. */
|
|
49
|
-
items: ReadonlyArray<RecomputeLineItem>
|
|
50
|
-
/** Optional tax rate ID to apply. Looked up server-side; rate is
|
|
51
|
-
* applied to subtotal AFTER discount. */
|
|
52
|
-
taxRateId?: number
|
|
53
|
-
/** Optional shipping rate cents. Apps that pre-validate the zone
|
|
54
|
-
* (#1879 Co-11) pass the resolved cost here. */
|
|
55
|
-
shippingCents?: number
|
|
56
|
-
/** Optional flat discount in cents (e.g. from coupon). Applied to
|
|
57
|
-
* subtotal before tax. */
|
|
58
|
-
discountCents?: number
|
|
59
|
-
/** Optional client-supplied total in cents for drift detection. */
|
|
60
|
-
clientTotalCents?: number
|
|
61
|
-
/** Max acceptable drift between client total and canonical total,
|
|
62
|
-
* default 5 cents. Drift past this returns `priceChanged: true`. */
|
|
63
|
-
maxDriftCents?: number
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface RecomputeOrderResult {
|
|
67
|
-
/** Sum of line-item subtotals (qty × unit price) in cents. */
|
|
68
|
-
subtotalCents: number
|
|
69
|
-
/** Discount applied (>= 0). */
|
|
70
|
-
discountCents: number
|
|
71
|
-
/** Tax in cents, computed from (subtotal - discount) × rate. */
|
|
72
|
-
taxCents: number
|
|
73
|
-
/** Shipping cost in cents. */
|
|
74
|
-
shippingCents: number
|
|
75
|
-
/** Final total: subtotal - discount + tax + shipping. */
|
|
76
|
-
totalCents: number
|
|
77
|
-
/** Per-line resolved unit prices, for the audit trail. */
|
|
78
|
-
resolvedItems: Array<{ productId: number, quantity: number, unitPriceCents: number, lineCents: number }>
|
|
79
|
-
/** Set when `clientTotalCents` was provided and differs from
|
|
80
|
-
* canonical by more than `maxDriftCents`. Caller should reject
|
|
81
|
-
* the checkout and ask the user to re-confirm. */
|
|
82
|
-
priceChanged: boolean
|
|
83
|
-
/** Absolute drift in cents (always >= 0). */
|
|
84
|
-
diffCents: number
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Fetch the current canonical unit price for each product id.
|
|
89
|
-
* Bulk single-query so a 10-item cart isn't a 10-query stampede.
|
|
90
|
-
* Returns a Map keyed by product id.
|
|
91
|
-
*/
|
|
92
|
-
async function fetchProductPrices(ids: number[]): Promise<Map<number, number>> {
|
|
93
|
-
if (ids.length === 0) return new Map()
|
|
94
|
-
const rows = await (db as any)
|
|
95
|
-
.selectFrom('products')
|
|
96
|
-
.where('id', 'in', ids)
|
|
97
|
-
.select(['id', 'price'])
|
|
98
|
-
.execute() as Array<{ id: number, price: number | string }>
|
|
99
|
-
const out = new Map<number, number>()
|
|
100
|
-
for (const r of rows) {
|
|
101
|
-
// Some schemas store price as DECIMAL/string — coerce to cents.
|
|
102
|
-
// We assume `price` is in cents already (that's the framework
|
|
103
|
-
// convention since money math should never touch floats); a
|
|
104
|
-
// string-stored numeric coerces cleanly via Number().
|
|
105
|
-
const cents = Number(r.price)
|
|
106
|
-
if (Number.isFinite(cents))
|
|
107
|
-
out.set(r.id, Math.round(cents))
|
|
108
|
-
}
|
|
109
|
-
return out
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Look up a tax rate by id and return its decimal multiplier
|
|
114
|
-
* (e.g. `0.0875` for 8.75% sales tax). Returns 0 when the id is
|
|
115
|
-
* missing or the lookup fails — the caller already passed
|
|
116
|
-
* `taxRateId` deliberately, so a missing rate is a config bug
|
|
117
|
-
* worth surfacing via the canonical total being wrong.
|
|
118
|
-
*/
|
|
119
|
-
async function fetchTaxRate(id: number): Promise<number> {
|
|
120
|
-
try {
|
|
121
|
-
const row = await (db as any)
|
|
122
|
-
.selectFrom('tax_rates')
|
|
123
|
-
.where('id', '=', id)
|
|
124
|
-
.select(['rate'])
|
|
125
|
-
.executeTakeFirst()
|
|
126
|
-
const rate = Number(row?.rate ?? 0)
|
|
127
|
-
return Number.isFinite(rate) ? rate : 0
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return 0
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function recomputeOrderTotals(input: RecomputeOrderInput): Promise<RecomputeOrderResult> {
|
|
135
|
-
// Lookups happen up-front so the rest of the function is pure
|
|
136
|
-
// arithmetic against in-memory data — easier to reason about
|
|
137
|
-
// and easier to mock in tests.
|
|
138
|
-
const productIds = input.items.map(i => i.productId)
|
|
139
|
-
const priceMap = await fetchProductPrices(productIds)
|
|
140
|
-
const taxRate = input.taxRateId != null ? await fetchTaxRate(input.taxRateId) : 0
|
|
141
|
-
|
|
142
|
-
let subtotalCents = 0
|
|
143
|
-
const resolvedItems: RecomputeOrderResult['resolvedItems'] = []
|
|
144
|
-
|
|
145
|
-
for (const item of input.items) {
|
|
146
|
-
// Prefer the caller's explicit override (admin discount, B2B
|
|
147
|
-
// contract pricing) over the products-table lookup.
|
|
148
|
-
const unitCents = item.unitPriceCents ?? priceMap.get(item.productId) ?? 0
|
|
149
|
-
const lineCents = Math.round(unitCents * item.quantity)
|
|
150
|
-
subtotalCents += lineCents
|
|
151
|
-
resolvedItems.push({
|
|
152
|
-
productId: item.productId,
|
|
153
|
-
quantity: item.quantity,
|
|
154
|
-
unitPriceCents: unitCents,
|
|
155
|
-
lineCents,
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const discountCents = Math.max(0, input.discountCents ?? 0)
|
|
160
|
-
// Tax applies to (subtotal - discount), clamped to non-negative.
|
|
161
|
-
const taxableCents = Math.max(0, subtotalCents - discountCents)
|
|
162
|
-
// Round half-up at the cent boundary to match how most payment
|
|
163
|
-
// processors round (Stripe documents this).
|
|
164
|
-
const taxCents = Math.round(taxableCents * taxRate)
|
|
165
|
-
const shippingCents = Math.max(0, input.shippingCents ?? 0)
|
|
166
|
-
const totalCents = subtotalCents - discountCents + taxCents + shippingCents
|
|
167
|
-
|
|
168
|
-
// Drift detection against client-supplied total. Default tolerance
|
|
169
|
-
// is 5 cents — covers most rounding-disagreement cases (one side
|
|
170
|
-
// rounded a quarter-cent up, the other rounded it down) without
|
|
171
|
-
// hiding genuine price changes.
|
|
172
|
-
const maxDrift = input.maxDriftCents ?? 5
|
|
173
|
-
let priceChanged = false
|
|
174
|
-
let diffCents = 0
|
|
175
|
-
if (input.clientTotalCents != null) {
|
|
176
|
-
diffCents = Math.abs(totalCents - input.clientTotalCents)
|
|
177
|
-
priceChanged = diffCents > maxDrift
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
subtotalCents,
|
|
182
|
-
discountCents,
|
|
183
|
-
taxCents,
|
|
184
|
-
shippingCents,
|
|
185
|
-
totalCents,
|
|
186
|
-
resolvedItems,
|
|
187
|
-
priceChanged,
|
|
188
|
-
diffCents,
|
|
189
|
-
}
|
|
190
|
-
}
|