@mohasinac/appkit 4.33.1 → 4.34.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/checkout/actions.js +9 -1
- package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
- package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
- package/dist/_internal/server/features/media/contextGuards.js +19 -5
- package/dist/_internal/server/features/orders/actions.d.ts +9 -0
- package/dist/_internal/server/features/orders/actions.js +47 -4
- package/dist/_internal/server/features/orders/adapters.js +10 -0
- package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
- package/dist/_internal/server/features/refunds/actions.js +36 -3
- package/dist/_internal/server/features/site-settings/adapters.js +1 -1
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
- package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
- package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
- package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
- package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
- package/dist/_internal/shared/features/orders/schema.js +20 -0
- package/dist/_internal/shared/features/products/schema.d.ts +12 -0
- package/dist/_internal/shared/features/products/schema.js +4 -1
- package/dist/_internal/shared/media/limits.d.ts +14 -0
- package/dist/_internal/shared/media/limits.js +29 -0
- package/dist/client.d.ts +9 -1
- package/dist/client.js +19 -1
- package/dist/features/admin/components/AdminProductEditorView.js +6 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
- package/dist/features/admin/components/index.d.ts +0 -2
- package/dist/features/admin/components/index.js +0 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +0 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
- package/dist/features/contact/components/ContactForm.js +46 -31
- package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
- package/dist/features/media/upload/MediaUploadList.js +58 -8
- package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
- package/dist/features/orders/components/RefundRequestView.js +108 -24
- package/dist/features/orders/schemas/firestore.d.ts +42 -0
- package/dist/features/orders/types/index.d.ts +24 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
- package/dist/features/products/components/ProductDetailPageView.js +14 -2
- package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
- package/dist/features/products/components/ProductFeatureBadges.js +17 -1
- package/dist/features/products/components/ProductForm.js +46 -10
- package/dist/features/products/components/ProductGrid.js +10 -1
- package/dist/features/products/constants/final-sale.d.ts +40 -0
- package/dist/features/products/constants/final-sale.js +40 -0
- package/dist/features/products/index.d.ts +1 -0
- package/dist/features/products/index.js +1 -0
- package/dist/features/products/schemas/firestore.d.ts +19 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
- package/dist/features/seller/components/SellerProductShell.js +82 -11
- package/dist/features/seller/index.d.ts +1 -0
- package/dist/features/seller/index.js +1 -0
- package/dist/features/seller/schemas/product-form.d.ts +304 -0
- package/dist/features/seller/schemas/product-form.js +132 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
- package/dist/features/seller/utils/product-draft-mapping.js +147 -0
- package/dist/features/shell/SectionForm.js +62 -7
- package/dist/features/shell/form-sections.js +4 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +271 -0
- package/dist/schemas/registry.js +9 -0
- package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
- package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
- package/dist/seed/products-art-seed-data.js +9 -1
- package/dist/seed/products-auctions-seed-data.js +2 -1
- package/dist/seed/products-classifieds-seed-data.js +2 -1
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.d.ts +2 -138
- package/dist/seed/products-preorders-seed-data.js +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +2 -1
- package/dist/seed/products-standard-seed-data.js +2 -1
- package/dist/seed/products-stickers-seed-data.js +9 -1
- package/dist/server.d.ts +6 -2
- package/dist/server.js +15 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/motion.d.ts +12 -0
- package/dist/tokens/motion.js +12 -0
- package/dist/ui/components/CollapsibleSection.d.ts +18 -1
- package/dist/ui/components/CollapsibleSection.js +20 -3
- package/dist/ui/components/Motion.js +17 -1
- package/package.json +1 -1
|
@@ -1068,7 +1068,15 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
1068
1068
|
orderId: primaryOrderId,
|
|
1069
1069
|
type: "partial",
|
|
1070
1070
|
amount: refundAmount,
|
|
1071
|
-
|
|
1071
|
+
/*
|
|
1072
|
+
* A SYSTEM refund, not a buyer claim: items the buyer paid for turned
|
|
1073
|
+
* out to be unavailable, so the seller could not deliver what was
|
|
1074
|
+
* described. `not_received` is the honest code and — not incidentally —
|
|
1075
|
+
* one final sale may never block, which is correct here: the buyer is
|
|
1076
|
+
* owed this money whatever the listing's return terms said.
|
|
1077
|
+
*/
|
|
1078
|
+
reasonCode: "not_received",
|
|
1079
|
+
reasonNote: `Automatic refund — ${unavailablePaid.length} item(s) unavailable at checkout: ${unavailablePaid.map((u) => u.productTitle).join(", ")}`,
|
|
1072
1080
|
method: "razorpay",
|
|
1073
1081
|
razorpayPaymentId,
|
|
1074
1082
|
confirmIrrevocable: true,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* rows back under one header, which it was already written to do.
|
|
19
19
|
*/
|
|
20
20
|
import { lineTotalFor, allocateAcrossMembers } from "../../../shared/checkout/order-math";
|
|
21
|
+
import { isFinalSale } from "../../../../features/products/constants/final-sale";
|
|
21
22
|
/**
|
|
22
23
|
* Expand one cart line into the order rows it should produce.
|
|
23
24
|
*
|
|
@@ -45,6 +46,10 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
45
46
|
quantity: item.quantity,
|
|
46
47
|
unitPrice: lineTotal / (item.quantity || 1),
|
|
47
48
|
totalPrice: lineTotal,
|
|
49
|
+
// Snapshot the return terms the buyer is agreeing to right now. Resolved
|
|
50
|
+
// through `isFinalSale` so this is always an explicit boolean, even
|
|
51
|
+
// though the product field is optional and absent means true.
|
|
52
|
+
finalSale: isFinalSale(representative),
|
|
48
53
|
// Legacy collapsed bundle rows (no groupMembers) keep their old shape so
|
|
49
54
|
// orders already in flight and their UI stay untouched.
|
|
50
55
|
...(item.bundleCategorySlug && item.bundleProductIds
|
|
@@ -73,6 +78,10 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
73
78
|
quantity,
|
|
74
79
|
unitPrice: quantity > 0 ? totalPrice / quantity : totalPrice,
|
|
75
80
|
totalPrice,
|
|
81
|
+
// Per MEMBER, not per line: a bundle may mix a final-sale item with a
|
|
82
|
+
// returnable one, and the refund gate reads each row separately so the
|
|
83
|
+
// returnable member stays returnable.
|
|
84
|
+
finalSale: isFinalSale(product),
|
|
76
85
|
groupSlug,
|
|
77
86
|
groupTitle: item.groupTitle ?? item.productTitle,
|
|
78
87
|
groupSource,
|
|
@@ -13,12 +13,12 @@ import { type AllowedMime } from "../../../shared/media/limits";
|
|
|
13
13
|
import { type MediaFilenameContext } from "../../../../utils/id-generators";
|
|
14
14
|
import type { FirestoreDocument } from "@mohasinac/appkit";
|
|
15
15
|
export declare const CONTEXT_LIMITS: {
|
|
16
|
-
readonly PRODUCT_IMAGE_MAX:
|
|
16
|
+
readonly PRODUCT_IMAGE_MAX: number;
|
|
17
17
|
readonly PRODUCT_VIDEO_MAX: 1;
|
|
18
18
|
readonly REVIEW_IMAGE_MAX: 5;
|
|
19
19
|
readonly REVIEW_VIDEO_MAX: 1;
|
|
20
|
-
readonly AUCTION_IMAGE_MAX:
|
|
21
|
-
readonly PREORDER_IMAGE_MAX:
|
|
20
|
+
readonly AUCTION_IMAGE_MAX: number;
|
|
21
|
+
readonly PREORDER_IMAGE_MAX: number;
|
|
22
22
|
readonly EVENT_COVER_MAX: 1;
|
|
23
23
|
readonly EVENT_IMAGE_MAX: 10;
|
|
24
24
|
readonly EVENT_WINNER_IMAGE_MAX: 5;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Pure logic — no Firebase SDK calls, no `Buffer` work — so it's safe to
|
|
10
10
|
* import from any server route.
|
|
11
11
|
*/
|
|
12
|
-
import { classifyMime, MIME_TO_EXT, } from "../../../shared/media/limits";
|
|
12
|
+
import { classifyMime, MIME_TO_EXT, PRODUCT_IMAGE_INDEX_MAX, PRODUCT_MAX_VIDEOS, } from "../../../shared/media/limits";
|
|
13
13
|
import { generateMediaFilename, validateMediaFilename, } from "../../../../utils/id-generators";
|
|
14
14
|
/**
|
|
15
15
|
* W1-51 — validate the generated filename before returning ok:true so a
|
|
@@ -29,12 +29,26 @@ function okWithFilename(ctx, filename, ext) {
|
|
|
29
29
|
// Per-context limits — mirrored from the previous inline constants in
|
|
30
30
|
// /api/media/upload. Adjust here, not at the call site.
|
|
31
31
|
export const CONTEXT_LIMITS = {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/*
|
|
33
|
+
* These are INDEX ceilings, not counts — `indexGuard` compares them against
|
|
34
|
+
* the `index` baked into the media filename, and `generateMediaFilename`
|
|
35
|
+
* gives the main image index 1 with the gallery running 2..N. So the index
|
|
36
|
+
* space is one larger than the gallery allowance, which is exactly what
|
|
37
|
+
* `PRODUCT_IMAGE_INDEX_MAX` encodes.
|
|
38
|
+
*
|
|
39
|
+
* Getting that wrong was a live 400: the gallery was capped at 5 and sent
|
|
40
|
+
* its 5th image as index 6, so the last slot of every product form was
|
|
41
|
+
* rejected by the signed-URL endpoint.
|
|
42
|
+
*
|
|
43
|
+
* FLAT across listing types — auctions and pre-orders are products with
|
|
44
|
+
* different sale mechanics, not products that deserve fewer photos.
|
|
45
|
+
*/
|
|
46
|
+
PRODUCT_IMAGE_MAX: PRODUCT_IMAGE_INDEX_MAX,
|
|
47
|
+
PRODUCT_VIDEO_MAX: PRODUCT_MAX_VIDEOS,
|
|
34
48
|
REVIEW_IMAGE_MAX: 5,
|
|
35
49
|
REVIEW_VIDEO_MAX: 1,
|
|
36
|
-
AUCTION_IMAGE_MAX:
|
|
37
|
-
PREORDER_IMAGE_MAX:
|
|
50
|
+
AUCTION_IMAGE_MAX: PRODUCT_IMAGE_INDEX_MAX,
|
|
51
|
+
PREORDER_IMAGE_MAX: PRODUCT_IMAGE_INDEX_MAX,
|
|
38
52
|
EVENT_COVER_MAX: 1,
|
|
39
53
|
EVENT_IMAGE_MAX: 10,
|
|
40
54
|
EVENT_WINNER_IMAGE_MAX: 5,
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type ActionResult } from "@mohasinac/appkit/server";
|
|
2
2
|
export declare function createOrderAction(input: unknown): Promise<ActionResult<unknown>>;
|
|
3
3
|
export declare function cancelOrderAction(input: unknown): Promise<ActionResult<unknown>>;
|
|
4
|
+
/**
|
|
5
|
+
* Buyer requests a return on a delivered order.
|
|
6
|
+
*
|
|
7
|
+
* Two things this did not do before, both of which made the feature unusable:
|
|
8
|
+
*
|
|
9
|
+
* 1. it parsed `cancelOrderSchema` and then **discarded the reason**, so an
|
|
10
|
+
* order reached `return_requested` with nothing recorded about why;
|
|
11
|
+
* 2. it had no caller anywhere — the whole path was unreachable.
|
|
12
|
+
*/
|
|
4
13
|
export declare function requestReturnAction(input: unknown): Promise<ActionResult<unknown>>;
|
|
5
14
|
export declare function updateOrderStatusAction(input: unknown): Promise<ActionResult<unknown>>;
|
|
6
15
|
export declare function attachPaymentProofAction(orderId: string, proof: {
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { wrapAction } from "@mohasinac/appkit/server";
|
|
3
3
|
import { orderRepository, storeRepository, userRepository } from "../../../../repositories";
|
|
4
4
|
import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
|
|
5
|
-
import { createOrderSchema, updateOrderStatusSchema, cancelOrderSchema, } from "../../../shared/features/orders/schema";
|
|
5
|
+
import { createOrderSchema, updateOrderStatusSchema, cancelOrderSchema, returnRequestSchema, } from "../../../shared/features/orders/schema";
|
|
6
|
+
import { isFinalSaleExempt } from "../../../shared/features/orders/return-reasons";
|
|
7
|
+
import { REFUND_COPY } from "../../../shared/features/orders/refund-copy";
|
|
6
8
|
import { assertOrderCancellable, assertReturnWindowOpen } from "./service";
|
|
7
9
|
import { ValidationError } from "../../../shared/errors/index";
|
|
8
10
|
import { OrderNotFoundError, OrderOwnershipError } from "../../../shared/features/orders/errors";
|
|
@@ -37,14 +39,55 @@ export async function cancelOrderAction(input) {
|
|
|
37
39
|
return orderRepository.cancelOrder(parsed.data.orderId, parsed.data.reason ?? "Cancelled by user");
|
|
38
40
|
});
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Buyer requests a return on a delivered order.
|
|
44
|
+
*
|
|
45
|
+
* Two things this did not do before, both of which made the feature unusable:
|
|
46
|
+
*
|
|
47
|
+
* 1. it parsed `cancelOrderSchema` and then **discarded the reason**, so an
|
|
48
|
+
* order reached `return_requested` with nothing recorded about why;
|
|
49
|
+
* 2. it had no caller anywhere — the whole path was unreachable.
|
|
50
|
+
*/
|
|
40
51
|
export async function requestReturnAction(input) {
|
|
41
52
|
return wrapAction(async () => {
|
|
42
53
|
const user = await requireRoleUser(["buyer", "seller", "admin"]);
|
|
43
|
-
const parsed =
|
|
54
|
+
const parsed = returnRequestSchema.safeParse(input);
|
|
44
55
|
if (!parsed.success)
|
|
45
56
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
const { orderId, reasonCode, reasonNote, itemIds } = parsed.data;
|
|
58
|
+
// Ownership + the 7-day post-delivery window.
|
|
59
|
+
await assertReturnWindowOpen(orderId, user.uid);
|
|
60
|
+
/*
|
|
61
|
+
* The final-sale gate, in the same shape as `processRefundAction`'s.
|
|
62
|
+
* Both entry points need it: this one is where the buyer asks, that one
|
|
63
|
+
* is where the money moves, and a buyer must not be able to lodge a
|
|
64
|
+
* request that is guaranteed to be refused at the second gate.
|
|
65
|
+
*
|
|
66
|
+
* Read from the order's per-line snapshot, never the live product.
|
|
67
|
+
*/
|
|
68
|
+
if (!isFinalSaleExempt(reasonCode)) {
|
|
69
|
+
const order = await orderRepository.findById(orderId);
|
|
70
|
+
if (!order)
|
|
71
|
+
throw new OrderNotFoundError(orderId);
|
|
72
|
+
const scopedItems = itemIds?.length
|
|
73
|
+
? (order.items ?? []).filter((i) => itemIds.includes(i.productId))
|
|
74
|
+
: (order.items ?? []);
|
|
75
|
+
if (scopedItems.some((i) => i.finalSale === true)) {
|
|
76
|
+
throw new ValidationError(REFUND_COPY.request.finalSaleBlockedMessage);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/*
|
|
80
|
+
* Record the reason BEFORE flipping the status. A status change is what
|
|
81
|
+
* every notification and dashboard query keys on, so a seller reading
|
|
82
|
+
* the row the moment it lands must already find the reason on it.
|
|
83
|
+
*/
|
|
84
|
+
await orderRepository.update(orderId, {
|
|
85
|
+
returnReasonCode: reasonCode,
|
|
86
|
+
...(reasonNote ? { returnReasonNote: reasonNote } : {}),
|
|
87
|
+
returnRequestedAt: new Date(),
|
|
88
|
+
...(itemIds?.length ? { returnRequestedItemIds: itemIds } : {}),
|
|
89
|
+
});
|
|
90
|
+
return orderRepository.updateStatus(orderId, "return_requested");
|
|
48
91
|
});
|
|
49
92
|
}
|
|
50
93
|
export async function updateOrderStatusAction(input) {
|
|
@@ -20,6 +20,10 @@ export function orderDocumentToOrder(doc) {
|
|
|
20
20
|
...(item.cancelledQuantity != null
|
|
21
21
|
? { cancelledQuantity: item.cancelledQuantity }
|
|
22
22
|
: {}),
|
|
23
|
+
// Snapshotted return terms. Without this the buyer's return form
|
|
24
|
+
// cannot tell a final-sale line from a returnable one and offers
|
|
25
|
+
// every reason on every order (Root Cause #57).
|
|
26
|
+
...(item.finalSale != null ? { finalSale: item.finalSale } : {}),
|
|
23
27
|
}))
|
|
24
28
|
: [
|
|
25
29
|
{
|
|
@@ -56,6 +60,12 @@ export function orderDocumentToOrder(doc) {
|
|
|
56
60
|
// missing value as "standard" rather than filtering it out.
|
|
57
61
|
orderType: doc.orderType,
|
|
58
62
|
offerId: doc.offerId,
|
|
63
|
+
// Refund-eligibility fields. All three were declared on the document and
|
|
64
|
+
// mapped nowhere, so every buyer-facing refund surface saw `undefined`.
|
|
65
|
+
...(doc.isNonRefundable != null ? { isNonRefundable: doc.isNonRefundable } : {}),
|
|
66
|
+
...(doc.contestable != null ? { contestable: doc.contestable } : {}),
|
|
67
|
+
...(doc.returnReasonCode ? { returnReasonCode: doc.returnReasonCode } : {}),
|
|
68
|
+
...(doc.returnReasonNote ? { returnReasonNote: doc.returnReasonNote } : {}),
|
|
59
69
|
subtotal,
|
|
60
70
|
shippingCost: shippingCost || undefined,
|
|
61
71
|
discount: discount || undefined,
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { type ActionResult } from "@mohasinac/appkit/server";
|
|
2
2
|
import type { RefundType } from "../../../../features/orders/schemas";
|
|
3
|
+
import { type ReturnReason } from "../../../shared/features/orders/return-reasons";
|
|
3
4
|
export type ProcessRefundInput = {
|
|
4
5
|
orderId: string;
|
|
5
6
|
type: RefundType;
|
|
6
7
|
/** Decimal rupees. Must be > 0 and ≤ remaining refundable amount on the order. */
|
|
7
8
|
amount: number;
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Coded reason. Replaced a free-text `reason: string` — final sale has to
|
|
11
|
+
* refuse a change-of-mind return while always allowing a "didn't get what I
|
|
12
|
+
* paid for" claim, and that decision cannot be made against prose.
|
|
13
|
+
*/
|
|
14
|
+
reasonCode: ReturnReason;
|
|
15
|
+
/** Optional human elaboration, stored alongside the code. */
|
|
16
|
+
reasonNote?: string;
|
|
9
17
|
/** Product/item ids affected (for partial refunds). */
|
|
10
18
|
itemIds?: string[];
|
|
11
19
|
/** Required: caller must explicitly confirm this action is irrevocable. */
|
|
@@ -22,6 +22,8 @@ import { orderRepository } from "../../../..";
|
|
|
22
22
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
23
23
|
import { NotFoundError, ValidationError } from "../../../../errors";
|
|
24
24
|
import { serverLogger } from "../../../../monitoring";
|
|
25
|
+
import { isFinalSaleExempt, RETURN_REASON_LABEL, } from "../../../shared/features/orders/return-reasons";
|
|
26
|
+
import { REFUND_COPY } from "../../../shared/features/orders/refund-copy";
|
|
25
27
|
import { applyRefundDeductionAction } from "../payouts/actions";
|
|
26
28
|
import { getAdminDb } from "../../../../providers/db-firebase";
|
|
27
29
|
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
|
|
@@ -86,10 +88,32 @@ export async function processRefundAction(input) {
|
|
|
86
88
|
if (input.amount > order.totalPrice) {
|
|
87
89
|
throw new ValidationError("Refund amount exceeds order total");
|
|
88
90
|
}
|
|
89
|
-
// Guard: non-refundable orders (prize draws, bundles).
|
|
91
|
+
// Guard: non-refundable orders (prize draws, bundles). A property of the
|
|
92
|
+
// listing TYPE, decided by the platform — checked first because it
|
|
93
|
+
// admits no exception, not even a seller-fault one.
|
|
90
94
|
if (order.isNonRefundable) {
|
|
91
95
|
throw new ValidationError("This order is marked non-refundable and cannot be refunded.");
|
|
92
96
|
}
|
|
97
|
+
/*
|
|
98
|
+
* Guard: final sale.
|
|
99
|
+
*
|
|
100
|
+
* Unlike `isNonRefundable` this is per-LINE and reason-dependent. A
|
|
101
|
+
* seller-fault claim is never blocked, however the listing was sold —
|
|
102
|
+
* "no returns" is a statement about changing your mind, not a waiver of
|
|
103
|
+
* the seller's obligation to ship what was described.
|
|
104
|
+
*
|
|
105
|
+
* Read from the ORDER's snapshot, never from the product: the buyer
|
|
106
|
+
* agreed to the terms shown at checkout, and re-reading the live product
|
|
107
|
+
* would let a seller flip the switch afterwards.
|
|
108
|
+
*/
|
|
109
|
+
if (!isFinalSaleExempt(input.reasonCode)) {
|
|
110
|
+
const scopedItems = input.itemIds?.length
|
|
111
|
+
? (order.items ?? []).filter((i) => input.itemIds.includes(i.productId))
|
|
112
|
+
: (order.items ?? []);
|
|
113
|
+
if (scopedItems.some((i) => i.finalSale === true)) {
|
|
114
|
+
throw new ValidationError(REFUND_COPY.request.finalSaleBlockedMessage);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
93
117
|
// SB-UNI-N — digital-code refund gate: block if code is already claimed (revealed).
|
|
94
118
|
const digitalCodeItems = (order.items ?? []).filter((i) => (i.listingType ?? "standard") === "digital-code");
|
|
95
119
|
await assertDigitalCodesNotClaimed(input.orderId, digitalCodeItems);
|
|
@@ -109,7 +133,14 @@ export async function processRefundAction(input) {
|
|
|
109
133
|
type: input.type,
|
|
110
134
|
amount: input.amount,
|
|
111
135
|
...(input.itemIds?.length ? { itemIds: input.itemIds } : {}),
|
|
112
|
-
|
|
136
|
+
reasonCode: input.reasonCode,
|
|
137
|
+
// `reason` stays the human-readable string every existing refund row
|
|
138
|
+
// and every rendering of RefundHistoryTable already reads. It is now
|
|
139
|
+
// DERIVED from the code rather than typed freehand, so the two can
|
|
140
|
+
// never disagree about why a refund happened.
|
|
141
|
+
reason: input.reasonNote
|
|
142
|
+
? `${RETURN_REASON_LABEL[input.reasonCode]} — ${input.reasonNote}`
|
|
143
|
+
: RETURN_REASON_LABEL[input.reasonCode],
|
|
113
144
|
refundedAt: now,
|
|
114
145
|
refundedBy: input.refundedBy,
|
|
115
146
|
...(razorpayRefundId ? { razorpayRefundId } : {}),
|
|
@@ -134,7 +165,9 @@ export async function processRefundAction(input) {
|
|
|
134
165
|
orderId: input.orderId,
|
|
135
166
|
refundId,
|
|
136
167
|
refundedAmount: input.amount,
|
|
137
|
-
|
|
168
|
+
// Same derived string the refund event records, so the payout
|
|
169
|
+
// deduction and the refund history read identically.
|
|
170
|
+
reason: event.reason,
|
|
138
171
|
}).catch(() => { });
|
|
139
172
|
}
|
|
140
173
|
return { success: true, refundId };
|
|
@@ -8,7 +8,7 @@ export const PUBLIC_SITE_SETTINGS_FIELDS = [
|
|
|
8
8
|
"payment", // CheckoutRouteClient — payment.otpCheckoutThreshold (narrowed)
|
|
9
9
|
"listings", // useListingTypeFlags — narrowed to listings.listingTypes; categoryTypes has no client reader
|
|
10
10
|
"notificationChannels", // NotificationPreferencesPanel + CheckoutRouteClient (whatsapp.otpEnabled)
|
|
11
|
-
"announcementBar", //
|
|
11
|
+
"announcementBar", // the homepage banner (AdminSiteSettingsView edits it)
|
|
12
12
|
"navConfig", // NavbarWithSettings, DashboardLayoutClient
|
|
13
13
|
"actionConfig", // useAuthGate
|
|
14
14
|
"background", // DashboardLayoutClient (background.light / .dark)
|
|
@@ -22,6 +22,18 @@ export declare const REFUND_COPY: {
|
|
|
22
22
|
readonly submittingLabel: "Submitting…";
|
|
23
23
|
readonly errorFallback: "Failed to submit refund request";
|
|
24
24
|
readonly nonRefundableMessage: "This order is non-refundable (prize draw or bundle). Refunds cannot be issued.";
|
|
25
|
+
/**
|
|
26
|
+
* Shown when a final-sale line is in scope and the chosen reason is a
|
|
27
|
+
* change-of-mind one. Deliberately names the reasons that ARE accepted —
|
|
28
|
+
* a bare "final sale, request refused" reads as a dead end and generates a
|
|
29
|
+
* support ticket, when in most cases the buyer simply picked the wrong
|
|
30
|
+
* reason from the list.
|
|
31
|
+
*/
|
|
32
|
+
readonly finalSaleBlockedMessage: "This order includes a final-sale item, so it can't be returned for a change of mind. If it never arrived, arrived damaged, was the wrong item, wasn't as described, or is counterfeit, choose that reason instead and your request will go through.";
|
|
33
|
+
readonly finalSaleNoticeHeading: "This order includes a final-sale item";
|
|
34
|
+
readonly reasonSelectLabel: "What went wrong?";
|
|
35
|
+
readonly reasonSelectPlaceholder: "Choose a reason";
|
|
36
|
+
readonly reasonNoteLabel: "Anything else we should know? (optional)";
|
|
25
37
|
readonly alreadyRefundedMessage: "A refund has already been processed on this order. No further disputes or refund requests can be filed.";
|
|
26
38
|
readonly acknowledgments: readonly ["I understand this refund request, once approved, permanently removes my ability to file any dispute or RMA claim on this order.", "I confirm the item has not been used, worn, or damaged by me, and I am returning it in original condition (if a return is required).", "I understand the refund amount may take 5–7 business days to reflect and that platform fees may be non-recoverable."];
|
|
27
39
|
};
|
|
@@ -22,6 +22,18 @@ export const REFUND_COPY = {
|
|
|
22
22
|
submittingLabel: "Submitting…",
|
|
23
23
|
errorFallback: "Failed to submit refund request",
|
|
24
24
|
nonRefundableMessage: "This order is non-refundable (prize draw or bundle). Refunds cannot be issued.",
|
|
25
|
+
/**
|
|
26
|
+
* Shown when a final-sale line is in scope and the chosen reason is a
|
|
27
|
+
* change-of-mind one. Deliberately names the reasons that ARE accepted —
|
|
28
|
+
* a bare "final sale, request refused" reads as a dead end and generates a
|
|
29
|
+
* support ticket, when in most cases the buyer simply picked the wrong
|
|
30
|
+
* reason from the list.
|
|
31
|
+
*/
|
|
32
|
+
finalSaleBlockedMessage: "This order includes a final-sale item, so it can't be returned for a change of mind. If it never arrived, arrived damaged, was the wrong item, wasn't as described, or is counterfeit, choose that reason instead and your request will go through.",
|
|
33
|
+
finalSaleNoticeHeading: "This order includes a final-sale item",
|
|
34
|
+
reasonSelectLabel: "What went wrong?",
|
|
35
|
+
reasonSelectPlaceholder: "Choose a reason",
|
|
36
|
+
reasonNoteLabel: "Anything else we should know? (optional)",
|
|
25
37
|
alreadyRefundedMessage: "A refund has already been processed on this order. No further disputes or refund requests can be filed.",
|
|
26
38
|
acknowledgments: [
|
|
27
39
|
"I understand this refund request, once approved, permanently removes my ability to file any dispute or RMA claim on this order.",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return / refund reasons, and which of them a FINAL SALE may refuse.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a closed enum and not free text
|
|
5
|
+
*
|
|
6
|
+
* Before this, every return and refund path took `reason: z.string()`. The
|
|
7
|
+
* seller-facing refund routes still recorded it, but `requestReturnAction`
|
|
8
|
+
* parsed it and then dropped it on the floor — the field never reached
|
|
9
|
+
* Firestore. So there was no stored answer to "why was this returned", and
|
|
10
|
+
* nothing a gate could branch on.
|
|
11
|
+
*
|
|
12
|
+
* Final sale needs exactly that branch. "No returns, unless the buyer didn't
|
|
13
|
+
* get what they paid for" is not a policy that can be enforced against a
|
|
14
|
+
* free-text box: it needs a machine-readable statement of what went wrong.
|
|
15
|
+
*
|
|
16
|
+
* ## The split
|
|
17
|
+
*
|
|
18
|
+
* The exempt set is not "serious reasons" — it is **the reasons where the
|
|
19
|
+
* buyer did not receive what the listing promised**. That is a claim about the
|
|
20
|
+
* seller's performance of the contract, and no shop-level policy may waive it.
|
|
21
|
+
* Everything in the blocked set is the buyer changing their mind about a thing
|
|
22
|
+
* they did receive as described, which is exactly what final sale exists to
|
|
23
|
+
* refuse.
|
|
24
|
+
*
|
|
25
|
+
* A reason is EXEMPT if the fault lies with the seller or the carrier.
|
|
26
|
+
* A reason is BLOCKED (under final sale) if the fault lies with nobody.
|
|
27
|
+
*/
|
|
28
|
+
export declare const RETURN_REASON: readonly ["not_received", "not_as_described", "damaged_in_transit", "wrong_item_sent", "counterfeit_or_inauthentic", "changed_mind", "ordered_by_mistake", "found_better_price", "no_longer_needed", "size_or_fit"];
|
|
29
|
+
export type ReturnReason = (typeof RETURN_REASON)[number];
|
|
30
|
+
/**
|
|
31
|
+
* Reasons a final-sale listing can never refuse.
|
|
32
|
+
*
|
|
33
|
+
* 🛑 Adding a member to `RETURN_REASON` does NOT automatically add it here,
|
|
34
|
+
* and that asymmetry is deliberate: a new reason defaults to BLOCKED under
|
|
35
|
+
* final sale, which is the conservative direction — it can be appealed to
|
|
36
|
+
* support, whereas a wrongly-exempt reason silently hands out refunds the
|
|
37
|
+
* seller never agreed to. `RETURN_REASON_LABEL` below is a
|
|
38
|
+
* `Record<ReturnReason, string>` precisely so a new member still cannot be
|
|
39
|
+
* added without a deliberate decision about it.
|
|
40
|
+
*/
|
|
41
|
+
export declare const FINAL_SALE_EXEMPT_REASONS: ReadonlySet<ReturnReason>;
|
|
42
|
+
/**
|
|
43
|
+
* Buyer-facing labels. `Record<ReturnReason, string>`, so a new reason is a
|
|
44
|
+
* COMPILE error here rather than a select option rendering `undefined`.
|
|
45
|
+
*
|
|
46
|
+
* Lives beside the enum rather than in the component, matching `REFUND_COPY` —
|
|
47
|
+
* these strings appear in the buyer form, the admin queue and the notification
|
|
48
|
+
* body, and three copies of them would drift.
|
|
49
|
+
*/
|
|
50
|
+
export declare const RETURN_REASON_LABEL: Record<ReturnReason, string>;
|
|
51
|
+
/** Short grouping label, used to head the two option groups in the picker. */
|
|
52
|
+
export declare const RETURN_REASON_GROUP_LABEL: {
|
|
53
|
+
readonly fault: "Something was wrong with the order";
|
|
54
|
+
readonly changeOfMind: "I changed my mind";
|
|
55
|
+
};
|
|
56
|
+
export declare function isFinalSaleExempt(reason: ReturnReason): boolean;
|
|
57
|
+
export declare function isReturnReason(value: unknown): value is ReturnReason;
|
|
58
|
+
/** The reasons a buyer may pick for this order, given its final-sale lines. */
|
|
59
|
+
export declare function selectableReturnReasons(hasFinalSaleLine: boolean): readonly ReturnReason[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return / refund reasons, and which of them a FINAL SALE may refuse.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a closed enum and not free text
|
|
5
|
+
*
|
|
6
|
+
* Before this, every return and refund path took `reason: z.string()`. The
|
|
7
|
+
* seller-facing refund routes still recorded it, but `requestReturnAction`
|
|
8
|
+
* parsed it and then dropped it on the floor — the field never reached
|
|
9
|
+
* Firestore. So there was no stored answer to "why was this returned", and
|
|
10
|
+
* nothing a gate could branch on.
|
|
11
|
+
*
|
|
12
|
+
* Final sale needs exactly that branch. "No returns, unless the buyer didn't
|
|
13
|
+
* get what they paid for" is not a policy that can be enforced against a
|
|
14
|
+
* free-text box: it needs a machine-readable statement of what went wrong.
|
|
15
|
+
*
|
|
16
|
+
* ## The split
|
|
17
|
+
*
|
|
18
|
+
* The exempt set is not "serious reasons" — it is **the reasons where the
|
|
19
|
+
* buyer did not receive what the listing promised**. That is a claim about the
|
|
20
|
+
* seller's performance of the contract, and no shop-level policy may waive it.
|
|
21
|
+
* Everything in the blocked set is the buyer changing their mind about a thing
|
|
22
|
+
* they did receive as described, which is exactly what final sale exists to
|
|
23
|
+
* refuse.
|
|
24
|
+
*
|
|
25
|
+
* A reason is EXEMPT if the fault lies with the seller or the carrier.
|
|
26
|
+
* A reason is BLOCKED (under final sale) if the fault lies with nobody.
|
|
27
|
+
*/
|
|
28
|
+
export const RETURN_REASON = [
|
|
29
|
+
// ── Seller / carrier fault. Never blocked, whatever the listing says. ──
|
|
30
|
+
"not_received",
|
|
31
|
+
"not_as_described",
|
|
32
|
+
"damaged_in_transit",
|
|
33
|
+
"wrong_item_sent",
|
|
34
|
+
"counterfeit_or_inauthentic",
|
|
35
|
+
// ── Change of mind. Blocked when the line is final sale. ──
|
|
36
|
+
"changed_mind",
|
|
37
|
+
"ordered_by_mistake",
|
|
38
|
+
"found_better_price",
|
|
39
|
+
"no_longer_needed",
|
|
40
|
+
"size_or_fit",
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Reasons a final-sale listing can never refuse.
|
|
44
|
+
*
|
|
45
|
+
* 🛑 Adding a member to `RETURN_REASON` does NOT automatically add it here,
|
|
46
|
+
* and that asymmetry is deliberate: a new reason defaults to BLOCKED under
|
|
47
|
+
* final sale, which is the conservative direction — it can be appealed to
|
|
48
|
+
* support, whereas a wrongly-exempt reason silently hands out refunds the
|
|
49
|
+
* seller never agreed to. `RETURN_REASON_LABEL` below is a
|
|
50
|
+
* `Record<ReturnReason, string>` precisely so a new member still cannot be
|
|
51
|
+
* added without a deliberate decision about it.
|
|
52
|
+
*/
|
|
53
|
+
export const FINAL_SALE_EXEMPT_REASONS = new Set([
|
|
54
|
+
"not_received",
|
|
55
|
+
"not_as_described",
|
|
56
|
+
"damaged_in_transit",
|
|
57
|
+
"wrong_item_sent",
|
|
58
|
+
"counterfeit_or_inauthentic",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Buyer-facing labels. `Record<ReturnReason, string>`, so a new reason is a
|
|
62
|
+
* COMPILE error here rather than a select option rendering `undefined`.
|
|
63
|
+
*
|
|
64
|
+
* Lives beside the enum rather than in the component, matching `REFUND_COPY` —
|
|
65
|
+
* these strings appear in the buyer form, the admin queue and the notification
|
|
66
|
+
* body, and three copies of them would drift.
|
|
67
|
+
*/
|
|
68
|
+
export const RETURN_REASON_LABEL = {
|
|
69
|
+
not_received: "It never arrived",
|
|
70
|
+
not_as_described: "Not as described in the listing",
|
|
71
|
+
damaged_in_transit: "Arrived damaged",
|
|
72
|
+
wrong_item_sent: "The wrong item was sent",
|
|
73
|
+
counterfeit_or_inauthentic: "Counterfeit or not authentic",
|
|
74
|
+
changed_mind: "I changed my mind",
|
|
75
|
+
ordered_by_mistake: "I ordered it by mistake",
|
|
76
|
+
found_better_price: "I found a better price elsewhere",
|
|
77
|
+
no_longer_needed: "I no longer need it",
|
|
78
|
+
size_or_fit: "Size or fit is wrong",
|
|
79
|
+
};
|
|
80
|
+
/** Short grouping label, used to head the two option groups in the picker. */
|
|
81
|
+
export const RETURN_REASON_GROUP_LABEL = {
|
|
82
|
+
fault: "Something was wrong with the order",
|
|
83
|
+
changeOfMind: "I changed my mind",
|
|
84
|
+
};
|
|
85
|
+
export function isFinalSaleExempt(reason) {
|
|
86
|
+
return FINAL_SALE_EXEMPT_REASONS.has(reason);
|
|
87
|
+
}
|
|
88
|
+
export function isReturnReason(value) {
|
|
89
|
+
return typeof value === "string" && RETURN_REASON.includes(value);
|
|
90
|
+
}
|
|
91
|
+
/** The reasons a buyer may pick for this order, given its final-sale lines. */
|
|
92
|
+
export function selectableReturnReasons(hasFinalSaleLine) {
|
|
93
|
+
return hasFinalSaleLine
|
|
94
|
+
? RETURN_REASON.filter(isFinalSaleExempt)
|
|
95
|
+
: RETURN_REASON;
|
|
96
|
+
}
|
|
@@ -127,6 +127,36 @@ export declare const cancelOrderSchema: z.ZodObject<{
|
|
|
127
127
|
orderId: string;
|
|
128
128
|
reason?: string | undefined;
|
|
129
129
|
}>;
|
|
130
|
+
/**
|
|
131
|
+
* A buyer asking to return a delivered order.
|
|
132
|
+
*
|
|
133
|
+
* `requestReturnAction` used to parse `cancelOrderSchema` — a cancellation is
|
|
134
|
+
* a different event with a different window and a different outcome, and its
|
|
135
|
+
* `reason` was optional free text that the action then dropped before writing.
|
|
136
|
+
* So an order could reach `return_requested` carrying no reason at all, and
|
|
137
|
+
* the seller had nothing to act on.
|
|
138
|
+
*
|
|
139
|
+
* The code is REQUIRED here: final sale has to decide whether this particular
|
|
140
|
+
* return is allowed, and it cannot do that against an absent value.
|
|
141
|
+
*/
|
|
142
|
+
export declare const returnRequestSchema: z.ZodObject<{
|
|
143
|
+
orderId: z.ZodString;
|
|
144
|
+
reasonCode: z.ZodEnum<["not_received", "not_as_described", "damaged_in_transit", "wrong_item_sent", "counterfeit_or_inauthentic", "changed_mind", "ordered_by_mistake", "found_better_price", "no_longer_needed", "size_or_fit"]>;
|
|
145
|
+
reasonNote: z.ZodOptional<z.ZodString>;
|
|
146
|
+
/** Omit for a whole-order return — matches `processRefundAction`'s itemIds. */
|
|
147
|
+
itemIds: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
148
|
+
}, "strip", z.ZodTypeAny, {
|
|
149
|
+
orderId: string;
|
|
150
|
+
reasonCode: "not_received" | "not_as_described" | "damaged_in_transit" | "wrong_item_sent" | "counterfeit_or_inauthentic" | "changed_mind" | "ordered_by_mistake" | "found_better_price" | "no_longer_needed" | "size_or_fit";
|
|
151
|
+
itemIds?: string[] | undefined;
|
|
152
|
+
reasonNote?: string | undefined;
|
|
153
|
+
}, {
|
|
154
|
+
orderId: string;
|
|
155
|
+
reasonCode: "not_received" | "not_as_described" | "damaged_in_transit" | "wrong_item_sent" | "counterfeit_or_inauthentic" | "changed_mind" | "ordered_by_mistake" | "found_better_price" | "no_longer_needed" | "size_or_fit";
|
|
156
|
+
itemIds?: string[] | undefined;
|
|
157
|
+
reasonNote?: string | undefined;
|
|
158
|
+
}>;
|
|
130
159
|
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
|
|
131
160
|
export type UpdateOrderStatusInput = z.infer<typeof updateOrderStatusSchema>;
|
|
132
161
|
export type CancelOrderInput = z.infer<typeof cancelOrderSchema>;
|
|
162
|
+
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { DEFAULT_COUNTRY, isValidPostalCode, postalLabelFor, } from "../../../../constants/geo/countries";
|
|
3
3
|
import { ORDER_CANCEL_REASON_MAX_LENGTH, ORDER_NOTE_MAX_LENGTH, ORDER_TRACKING_NUMBER_MAX_LENGTH, } from "./config";
|
|
4
|
+
import { RETURN_REASON } from "./return-reasons";
|
|
4
5
|
/*
|
|
5
6
|
* The order's own SNAPSHOT of where it shipped, denormalised onto the order
|
|
6
7
|
* document at creation. Its field names (`pincode`) are stored on every
|
|
@@ -51,3 +52,22 @@ export const cancelOrderSchema = z.object({
|
|
|
51
52
|
orderId: z.string().min(1),
|
|
52
53
|
reason: z.string().max(ORDER_CANCEL_REASON_MAX_LENGTH).optional(),
|
|
53
54
|
});
|
|
55
|
+
/**
|
|
56
|
+
* A buyer asking to return a delivered order.
|
|
57
|
+
*
|
|
58
|
+
* `requestReturnAction` used to parse `cancelOrderSchema` — a cancellation is
|
|
59
|
+
* a different event with a different window and a different outcome, and its
|
|
60
|
+
* `reason` was optional free text that the action then dropped before writing.
|
|
61
|
+
* So an order could reach `return_requested` carrying no reason at all, and
|
|
62
|
+
* the seller had nothing to act on.
|
|
63
|
+
*
|
|
64
|
+
* The code is REQUIRED here: final sale has to decide whether this particular
|
|
65
|
+
* return is allowed, and it cannot do that against an absent value.
|
|
66
|
+
*/
|
|
67
|
+
export const returnRequestSchema = z.object({
|
|
68
|
+
orderId: z.string().min(1),
|
|
69
|
+
reasonCode: z.enum(RETURN_REASON),
|
|
70
|
+
reasonNote: z.string().max(ORDER_CANCEL_REASON_MAX_LENGTH).optional(),
|
|
71
|
+
/** Omit for a whole-order return — matches `processRefundAction`'s itemIds. */
|
|
72
|
+
itemIds: z.array(z.string().min(1)).optional(),
|
|
73
|
+
});
|