@stacksjs/commerce 0.70.45 → 0.70.54
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/LICENSE.md +21 -0
- package/dist/index.js +13 -13
- package/dist/src/coupons/index.d.ts +2 -0
- package/dist/src/coupons/update.d.ts +36 -0
- package/dist/src/gift-cards/update.d.ts +13 -1
- package/dist/src/orders/events.d.ts +46 -0
- package/dist/src/orders/guards.d.ts +93 -0
- package/dist/src/orders/index.d.ts +55 -0
- package/dist/src/orders/place-order.d.ts +18 -0
- package/dist/src/orders/totals.d.ts +24 -0
- package/dist/src/orders/update.d.ts +2 -1
- package/dist/src/orders/webhook.d.ts +31 -0
- package/dist/src/products/items/update.d.ts +37 -0
- package/dist/src/shippings/shipping-rates/fetch.d.ts +63 -2
- package/dist/src/shippings/shipping-rates/index.d.ts +6 -0
- package/package.json +1 -1
|
@@ -6,5 +6,41 @@
|
|
|
6
6
|
* @returns The updated coupon record
|
|
7
7
|
*/
|
|
8
8
|
export declare function update(id: number, data: Omit<CouponUpdate, 'id'>): Promise<CouponJsonResponse | undefined>;
|
|
9
|
+
/**
|
|
10
|
+
* Atomically redeem a coupon (stacksjs/stacks#1879 Co-5).
|
|
11
|
+
*
|
|
12
|
+
* Pre-fix: callers fetched the coupon, checked `usage_count <
|
|
13
|
+
* max_uses`, then bumped `usage_count` via `update()`. Two concurrent
|
|
14
|
+
* redemption requests against a coupon with `max_uses=1` both saw
|
|
15
|
+
* `usage_count=0`, both incremented, both succeeded. The coupon
|
|
16
|
+
* was redeemed twice — direct revenue loss equal to `discount_value`
|
|
17
|
+
* times the over-redemption count.
|
|
18
|
+
*
|
|
19
|
+
* Post-fix: single conditional UPDATE that bumps `usage_count` and
|
|
20
|
+
* enforces `max_uses` / `is_active` / expiry in the WHERE clause.
|
|
21
|
+
* The database guarantees only one writer wins the race. Returns a
|
|
22
|
+
* discriminated result so callers can surface a useful reason
|
|
23
|
+
* ("limit reached" vs "expired" vs "inactive") instead of a generic
|
|
24
|
+
* failure.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const result = await redeem(couponId)
|
|
29
|
+
* if (!result.ok) {
|
|
30
|
+
* throw new HttpError(400, `Coupon cannot be redeemed: ${result.reason}`)
|
|
31
|
+
* }
|
|
32
|
+
* // Use result.coupon — it reflects the post-redemption state.
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function redeem(id: number): Promise<CouponRedemptionResult>;
|
|
9
36
|
declare type CouponJsonResponse = ModelRow<typeof Coupon>;
|
|
10
37
|
declare type CouponUpdate = UpdateModelData<typeof Coupon>;
|
|
38
|
+
/**
|
|
39
|
+
* Discriminated result for `redeem` — `ok: true` on a successful
|
|
40
|
+
* atomic redemption (the row's `usage_count` was bumped and the
|
|
41
|
+
* limit wasn't already hit); `ok: false` with a `reason` when the
|
|
42
|
+
* redemption failed (out of uses, expired, inactive, missing).
|
|
43
|
+
* Callers branch on `reason` to surface a useful error to the user.
|
|
44
|
+
*/
|
|
45
|
+
export type CouponRedemptionResult = | { ok: true, coupon: CouponJsonResponse }
|
|
46
|
+
| { ok: false, reason: 'not-found' | 'inactive' | 'expired' | 'limit-reached' }
|
|
@@ -7,7 +7,19 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function update(id: number, data: Omit<GiftCardUpdate, 'id'>): Promise<GiftCardJsonResponse | undefined>;
|
|
9
9
|
/**
|
|
10
|
-
* Update a gift card's balance
|
|
10
|
+
* Update a gift card's balance atomically (stacksjs/stacks#1879 Co-8).
|
|
11
|
+
*
|
|
12
|
+
* Pre-fix: read balance → compute new → write. Two parallel $50
|
|
13
|
+
* redemptions of a $100 card both saw `current_balance = 100`,
|
|
14
|
+
* both wrote `50`, leaving `50` instead of `0`. The post-read
|
|
15
|
+
* negative-balance guard only caught single-threaded misuse.
|
|
16
|
+
*
|
|
17
|
+
* Post-fix: single conditional UPDATE that does the arithmetic and
|
|
18
|
+
* enforces every precondition in the WHERE clause. The database
|
|
19
|
+
* guarantees only one writer wins the race. Throws when the row
|
|
20
|
+
* was found but the precondition failed (insufficient balance,
|
|
21
|
+
* inactive, missing) so the caller can distinguish from a
|
|
22
|
+
* not-found row.
|
|
11
23
|
*
|
|
12
24
|
* @param id The ID of the gift card
|
|
13
25
|
* @param amount The amount to adjust (positive to add, negative to deduct)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether `from → to` is a legal status transition
|
|
3
|
+
* (stacksjs/stacks#1879 Co-4). Use in `updateStatus` to reject
|
|
4
|
+
* illegal jumps like SHIPPED → PENDING before they hit the database.
|
|
5
|
+
*/
|
|
6
|
+
export declare function canTransition(from: OrderStatus, to: OrderStatus): boolean;
|
|
7
|
+
/** Emit `order:created` after a successful placeOrder / store call. */
|
|
8
|
+
export declare function emitOrderCreated(order: Record<string, unknown>): Promise<void>;
|
|
9
|
+
/** Emit `order:paid` after payment confirmation lands. */
|
|
10
|
+
export declare function emitOrderPaid(order: Record<string, unknown>, payment?: Record<string, unknown>): Promise<void>;
|
|
11
|
+
/** Emit `order:shipped` after fulfillment marks the order shipped. */
|
|
12
|
+
export declare function emitOrderShipped(order: Record<string, unknown>): Promise<void>;
|
|
13
|
+
/** Emit `order:delivered` after the carrier confirms delivery. */
|
|
14
|
+
export declare function emitOrderDelivered(order: Record<string, unknown>): Promise<void>;
|
|
15
|
+
/** Emit `order:cancelled` on cancellation (pre- or post-payment). */
|
|
16
|
+
export declare function emitOrderCancelled(order: Record<string, unknown>, reason?: string): Promise<void>;
|
|
17
|
+
/** Emit `order:refunded` after a refund settles. */
|
|
18
|
+
export declare function emitOrderRefunded(order: Record<string, unknown>, refundAmount?: number): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Convenience dispatch keyed by status. Used from `updateStatus`
|
|
21
|
+
* so a single function fires the right event for whichever status
|
|
22
|
+
* we just transitioned into.
|
|
23
|
+
*/
|
|
24
|
+
export declare function emitForStatus(status: OrderStatus, order: Record<string, unknown>): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Commerce → events bus integration (stacksjs/stacks#1879 Co-18).
|
|
27
|
+
*
|
|
28
|
+
* Background: orders moved through created → paid → shipped →
|
|
29
|
+
* delivered states with no `events.dispatch(...)` calls. Every
|
|
30
|
+
* downstream service (email confirmation, inventory replenishment,
|
|
31
|
+
* analytics, fraud detection) had to poll the orders table because
|
|
32
|
+
* there was no subscription path. The framework's event bus from
|
|
33
|
+
* `@stacksjs/events` has been ready since #1878 — commerce just
|
|
34
|
+
* wasn't wired into it.
|
|
35
|
+
*
|
|
36
|
+
* This module wraps the dispatch calls so they:
|
|
37
|
+
* - Lazy-import the events package so commerce stays usable in
|
|
38
|
+
* environments that don't load it (CLI scripts, migrations)
|
|
39
|
+
* - Silently no-op when the events package isn't installed
|
|
40
|
+
* - Use a structured payload shape callers can subscribe with type safety
|
|
41
|
+
* - Never throw — emission failures shouldn't abort the order write
|
|
42
|
+
*
|
|
43
|
+
* Status-transition guard (stacksjs/stacks#1879 Co-4) lives here
|
|
44
|
+
* too so the rules are co-located with the events they trigger.
|
|
45
|
+
*/
|
|
46
|
+
export type OrderStatus = 'PENDING' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED' | 'REFUNDED';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate that a set of coupon ids satisfies the stacking policy
|
|
3
|
+
* (stacksjs/stacks#1879 Co-7). Pre-fix there was no enforcement
|
|
4
|
+
* anywhere — frontend could submit two coupons and double-discount
|
|
5
|
+
* the order with no signal. Default policy is single-coupon-only;
|
|
6
|
+
* apps that intentionally support stacking opt in.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const stack = validateCouponStacking(cart.couponIds)
|
|
11
|
+
* if (!stack.ok)
|
|
12
|
+
* throw new HttpError(400, `Coupon stacking violation: ${stack.reason}`)
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function validateCouponStacking(couponIds: ReadonlyArray<number>, policy?: CouponStackingPolicy): CouponStackingResult;
|
|
16
|
+
/**
|
|
17
|
+
* Validate that a cart line-item's quantity respects the product's
|
|
18
|
+
* `min_order_qty` / `max_order_qty` fields
|
|
19
|
+
* (stacksjs/stacks#1879 Co-14). Looks up the product server-side
|
|
20
|
+
* so the client can't bypass by lying about the bounds.
|
|
21
|
+
*
|
|
22
|
+
* Products without explicit bounds default to "any positive int."
|
|
23
|
+
* A quantity of 0 or negative always fails — the cart's UI is
|
|
24
|
+
* responsible for letting users remove items, not for passing
|
|
25
|
+
* sentinel zeros through the API.
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateQuantityBounds(productId: number, quantity: number): Promise<QuantityBoundsResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Batch variant — short-circuits on the first violation so the
|
|
30
|
+
* caller can surface a single useful "item X has min qty Y"
|
|
31
|
+
* error rather than a list of every failed line.
|
|
32
|
+
*/
|
|
33
|
+
export declare function validateCartQuantities(items: ReadonlyArray<{ productId: number, quantity: number }>): Promise<QuantityBoundsResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete carts that have been untouched for more than
|
|
36
|
+
* `olderThanDays` days (stacksjs/stacks#1879 Co-15). Default 30.
|
|
37
|
+
* Capped at `limit` rows per call (default 1000) so the delete
|
|
38
|
+
* doesn't hold a long write lock on a busy database. Returns the
|
|
39
|
+
* count + cutoff so callers can log or loop.
|
|
40
|
+
*
|
|
41
|
+
* Schedule via `@stacksjs/scheduler` (now fully wired since
|
|
42
|
+
* #1877's scheduler audit closed) to run nightly:
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* // app/Scheduler.ts
|
|
47
|
+
* import { cleanupAbandonedCarts } from '@stacksjs/commerce'
|
|
48
|
+
*
|
|
49
|
+
* schedule.job(async () => {
|
|
50
|
+
* let total = 0
|
|
51
|
+
* while (true) {
|
|
52
|
+
* const r = await cleanupAbandonedCarts({ olderThanDays: 30, limit: 1000 })
|
|
53
|
+
* total += r.deleted
|
|
54
|
+
* if (r.deleted < 1000) break
|
|
55
|
+
* }
|
|
56
|
+
* log.info(`Cleaned up ${total} abandoned carts`)
|
|
57
|
+
* }).daily().setTimeZone('UTC').withName('CleanupAbandonedCarts')
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function cleanupAbandonedCarts(options?: CleanupAbandonedCartsOptions): Promise<CleanupAbandonedCartsResult>;
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Co-7: coupon stacking guard
|
|
63
|
+
// ============================================================================
|
|
64
|
+
export declare interface CouponStackingPolicy {
|
|
65
|
+
allowMultiple?: boolean
|
|
66
|
+
maxStacked?: number
|
|
67
|
+
}
|
|
68
|
+
export declare interface CouponStackingResult {
|
|
69
|
+
ok: boolean
|
|
70
|
+
count: number
|
|
71
|
+
reason?: 'multiple-not-allowed' | 'exceeds-max-stacked'
|
|
72
|
+
}
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Co-14: min/max order quantity bounds
|
|
75
|
+
// ============================================================================
|
|
76
|
+
export declare interface QuantityBoundsResult {
|
|
77
|
+
ok: boolean
|
|
78
|
+
productId: number
|
|
79
|
+
quantity: number
|
|
80
|
+
reason?: 'below-min' | 'above-max' | 'non-positive' | 'product-missing'
|
|
81
|
+
bound?: number
|
|
82
|
+
}
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Co-15: abandoned cart cleanup
|
|
85
|
+
// ============================================================================
|
|
86
|
+
export declare interface CleanupAbandonedCartsOptions {
|
|
87
|
+
olderThanDays?: number
|
|
88
|
+
limit?: number
|
|
89
|
+
}
|
|
90
|
+
export declare interface CleanupAbandonedCartsResult {
|
|
91
|
+
deleted: number
|
|
92
|
+
cutoffAt: string
|
|
93
|
+
}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export type { PlaceOrderInput, PlaceOrderResult } from './place-order';
|
|
2
|
+
export type { OrderStatus } from './events';
|
|
3
|
+
export type {
|
|
4
|
+
RecomputeLineItem,
|
|
5
|
+
RecomputeOrderInput,
|
|
6
|
+
RecomputeOrderResult,
|
|
7
|
+
} from './totals';
|
|
8
|
+
export type {
|
|
9
|
+
CleanupAbandonedCartsOptions,
|
|
10
|
+
CleanupAbandonedCartsResult,
|
|
11
|
+
CouponStackingPolicy,
|
|
12
|
+
CouponStackingResult,
|
|
13
|
+
QuantityBoundsResult,
|
|
14
|
+
} from './guards';
|
|
1
15
|
export {
|
|
2
16
|
bulkDestroy,
|
|
3
17
|
bulkSoftDelete,
|
|
@@ -20,8 +34,49 @@ export {
|
|
|
20
34
|
export {
|
|
21
35
|
store,
|
|
22
36
|
} from './store';
|
|
37
|
+
// Atomic order placement (stacksjs/stacks#1879 Co-1).
|
|
38
|
+
// Wraps order + payment + inventory decrement in a single
|
|
39
|
+
// transaction so any failure rolls back the rest.
|
|
40
|
+
export { placeOrder } from './place-order';
|
|
41
|
+
// Event bus integration + status-transition state machine
|
|
42
|
+
// (stacksjs/stacks#1879 Co-18, Co-4). Emit helpers fire
|
|
43
|
+
// order:created / order:paid / order:shipped / order:delivered /
|
|
44
|
+
// order:cancelled / order:refunded for downstream subscribers;
|
|
45
|
+
// `canTransition` rejects illegal state changes.
|
|
46
|
+
export {
|
|
47
|
+
canTransition,
|
|
48
|
+
emitForStatus,
|
|
49
|
+
emitOrderCancelled,
|
|
50
|
+
emitOrderCreated,
|
|
51
|
+
emitOrderDelivered,
|
|
52
|
+
emitOrderPaid,
|
|
53
|
+
emitOrderRefunded,
|
|
54
|
+
emitOrderShipped,
|
|
55
|
+
} from './events';
|
|
23
56
|
export {
|
|
24
57
|
update,
|
|
25
58
|
updateDeliveryInfo,
|
|
26
59
|
updateStatus,
|
|
27
60
|
} from './update';
|
|
61
|
+
// Stripe webhook handlers (stacksjs/stacks#1879 Co-17). Call
|
|
62
|
+
// registerCommerceWebhookHandlers() once at boot; the payments
|
|
63
|
+
// package's processWebhook() verifies signatures + dispatches.
|
|
64
|
+
export {
|
|
65
|
+
handleChargeRefunded,
|
|
66
|
+
handlePaymentIntentFailed,
|
|
67
|
+
handlePaymentIntentSucceeded,
|
|
68
|
+
registerCommerceWebhookHandlers,
|
|
69
|
+
} from './webhook';
|
|
70
|
+
// Server-side cart-to-order total recompute (stacksjs/stacks#1879 Co-13).
|
|
71
|
+
// Apps pre-flight the cart through this before placeOrder to detect
|
|
72
|
+
// client-supplied drift and reject "price changed" cases.
|
|
73
|
+
export { recomputeOrderTotals } from './totals';
|
|
74
|
+
// Cart / order policy guards (stacksjs/stacks#1879 Co-7, Co-14, Co-15).
|
|
75
|
+
// Pre-flight the cart through these before placeOrder; abandoned-
|
|
76
|
+
// cart cleanup runs as a scheduled job.
|
|
77
|
+
export {
|
|
78
|
+
cleanupAbandonedCarts,
|
|
79
|
+
validateCartQuantities,
|
|
80
|
+
validateCouponStacking,
|
|
81
|
+
validateQuantityBounds,
|
|
82
|
+
} from './guards';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function placeOrder(input: PlaceOrderInput): Promise<PlaceOrderResult>;
|
|
2
|
+
export declare interface PlaceOrderInput {
|
|
3
|
+
order: NewOrder
|
|
4
|
+
payment?: NewPayment
|
|
5
|
+
inventory?: ReadonlyArray<{ id: number, delta: number }>
|
|
6
|
+
idempotencyKey?: string
|
|
7
|
+
}
|
|
8
|
+
declare type OrderJsonResponse = ModelRow<typeof Order>;
|
|
9
|
+
declare type NewOrder = NewModelData<typeof Order>;
|
|
10
|
+
declare type PaymentJsonResponse = ModelRow<typeof Payment>;
|
|
11
|
+
declare type NewPayment = NewModelData<typeof Payment>;
|
|
12
|
+
/**
|
|
13
|
+
* Discriminated result. `ok: true` means the order, payment, and
|
|
14
|
+
* inventory all committed; `ok: false` carries a `reason` so the
|
|
15
|
+
* caller can surface a specific error to the user.
|
|
16
|
+
*/
|
|
17
|
+
export type PlaceOrderResult = | { ok: true, order: OrderJsonResponse, payment?: PaymentJsonResponse }
|
|
18
|
+
| { ok: false, reason: 'out-of-stock' | 'duplicate-payment' | 'duplicate-idempotency-key' | 'unknown', failedAt?: string, error?: unknown }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare function recomputeOrderTotals(input: RecomputeOrderInput): Promise<RecomputeOrderResult>;
|
|
2
|
+
export declare interface RecomputeLineItem {
|
|
3
|
+
productId: number
|
|
4
|
+
quantity: number
|
|
5
|
+
unitPriceCents?: number
|
|
6
|
+
}
|
|
7
|
+
export declare interface RecomputeOrderInput {
|
|
8
|
+
items: ReadonlyArray<RecomputeLineItem>
|
|
9
|
+
taxRateId?: number
|
|
10
|
+
shippingCents?: number
|
|
11
|
+
discountCents?: number
|
|
12
|
+
clientTotalCents?: number
|
|
13
|
+
maxDriftCents?: number
|
|
14
|
+
}
|
|
15
|
+
export declare interface RecomputeOrderResult {
|
|
16
|
+
subtotalCents: number
|
|
17
|
+
discountCents: number
|
|
18
|
+
taxCents: number
|
|
19
|
+
shippingCents: number
|
|
20
|
+
totalCents: number
|
|
21
|
+
resolvedItems: Array<{ productId: number, quantity: number, unitPriceCents: number, lineCents: number }>
|
|
22
|
+
priceChanged: boolean
|
|
23
|
+
diffCents: number
|
|
24
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrderStatus } from './events';
|
|
1
2
|
/**
|
|
2
3
|
* Update an order by ID
|
|
3
4
|
*
|
|
@@ -13,7 +14,7 @@ export declare function update(id: number, data: Omit<OrderUpdate, 'id'>): Promi
|
|
|
13
14
|
* @param status The new order status
|
|
14
15
|
* @returns The updated order with the new status
|
|
15
16
|
*/
|
|
16
|
-
export declare function updateStatus(id: number, status:
|
|
17
|
+
export declare function updateStatus(id: number, status: OrderStatus): Promise<OrderJsonResponse | undefined>;
|
|
17
18
|
/**
|
|
18
19
|
* Update delivery information for an order
|
|
19
20
|
*
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for `payment_intent.succeeded`. Marks the linked payment
|
|
3
|
+
* row + order row as paid in a single transaction. Idempotent via
|
|
4
|
+
* `recordEventOrSkip`.
|
|
5
|
+
*/
|
|
6
|
+
declare function handlePaymentIntentSucceeded(event: { id: string, data: { object: any } }): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Handler for `payment_intent.payment_failed`. Marks the payment
|
|
9
|
+
* as failed and cancels the linked order. Records the failure
|
|
10
|
+
* reason on the payment row for ops triage.
|
|
11
|
+
*/
|
|
12
|
+
declare function handlePaymentIntentFailed(event: { id: string, data: { object: any } }): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Handler for `charge.refunded`. Marks the order REFUNDED and
|
|
15
|
+
* records the refund amount on the linked payment.
|
|
16
|
+
*/
|
|
17
|
+
declare function handleChargeRefunded(event: { id: string, data: { object: any } }): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Wire commerce's Stripe webhook handlers into the payments
|
|
20
|
+
* package's event router. Call once at boot.
|
|
21
|
+
*
|
|
22
|
+
* Returns an unregister callback for tests that want to swap the
|
|
23
|
+
* handler set between cases.
|
|
24
|
+
*/
|
|
25
|
+
export declare function registerCommerceWebhookHandlers(): Promise<() => void>;
|
|
26
|
+
// Export the individual handlers for direct testing.
|
|
27
|
+
export {
|
|
28
|
+
handleChargeRefunded,
|
|
29
|
+
handlePaymentIntentFailed,
|
|
30
|
+
handlePaymentIntentSucceeded,
|
|
31
|
+
};
|
|
@@ -33,6 +33,34 @@ export declare function updateAvailability(id: number, isAvailable: boolean): Pr
|
|
|
33
33
|
* semantics for admin tooling.
|
|
34
34
|
*/
|
|
35
35
|
export declare function adjustInventory(id: number, delta: number): Promise<ProductJsonResponse | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Atomic batch inventory adjustment (stacksjs/stacks#1879 Co-2).
|
|
38
|
+
*
|
|
39
|
+
* Pre-fix: a multi-item cart called `adjustInventory(id, delta)` N
|
|
40
|
+
* times sequentially. If item 2 failed (out of stock), item 1's
|
|
41
|
+
* decrement was already committed with no rollback — the order
|
|
42
|
+
* persisted with partial stock reservation and the customer saw
|
|
43
|
+
* a refund-on-next-page-load surprise.
|
|
44
|
+
*
|
|
45
|
+
* Post-fix: wraps the per-item loop in `db.transaction`. Any item
|
|
46
|
+
* that can't be adjusted (the existing `adjustInventory` returns
|
|
47
|
+
* null on insufficient stock) throws inside the transaction; the
|
|
48
|
+
* driver rolls every prior decrement back. Returns a discriminated
|
|
49
|
+
* result so the cart handler can surface "item X was out of stock"
|
|
50
|
+
* specifically instead of a generic transaction-failed error.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const result = await adjustInventoryMany([
|
|
55
|
+
* { id: cart.itemA, delta: -2 },
|
|
56
|
+
* { id: cart.itemB, delta: -1 },
|
|
57
|
+
* ])
|
|
58
|
+
* if (!result.ok) {
|
|
59
|
+
* throw new HttpError(409, `Item ${result.productId} is out of stock`)
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare function adjustInventoryMany(updates: ReadonlyArray<{ id: number, delta: number }>): Promise<InventoryBatchResult>;
|
|
36
64
|
/**
|
|
37
65
|
* Update inventory information for a product item
|
|
38
66
|
*
|
|
@@ -43,3 +71,12 @@ export declare function adjustInventory(id: number, delta: number): Promise<Prod
|
|
|
43
71
|
export declare function updateInventory(id: number, inventoryCount?: number): Promise<ProductJsonResponse | undefined>;
|
|
44
72
|
declare type ProductJsonResponse = ModelRow<typeof Product>;
|
|
45
73
|
declare type ProductUpdate = UpdateModelData<typeof Product>;
|
|
74
|
+
/**
|
|
75
|
+
* Discriminated result for `adjustInventoryMany` — `ok: true` when
|
|
76
|
+
* every per-item adjustment succeeded; `ok: false` with `failedAt`
|
|
77
|
+
* (the index in the input array that triggered the rollback) when
|
|
78
|
+
* any single item couldn't be adjusted (out of stock, missing,
|
|
79
|
+
* etc.). All adjustments roll back as a unit on failure.
|
|
80
|
+
*/
|
|
81
|
+
export type InventoryBatchResult = | { ok: true, products: ProductJsonResponse[] }
|
|
82
|
+
| { ok: false, failedAt: number, productId: number, reason: 'out-of-stock' | 'not-found' }
|
|
@@ -14,13 +14,63 @@ export declare function fetchAll(): Promise<ShippingRateJsonResponse[]>;
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function getRatesByZone(zoneId: number): Promise<ShippingRateJsonResponse[]>;
|
|
16
16
|
/**
|
|
17
|
-
* Get shipping rate based on weight and zone
|
|
17
|
+
* Get shipping rate based on weight and zone.
|
|
18
|
+
*
|
|
19
|
+
* **⚠️ Caller-trusted zone (stacksjs/stacks#1879 Co-11).** This
|
|
20
|
+
* function does NOT verify that the `zoneId` actually serves the
|
|
21
|
+
* customer's declared address. A user in NYC can pass a Texas
|
|
22
|
+
* `zoneId` and get cheaper shipping. Use `getRateByWeightAndAddress`
|
|
23
|
+
* below when the request boundary involves untrusted input — it
|
|
24
|
+
* resolves the zone server-side from the address.
|
|
25
|
+
*
|
|
26
|
+
* Kept for back-compat with internal callers that have already
|
|
27
|
+
* resolved the zone (admin tooling, scheduled fulfillment jobs).
|
|
18
28
|
*
|
|
19
29
|
* @param weight Weight in the appropriate unit
|
|
20
|
-
* @param zoneId Shipping zone identifier
|
|
30
|
+
* @param zoneId Shipping zone identifier (caller-trusted)
|
|
21
31
|
* @returns Matching shipping rate or undefined
|
|
22
32
|
*/
|
|
23
33
|
export declare function getRateByWeightAndZone(weight: number, zoneId: number): Promise<ShippingRateJsonResponse | undefined>;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the canonical shipping zone for a delivery address
|
|
36
|
+
* (stacksjs/stacks#1879 Co-11). Walks the zones table looking for
|
|
37
|
+
* the most specific match: postal-code → region → country-only.
|
|
38
|
+
* Returns null when no zone covers the address.
|
|
39
|
+
*
|
|
40
|
+
* The current zones schema stores `countries` as a comma-separated
|
|
41
|
+
* string (per `getZonesByCountry`'s LIKE-query); we use the same
|
|
42
|
+
* convention here. Apps with a normalized zone-country join table
|
|
43
|
+
* can override by replacing this function via the package facade.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveZoneForAddress(address: ShippingAddress): Promise<number | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Safer counterpart to `getRateByWeightAndZone`. Resolves the zone
|
|
48
|
+
* server-side from the delivery address, then fetches the rate
|
|
49
|
+
* for that zone. Throws when the address doesn't match any active
|
|
50
|
+
* zone — caller surfaces "we don't ship to your area" to the user.
|
|
51
|
+
*
|
|
52
|
+
* Use this from any HTTP/API boundary where the address is
|
|
53
|
+
* caller-controlled. Internal callers that already have a verified
|
|
54
|
+
* zone can stay on `getRateByWeightAndZone`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const rate = await getRateByWeightAndAddress(2.5, {
|
|
59
|
+
* countryCode: 'US',
|
|
60
|
+
* region: 'NY',
|
|
61
|
+
* postalCode: '10001',
|
|
62
|
+
* })
|
|
63
|
+
* if (!rate) throw new HttpError(404, 'No shipping rate for this address')
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare function getRateByWeightAndAddress(weight: number, address: ShippingAddress): Promise<ShippingRateJsonResponse | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Verify that a caller-supplied `zoneId` actually serves the given
|
|
69
|
+
* address. Returns true when the address resolves to the same zone.
|
|
70
|
+
* Use when a flow already has a `zoneId` from earlier and just
|
|
71
|
+
* wants to confirm it wasn't tampered with mid-checkout.
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateZoneMatchesAddress(zoneId: number, address: ShippingAddress): Promise<boolean>;
|
|
24
74
|
/**
|
|
25
75
|
* Format shipping rate options for dropdown menus or selectors
|
|
26
76
|
*
|
|
@@ -34,4 +84,15 @@ export declare function formatShippingRateOptions(): Promise<{ id: number, shipp
|
|
|
34
84
|
* @returns List of shipping rates for the specified method
|
|
35
85
|
*/
|
|
36
86
|
export declare function getShippingRatesByMethod(methodId: number): Promise<ShippingRateJsonResponse[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Shipping address shape used by the safe-by-default lookup helpers
|
|
89
|
+
* (stacksjs/stacks#1879 Co-11). All fields except `countryCode` are
|
|
90
|
+
* optional — zone matching tries them in order of specificity
|
|
91
|
+
* (postal → region → country) and returns the most specific match.
|
|
92
|
+
*/
|
|
93
|
+
export declare interface ShippingAddress {
|
|
94
|
+
countryCode: string
|
|
95
|
+
region?: string
|
|
96
|
+
postalCode?: string
|
|
97
|
+
}
|
|
37
98
|
declare type ShippingRateJsonResponse = ModelRow<typeof ShippingRate>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type { ShippingAddress } from './fetch';
|
|
1
2
|
export {
|
|
2
3
|
bulkDestroy,
|
|
3
4
|
destroy,
|
|
@@ -9,9 +10,14 @@ export {
|
|
|
9
10
|
fetchAll,
|
|
10
11
|
fetchById,
|
|
11
12
|
formatShippingRateOptions,
|
|
13
|
+
// Safe-by-default address-based lookups (stacksjs/stacks#1879 Co-11).
|
|
14
|
+
// Use these from request handlers where the address is caller-controlled.
|
|
15
|
+
getRateByWeightAndAddress,
|
|
12
16
|
getRateByWeightAndZone,
|
|
13
17
|
getRatesByZone,
|
|
14
18
|
getShippingRatesByMethod,
|
|
19
|
+
resolveZoneForAddress,
|
|
20
|
+
validateZoneMatchesAddress,
|
|
15
21
|
} from './fetch';
|
|
16
22
|
// Functions from store.ts
|
|
17
23
|
export {
|