@mohasinac/appkit 4.24.0 → 4.26.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/_internal/server/features/bundles/actions.d.ts +4 -1
- package/dist/_internal/server/features/bundles/actions.js +54 -5
- package/dist/_internal/server/features/checkout/actions.js +44 -73
- package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
- package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +35 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
- package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
- package/dist/_internal/server/features/grouped/index.d.ts +2 -0
- package/dist/_internal/server/features/grouped/index.js +2 -0
- package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
- package/dist/_internal/server/features/grouped/metadata.js +48 -0
- package/dist/_internal/server/features/grouped/og.d.ts +16 -0
- package/dist/_internal/server/features/grouped/og.js +24 -0
- package/dist/_internal/server/features/products/group-members.d.ts +55 -0
- package/dist/_internal/server/features/products/group-members.js +41 -0
- package/dist/_internal/server/features/seo/sitemap.js +24 -1
- package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
- package/dist/_internal/shared/checkout/line-members.js +68 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +63 -4
- package/dist/_internal/shared/checkout/order-math.js +119 -7
- package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
- package/dist/client.d.ts +9 -3
- package/dist/client.js +4 -2
- package/dist/constants/api-endpoints.d.ts +21 -0
- package/dist/constants/api-endpoints.js +7 -0
- package/dist/features/admin/schemas/firestore.d.ts +10 -26
- package/dist/features/admin/schemas/firestore.js +10 -26
- package/dist/features/analytics/types.d.ts +1 -1
- package/dist/features/analytics/types.js +3 -0
- package/dist/features/cart/actions/cart-actions.d.ts +31 -1
- package/dist/features/cart/actions/cart-actions.js +163 -0
- package/dist/features/cart/components/CartGroupLineRow.d.ts +42 -0
- package/dist/features/cart/components/CartGroupLineRow.js +33 -0
- package/dist/features/cart/components/index.d.ts +2 -0
- package/dist/features/cart/components/index.js +1 -0
- package/dist/features/cart/repository/cart.repository.d.ts +33 -1
- package/dist/features/cart/repository/cart.repository.js +152 -2
- package/dist/features/cart/schemas/firestore.d.ts +7 -2
- package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
- package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
- package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
- package/dist/features/categories/components/BundleDetailView.js +2 -2
- package/dist/features/categories/components/BundlesListView.d.ts +1 -0
- package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
- package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
- package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
- package/dist/features/orders/index.d.ts +1 -1
- package/dist/features/orders/index.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
- package/dist/features/orders/utils/bundle-grouping.js +32 -22
- package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
- package/dist/features/products/components/GroupMemberPicker.js +108 -0
- package/dist/features/products/components/ShowGroupSection.js +7 -9
- package/dist/features/tester/seed-data/categories-tester-seed-data.js +6 -0
- package/dist/features/tester/seed-data/products-tester-seed-data.js +44 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +33 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -2
- package/dist/next/routing/route-map.d.ts +12 -0
- package/dist/next/routing/route-map.js +6 -0
- package/dist/seed/notifications-seed-data.js +225 -0
- package/dist/seed/payouts-seed-data.js +47 -0
- package/dist/seed/scammers-seed-data.js +34 -0
- package/dist/seed/stores-seed-data.js +34 -0
- package/dist/seed/support-tickets-seed-data.js +34 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +5 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -12,4 +12,7 @@
|
|
|
12
12
|
* bundleCategorySlug + bundleProductIds so checkout's bundle-expansion
|
|
13
13
|
* helpers can fan out stock decrements to member products.
|
|
14
14
|
*/
|
|
15
|
-
export declare function addBundleToCartAction(userId: string, bundleSlug: string
|
|
15
|
+
export declare function addBundleToCartAction(userId: string, bundleSlug: string,
|
|
16
|
+
/** Number of COPIES of the whole bundle. Bundles are all-or-nothing, so this
|
|
17
|
+
* is the only quantity a buyer can change — member quantities are fixed. */
|
|
18
|
+
quantity?: number): Promise<void>;
|
|
@@ -16,12 +16,20 @@ import { NotFoundError, ValidationError } from "../../../../errors";
|
|
|
16
16
|
import { categoriesRepository } from "../../../../repositories";
|
|
17
17
|
import { cartRepository } from "../../../../features/cart/repository/cart.repository";
|
|
18
18
|
import { assertCanAddNewItems } from "../../../../features/cart/actions/cart-actions";
|
|
19
|
+
import { productRepository } from "../../../../features/products/repository/products.repository";
|
|
20
|
+
import { normalizeListingType } from "../../../../features/products/utils/listing-type";
|
|
19
21
|
import { getDefaultCurrency } from "../../../../core/baseline-resolver";
|
|
20
|
-
export async function addBundleToCartAction(userId, bundleSlug
|
|
22
|
+
export async function addBundleToCartAction(userId, bundleSlug,
|
|
23
|
+
/** Number of COPIES of the whole bundle. Bundles are all-or-nothing, so this
|
|
24
|
+
* is the only quantity a buyer can change — member quantities are fixed. */
|
|
25
|
+
quantity = 1) {
|
|
21
26
|
if (!userId)
|
|
22
27
|
throw new ValidationError("User id is required");
|
|
23
28
|
if (!bundleSlug)
|
|
24
29
|
throw new ValidationError("Bundle slug is required");
|
|
30
|
+
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
|
|
31
|
+
throw new ValidationError("Choose between 1 and 99 copies of this bundle.");
|
|
32
|
+
}
|
|
25
33
|
const bundle = await categoriesRepository.findBySlugAndType(bundleSlug, "bundle");
|
|
26
34
|
if (!bundle)
|
|
27
35
|
throw new NotFoundError("Bundle not found");
|
|
@@ -39,17 +47,58 @@ export async function addBundleToCartAction(userId, bundleSlug) {
|
|
|
39
47
|
// silently skipped it: a buyer with an unpaid auction win could keep shopping
|
|
40
48
|
// by adding bundles.
|
|
41
49
|
await assertCanAddNewItems(userId);
|
|
50
|
+
// Members, so the cart can show WHAT is in the bundle and checkout can
|
|
51
|
+
// prorate GST across them.
|
|
52
|
+
//
|
|
53
|
+
// Every member is ONE unit per copy. `BundleItemDetail` has no quantity field
|
|
54
|
+
// — its only per-member number is `drawCount` (prize-draw entries), and
|
|
55
|
+
// treating that as a stock quantity would silently change how much a
|
|
56
|
+
// prize-draw member decrements, which the stock fan-out has never done.
|
|
57
|
+
const memberIds = bundle.bundleProductIds ?? [];
|
|
58
|
+
const memberDocs = await Promise.all(memberIds.map((id) => productRepository.findById(id).catch(() => null)));
|
|
59
|
+
const groupMembers = memberDocs
|
|
60
|
+
.filter((p) => p !== null)
|
|
61
|
+
.map((p) => ({
|
|
62
|
+
productId: p.id,
|
|
63
|
+
quantity: 1,
|
|
64
|
+
unitPrice: p.price,
|
|
65
|
+
title: p.title,
|
|
66
|
+
image: p.images?.[0] ?? p.mainImage,
|
|
67
|
+
gstRate: p.gstRate,
|
|
68
|
+
hsnCode: p.hsnCode,
|
|
69
|
+
listingType: normalizeListingType(p),
|
|
70
|
+
}));
|
|
71
|
+
// `createdByStoreId` is optional on a bundle category and the tester fixture
|
|
72
|
+
// has none, so this used to write `storeId: ""` — which produces an order
|
|
73
|
+
// group keyed `standard:` with `storeId: undefined`: no seller notification,
|
|
74
|
+
// no shipping resolution, no payout. Fall back to a member's store.
|
|
75
|
+
const storeId = bundle.createdByStoreId ||
|
|
76
|
+
memberDocs.find((p) => p?.storeId)?.storeId ||
|
|
77
|
+
"";
|
|
78
|
+
const storeName = bundle.createdByStoreName ||
|
|
79
|
+
memberDocs.find((p) => p?.storeId === storeId)?.storeName ||
|
|
80
|
+
"";
|
|
42
81
|
await cartRepository.addItem(userId, {
|
|
43
82
|
productId: bundle.id,
|
|
44
83
|
productTitle: bundle.name,
|
|
45
84
|
productImage: bundle.display?.coverImage ?? "",
|
|
85
|
+
// The bundle's own locked price — NOT the sum of its members. That is the
|
|
86
|
+
// whole point of a bundle, and `unitPriceFor` honours it.
|
|
46
87
|
price: bundle.bundlePrice,
|
|
47
88
|
currency: getDefaultCurrency(),
|
|
48
|
-
quantity
|
|
49
|
-
storeId
|
|
50
|
-
storeName
|
|
89
|
+
quantity,
|
|
90
|
+
storeId,
|
|
91
|
+
storeName,
|
|
51
92
|
listingType: "standard",
|
|
93
|
+
lineKind: "bundle",
|
|
94
|
+
groupId: bundle.id,
|
|
95
|
+
groupSlug: bundle.slug,
|
|
96
|
+
groupTitle: bundle.name,
|
|
52
97
|
bundleCategorySlug: bundle.slug,
|
|
53
|
-
|
|
98
|
+
...(groupMembers.length > 0 && { groupMembers }),
|
|
99
|
+
// `bundleProductIds` stays written ONLY when we could not resolve members
|
|
100
|
+
// — it is the legacy read path (see CartItemDocument), and writing both
|
|
101
|
+
// would be exactly the mirror-drift Root Cause #42 warns about.
|
|
102
|
+
...(groupMembers.length === 0 && { bundleProductIds: memberIds }),
|
|
54
103
|
});
|
|
55
104
|
}
|
|
@@ -13,8 +13,7 @@ import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../
|
|
|
13
13
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
14
14
|
import { serverLogger } from "../../../../monitoring";
|
|
15
15
|
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
16
|
-
import {
|
|
17
|
-
import { computePreOrderDepositAmount, lineTotalFor, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
16
|
+
import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math";
|
|
18
17
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
19
18
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
20
19
|
import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
|
|
@@ -26,7 +25,8 @@ import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../provide
|
|
|
26
25
|
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
|
|
27
26
|
import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
|
|
28
27
|
// S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
|
|
29
|
-
import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, } from "./bundle-expansion";
|
|
28
|
+
import { getCartItemMemberIds, getExpandedDecrements, bucketCartItemsByStock, isMultiMemberLine, } from "./bundle-expansion";
|
|
29
|
+
import { expandCartLineToOrderRows } from "./expand-order-items";
|
|
30
30
|
import { OrderStatusValues, PaymentStatusValues, PaymentMethodValues, OutOfStockPolicyValues, } from "../../../../features/orders/schemas/index";
|
|
31
31
|
import { getDefaultCurrency } from "../../../../core/index";
|
|
32
32
|
import { verifyPaymentSignatureWithKeys, fetchRazorpayOrder, paiseToRupees, } from "../../../../providers/payment-razorpay/index";
|
|
@@ -376,32 +376,13 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
376
376
|
const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
|
|
377
377
|
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
378
378
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
379
|
-
|
|
379
|
+
// A multi-member line expands into one row PER MEMBER sharing a groupSlug, so
|
|
380
|
+
// each row carries its own HSN/GST and can be cancelled independently; the
|
|
381
|
+
// receipt still collapses them under one header.
|
|
382
|
+
const orderItems = group.flatMap(({ item, product }) => {
|
|
380
383
|
const lt = (product.listingType ?? "standard");
|
|
381
384
|
const itemRule = getListingRule(lt);
|
|
382
|
-
|
|
383
|
-
// UI can collapse expanded lines back under a "Bundle: <name>" header.
|
|
384
|
-
const bundleFields = item.bundleCategorySlug && item.bundleProductIds
|
|
385
|
-
? {
|
|
386
|
-
bundleCategorySlug: item.bundleCategorySlug,
|
|
387
|
-
bundleProductIds: item.bundleProductIds,
|
|
388
|
-
}
|
|
389
|
-
: {};
|
|
390
|
-
const unitPrice = unitPriceFor(item, product);
|
|
391
|
-
const gstFields = product.gstRate != null
|
|
392
|
-
? { gstRate: product.gstRate, ...(product.hsnCode ? { hsnCode: product.hsnCode } : {}) }
|
|
393
|
-
: {};
|
|
394
|
-
const baseLine = {
|
|
395
|
-
productId: item.productId,
|
|
396
|
-
productTitle: item.productTitle,
|
|
397
|
-
image: item.productImage,
|
|
398
|
-
quantity: item.quantity,
|
|
399
|
-
unitPrice,
|
|
400
|
-
totalPrice: unitPrice * item.quantity,
|
|
401
|
-
...bundleFields,
|
|
402
|
-
...gstFields,
|
|
403
|
-
};
|
|
404
|
-
return itemRule.decorateOrderItem(baseLine, product);
|
|
385
|
+
return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
|
|
405
386
|
});
|
|
406
387
|
const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
|
|
407
388
|
const { shippingFee, storeOwnerId, storeEmiEnabled, storeState } = await resolveShippingCost(firstItem.storeId);
|
|
@@ -412,16 +393,11 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
412
393
|
let gstBreakdown;
|
|
413
394
|
if (gstSettings?.enabled && buyerState) {
|
|
414
395
|
const intraState = !!storeState && storeState === buyerState;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (rate > 0) {
|
|
421
|
-
taxableAmount += lineTotal;
|
|
422
|
-
gstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
396
|
+
// Per-member, not per-line: a bundle or grouped line spans several products
|
|
397
|
+
// with several different rates, and taxing the whole line at one product's
|
|
398
|
+
// rate is wrong in both directions. Shared with previewCheckoutPricing so
|
|
399
|
+
// the figure shown and the figure charged cannot drift.
|
|
400
|
+
const { taxableAmount, gstAmount } = sumGroupGst(group.map(({ item }) => item), ctx.productById, intraState);
|
|
425
401
|
if (taxableAmount > 0) {
|
|
426
402
|
gstBreakdown = intraState
|
|
427
403
|
? { taxableAmount, cgst: Math.round(gstAmount / 2), sgst: gstAmount - Math.round(gstAmount / 2), igst: 0, gstAmount }
|
|
@@ -756,8 +732,9 @@ export async function createCheckoutOrderAction(input) {
|
|
|
756
732
|
const availableItems = [];
|
|
757
733
|
for (const { item, product } of bucketed.available) {
|
|
758
734
|
// S-SBUNI-RULES 2026-05-13 — sync preflight (prize-pool cap, pre-order
|
|
759
|
-
// quota) via rule registry.
|
|
760
|
-
|
|
735
|
+
// quota) via rule registry. Multi-member lines skip: their listingType
|
|
736
|
+
// describes the wrapper, not the members.
|
|
737
|
+
if (!isMultiMemberLine(item)) {
|
|
761
738
|
runSyncPreflight([{ item, product }], paymentMethod);
|
|
762
739
|
}
|
|
763
740
|
availableItems.push({ item, product });
|
|
@@ -792,7 +769,7 @@ export async function createCheckoutOrderAction(input) {
|
|
|
792
769
|
updatedAt: new Date(),
|
|
793
770
|
}, { merge: true });
|
|
794
771
|
}
|
|
795
|
-
return { available: availableItems, unavailable: unavailableItems };
|
|
772
|
+
return { available: availableItems, unavailable: unavailableItems, productById };
|
|
796
773
|
});
|
|
797
774
|
}
|
|
798
775
|
catch (err) {
|
|
@@ -805,7 +782,7 @@ export async function createCheckoutOrderAction(input) {
|
|
|
805
782
|
.catch((logErr) => { void normalizeError(logErr); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: logErr instanceof Error ? logErr.message : String(logErr) }); });
|
|
806
783
|
throw err;
|
|
807
784
|
}
|
|
808
|
-
const { available, unavailable } = stockResult;
|
|
785
|
+
const { available, unavailable, productById: checkoutProductById } = stockResult;
|
|
809
786
|
if (available.length === 0) {
|
|
810
787
|
failedCheckoutRepository
|
|
811
788
|
.logCheckout(uid, "stock_failed", "All items out of stock", {
|
|
@@ -859,6 +836,7 @@ export async function createCheckoutOrderAction(input) {
|
|
|
859
836
|
siteContactUpiVpa: siteSettings?.contact?.upiVpa,
|
|
860
837
|
storeAddons: cart.storeAddons,
|
|
861
838
|
platformFeeByStore,
|
|
839
|
+
productById: checkoutProductById,
|
|
862
840
|
};
|
|
863
841
|
for (const { items: group, orderType } of orderGroups) {
|
|
864
842
|
total += await createOrderForGroup(group, orderType, groupCtx);
|
|
@@ -919,9 +897,18 @@ export async function previewCheckoutPricing(input) {
|
|
|
919
897
|
// Best-effort product lookup — a preview never throws on a since-unpublished
|
|
920
898
|
// product, it just falls back to the item's cart-snapshot price for that
|
|
921
899
|
// line (same fallback cartRepository.getSubtotal() implicitly relies on).
|
|
922
|
-
|
|
900
|
+
//
|
|
901
|
+
// Fetch by the EXPANDED member set, not by `item.productId`. On a bundle line
|
|
902
|
+
// `productId` is a CATEGORY id, so it resolved to null and the GST loop below
|
|
903
|
+
// read `product?.gstRate ?? 0` — which is precisely why a bundle has paid no
|
|
904
|
+
// GST since bundles reached the cart. The expansion is the same fan-out the
|
|
905
|
+
// stock decrement uses, so preview and placement agree by construction.
|
|
906
|
+
const expandedIds = getExpandedDecrements(cartItems).productIds;
|
|
907
|
+
const uniqueProductIds = [...new Set([...expandedIds, ...cartItems.map((i) => i.productId)])];
|
|
923
908
|
const productDocs = await Promise.all(uniqueProductIds.map((pid) => productRepository.findById(pid)));
|
|
924
909
|
const productById = new Map(uniqueProductIds.map((pid, i) => [pid, productDocs[i]]));
|
|
910
|
+
/** Non-null view, for helpers that need a definite product map. */
|
|
911
|
+
const resolvedProductById = new Map([...productById.entries()].filter(([, p]) => p != null));
|
|
925
912
|
let resolvedAddress = null;
|
|
926
913
|
if (addressId) {
|
|
927
914
|
const addressDoc = await unitOfWork.addresses.findById(addressId);
|
|
@@ -955,12 +942,10 @@ export async function previewCheckoutPricing(input) {
|
|
|
955
942
|
let groupGstAmount = 0;
|
|
956
943
|
if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
|
|
957
944
|
const intraState = !!storeState && storeState === resolvedAddress.state;
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
|
|
963
|
-
}
|
|
945
|
+
// Same helper createOrderForGroup uses — the two disagreeing is the
|
|
946
|
+
// recurring defect here (Root Cause #59), and it is the buyer seeing one
|
|
947
|
+
// tax figure and being charged another.
|
|
948
|
+
groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intraState).gstAmount;
|
|
964
949
|
}
|
|
965
950
|
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
966
951
|
const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
@@ -1137,7 +1122,7 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
1137
1122
|
* from the inline version this replaced.
|
|
1138
1123
|
*/
|
|
1139
1124
|
async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
1140
|
-
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, } = ctx;
|
|
1125
|
+
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: razorpayProductById, } = ctx;
|
|
1141
1126
|
const firstItem = group[0].item;
|
|
1142
1127
|
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1143
1128
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
@@ -1185,30 +1170,15 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
|
1185
1170
|
// alongside P-13 (Razorpay is disabled by default today, so this order
|
|
1186
1171
|
// type doesn't currently carry a GST breakdown).
|
|
1187
1172
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
1188
|
-
|
|
1173
|
+
// Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
|
|
1174
|
+
// the price rule (`isBundle ? item.price : product.price`) that — like the
|
|
1175
|
+
// two in the consumer's Razorpay route — silently omitted `lockedPrice`, so
|
|
1176
|
+
// an accepted offer paid through Razorpay was RECORDED at the seller's list
|
|
1177
|
+
// price. `lineTotalFor` inside the expansion is the single definition.
|
|
1178
|
+
const orderItems = group.flatMap(({ item, product }) => {
|
|
1189
1179
|
const lt = (product?.listingType ?? "standard");
|
|
1190
1180
|
const itemRule = getListingRule(lt);
|
|
1191
|
-
|
|
1192
|
-
// bundlePrice (item.price), not the representative member's
|
|
1193
|
-
// product.price. Stock decrement still runs per member elsewhere.
|
|
1194
|
-
const isBundle = Boolean(item.bundleCategorySlug && item.bundleProductIds?.length);
|
|
1195
|
-
const unitPrice = isBundle ? item.price : product.price;
|
|
1196
|
-
const bundleFields = isBundle
|
|
1197
|
-
? {
|
|
1198
|
-
bundleCategorySlug: item.bundleCategorySlug,
|
|
1199
|
-
bundleProductIds: item.bundleProductIds,
|
|
1200
|
-
}
|
|
1201
|
-
: {};
|
|
1202
|
-
const baseLine = {
|
|
1203
|
-
productId: item.productId,
|
|
1204
|
-
productTitle: item.productTitle,
|
|
1205
|
-
image: item.productImage,
|
|
1206
|
-
quantity: item.quantity,
|
|
1207
|
-
unitPrice,
|
|
1208
|
-
totalPrice: unitPrice * item.quantity,
|
|
1209
|
-
...bundleFields,
|
|
1210
|
-
};
|
|
1211
|
-
return itemRule.decorateOrderItem(baseLine, product);
|
|
1181
|
+
return expandCartLineToOrderRows(item, product, razorpayProductById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
|
|
1212
1182
|
});
|
|
1213
1183
|
const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
|
|
1214
1184
|
const imageUrls = [
|
|
@@ -1406,8 +1376,8 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1406
1376
|
.map(({ item, product }) => ({ item, product })),
|
|
1407
1377
|
});
|
|
1408
1378
|
// S-SBUNI-RULES 2026-05-13 — sync preflight via rule registry.
|
|
1409
|
-
//
|
|
1410
|
-
const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !p.item
|
|
1379
|
+
// Multi-member lines skip the prize-pool cap, same reasoning as the COD path.
|
|
1380
|
+
const preflightPairs = productChecks.filter((p) => p.product !== null && p.product !== undefined && !isMultiMemberLine(p.item));
|
|
1411
1381
|
// This whole action is the Razorpay path — paymentMethod is always "online".
|
|
1412
1382
|
runSyncPreflight(preflightPairs, "online");
|
|
1413
1383
|
// SB-UNI-O 2026-05-15 — Live-item jurisdiction guard.
|
|
@@ -1536,6 +1506,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1536
1506
|
orderIds,
|
|
1537
1507
|
emailsToSend,
|
|
1538
1508
|
couponUsageAccumulator,
|
|
1509
|
+
productById: productByIdPaid,
|
|
1539
1510
|
});
|
|
1540
1511
|
}
|
|
1541
1512
|
await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
|
|
@@ -22,19 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { CartItemDocument } from "../../../../features/cart/schemas";
|
|
24
24
|
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
25
|
+
export { getCartLineMembers, getCartItemMemberIds, isMultiMemberLine, type CartLineMemberRef, } from "../../../shared/checkout/line-members";
|
|
25
26
|
export interface BundleExpansion {
|
|
26
27
|
/** Unique product ids referenced by any cart item (regular or bundle member). */
|
|
27
28
|
productIds: string[];
|
|
28
29
|
/**
|
|
29
|
-
* Sum of requested quantities per product id
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Sum of requested quantities per product id, weighted by BOTH the number of
|
|
31
|
+
* copies of the line and each member's per-copy quantity — so 2 copies of a
|
|
32
|
+
* selection containing 3× member X decrement X by 6.
|
|
32
33
|
*/
|
|
33
34
|
decrements: Map<string, number>;
|
|
34
35
|
}
|
|
35
|
-
/** Resolve the member-product ids for a cart item. Bundle items expand to
|
|
36
|
-
* `bundleProductIds[]`; regular items collapse to `[productId]`. */
|
|
37
|
-
export declare function getCartItemMemberIds(item: CartItemDocument): string[];
|
|
38
36
|
/** Build the unique-product-id + per-product decrement map for a cart. */
|
|
39
37
|
export declare function getExpandedDecrements(cartItems: CartItemDocument[]): BundleExpansion;
|
|
40
38
|
export interface StockShortfall {
|
|
@@ -21,21 +21,21 @@
|
|
|
21
21
|
* the first's decrement before re-running its own validation.
|
|
22
22
|
*/
|
|
23
23
|
import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
return [item.productId];
|
|
31
|
-
}
|
|
24
|
+
import { getCartLineMembers } from "../../../shared/checkout/line-members";
|
|
25
|
+
// Re-exported here so the many existing importers of this module keep working;
|
|
26
|
+
// the definitions live in shared/ because `rules/_registry.ts` and
|
|
27
|
+
// `order-math.ts` need them too and cannot import from server/.
|
|
28
|
+
export { getCartLineMembers, getCartItemMemberIds, isMultiMemberLine, } from "../../../shared/checkout/line-members";
|
|
32
29
|
/** Build the unique-product-id + per-product decrement map for a cart. */
|
|
33
30
|
export function getExpandedDecrements(cartItems) {
|
|
34
31
|
const decrements = new Map();
|
|
35
32
|
for (const item of cartItems) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
for (const member of getCartLineMembers(item)) {
|
|
34
|
+
// item.quantity = copies of the line, member.quantity = units per copy.
|
|
35
|
+
// Multiplying is the whole point: this used to add `item.quantity` alone,
|
|
36
|
+
// which silently assumed exactly one of each member.
|
|
37
|
+
const needed = item.quantity * member.quantity;
|
|
38
|
+
decrements.set(member.productId, (decrements.get(member.productId) ?? 0) + needed);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
return {
|
|
@@ -55,15 +55,17 @@ export function getExpandedDecrements(cartItems) {
|
|
|
55
55
|
* that product, not just this item's slice.
|
|
56
56
|
*/
|
|
57
57
|
export function validateCartItemStock(item, productById, decrements) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
const product = productById.get(pid);
|
|
58
|
+
for (const member of getCartLineMembers(item)) {
|
|
59
|
+
const product = productById.get(member.productId);
|
|
61
60
|
if (!product || product.status !== ProductStatusValues.PUBLISHED) {
|
|
62
|
-
return { productId:
|
|
61
|
+
return { productId: member.productId, availableQty: product?.availableQuantity ?? 0 };
|
|
63
62
|
}
|
|
64
|
-
|
|
63
|
+
// The fallback must be member-weighted too. Left as a bare `item.quantity`
|
|
64
|
+
// it would validate a line needing 3× of a member against demand for 1
|
|
65
|
+
// whenever the cumulative map didn't happen to carry that product.
|
|
66
|
+
const totalNeeded = decrements.get(member.productId) ?? item.quantity * member.quantity;
|
|
65
67
|
if (product.availableQuantity < totalNeeded) {
|
|
66
|
-
return { productId:
|
|
68
|
+
return { productId: member.productId, availableQty: product.availableQuantity };
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
return null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart line → order rows.
|
|
3
|
+
*
|
|
4
|
+
* A multi-member line (a bundle, or a buyer-assembled group selection) becomes
|
|
5
|
+
* **N rows, one per member**, all sharing a `groupSlug`. Not one collapsed row.
|
|
6
|
+
*
|
|
7
|
+
* Why expand:
|
|
8
|
+
* - `hsnCode` and `gstRate` are per-PRODUCT, and a GST invoice legally needs
|
|
9
|
+
* HSN per line. A collapsed row can carry exactly one — which is why the
|
|
10
|
+
* collapsed bundle row has always carried none.
|
|
11
|
+
* - `cancelledQuantity` is per-product; partially cancelling one member of a
|
|
12
|
+
* three-item group is impossible on a collapsed row.
|
|
13
|
+
* - After per-member GST landed, `order.taxableAmount` is a sum over members.
|
|
14
|
+
* With one collapsed row it would not equal the sum of the order's own
|
|
15
|
+
* lines, and the invoice would visibly fail to add up.
|
|
16
|
+
*
|
|
17
|
+
* The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
|
|
18
|
+
* rows back under one header, which it was already written to do.
|
|
19
|
+
*/
|
|
20
|
+
import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
|
|
21
|
+
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
22
|
+
import type { OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
|
|
23
|
+
/** A row before rule decoration — the shape `decorateOrderItem` receives. */
|
|
24
|
+
export type BaseOrderLine = Omit<OrderDocumentItem, "listingType">;
|
|
25
|
+
/**
|
|
26
|
+
* Expand one cart line into the order rows it should produce.
|
|
27
|
+
*
|
|
28
|
+
* A single-product line yields exactly one row, identical to before.
|
|
29
|
+
*
|
|
30
|
+
* For a BUNDLE the per-row `totalPrice` is a prorated share of the bundle's
|
|
31
|
+
* locked price — never the member's list price — so the rows sum to what the
|
|
32
|
+
* buyer actually pays. For a GROUP the member prices already are the price, so
|
|
33
|
+
* the shares are exact.
|
|
34
|
+
*/
|
|
35
|
+
export declare function expandCartLineToOrderRows(item: CartItemDocument, representative: ProductDocument, productById: Map<string, ProductDocument>): BaseOrderLine[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart line → order rows.
|
|
3
|
+
*
|
|
4
|
+
* A multi-member line (a bundle, or a buyer-assembled group selection) becomes
|
|
5
|
+
* **N rows, one per member**, all sharing a `groupSlug`. Not one collapsed row.
|
|
6
|
+
*
|
|
7
|
+
* Why expand:
|
|
8
|
+
* - `hsnCode` and `gstRate` are per-PRODUCT, and a GST invoice legally needs
|
|
9
|
+
* HSN per line. A collapsed row can carry exactly one — which is why the
|
|
10
|
+
* collapsed bundle row has always carried none.
|
|
11
|
+
* - `cancelledQuantity` is per-product; partially cancelling one member of a
|
|
12
|
+
* three-item group is impossible on a collapsed row.
|
|
13
|
+
* - After per-member GST landed, `order.taxableAmount` is a sum over members.
|
|
14
|
+
* With one collapsed row it would not equal the sum of the order's own
|
|
15
|
+
* lines, and the invoice would visibly fail to add up.
|
|
16
|
+
*
|
|
17
|
+
* The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
|
|
18
|
+
* rows back under one header, which it was already written to do.
|
|
19
|
+
*/
|
|
20
|
+
import { lineTotalFor, allocateAcrossMembers } from "../../../shared/checkout/order-math";
|
|
21
|
+
/**
|
|
22
|
+
* Expand one cart line into the order rows it should produce.
|
|
23
|
+
*
|
|
24
|
+
* A single-product line yields exactly one row, identical to before.
|
|
25
|
+
*
|
|
26
|
+
* For a BUNDLE the per-row `totalPrice` is a prorated share of the bundle's
|
|
27
|
+
* locked price — never the member's list price — so the rows sum to what the
|
|
28
|
+
* buyer actually pays. For a GROUP the member prices already are the price, so
|
|
29
|
+
* the shares are exact.
|
|
30
|
+
*/
|
|
31
|
+
export function expandCartLineToOrderRows(item, representative, productById) {
|
|
32
|
+
const members = item.groupMembers;
|
|
33
|
+
const lineTotal = lineTotalFor(item, representative);
|
|
34
|
+
if (!members?.length) {
|
|
35
|
+
const gstFields = representative.gstRate != null
|
|
36
|
+
? {
|
|
37
|
+
gstRate: representative.gstRate,
|
|
38
|
+
...(representative.hsnCode ? { hsnCode: representative.hsnCode } : {}),
|
|
39
|
+
}
|
|
40
|
+
: {};
|
|
41
|
+
return [{
|
|
42
|
+
productId: item.productId,
|
|
43
|
+
productTitle: item.productTitle,
|
|
44
|
+
image: item.productImage,
|
|
45
|
+
quantity: item.quantity,
|
|
46
|
+
unitPrice: lineTotal / (item.quantity || 1),
|
|
47
|
+
totalPrice: lineTotal,
|
|
48
|
+
// Legacy collapsed bundle rows (no groupMembers) keep their old shape so
|
|
49
|
+
// orders already in flight and their UI stay untouched.
|
|
50
|
+
...(item.bundleCategorySlug && item.bundleProductIds
|
|
51
|
+
? {
|
|
52
|
+
bundleCategorySlug: item.bundleCategorySlug,
|
|
53
|
+
bundleProductIds: item.bundleProductIds,
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
...gstFields,
|
|
57
|
+
}];
|
|
58
|
+
}
|
|
59
|
+
const groupSlug = item.groupSlug ?? item.bundleCategorySlug ?? item.groupId ?? item.itemId;
|
|
60
|
+
const groupSource = item.lineKind === "bundle" ? "bundle" : item.groupSource ?? "product-group";
|
|
61
|
+
const shares = allocateAcrossMembers(lineTotal, members.map((m) => m.unitPrice * m.quantity));
|
|
62
|
+
return members.map((m, i) => {
|
|
63
|
+
const product = productById.get(m.productId);
|
|
64
|
+
const gstRate = product?.gstRate ?? m.gstRate;
|
|
65
|
+
const hsnCode = product?.hsnCode ?? m.hsnCode;
|
|
66
|
+
// Units of this member across all copies of the line.
|
|
67
|
+
const quantity = m.quantity * item.quantity;
|
|
68
|
+
const totalPrice = shares[i];
|
|
69
|
+
return {
|
|
70
|
+
productId: m.productId,
|
|
71
|
+
productTitle: m.title,
|
|
72
|
+
image: m.image ?? "",
|
|
73
|
+
quantity,
|
|
74
|
+
unitPrice: quantity > 0 ? totalPrice / quantity : totalPrice,
|
|
75
|
+
totalPrice,
|
|
76
|
+
groupSlug,
|
|
77
|
+
groupTitle: item.groupTitle ?? item.productTitle,
|
|
78
|
+
groupSource,
|
|
79
|
+
// Kept on bundle rows so the pre-existing receipt grouping (which keys on
|
|
80
|
+
// this) keeps working for bundles without any UI change.
|
|
81
|
+
...(item.bundleCategorySlug ? { bundleCategorySlug: item.bundleCategorySlug } : {}),
|
|
82
|
+
...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ORDER_COLLECTION } from "../../../../features/orders/schemas/firestore";
|
|
2
2
|
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
|
|
3
3
|
import { getListingRule } from "../../../shared/checkout/rules";
|
|
4
|
+
import { getOrderItemMemberRefs } from "../../../shared/checkout/line-members";
|
|
4
5
|
/**
|
|
5
6
|
* Per-product quantity to restore for one order — the inverse of
|
|
6
7
|
* `getExpandedDecrements` (bundle-expansion.ts), but reads from
|
|
@@ -15,11 +16,11 @@ function getOrderRestoreMap(order) {
|
|
|
15
16
|
const remainingQty = item.quantity - (item.cancelledQuantity ?? 0);
|
|
16
17
|
if (remainingQty <= 0)
|
|
17
18
|
continue;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
map.set(pid, (map.get(pid) ?? 0) + remainingQty);
|
|
19
|
+
// Must mirror `getExpandedDecrements` exactly, including the per-member
|
|
20
|
+
// weighting — restoring fewer units than were taken silently leaks stock.
|
|
21
|
+
for (const member of getOrderItemMemberRefs(item)) {
|
|
22
|
+
const pid = member.productId;
|
|
23
|
+
map.set(pid, (map.get(pid) ?? 0) + remainingQty * member.quantity);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
return map;
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
|
|
2
|
+
export { buildGroupedListingMetadata, type GroupedListingMetadataOptions, } from "./metadata";
|
|
3
|
+
export { renderGroupedListingOg } from "./og";
|
|
2
4
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand, } from "./data";
|
|
2
|
+
export { buildGroupedListingMetadata, } from "./metadata";
|
|
3
|
+
export { renderGroupedListingOg } from "./og";
|
|
2
4
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grouped-listing metadata helper.
|
|
3
|
+
*
|
|
4
|
+
* Maps a `GroupedListingDocument` to a Next.js Metadata object. Does NOT fetch
|
|
5
|
+
* — the page already did, and both share one `React.cache`d call. Site branding
|
|
6
|
+
* flows through opts so this file stays appkit-internal and
|
|
7
|
+
* consumer-configurable (§ "SSR Architecture").
|
|
8
|
+
*/
|
|
9
|
+
import type { Metadata } from "next";
|
|
10
|
+
import type { GroupedListingDocument } from "../../../../features/grouped/schemas/firestore";
|
|
11
|
+
export interface GroupedListingMetadataOptions {
|
|
12
|
+
siteName?: string;
|
|
13
|
+
siteUrl?: string;
|
|
14
|
+
/** Override the OG image — defaults to the group's coverImage. */
|
|
15
|
+
ogImageUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function buildGroupedListingMetadata(group: GroupedListingDocument | null, opts?: GroupedListingMetadataOptions): Metadata;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grouped-listing metadata helper.
|
|
3
|
+
*
|
|
4
|
+
* Maps a `GroupedListingDocument` to a Next.js Metadata object. Does NOT fetch
|
|
5
|
+
* — the page already did, and both share one `React.cache`d call. Site branding
|
|
6
|
+
* flows through opts so this file stays appkit-internal and
|
|
7
|
+
* consumer-configurable (§ "SSR Architecture").
|
|
8
|
+
*/
|
|
9
|
+
const FALLBACK_DESCRIPTION = "A curated group of related listings — pick the pieces you want and add them together.";
|
|
10
|
+
export function buildGroupedListingMetadata(group, opts) {
|
|
11
|
+
const siteName = opts?.siteName?.trim() || "";
|
|
12
|
+
const suffix = siteName ? ` — ${siteName}` : "";
|
|
13
|
+
if (!group) {
|
|
14
|
+
return {
|
|
15
|
+
title: `Group not found${suffix}`,
|
|
16
|
+
description: "This group is unavailable or has been removed.",
|
|
17
|
+
robots: { index: false },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const title = `${group.title}${suffix}`;
|
|
21
|
+
const description = group.description?.trim() || FALLBACK_DESCRIPTION;
|
|
22
|
+
const siteUrl = opts?.siteUrl?.replace(/\/+$/, "");
|
|
23
|
+
const canonical = siteUrl ? `${siteUrl}/groups/${group.slug}` : undefined;
|
|
24
|
+
const cover = opts?.ogImageUrl ?? group.coverImage;
|
|
25
|
+
// A group below its `minActiveMembers` floor is hidden from every carousel;
|
|
26
|
+
// letting a search engine index it would surface a page the site itself has
|
|
27
|
+
// decided not to show.
|
|
28
|
+
const isPublic = group.isActive && group.visibilityStatus === "visible";
|
|
29
|
+
return {
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
robots: isPublic ? undefined : { index: false },
|
|
33
|
+
alternates: canonical ? { canonical } : undefined,
|
|
34
|
+
openGraph: {
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
url: canonical,
|
|
38
|
+
type: "website",
|
|
39
|
+
images: cover ? [{ url: cover }] : undefined,
|
|
40
|
+
},
|
|
41
|
+
twitter: {
|
|
42
|
+
card: cover ? "summary_large_image" : "summary",
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
images: cover ? [cover] : undefined,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|