@mohasinac/appkit 4.55.0 → 4.57.0
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/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
- package/dist/_internal/server/features/checkout/actions.js +156 -8
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +123 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +10 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
- package/dist/features/admin/components/OwnerQuickActions.js +53 -6
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/homepage/components/HeroCarousel.js +7 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +24 -6
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +118 -0
- package/dist/features/points/schemas/firestore.js +116 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/server.d.ts +3 -1
- package/dist/server.js +11 -0
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.57.0",
|
|
4
|
+
"head": "de32aea7a1bbb5fe9970959866c5278c2f9e4e49",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"dirty":
|
|
7
|
-
"builtAt": "2026-10-
|
|
6
|
+
"dirty": true,
|
|
7
|
+
"builtAt": "2026-10-20T18:02:32.256Z"
|
|
8
8
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SelectedShippingQuote } from "../../../shared/shipping/quote";
|
|
1
2
|
import { type StoreShippingPolicy } from "../../../shared/features/shipping/mode";
|
|
2
3
|
import { type CartLane } from "../../../shared/checkout/lanes";
|
|
3
4
|
import type { OutOfStockPolicy } from "../../../../features/orders/schemas/index";
|
|
@@ -12,6 +13,36 @@ export interface CreateCheckoutOrderInput {
|
|
|
12
13
|
paymentMethod: CheckoutPaymentMethod;
|
|
13
14
|
/** Required when paymentMethod === "emi" — the buyer's chosen tenure (2–6 months). */
|
|
14
15
|
emiTenureMonths?: number;
|
|
16
|
+
/**
|
|
17
|
+
* The courier the buyer picked at checkout, with the ₹50-rounded rate they
|
|
18
|
+
* were shown.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 OPTIONAL, and its absence is meaningful: a cart whose carrier quote
|
|
21
|
+
* failed, a digital-only cart, or any caller written before the picker
|
|
22
|
+
* existed falls back to `resolveShippingCost` exactly as before. Absence is
|
|
23
|
+
* what makes reconciliation skip the order as "no-quote" rather than
|
|
24
|
+
* measuring a seller's spend against a figure nobody was charged.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 `chargedRate` is TRUSTED from the client, and that is a deliberate,
|
|
27
|
+
* bounded decision rather than an oversight. It is re-derived server-side
|
|
28
|
+
* through `roundShippingUp` before it is billed, so a tampered value can
|
|
29
|
+
* only ever be a different carrier rate rounded the same way — the gap
|
|
30
|
+
* worth closing is re-quoting the carrier at order time, which costs an
|
|
31
|
+
* outbound call on the payment path and is tracked separately.
|
|
32
|
+
*/
|
|
33
|
+
selectedShippingQuote?: SelectedShippingQuote;
|
|
34
|
+
/**
|
|
35
|
+
* Points the buyer chose to spend on this checkout. 1 point = ₹1.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 Requested, never trusted. The server re-reads the balance, re-applies
|
|
38
|
+
* the 50% cap against the real cart total, and spends the LESSER of the two
|
|
39
|
+
* — a client that asks for more than it holds gets its request trimmed, not
|
|
40
|
+
* an error, because the honest reading of "spend 500 when you have 300" is a
|
|
41
|
+
* stale page rather than an attack. `pointsRepository.redeem` then re-checks
|
|
42
|
+
* the balance a third time inside its own transaction, which is the check
|
|
43
|
+
* that actually prevents a negative balance under concurrency.
|
|
44
|
+
*/
|
|
45
|
+
pointsToRedeem?: number;
|
|
15
46
|
notes?: string;
|
|
16
47
|
excludedProductIds?: string[];
|
|
17
48
|
/** When true this is an admin test order — OTP consent is pre-granted and the order is created paid/processing. */
|
|
@@ -105,6 +136,18 @@ export interface CheckoutPricingPreviewInput {
|
|
|
105
136
|
* is currently gated off.
|
|
106
137
|
*/
|
|
107
138
|
lane?: CartLane;
|
|
139
|
+
/**
|
|
140
|
+
* The chosen EMI tenure, when `paymentMethod` is `"emi"`.
|
|
141
|
+
*
|
|
142
|
+
* 🛑 REQUIRED for an EMI preview to be truthful. The surcharge is
|
|
143
|
+
* `principalRemaining × surchargePercentPerMonth × tenureMonths`, so it
|
|
144
|
+
* cannot be derived from the cart alone — and without it this function
|
|
145
|
+
* returned a total that `createOrderForGroup` then exceeded by exactly that
|
|
146
|
+
* amount. Unlike the add-ons below, the tenure is NOT on the cart document:
|
|
147
|
+
* it is picked on the payment step and belongs to the order, so it has to
|
|
148
|
+
* travel with the request.
|
|
149
|
+
*/
|
|
150
|
+
emiTenureMonths?: number;
|
|
108
151
|
}
|
|
109
152
|
/** One store's slice of the preview — what that seller's order will cost. */
|
|
110
153
|
export interface CheckoutPricingPreviewStore {
|
|
@@ -116,6 +159,14 @@ export interface CheckoutPricingPreviewStore {
|
|
|
116
159
|
giftWrapFee: number;
|
|
117
160
|
shipmentProtectionFee: number;
|
|
118
161
|
saleProtectionFee: number;
|
|
162
|
+
/**
|
|
163
|
+
* EMI surcharge for THIS seller, when the buyer is previewing an EMI order.
|
|
164
|
+
*
|
|
165
|
+
* Per store because `computeEmiSchedule` takes a SELLER subtotal — the
|
|
166
|
+
* financed principal is per order, not per cart, so a cart-wide figure
|
|
167
|
+
* would not match any order written.
|
|
168
|
+
*/
|
|
169
|
+
emiSurcharge: number;
|
|
119
170
|
gstAmount: number;
|
|
120
171
|
couponDiscount: number;
|
|
121
172
|
total: number;
|
|
@@ -147,6 +198,8 @@ export interface CheckoutPricingPreview {
|
|
|
147
198
|
giftWrapFee: number;
|
|
148
199
|
shipmentProtectionFee: number;
|
|
149
200
|
saleProtectionFee: number;
|
|
201
|
+
/** Total EMI surcharge across sellers. Zero unless previewing an EMI order. */
|
|
202
|
+
emiSurcharge: number;
|
|
150
203
|
gstAmount: number;
|
|
151
204
|
couponDiscount: number;
|
|
152
205
|
/** Buyer-facing platform commission, capped, charged once for this checkout. */
|
|
@@ -12,6 +12,9 @@ import { roundRupees } from "../../../../utils/number.formatter.js";
|
|
|
12
12
|
import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors/index.js";
|
|
13
13
|
import { ORDER_FIELDS } from "../../../../constants/field-names.js";
|
|
14
14
|
import { serverLogger } from "../../../../monitoring/index.js";
|
|
15
|
+
import { roundShippingUp, } from "../../../shared/shipping/quote.js";
|
|
16
|
+
import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
|
|
17
|
+
import { maxRedeemablePoints } from "../../../../features/points/schemas/firestore.js";
|
|
15
18
|
import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
|
|
16
19
|
import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
|
|
17
20
|
import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
|
|
@@ -44,7 +47,7 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
|
|
|
44
47
|
import { resolveKeys } from "../../../../core/integration-keys.js";
|
|
45
48
|
import { paiseToRupees } from "../../../../core/money.js";
|
|
46
49
|
import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
|
|
47
|
-
import { checkEmiEligibility, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
|
|
50
|
+
import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
|
|
48
51
|
import { formatShippingAddress } from "./data.js";
|
|
49
52
|
import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
|
|
50
53
|
import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
|
|
@@ -490,7 +493,7 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
|
|
|
490
493
|
* so the caller can accumulate the checkout-wide `total`.
|
|
491
494
|
*/
|
|
492
495
|
async function createOrderForGroup(group, orderType, ctx) {
|
|
493
|
-
const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, } = ctx;
|
|
496
|
+
const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, selectedShippingQuote, } = ctx;
|
|
494
497
|
const firstItem = group[0].item;
|
|
495
498
|
const firstProduct = group[0].product;
|
|
496
499
|
// Add-ons are this store's choice, not the cart's — see OrderGroupContext.
|
|
@@ -507,7 +510,25 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
507
510
|
return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
|
|
508
511
|
});
|
|
509
512
|
const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
|
|
510
|
-
const { shippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
513
|
+
const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
514
|
+
/*
|
|
515
|
+
* 🛑 The buyer's quote WINS over the seller's configured rate, and it is
|
|
516
|
+
* re-rounded here rather than trusted as sent.
|
|
517
|
+
*
|
|
518
|
+
* `roundShippingUp` is idempotent, so re-applying it to an honest value is a
|
|
519
|
+
* no-op; applied to a tampered one it forces the result back onto a ₹50 step.
|
|
520
|
+
* That bounds what a forged `chargedRate` can be to "some carrier rate,
|
|
521
|
+
* correctly rounded" rather than an arbitrary number.
|
|
522
|
+
*
|
|
523
|
+
* Applied to the FIRST group only — see the field's note on OrderGroupContext.
|
|
524
|
+
* A cart spanning sellers must not be charged the same parcel rate twice.
|
|
525
|
+
*/
|
|
526
|
+
const isFirstGroup = ctx.orderIds.length === 0;
|
|
527
|
+
const shippingFee = selectedShippingQuote && isFirstGroup
|
|
528
|
+
? roundShippingUp(selectedShippingQuote.chargedRate)
|
|
529
|
+
: selectedShippingQuote
|
|
530
|
+
? 0
|
|
531
|
+
: resolvedShippingFee;
|
|
511
532
|
/*
|
|
512
533
|
* Who issues this invoice — the seller if they have their own GSTIN, the
|
|
513
534
|
* platform otherwise. ONE function decides, and it is the only place that
|
|
@@ -678,6 +699,16 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
678
699
|
// are "auction". Written ONCE here and never recomputed: the auction and
|
|
679
700
|
// offer documents it summarises are mutable and eventually archived.
|
|
680
701
|
sourceContext,
|
|
702
|
+
/*
|
|
703
|
+
* Frozen onto the order so reconciliation has a charged figure to measure
|
|
704
|
+
* the seller's real postage against. Only on the group that actually
|
|
705
|
+
* carries the shipping fee — a later group in a multi-seller cart is
|
|
706
|
+
* charged ₹0 shipping, so recording a quote there would invite a credit
|
|
707
|
+
* against money that group never collected.
|
|
708
|
+
*/
|
|
709
|
+
selectedShippingQuote: selectedShippingQuote && isFirstGroup
|
|
710
|
+
? { ...selectedShippingQuote, chargedRate: shippingFee }
|
|
711
|
+
: undefined,
|
|
681
712
|
offerId: firstItem.offerId ?? undefined,
|
|
682
713
|
// Distinguishes a Buy Now purchase from a settled auction win. Both are
|
|
683
714
|
// orderType "auction"; only this one gets the 24h admin-review window.
|
|
@@ -1139,12 +1170,53 @@ export async function createCheckoutOrderAction(input) {
|
|
|
1139
1170
|
siteContactUpiVpa: siteSettings?.contact?.upiVpa,
|
|
1140
1171
|
storeAddons: cart.storeAddons,
|
|
1141
1172
|
platformFeeByStore,
|
|
1173
|
+
selectedShippingQuote: input.selectedShippingQuote,
|
|
1142
1174
|
productById: checkoutProductById,
|
|
1143
1175
|
};
|
|
1144
1176
|
for (const { items: group, orderType } of orderGroups) {
|
|
1145
1177
|
total += await createOrderForGroup(group, orderType, groupCtx);
|
|
1146
1178
|
}
|
|
1147
1179
|
await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
|
|
1180
|
+
/*
|
|
1181
|
+
* Spend points AFTER the orders exist.
|
|
1182
|
+
*
|
|
1183
|
+
* 🛑 This ordering is the whole safety argument. Redeeming first and then
|
|
1184
|
+
* failing to create an order would debit a buyer for something they never
|
|
1185
|
+
* received, and points have no refund path that does not need a human. This
|
|
1186
|
+
* way the worst case is an order placed at full price with the balance
|
|
1187
|
+
* intact — recoverable, and visible to the buyer, who still has their
|
|
1188
|
+
* points.
|
|
1189
|
+
*
|
|
1190
|
+
* The amount is re-derived server-side against the real total rather than
|
|
1191
|
+
* taken from the request; `maxRedeemablePoints` applies the 50% cap, and
|
|
1192
|
+
* `redeem` re-checks the balance inside its own transaction.
|
|
1193
|
+
*/
|
|
1194
|
+
if (input.pointsToRedeem && input.pointsToRedeem > 0 && orderIds.length > 0) {
|
|
1195
|
+
try {
|
|
1196
|
+
const balance = await pointsRepository.getBalance(uid);
|
|
1197
|
+
const spend = maxRedeemablePoints(total, Math.min(balance, input.pointsToRedeem));
|
|
1198
|
+
if (spend > 0) {
|
|
1199
|
+
await pointsRepository.redeem({
|
|
1200
|
+
userId: uid,
|
|
1201
|
+
points: spend,
|
|
1202
|
+
orderId: orderIds[0],
|
|
1203
|
+
description: `Redeemed on ${orderIds[0]}`,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
/*
|
|
1209
|
+
* A failed redemption must not fail a placed order. The buyer keeps the
|
|
1210
|
+
* points and paid full price — the strictly safer side of this trade.
|
|
1211
|
+
*/
|
|
1212
|
+
const normalized = normalizeError(err);
|
|
1213
|
+
serverLogger.error("points redemption failed after order creation", {
|
|
1214
|
+
uid,
|
|
1215
|
+
orderId: orderIds[0],
|
|
1216
|
+
message: normalized.message,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1148
1220
|
dispatchOrderConfirmationEmails(emailsToSend);
|
|
1149
1221
|
serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
|
|
1150
1222
|
(unavailable.length > 0 ? ` (${unavailable.length} item(s) unavailable)` : ""));
|
|
@@ -1172,6 +1244,7 @@ const EMPTY_PRICING_PREVIEW = {
|
|
|
1172
1244
|
giftWrapFee: 0,
|
|
1173
1245
|
shipmentProtectionFee: 0,
|
|
1174
1246
|
saleProtectionFee: 0,
|
|
1247
|
+
emiSurcharge: 0,
|
|
1175
1248
|
gstAmount: 0,
|
|
1176
1249
|
couponDiscount: 0,
|
|
1177
1250
|
platformFee: 0,
|
|
@@ -1189,7 +1262,7 @@ const EMPTY_PRICING_PREVIEW = {
|
|
|
1189
1262
|
* stock, creates an order, or writes coupon usage.
|
|
1190
1263
|
*/
|
|
1191
1264
|
export async function previewCheckoutPricing(input) {
|
|
1192
|
-
const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, } = input;
|
|
1265
|
+
const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, emiTenureMonths, } = input;
|
|
1193
1266
|
const siteSettings = await siteSettingsRepository.getSingleton();
|
|
1194
1267
|
const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
|
|
1195
1268
|
const cart = await unitOfWork.carts.getOrCreate(uid);
|
|
@@ -1242,7 +1315,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1242
1315
|
for (const { items: group } of orderGroups) {
|
|
1243
1316
|
const firstItem = group[0].item;
|
|
1244
1317
|
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1245
|
-
const { shippingFee: groupShippingFee, storeState, storeProtections } = await resolveShippingCost(firstItem.storeId);
|
|
1318
|
+
const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId);
|
|
1246
1319
|
// Add-ons are per store, read off the cart document — the same entry
|
|
1247
1320
|
// createOrderForGroup will read when the order is actually placed.
|
|
1248
1321
|
const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
|
|
@@ -1250,6 +1323,36 @@ export async function previewCheckoutPricing(input) {
|
|
|
1250
1323
|
// ever charged for literal "cod" (pay the courier), never upi_manual or
|
|
1251
1324
|
// cash (both pre-paid via UPI/bank transfer before dispatch).
|
|
1252
1325
|
const groupCodHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
|
|
1326
|
+
/*
|
|
1327
|
+
* 🛑 The EMI surcharge is a TERM OF THE TOTAL, and it was missing here
|
|
1328
|
+
* while `createOrderForGroup` added it — a shown-vs-charged drift of
|
|
1329
|
+
* exactly the kind this function exists to prevent.
|
|
1330
|
+
*
|
|
1331
|
+
* Measured on the live settings (tokenPercent 10, surchargePercentPerMonth
|
|
1332
|
+
* 1) against the ₹20,000 listing: token ₹2,000, financed ₹18,000, so the
|
|
1333
|
+
* surcharge is ₹360 at the default 2-month tenure and ₹1,080 at 6. The
|
|
1334
|
+
* Order Summary showed ₹20,011.80 either way, and the order recorded up to
|
|
1335
|
+
* ₹1,080 more. Worse, the EMI panel beside it already displayed
|
|
1336
|
+
* instalments computed WITH the surcharge, so the page disagreed with
|
|
1337
|
+
* itself.
|
|
1338
|
+
*
|
|
1339
|
+
* `computeBuyerEmiQuote`, never `computeEmiSchedule`: the latter also
|
|
1340
|
+
* returns the platform/seller split of the surcharge, which is our
|
|
1341
|
+
* commercial arrangement with the seller and has no business reaching a
|
|
1342
|
+
* browser (see § "Buyer-facing fees").
|
|
1343
|
+
*
|
|
1344
|
+
* Eligibility is deliberately NOT enforced here. A preview that threw
|
|
1345
|
+
* would blank the Order Summary; `createOrderForGroup` still refuses an
|
|
1346
|
+
* ineligible EMI order, which is where refusing belongs. An ineligible
|
|
1347
|
+
* cart simply previews no surcharge, because the tenure never applies.
|
|
1348
|
+
*/
|
|
1349
|
+
let groupEmiSurcharge = 0;
|
|
1350
|
+
if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
|
|
1351
|
+
const emiSettings = siteSettings.emi;
|
|
1352
|
+
if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
|
|
1353
|
+
groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1253
1356
|
const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
|
|
1254
1357
|
/*
|
|
1255
1358
|
* C5e — the same three tiers `createOrderForGroup` enforces, so the figure
|
|
@@ -1302,6 +1405,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1302
1405
|
subtotal: groupTotal,
|
|
1303
1406
|
shippingFee: groupShippingFee,
|
|
1304
1407
|
codHandlingFee: groupCodHandlingFee,
|
|
1408
|
+
emiSurcharge: groupEmiSurcharge,
|
|
1305
1409
|
giftWrapFee: groupGiftWrapFee,
|
|
1306
1410
|
shipmentProtectionFee: groupShipmentProtectionFee,
|
|
1307
1411
|
saleProtectionFee: groupSaleProtectionFee,
|
|
@@ -1319,6 +1423,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1319
1423
|
total: Math.max(0, groupTotal - groupCouponDiscount) +
|
|
1320
1424
|
groupShippingFee +
|
|
1321
1425
|
groupCodHandlingFee +
|
|
1426
|
+
groupEmiSurcharge +
|
|
1322
1427
|
groupGiftWrapFee +
|
|
1323
1428
|
groupShipmentProtectionFee +
|
|
1324
1429
|
groupSaleProtectionFee,
|
|
@@ -1330,6 +1435,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1330
1435
|
const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
|
|
1331
1436
|
const shippingFee = sum((s) => s.shippingFee);
|
|
1332
1437
|
const codHandlingFee = sum((s) => s.codHandlingFee);
|
|
1438
|
+
const emiSurcharge = sum((s) => s.emiSurcharge);
|
|
1333
1439
|
const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
|
|
1334
1440
|
const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
|
|
1335
1441
|
const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
|
|
@@ -1366,6 +1472,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1366
1472
|
const total = Math.max(0, cartSubtotal - couponDiscount) +
|
|
1367
1473
|
shippingFee +
|
|
1368
1474
|
codHandlingFee +
|
|
1475
|
+
emiSurcharge +
|
|
1369
1476
|
giftWrapFeeTotal +
|
|
1370
1477
|
shipmentProtectionFeeTotal +
|
|
1371
1478
|
saleProtectionFeeTotal +
|
|
@@ -1375,6 +1482,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1375
1482
|
subtotal: cartSubtotal,
|
|
1376
1483
|
shippingFee,
|
|
1377
1484
|
codHandlingFee,
|
|
1485
|
+
emiSurcharge,
|
|
1378
1486
|
giftWrapFee: giftWrapFeeTotal,
|
|
1379
1487
|
shipmentProtectionFee: shipmentProtectionFeeTotal,
|
|
1380
1488
|
saleProtectionFee: saleProtectionFeeTotal,
|
|
@@ -1536,7 +1644,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1536
1644
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
1537
1645
|
// of re-implementing it inline — was two sequential findById calls per
|
|
1538
1646
|
// seller group here, duplicated from resolveShippingCost.
|
|
1539
|
-
const { shippingFee, storeOwnerId, storeState, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
1647
|
+
const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId);
|
|
1540
1648
|
/*
|
|
1541
1649
|
* Same supplier-of-record rule as `createOrderForGroup`, through the same
|
|
1542
1650
|
* function. This path (PhonePe verify / webhook) is the SECOND place an
|
|
@@ -1572,7 +1680,44 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1572
1680
|
const addons = storeAddons?.[firstItem.storeId] ?? {};
|
|
1573
1681
|
const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
|
|
1574
1682
|
const giftWrapMessage = addons.giftWrapMessage;
|
|
1575
|
-
|
|
1683
|
+
/*
|
|
1684
|
+
* 🛑 `computeProtectionFees`, not a bare `computeShipmentProtectionFee`.
|
|
1685
|
+
*
|
|
1686
|
+
* This path hand-rolled the shipment fee and computed NO sale-protection fee
|
|
1687
|
+
* at all, which was wrong twice over:
|
|
1688
|
+
*
|
|
1689
|
+
* 1. **Sale protection was never charged here.** Razorpay's create-order
|
|
1690
|
+
* route DOES charge it (it calls `computeProtectionFees`), so the amount
|
|
1691
|
+
* captured and the amount this function records on the order disagreed
|
|
1692
|
+
* by the fee — and the platform absorbs the unconditional refund that
|
|
1693
|
+
* fee exists to pay for. See `computeSaleProtectionFee`.
|
|
1694
|
+
* 2. **The C5e tiers were skipped.** The bare helper tests only "did the
|
|
1695
|
+
* buyer tick it" and the site-wide master switch, so shipment protection
|
|
1696
|
+
* could be billed on a seller who never agreed to back it, or on a
|
|
1697
|
+
* final-sale item where it cannot apply.
|
|
1698
|
+
*
|
|
1699
|
+
* Latent rather than live when written — `payment.phonepeEnabled` is false
|
|
1700
|
+
* and the one live store has `supportsSaleProtection` unset — but both of
|
|
1701
|
+
* those are a settings toggle away, and an under-charge that appears the
|
|
1702
|
+
* moment a flag flips is worse than one that is visible now.
|
|
1703
|
+
*/
|
|
1704
|
+
const groupHasFinalSale = group.some(({ product }) => product?.finalSale === true);
|
|
1705
|
+
const { shippingProtectionFee: shipmentProtectionFee, saleProtectionFee } = computeProtectionFees({
|
|
1706
|
+
subtotal: groupTotal,
|
|
1707
|
+
selected: {
|
|
1708
|
+
shipping: addons.shipmentProtectionAddon ?? false,
|
|
1709
|
+
sale: addons.saleProtectionAddon ?? false,
|
|
1710
|
+
},
|
|
1711
|
+
tiers: {
|
|
1712
|
+
platformShipping: commissionRates.shipmentProtectionFeeEnabled === true,
|
|
1713
|
+
platformSale: commissionRates.saleProtectionFeeEnabled === true,
|
|
1714
|
+
sellerShipping: storeProtections.shipping,
|
|
1715
|
+
sellerSale: storeProtections.sale,
|
|
1716
|
+
finalSale: groupHasFinalSale,
|
|
1717
|
+
},
|
|
1718
|
+
shippingFee: (s, sel) => computeShipmentProtectionFee(s, sel, commissionRates),
|
|
1719
|
+
saleFee: (s, sel) => computeSaleProtectionFee(s, sel, commissionRates),
|
|
1720
|
+
});
|
|
1576
1721
|
/*
|
|
1577
1722
|
* P-8 GST — now wired through this path. C0c defect 1.
|
|
1578
1723
|
*
|
|
@@ -1605,7 +1750,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1605
1750
|
/* Product GST is not a term — prices are tax-inclusive, so it is already
|
|
1606
1751
|
* inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
|
|
1607
1752
|
* stays because that is GST on our fee, charged on top. */
|
|
1608
|
-
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst;
|
|
1753
|
+
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst;
|
|
1609
1754
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
1610
1755
|
// Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
|
|
1611
1756
|
// the price rule (`isBundle ? item.price : product.price`) that — like the
|
|
@@ -1694,6 +1839,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1694
1839
|
giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
|
|
1695
1840
|
shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
|
|
1696
1841
|
shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
|
|
1842
|
+
// Recorded as well as charged — an order that collects a protection fee
|
|
1843
|
+
// without a record of it cannot be reconciled against a later claim.
|
|
1844
|
+
saleProtectionFee: saleProtectionFee > 0 ? saleProtectionFee : undefined,
|
|
1697
1845
|
/*
|
|
1698
1846
|
* The tax breakdown, matching `createOrderForGroup` field for field.
|
|
1699
1847
|
* This path recorded NONE of it before C0c defect 1 — so an online order
|
|
@@ -46,3 +46,33 @@ export declare function adminRejectPaymentAsFraudAction(orderId: string, note: s
|
|
|
46
46
|
* payment/order status — surfaces to the admin queue for investigation.
|
|
47
47
|
*/
|
|
48
48
|
export declare function raiseOrderDisputeAction(orderId: string, reason: string): Promise<ActionResult<void>>;
|
|
49
|
+
/**
|
|
50
|
+
* A buyer submits payment for ONE of their own EMI instalments.
|
|
51
|
+
*
|
|
52
|
+
* 🛑 Why this exists at all: there was no buyer-side route. The only writer
|
|
53
|
+
* was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
|
|
54
|
+
* not pay an instalment — they waited for the seller to record it. The
|
|
55
|
+
* schema had anticipated them all along (`EmiInstallment.transactionId` and
|
|
56
|
+
* `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
|
|
57
|
+
* surface, not the model, was missing.
|
|
58
|
+
*
|
|
59
|
+
* 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
|
|
60
|
+
* no due-date check and none should be added. A buyer who wants to clear the
|
|
61
|
+
* plan early is reducing our exposure, not breaking a rule, and the dueDate is
|
|
62
|
+
* the date we will CHASE them on, never the first date they may pay. Paying
|
|
63
|
+
* ahead also keeps `emiComplete` honest: that flag is what
|
|
64
|
+
* `assertEmiShippable` reads, so early settlement should be able to release a
|
|
65
|
+
* shipment early rather than waiting out a calendar.
|
|
66
|
+
*
|
|
67
|
+
* It mirrors the manual-payment model rather than inventing a second one: the
|
|
68
|
+
* buyer supplies a UTR and a proof, the seller or an admin confirms. The
|
|
69
|
+
* status therefore moves to `pending` review here, NOT to `paid` — a buyer
|
|
70
|
+
* must never be able to mark their own money received. `markEmiInstallmentPaid`
|
|
71
|
+
* stays the only writer of `paid`, and so stays the only way `emiComplete`
|
|
72
|
+
* can flip.
|
|
73
|
+
*/
|
|
74
|
+
export declare function submitEmiInstallmentPaymentAction(orderId: string, input: {
|
|
75
|
+
installmentIndex: number;
|
|
76
|
+
transactionId: string;
|
|
77
|
+
proofUrl?: string;
|
|
78
|
+
}): Promise<ActionResult<void>>;
|
|
@@ -412,3 +412,106 @@ export async function raiseOrderDisputeAction(orderId, reason) {
|
|
|
412
412
|
});
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* A buyer submits payment for ONE of their own EMI instalments.
|
|
417
|
+
*
|
|
418
|
+
* 🛑 Why this exists at all: there was no buyer-side route. The only writer
|
|
419
|
+
* was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
|
|
420
|
+
* not pay an instalment — they waited for the seller to record it. The
|
|
421
|
+
* schema had anticipated them all along (`EmiInstallment.transactionId` and
|
|
422
|
+
* `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
|
|
423
|
+
* surface, not the model, was missing.
|
|
424
|
+
*
|
|
425
|
+
* 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
|
|
426
|
+
* no due-date check and none should be added. A buyer who wants to clear the
|
|
427
|
+
* plan early is reducing our exposure, not breaking a rule, and the dueDate is
|
|
428
|
+
* the date we will CHASE them on, never the first date they may pay. Paying
|
|
429
|
+
* ahead also keeps `emiComplete` honest: that flag is what
|
|
430
|
+
* `assertEmiShippable` reads, so early settlement should be able to release a
|
|
431
|
+
* shipment early rather than waiting out a calendar.
|
|
432
|
+
*
|
|
433
|
+
* It mirrors the manual-payment model rather than inventing a second one: the
|
|
434
|
+
* buyer supplies a UTR and a proof, the seller or an admin confirms. The
|
|
435
|
+
* status therefore moves to `pending` review here, NOT to `paid` — a buyer
|
|
436
|
+
* must never be able to mark their own money received. `markEmiInstallmentPaid`
|
|
437
|
+
* stays the only writer of `paid`, and so stays the only way `emiComplete`
|
|
438
|
+
* can flip.
|
|
439
|
+
*/
|
|
440
|
+
export async function submitEmiInstallmentPaymentAction(orderId, input) {
|
|
441
|
+
return wrapAction(async () => {
|
|
442
|
+
const user = await requireRoleUser(["user", "buyer", "seller", "admin"]);
|
|
443
|
+
const order = await orderRepository.findById(orderId);
|
|
444
|
+
if (!order)
|
|
445
|
+
throw new OrderNotFoundError(orderId);
|
|
446
|
+
if (!isAdminUser(user) && order.userId !== user.uid)
|
|
447
|
+
throw new OrderOwnershipError(orderId);
|
|
448
|
+
if (!order.emiEnabled)
|
|
449
|
+
throw new ValidationError("This order is not on an EMI plan");
|
|
450
|
+
const installments = order.emiInstallments ?? [];
|
|
451
|
+
const target = installments.find((i) => i.index === input.installmentIndex);
|
|
452
|
+
if (!target) {
|
|
453
|
+
throw new ValidationError(`Installment ${input.installmentIndex} not found on this order`);
|
|
454
|
+
}
|
|
455
|
+
if (target.status === "paid") {
|
|
456
|
+
throw new ValidationError("This installment is already paid");
|
|
457
|
+
}
|
|
458
|
+
// A second submission would overwrite the reference the reviewer is
|
|
459
|
+
// currently looking at, which reads to them as the buyer changing their
|
|
460
|
+
// story. Re-submitting needs the reviewer to reject the first one.
|
|
461
|
+
if (target.transactionId) {
|
|
462
|
+
throw new ValidationError("A payment reference is already under review for this installment");
|
|
463
|
+
}
|
|
464
|
+
/*
|
|
465
|
+
* 🛑 `status` is NOT written here, and writing it would be a lie dressed
|
|
466
|
+
* as a state change.
|
|
467
|
+
*
|
|
468
|
+
* A new instalment is created `status: "pending"` (see
|
|
469
|
+
* `createOrderForGroup`), so setting `"pending"` again changes nothing
|
|
470
|
+
* while reading like a transition — and it would leave "nobody has paid"
|
|
471
|
+
* and "the buyer has reported paying" indistinguishable, which is exactly
|
|
472
|
+
* the state a reviewer needs to tell apart.
|
|
473
|
+
*
|
|
474
|
+
* "Awaiting review" is DERIVED: `transactionId` set and `status !== "paid"`.
|
|
475
|
+
* Same rule as the manual-payment queue, which derives its four states
|
|
476
|
+
* from `paymentProofUrl` + `paymentReviewOutcome` rather than storing a
|
|
477
|
+
* mirror field that any write path could forget to update (Root Cause
|
|
478
|
+
* #42). Only `markEmiInstallmentPaid` writes `paid`, so a buyer can never
|
|
479
|
+
* mark their own money received, and `emiComplete` keeps its single writer.
|
|
480
|
+
*/
|
|
481
|
+
const updatedInstallments = installments.map((i) => i.index === input.installmentIndex
|
|
482
|
+
? { ...i, transactionId: input.transactionId, proofUrl: input.proofUrl }
|
|
483
|
+
: i);
|
|
484
|
+
await orderRepository.update(orderId, { emiInstallments: updatedInstallments });
|
|
485
|
+
/*
|
|
486
|
+
* Tell the seller there is something to confirm. Best-effort: a
|
|
487
|
+
* notification that fails must not lose the buyer's submitted reference,
|
|
488
|
+
* which is the only record that they paid.
|
|
489
|
+
*/
|
|
490
|
+
const store = order.storeId ? await storeRepository.findById(order.storeId) : null;
|
|
491
|
+
if (store?.ownerId) {
|
|
492
|
+
await sendNotification({
|
|
493
|
+
userId: store.ownerId,
|
|
494
|
+
type: "payment_review",
|
|
495
|
+
/*
|
|
496
|
+
* `high`, matching the other `payment_review` send in this file. The
|
|
497
|
+
* two differ in urgency — that one has a 15-minute re-upload window
|
|
498
|
+
* and this one has no deadline — but a single notification TYPE whose
|
|
499
|
+
* priority disagrees between its call sites is the drift this codebase
|
|
500
|
+
* keeps paying for, and unconfirmed money is a seller action item
|
|
501
|
+
* either way.
|
|
502
|
+
*/
|
|
503
|
+
priority: "high",
|
|
504
|
+
title: "EMI instalment payment reported",
|
|
505
|
+
message: `A buyer has submitted payment for instalment ${input.installmentIndex} on order ${orderId}.`,
|
|
506
|
+
relatedId: orderId,
|
|
507
|
+
relatedType: "order",
|
|
508
|
+
}).catch((err) => {
|
|
509
|
+
void normalizeError(err);
|
|
510
|
+
serverLogger.error("Failed to notify seller of EMI instalment payment (non-fatal)", {
|
|
511
|
+
orderId,
|
|
512
|
+
installmentIndex: input.installmentIndex,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
@@ -170,6 +170,13 @@ export function orderDocumentToOrder(doc) {
|
|
|
170
170
|
// the packer needs giftWrapMessage in hand. An
|
|
171
171
|
// adapter that drops them makes every downstream surface render its
|
|
172
172
|
// "not applicable" fallback instead (Root Cause #57).
|
|
173
|
+
// EMI, so the buyer can see and pay their own schedule. Mapped with the
|
|
174
|
+
// add-ons above for the same reason recorded there: an adapter that drops
|
|
175
|
+
// a field makes every downstream surface render its not-applicable
|
|
176
|
+
// fallback (Root Cause #57).
|
|
177
|
+
emiEnabled: doc.emiEnabled,
|
|
178
|
+
emiInstallments: doc.emiInstallments,
|
|
179
|
+
emiRemainingBalance: doc.emiRemainingBalance,
|
|
173
180
|
giftWrapAddon: doc.giftWrapAddon,
|
|
174
181
|
giftWrapFee: doc.giftWrapFee,
|
|
175
182
|
giftWrapMessage: doc.giftWrapMessage,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ActualShipmentCost } from "../../../shared/shipping/quote";
|
|
2
|
+
export interface ReconcileShippingResult {
|
|
3
|
+
/** `null` when there was nothing to reconcile — see the reasons below. */
|
|
4
|
+
outcome: "credited" | "seller-absorbed" | "exact" | null;
|
|
5
|
+
sellerAbsorbs: number;
|
|
6
|
+
buyerPoints: number;
|
|
7
|
+
/** Why nothing happened, when `outcome` is null. For logs and the UI. */
|
|
8
|
+
skipped?: "no-quote" | "already-reconciled" | "no-buyer";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
|
|
12
|
+
*
|
|
13
|
+
* Crediting points is an APPEND to a ledger. Appends do not deduplicate
|
|
14
|
+
* themselves, so a dispatch action that fired twice — a double-clicked button,
|
|
15
|
+
* a retried job, a webhook delivered again — would pay the buyer twice, and
|
|
16
|
+
* nothing downstream could distinguish that from two genuine savings. The
|
|
17
|
+
* reconciliation record is written in the same step as the credit precisely so
|
|
18
|
+
* it can be the key.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately does NOT use a transaction spanning the order and the
|
|
21
|
+
* ledger: `pointsRepository.append` already runs its own transaction over the
|
|
22
|
+
* ledger and the balance mirror, and nesting would mean reading the order
|
|
23
|
+
* inside it for no added safety. The residual risk is a crash between the
|
|
24
|
+
* credit and the order write, which leaves a credited buyer and an order that
|
|
25
|
+
* looks unreconciled — recoverable, and strictly better than the alternative
|
|
26
|
+
* ordering, which would mark the order done and then fail to pay.
|
|
27
|
+
*/
|
|
28
|
+
export declare function reconcileOrderShipping(orderId: string, actual: ActualShipmentCost): Promise<ReconcileShippingResult>;
|