@mohasinac/appkit 4.33.1 → 4.34.1

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.
Files changed (104) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +9 -1
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
  3. package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
  4. package/dist/_internal/server/features/media/contextGuards.js +19 -5
  5. package/dist/_internal/server/features/orders/actions.d.ts +9 -0
  6. package/dist/_internal/server/features/orders/actions.js +47 -4
  7. package/dist/_internal/server/features/orders/adapters.js +10 -0
  8. package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
  9. package/dist/_internal/server/features/refunds/actions.js +36 -3
  10. package/dist/_internal/server/features/seo/sitemap.js +2 -2
  11. package/dist/_internal/server/features/site-settings/adapters.js +1 -1
  12. package/dist/_internal/server/functions/scheduled.d.ts +1 -2
  13. package/dist/_internal/server/functions/scheduled.js +8 -9
  14. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
  15. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
  17. package/dist/_internal/server/jobs/handlers/index.js +0 -1
  18. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  19. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  20. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  21. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  22. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  23. package/dist/_internal/shared/features/orders/schema.js +20 -0
  24. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  25. package/dist/_internal/shared/features/products/schema.js +4 -1
  26. package/dist/_internal/shared/media/limits.d.ts +14 -0
  27. package/dist/_internal/shared/media/limits.js +29 -0
  28. package/dist/client.d.ts +9 -1
  29. package/dist/client.js +19 -1
  30. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  31. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  32. package/dist/features/admin/components/index.d.ts +0 -2
  33. package/dist/features/admin/components/index.js +0 -1
  34. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  35. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  36. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  37. package/dist/features/auth/schemas/firestore.d.ts +1 -0
  38. package/dist/features/contact/components/ContactForm.js +46 -31
  39. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  40. package/dist/features/media/upload/MediaUploadList.js +58 -8
  41. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  42. package/dist/features/orders/components/RefundRequestView.js +108 -24
  43. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  44. package/dist/features/orders/types/index.d.ts +24 -0
  45. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  46. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  47. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  48. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  49. package/dist/features/products/components/ProductForm.js +46 -10
  50. package/dist/features/products/components/ProductGrid.js +10 -1
  51. package/dist/features/products/constants/final-sale.d.ts +40 -0
  52. package/dist/features/products/constants/final-sale.js +40 -0
  53. package/dist/features/products/index.d.ts +1 -0
  54. package/dist/features/products/index.js +1 -0
  55. package/dist/features/products/schemas/firestore.d.ts +19 -2
  56. package/dist/features/products/schemas/firestore.js +4 -0
  57. package/dist/features/products/types/index.d.ts +2 -0
  58. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  59. package/dist/features/seller/components/SellerProductShell.js +82 -11
  60. package/dist/features/seller/index.d.ts +1 -0
  61. package/dist/features/seller/index.js +1 -0
  62. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  63. package/dist/features/seller/schemas/product-form.js +132 -0
  64. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  65. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  66. package/dist/features/shell/SectionForm.js +62 -7
  67. package/dist/features/shell/form-sections.js +4 -0
  68. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
  69. package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
  70. package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
  71. package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
  72. package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
  74. package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
  75. package/dist/features/tester/seed-data/tester-window.js +66 -0
  76. package/dist/index.d.ts +6 -3
  77. package/dist/index.js +12 -3
  78. package/dist/next/routing/route-map.d.ts +2 -0
  79. package/dist/next/routing/route-map.js +1 -0
  80. package/dist/schemas/registry.d.ts +271 -0
  81. package/dist/schemas/registry.js +9 -0
  82. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  83. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  84. package/dist/seed/products-art-seed-data.js +9 -1
  85. package/dist/seed/products-auctions-seed-data.js +2 -1
  86. package/dist/seed/products-classifieds-seed-data.js +2 -1
  87. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  88. package/dist/seed/products-live-items-seed-data.js +2 -1
  89. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  90. package/dist/seed/products-preorders-seed-data.js +2 -1
  91. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  92. package/dist/seed/products-standard-seed-data.js +2 -1
  93. package/dist/seed/products-stickers-seed-data.js +9 -1
  94. package/dist/seed/users-seed-data.js +107 -4
  95. package/dist/server.d.ts +6 -2
  96. package/dist/server.js +15 -2
  97. package/dist/styles.css +1 -1
  98. package/dist/tailwind-utilities.css +1 -1
  99. package/dist/tokens/motion.d.ts +12 -0
  100. package/dist/tokens/motion.js +12 -0
  101. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  102. package/dist/ui/components/CollapsibleSection.js +20 -3
  103. package/dist/ui/components/Motion.js +17 -1
  104. 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
- reason: `Automatic refund — ${unavailablePaid.length} item(s) unavailable at checkout: ${unavailablePaid.map((u) => u.productTitle).join(", ")}`,
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: 5;
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: 5;
21
- readonly PREORDER_IMAGE_MAX: 5;
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
- PRODUCT_IMAGE_MAX: 5,
33
- PRODUCT_VIDEO_MAX: 1,
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: 5,
37
- PREORDER_IMAGE_MAX: 5,
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 = cancelOrderSchema.safeParse(input);
54
+ const parsed = returnRequestSchema.safeParse(input);
44
55
  if (!parsed.success)
45
56
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
46
- await assertReturnWindowOpen(parsed.data.orderId, user.uid);
47
- return orderRepository.updateStatus(parsed.data.orderId, "return_requested");
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
- reason: string;
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
- reason: input.reason,
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
- reason: input.reason,
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 };
@@ -16,8 +16,8 @@ const PRODUCT_SLUG = "slug";
16
16
  const PRODUCT_UPDATED_AT = "updatedAt";
17
17
  /**
18
18
  * Tester-sandbox fixtures must never reach the public sitemap. They are created
19
- * and destroyed on a cycle by `testerSandboxCleanup` / `testerSandboxRefresh`,
20
- * so anything Google indexes from them 404s shortly after. 34 of 182 sitemap
19
+ * and destroyed on a cycle by `testerSandboxCleanup` and by each tester run's
20
+ * wipe/seed, so anything Google indexes from them 404s shortly after. 34 of 182 sitemap
21
21
  * URLs were disposable test fixtures before this filter existed.
22
22
  *
23
23
  * 🛑 This MUST be an in-memory filter, never a Firestore inequality on the
@@ -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", // AdminSiteView; also the homepage banner
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,9 +22,8 @@ export declare const cleanupRtdbEvents: import("./types").ScheduledFunctionDefin
22
22
  export declare const mediaTmpCleanup: import("./types").ScheduledFunctionDefinition;
23
23
  export declare const draftPrune: import("./types").ScheduledFunctionDefinition;
24
24
  export declare const testerSandboxCleanup: import("./types").ScheduledFunctionDefinition;
25
- export declare const testerSandboxRefresh: import("./types").ScheduledFunctionDefinition;
26
25
  export declare const prizeDrawExpiryReveal: import("./types").ScheduledFunctionDefinition;
27
26
  export declare const bundleStockSync: import("./types").ScheduledFunctionDefinition;
28
27
  export declare const emiInstallmentReminder: import("./types").ScheduledFunctionDefinition;
29
28
  export declare const catalogueImageStalenessReminder: import("./types").ScheduledFunctionDefinition;
30
- export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
29
+ export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
@@ -1,6 +1,6 @@
1
1
  // Scheduled function definitions (Cloud Scheduler triggers).
2
2
  // Cron syntax accepts both Firebase shorthand and standard cron expressions.
3
- import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, testerSandboxRefreshHandler, } from "../jobs/handlers";
3
+ import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, } from "../jobs/handlers";
4
4
  import { defineFunction } from "./define";
5
5
  const REGION = "asia-south1";
6
6
  const EVERY_5_MIN = "every 5 minutes";
@@ -182,13 +182,13 @@ export const testerSandboxCleanup = defineFunction({
182
182
  handler: testerSandboxCleanupHandler,
183
183
  options: { region: REGION, timeoutSeconds: 300, memory: "256MiB", maxInstances: 1 },
184
184
  });
185
- export const testerSandboxRefresh = defineFunction({
186
- name: "testerSandboxRefresh",
187
- description: "Revert live tester QA sandbox fixtures to their canonical seed shape and prune tester-created extras (every 4 hours).",
188
- trigger: { kind: "schedule", cron: "0 */4 * * *", timeZone: "UTC" },
189
- handler: testerSandboxRefreshHandler,
190
- options: { region: REGION, timeoutSeconds: 300, memory: "256MiB", maxInstances: 1 },
191
- });
185
+ // testerSandboxRefresh (every 4h: revert fixtures to seed shape, prune extras) was
186
+ // REMOVED. The tester runner now owns fixture state — it seeds a known catalog at the
187
+ // start of a run and wipes at the end — so a background job independently rewriting
188
+ // those same documents was redundant and, worse, raced a run in progress: it reset
189
+ // currentBid and recomputed auctionEndDate underneath a test that was mid-assertion.
190
+ // Do not reinstate it without also deciding who owns fixture state; two owners is the
191
+ // bug it caused. See tester/scripts/lifecycle.mjs.
192
192
  export const prizeDrawExpiryReveal = defineFunction({
193
193
  name: "prizeDrawExpiryReveal",
194
194
  description: "Assign winners for outstanding paid orders on scheduled-mode prize draws past their expiry, then close them (every 5 minutes).",
@@ -242,7 +242,6 @@ export const SCHEDULED_FUNCTIONS = [
242
242
  mediaTmpCleanup,
243
243
  draftPrune,
244
244
  testerSandboxCleanup,
245
- testerSandboxRefresh,
246
245
  prizeDrawExpiryReveal,
247
246
  bundleStockSync,
248
247
  emiInstallmentReminder,
@@ -1,4 +1,19 @@
1
1
  import type { JobContext } from "../runtime/types";
2
+ /**
3
+ * 🛑 THE definition of "sandbox scope" — six collections, and nothing else.
4
+ *
5
+ * Everything OUTSIDE this list is permanent: neither this job nor anything else
6
+ * reclaims it. That is why `users`, `addresses`, `sessions` and `siteSettings`
7
+ * survive a tester run untouched, and why the tester lifecycle's cascade delete
8
+ * (tester/scripts/lib/collections.mjs) mirrors this list rather than inventing
9
+ * its own — a second definition is how the two silently disagree.
10
+ *
11
+ * Adding a collection here makes it disposable. Do not add one without also
12
+ * adding a seed fixture for it, or a run will delete rows nothing recreates.
13
+ *
14
+ * (This used to be shared with testerSandboxRefresh.ts, which was removed when
15
+ * the tester runner took over fixture state — see scheduled.ts.)
16
+ */
2
17
  export declare const SANDBOX_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events", "offers"];
3
18
  export interface TesterSandboxCleanupOptions {
4
19
  /** true = wipe every isTestData:true doc regardless of testDataExpiresAt (manual force-purge). */
@@ -1,11 +1,20 @@
1
1
  import { batchDelete, getTestDataRefs } from "../handlers/_helpers";
2
2
  import { BID_FIELDS } from "../../../../constants/field-names";
3
- // Exported so testerSandboxRefresh.ts (the every-4h revert+prune job) reuses
4
- // the exact same collection scope instead of redefining it — one source of
5
- // truth for "what counts as sandbox scope."
6
- // Keep in sync with testerSandboxRefresh.ts's SEED_BY_COLLECTION — the daily
7
- // TTL cleanup and the 4-hourly revert+prune must agree on what "sandbox" means
8
- // (CLAUDE.md § Tester QA standards #3).
3
+ /**
4
+ * 🛑 THE definition of "sandbox scope" — six collections, and nothing else.
5
+ *
6
+ * Everything OUTSIDE this list is permanent: neither this job nor anything else
7
+ * reclaims it. That is why `users`, `addresses`, `sessions` and `siteSettings`
8
+ * survive a tester run untouched, and why the tester lifecycle's cascade delete
9
+ * (tester/scripts/lib/collections.mjs) mirrors this list rather than inventing
10
+ * its own — a second definition is how the two silently disagree.
11
+ *
12
+ * Adding a collection here makes it disposable. Do not add one without also
13
+ * adding a seed fixture for it, or a run will delete rows nothing recreates.
14
+ *
15
+ * (This used to be shared with testerSandboxRefresh.ts, which was removed when
16
+ * the tester runner took over fixture state — see scheduled.ts.)
17
+ */
9
18
  export const SANDBOX_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events", "offers"];
10
19
  const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
11
20
  export async function runTesterSandboxCleanup(ctx, opts = {}) {
@@ -54,7 +54,6 @@ export { onScamReportCreateHandler } from "./onScamReportCreate";
54
54
  export { onScamReportUpdateHandler } from "./onScamReportUpdate";
55
55
  export { draftPruneHandler } from "./draftPrune";
56
56
  export { testerSandboxCleanupHandler } from "./testerSandboxCleanup";
57
- export { testerSandboxRefreshHandler } from "./testerSandboxRefresh";
58
57
  export { emiInstallmentReminderHandler } from "./emiInstallmentReminder";
59
58
  export { onShipmentItemWriteHandler } from "./onShipmentItemWrite";
60
59
  export { onShipmentLotWriteHandler, onShipmentHeaderWriteHandler } from "./onShipmentAllocationSync";
@@ -67,7 +67,6 @@ export { draftPruneHandler } from "./draftPrune";
67
67
  // Tester program — 7-day auto-expiry sweep for the shared admin-seeded test sandbox
68
68
  export { testerSandboxCleanupHandler } from "./testerSandboxCleanup";
69
69
  // Tester program — every-4h revert-live-edits + prune-extras refresh (faster than the 7-day sweep above)
70
- export { testerSandboxRefreshHandler } from "./testerSandboxRefresh";
71
70
  // EMI — installment reminder + overdue-flagging sweep
72
71
  export { emiInstallmentReminderHandler } from "./emiInstallmentReminder";
73
72
  // Procurement Shipments — 3-function recompute cascade (item→lot, lot/header→shipment allocation, cascade-delete)
@@ -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[];