@mohasinac/appkit 4.21.0 → 4.23.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.
Files changed (102) hide show
  1. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  2. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/stores/adapters.js +5 -0
  4. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  5. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  6. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  7. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  8. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  9. package/dist/_internal/shared/history/index.d.ts +1 -1
  10. package/dist/_internal/shared/history/types.d.ts +16 -0
  11. package/dist/client.d.ts +7 -0
  12. package/dist/client.js +4 -0
  13. package/dist/constants/field-names.d.ts +12 -0
  14. package/dist/constants/field-names.js +12 -0
  15. package/dist/features/account/components/AddressForm.js +45 -29
  16. package/dist/features/admin/actions/admin-actions.js +13 -3
  17. package/dist/features/admin/components/AdminBidsView.js +7 -1
  18. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  19. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  20. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  21. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  22. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  23. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  24. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  25. package/dist/features/auctions/repository/bid.repository.js +57 -33
  26. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  27. package/dist/features/auctions/schemas/firestore.js +21 -0
  28. package/dist/features/auth/schemas/index.d.ts +2 -2
  29. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  30. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  31. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  32. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  33. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  34. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  35. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  36. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  37. package/dist/features/catalogue/schemas/firestore.js +12 -0
  38. package/dist/features/categories/schemas/index.d.ts +2 -2
  39. package/dist/features/layout/BottomActions.js +18 -1
  40. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  41. package/dist/features/layout/BottomActionsContext.js +37 -0
  42. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  43. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  44. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  45. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  46. package/dist/features/layout/index.d.ts +2 -0
  47. package/dist/features/layout/index.js +1 -0
  48. package/dist/features/messages/schemas/index.d.ts +6 -6
  49. package/dist/features/orders/schemas/index.d.ts +12 -12
  50. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  51. package/dist/features/payments/repository/payout.repository.js +45 -19
  52. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  53. package/dist/features/payments/schemas/firestore.js +39 -0
  54. package/dist/features/payments/schemas/index.d.ts +12 -12
  55. package/dist/features/products/components/MakeOfferButton.js +5 -3
  56. package/dist/features/products/schemas/index.d.ts +2 -2
  57. package/dist/features/promotions/schemas/index.d.ts +12 -12
  58. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  59. package/dist/features/scams/repository/scammer.repository.js +24 -2
  60. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  61. package/dist/features/scams/schemas/firestore.js +30 -0
  62. package/dist/features/shell/QuickFormDrawer.js +15 -1
  63. package/dist/features/shell/SectionForm.d.ts +16 -2
  64. package/dist/features/shell/SectionForm.js +28 -2
  65. package/dist/features/shipments/components/AdminShipmentEditorView.js +5 -2
  66. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  67. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  68. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  69. package/dist/features/shipments/schemas/firestore.js +19 -0
  70. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  71. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  72. package/dist/features/status-history/index.d.ts +2 -0
  73. package/dist/features/status-history/index.js +1 -0
  74. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  75. package/dist/features/stores/repository/store.repository.js +43 -4
  76. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  77. package/dist/features/stores/schemas/firestore.js +31 -0
  78. package/dist/features/support/repository/support.repository.d.ts +47 -3
  79. package/dist/features/support/repository/support.repository.js +72 -12
  80. package/dist/features/support/schemas/firestore.d.ts +38 -1
  81. package/dist/features/support/schemas/firestore.js +34 -0
  82. package/dist/features/support/schemas/index.d.ts +2 -2
  83. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +90 -0
  84. package/dist/index.d.ts +7 -1
  85. package/dist/index.js +16 -1
  86. package/dist/schemas/registry.d.ts +32 -32
  87. package/dist/seed/bids-seed-data.js +8 -0
  88. package/dist/ui/components/Form.d.ts +22 -1
  89. package/dist/ui/components/Form.js +30 -2
  90. package/dist/ui/components/Modal.js +4 -3
  91. package/dist/ui/components/SideDrawer.js +5 -4
  92. package/dist/ui/components/overlay-context.d.ts +21 -0
  93. package/dist/ui/components/overlay-context.js +25 -0
  94. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  95. package/dist/ui/forms/FormErrorList.js +36 -0
  96. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  97. package/dist/ui/forms/FormErrorSummary.js +15 -8
  98. package/dist/ui/forms/FormShell.d.ts +25 -1
  99. package/dist/ui/forms/FormShell.js +39 -4
  100. package/dist/ui/forms/index.d.ts +2 -0
  101. package/dist/ui/forms/index.js +1 -0
  102. package/package.json +1 -1
@@ -5,7 +5,8 @@
5
5
  * Handles seller payout requests and admin payout processing.
6
6
  */
7
7
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
8
- import { PAYOUT_COLLECTION, PAYOUT_FIELDS, createPayoutId } from "../schemas";
8
+ import { PAYOUT_COLLECTION, PAYOUT_FIELDS, PAYOUT_TRACKED_FIELDS, createPayoutId } from "../schemas";
9
+ import { withHistory } from "../../../_internal/shared/history/index";
9
10
  import { encryptPiiFields, decryptPiiFields, addPiiIndices, PAYOUT_PII_FIELDS, PAYOUT_PII_INDEX_MAP, encryptPayoutBankAccount, decryptPayoutBankAccount, } from "../../../security";
10
11
  export class PayoutRepository extends BaseRepository {
11
12
  constructor() {
@@ -120,10 +121,14 @@ export class PayoutRepository extends BaseRepository {
120
121
  }));
121
122
  }
122
123
  /**
123
- * Update payout status (admin action)
124
+ * Update payout status (admin action).
125
+ *
126
+ * `prior` keeps the timeline entry read-free — the admin route already
127
+ * fetches the payout to authorise the change (Rule #6).
124
128
  */
125
- async updateStatus(payoutId, status, extra) {
126
- return this.update(payoutId, {
129
+ async updateStatus(payoutId, status, extra, ctx, prior) {
130
+ const current = prior !== undefined ? prior : await this.findById(payoutId);
131
+ return this.update(payoutId, this.withPayoutHistory(current, {
127
132
  status,
128
133
  ...extra,
129
134
  ...(status === PAYOUT_FIELDS.STATUS_VALUES.PAID ||
@@ -131,29 +136,50 @@ export class PayoutRepository extends BaseRepository {
131
136
  ? { processedAt: new Date() }
132
137
  : {}),
133
138
  updatedAt: new Date(),
134
- });
139
+ }, ctx, "adminUpdatePayout"));
135
140
  }
136
- markProcessing(ref) {
137
- return ref.update({
138
- status: "processing",
139
- processedAt: new Date(),
140
- updatedAt: new Date(),
141
- });
141
+ /*
142
+ * ── Batch dispatch primitives ──────────────────────────────────────────
143
+ *
144
+ * These take a `DocumentReference` rather than an id because the payouts
145
+ * batch already holds one from its query snapshot. `prior` comes from the
146
+ * SAME snapshot, so history costs no read here either — but it must be the
147
+ * caller's running copy, not the original: the sequence is
148
+ * pending → processing → failed, and diffing the final write against the
149
+ * ORIGINAL snapshot would record "pending → failed" and lose the dispatch
150
+ * attempt entirely.
151
+ */
152
+ markProcessing(ref, ctx, prior) {
153
+ return ref.update(this.withPayoutHistory(prior, { status: "processing", processedAt: new Date(), updatedAt: new Date() }, ctx, "payoutBatch:dispatch"));
142
154
  }
143
- recordSuccess(ref, razorpayPayoutId, razorpayStatus) {
144
- return ref.update({
145
- razorpayPayoutId,
146
- razorpayStatus,
147
- updatedAt: new Date(),
148
- });
155
+ recordSuccess(ref, razorpayPayoutId, razorpayStatus, ctx, prior) {
156
+ return ref.update(this.withPayoutHistory(prior, { razorpayPayoutId, razorpayStatus, updatedAt: new Date() }, ctx, "payoutBatch:accepted"));
149
157
  }
150
- recordFailure(ref, failureCount, reason, isFinal) {
151
- return ref.update({
158
+ recordFailure(ref, failureCount, reason, isFinal, ctx, prior) {
159
+ return ref.update(this.withPayoutHistory(prior, {
152
160
  status: isFinal ? "failed" : "pending",
153
161
  failureCount,
154
162
  lastFailureReason: reason,
155
163
  updatedAt: new Date(),
164
+ },
165
+ // The reason is the point of the entry — `lastFailureReason` is
166
+ // overwritten by the next retry, so history is where the earlier
167
+ // attempts survive.
168
+ { ...ctx, reason }, isFinal ? "payoutBatch:failedFinal" : "payoutBatch:failedRetrying"));
169
+ }
170
+ /** Append a timeline entry when the patch touches a tracked field. */
171
+ withPayoutHistory(current, patch, ctx, defaultTrigger) {
172
+ const withEntry = withHistory(current, patch, {
173
+ tracked: PAYOUT_TRACKED_FIELDS,
174
+ actor: ctx?.actor ?? { role: "system" },
175
+ trigger: ctx?.trigger ?? defaultTrigger,
176
+ reason: ctx?.reason,
177
+ note: ctx?.note,
178
+ // `sellerEmail` / `upiId` — `encryptPiiFields` never descends into
179
+ // arrays, so either reaching `changes` would persist in PLAINTEXT.
180
+ piiFields: PAYOUT_PII_FIELDS,
156
181
  });
182
+ return withEntry ?? patch;
157
183
  }
158
184
  /**
159
185
  * Get all order IDs that have already been paid out for a seller.
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { type GeneratePayoutIdInput } from "../../../utils/id-generators";
5
5
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
6
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
6
7
  export interface PayoutBankAccount {
7
8
  accountHolderName: string;
8
9
  accountNumberMasked: string;
@@ -70,7 +71,38 @@ export interface PayoutDocument extends BaseDocument {
70
71
  transactionId?: string;
71
72
  /** Seller-set personal follow-up flag — surfaced only to the owning seller, never mutated by admin. */
72
73
  sellerReminderFlag?: boolean;
74
+ /** Razorpay's payout id, once the API accepted the dispatch. */
75
+ razorpayPayoutId?: string;
76
+ /** Razorpay's own status string — NOT this document's `status`. */
77
+ razorpayStatus?: string;
78
+ /** Consecutive dispatch failures. At MAX_FAILURES the payout goes `failed`. */
79
+ failureCount?: number;
80
+ /**
81
+ * Why the LAST attempt failed. Overwritten by every retry, which is why the
82
+ * timeline tracks it: `statusHistory` is the only place the earlier reasons
83
+ * survive.
84
+ */
85
+ lastFailureReason?: string;
86
+ /**
87
+ * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
88
+ *
89
+ * Payouts are the entity where this matters most to a seller: "it says
90
+ * failed, what happened" was previously answerable only from
91
+ * `lastFailureReason`, which the NEXT retry overwrites.
92
+ */
93
+ statusHistory?: StatusChangeEntry[];
94
+ statusHistoryTruncated?: number;
73
95
  }
96
+ /**
97
+ * The fields whose changes earn a timeline entry.
98
+ *
99
+ * `netAmount`/`refundDeductions` are excluded — a refund deduction is money
100
+ * churn before dispatch, and § "Status History" keeps money off the timeline;
101
+ * `refundDeductions[]` on the document is already its own record.
102
+ * `failureCount` is excluded because `lastFailureReason` changing is the
103
+ * event, and tracking both would double every retry entry.
104
+ */
105
+ export declare const PAYOUT_TRACKED_FIELDS: readonly ["status", "processedAt", "transactionId", "razorpayPayoutId", "razorpayStatus", "lastFailureReason"];
74
106
  export declare const PAYOUT_COLLECTION: "payouts";
75
107
  export declare const PAYOUT_INDEXED_FIELDS: readonly ["storeId", "status", "requestedAt", "createdAt"];
76
108
  export declare const DEFAULT_PLATFORM_FEE_RATE: 0.05;
@@ -91,7 +123,7 @@ export declare const PAYOUT_FIELDS: {
91
123
  };
92
124
  };
93
125
  export declare const PAYOUT_PUBLIC_FIELDS: readonly ["id", "storeId", "amount", "currency", "status", "paymentMethod", "requestedAt", "processedAt", "createdAt"];
94
- export declare const PAYOUT_ADMIN_UPDATEABLE_FIELDS: readonly ["status", "adminNote", "processedAt"];
126
+ export declare const PAYOUT_ADMIN_UPDATEABLE_FIELDS: readonly ["status", "adminNote", "processedAt", "transactionId"];
95
127
  /** The only field a seller may write on their own payout doc. */
96
128
  export declare const PAYOUT_SELLER_UPDATEABLE_FIELDS: readonly ["sellerReminderFlag"];
97
129
  export type PayoutCreateInput = Omit<PayoutDocument, "id" | "createdAt" | "updatedAt" | "status" | "requestedAt">;
@@ -8,6 +8,23 @@ export const PayoutStatusValues = {
8
8
  PAID: "paid",
9
9
  FAILED: "failed",
10
10
  };
11
+ /**
12
+ * The fields whose changes earn a timeline entry.
13
+ *
14
+ * `netAmount`/`refundDeductions` are excluded — a refund deduction is money
15
+ * churn before dispatch, and § "Status History" keeps money off the timeline;
16
+ * `refundDeductions[]` on the document is already its own record.
17
+ * `failureCount` is excluded because `lastFailureReason` changing is the
18
+ * event, and tracking both would double every retry entry.
19
+ */
20
+ export const PAYOUT_TRACKED_FIELDS = [
21
+ "status",
22
+ "processedAt",
23
+ "transactionId",
24
+ "razorpayPayoutId",
25
+ "razorpayStatus",
26
+ "lastFailureReason",
27
+ ];
11
28
  export const PAYOUT_COLLECTION = "payouts";
12
29
  export const PAYOUT_INDEXED_FIELDS = [
13
30
  "storeId",
@@ -47,10 +64,32 @@ export const PAYOUT_PUBLIC_FIELDS = [
47
64
  "processedAt",
48
65
  "createdAt",
49
66
  ];
67
+ /*
68
+ * 🛑 `transactionId` was MISSING here until 2026-08-26, and the whole
69
+ * mark-paid flow was built on top of the gap:
70
+ *
71
+ * · `AdminPayoutMarkPaidModal` collects a UTR and makes it REQUIRED
72
+ * · `PATCH /api/admin/payouts/[id]` declares it in its Zod schema and its
73
+ * own docstring advertises "+ transaction reference"
74
+ * · that route's test asserts it reaches `adminUpdatePayout`
75
+ * · `adminUpdatePayout` picks only the fields in THIS list, so it was
76
+ * dropped — and the route replies `{ id, ...body }`, echoing the
77
+ * discarded UTR back as though it had been stored
78
+ *
79
+ * Every payout marked paid before this therefore has no payment reference,
80
+ * which is the one field a seller disputing "you say you paid me" needs.
81
+ * Root Cause #40's shape: a 200 that confirms a write which never happened.
82
+ *
83
+ * `processedAt` stays accepted for back-compat but is server-stamped by
84
+ * `updateStatus` on the transition into paid/failed — a client-sent
85
+ * timestamp is how the reports collection ended up with two shapes in one
86
+ * field.
87
+ */
50
88
  export const PAYOUT_ADMIN_UPDATEABLE_FIELDS = [
51
89
  "status",
52
90
  "adminNote",
53
91
  "processedAt",
92
+ "transactionId",
54
93
  ];
55
94
  /** The only field a seller may write on their own payout doc. */
56
95
  export const PAYOUT_SELLER_UPDATEABLE_FIELDS = ["sellerReminderFlag"];
@@ -33,20 +33,20 @@ export declare const payoutRefundDeductionSchema: z.ZodObject<{
33
33
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
34
34
  }, z.ZodTypeAny, "passthrough">>]>;
35
35
  }, "strip", z.ZodTypeAny, {
36
+ reason: string;
36
37
  orderId: string;
37
38
  refundId: string;
38
39
  refundedAmount: number;
39
40
  deductedAmount: number;
40
- reason: string;
41
41
  appliedAt: string | Date | z.objectOutputType<{
42
42
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
43
43
  }, z.ZodTypeAny, "passthrough">;
44
44
  }, {
45
+ reason: string;
45
46
  orderId: string;
46
47
  refundId: string;
47
48
  refundedAmount: number;
48
49
  deductedAmount: number;
49
- reason: string;
50
50
  appliedAt: string | Date | z.objectInputType<{
51
51
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
52
52
  }, z.ZodTypeAny, "passthrough">;
@@ -116,20 +116,20 @@ export declare const payoutFirestoreSchema: z.ZodObject<{
116
116
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
117
117
  }, z.ZodTypeAny, "passthrough">>]>;
118
118
  }, "strip", z.ZodTypeAny, {
119
+ reason: string;
119
120
  orderId: string;
120
121
  refundId: string;
121
122
  refundedAmount: number;
122
123
  deductedAmount: number;
123
- reason: string;
124
124
  appliedAt: string | Date | z.objectOutputType<{
125
125
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
126
126
  }, z.ZodTypeAny, "passthrough">;
127
127
  }, {
128
+ reason: string;
128
129
  orderId: string;
129
130
  refundId: string;
130
131
  refundedAmount: number;
131
132
  deductedAmount: number;
132
- reason: string;
133
133
  appliedAt: string | Date | z.objectInputType<{
134
134
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
135
135
  }, z.ZodTypeAny, "passthrough">;
@@ -171,6 +171,9 @@ export declare const payoutFirestoreSchema: z.ZodObject<{
171
171
  platformFeeRate: number;
172
172
  paymentMethod: "bank_transfer" | "upi";
173
173
  orderIds: string[];
174
+ processedAt?: string | Date | z.objectOutputType<{
175
+ toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
176
+ }, z.ZodTypeAny, "passthrough"> | undefined;
174
177
  bankAccount?: {
175
178
  accountHolderName: string;
176
179
  accountNumberMasked: string;
@@ -186,19 +189,16 @@ export declare const payoutFirestoreSchema: z.ZodObject<{
186
189
  gstRate?: number | undefined;
187
190
  isAutomatic?: boolean | undefined;
188
191
  refundDeductions?: {
192
+ reason: string;
189
193
  orderId: string;
190
194
  refundId: string;
191
195
  refundedAmount: number;
192
196
  deductedAmount: number;
193
- reason: string;
194
197
  appliedAt: string | Date | z.objectOutputType<{
195
198
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
196
199
  }, z.ZodTypeAny, "passthrough">;
197
200
  }[] | undefined;
198
201
  netAmount?: number | undefined;
199
- processedAt?: string | Date | z.objectOutputType<{
200
- toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
201
- }, z.ZodTypeAny, "passthrough"> | undefined;
202
202
  }, {
203
203
  currency: string;
204
204
  status: "paid" | "failed" | "pending" | "processing";
@@ -221,6 +221,9 @@ export declare const payoutFirestoreSchema: z.ZodObject<{
221
221
  platformFeeRate: number;
222
222
  paymentMethod: "bank_transfer" | "upi";
223
223
  orderIds: string[];
224
+ processedAt?: string | Date | z.objectInputType<{
225
+ toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
226
+ }, z.ZodTypeAny, "passthrough"> | undefined;
224
227
  bankAccount?: {
225
228
  accountHolderName: string;
226
229
  accountNumberMasked: string;
@@ -236,19 +239,16 @@ export declare const payoutFirestoreSchema: z.ZodObject<{
236
239
  gstRate?: number | undefined;
237
240
  isAutomatic?: boolean | undefined;
238
241
  refundDeductions?: {
242
+ reason: string;
239
243
  orderId: string;
240
244
  refundId: string;
241
245
  refundedAmount: number;
242
246
  deductedAmount: number;
243
- reason: string;
244
247
  appliedAt: string | Date | z.objectInputType<{
245
248
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
246
249
  }, z.ZodTypeAny, "passthrough">;
247
250
  }[] | undefined;
248
251
  netAmount?: number | undefined;
249
- processedAt?: string | Date | z.objectInputType<{
250
- toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
251
- }, z.ZodTypeAny, "passthrough"> | undefined;
252
252
  }>;
253
253
  export type PayoutFromSchema = z.infer<typeof payoutFirestoreSchema>;
254
254
  /** Zod schema for payout status — use instead of inline `z.enum(["pending",...])`. */
@@ -38,7 +38,9 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
38
38
  const schema = makeOfferFormSchema({ listedPrice, minOffer, formatAmount: fmt });
39
39
  const [offerAmount, setOfferAmount] = useState(String(Math.max(minOfferAmount(listedPrice, 90), minOffer)));
40
40
  const [buyerNote, setBuyerNote] = useState("");
41
- function submit(setFieldError, clearErrors) {
41
+ function submit(setFieldError, clearErrors, markSubmitAttempted) {
42
+ // type="button" — no native submit, so unhide the summary here.
43
+ markSubmitAttempted();
42
44
  clearErrors();
43
45
  const parsed = schema.safeParse({ offerAmount, buyerNote: buyerNote || undefined });
44
46
  if (!parsed.success) {
@@ -75,8 +77,8 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
75
77
  if (state === "pending") {
76
78
  return (_jsxs(Div, { className: `${CLS_PENDING_BOX} ${className}`, children: [_jsx(Span, { size: "lg", children: "\u23F3" }), _jsx(Text, { className: CLS_PENDING_TITLE, children: "Offer Pending" }), _jsx(Text, { className: CLS_PENDING_BODY, children: "You already have an offer on this item. Check My Offers for updates." })] }));
77
79
  }
78
- return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`, onClick: () => setState("open"), children: "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] }), _jsx(FieldInput, { name: "offerAmount", type: "number", label: "Your offer amount", required: true, value: offerAmount, onChange: (value) => {
80
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", border: "strong", className: `w-full ${className}`, onClick: () => setState("open"), children: "Make Offer" }), _jsx(Modal, { isOpen: state === "open", onClose: () => setState("idle"), size: "md", title: "Make an offer", children: _jsx(Form, { schema: schema, onSubmit: (e) => e.preventDefault(), children: ({ setFieldError, clearErrors, markSubmitAttempted }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] }), _jsx(FieldInput, { name: "offerAmount", type: "number", label: "Your offer amount", required: true, value: offerAmount, onChange: (value) => {
79
81
  setOfferAmount(value);
80
82
  clearErrors();
81
- }, min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { size: "xs", color: "faint", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] }), _jsx(FieldInput, { name: "buyerNote", type: "text", label: "Note to seller (optional)", value: buyerNote, onChange: setBuyerNote, placeholder: "E.g. Bundle deal, long-time fan\u2026", maxLength: 300, "aria-label": "Note to seller" }), _jsx(Text, { size: "xs", color: "muted", children: "The seller will accept, decline, or suggest a counter price." }), _jsx(FormErrorSummary, {}), _jsxs(Stack, { gap: "xs", children: [_jsx(Button, { action: ACTIONS.PRODUCT["make-offer"], variant: "primary", size: "sm", type: "button", isLoading: isPending, disabled: isPending, onClick: () => submit(setFieldError, clearErrors), children: isPending ? "Sending…" : `Send offer of ${fmt(Number(offerAmount) || 0)}` }), _jsx(Button, { variant: "ghost", size: "sm", type: "button", onClick: () => setState("idle"), disabled: isPending, children: "Cancel" })] })] })) }) })] }));
83
+ }, min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { size: "xs", color: "faint", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] }), _jsx(FieldInput, { name: "buyerNote", type: "text", label: "Note to seller (optional)", value: buyerNote, onChange: setBuyerNote, placeholder: "E.g. Bundle deal, long-time fan\u2026", maxLength: 300, "aria-label": "Note to seller" }), _jsx(Text, { size: "xs", color: "muted", children: "The seller will accept, decline, or suggest a counter price." }), _jsx(FormErrorSummary, {}), _jsxs(Stack, { gap: "xs", children: [_jsx(Button, { action: ACTIONS.PRODUCT["make-offer"], variant: "primary", size: "sm", type: "button", isLoading: isPending, disabled: isPending, onClick: () => submit(setFieldError, clearErrors, markSubmitAttempted), children: isPending ? "Sending…" : `Send offer of ${fmt(Number(offerAmount) || 0)}` }), _jsx(Button, { variant: "ghost", size: "sm", type: "button", onClick: () => setState("idle"), disabled: isPending, children: "Cancel" })] })] })) }) })] }));
82
84
  }
@@ -373,7 +373,7 @@ export declare const productItemSchema: z.ZodObject<{
373
373
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | "graded" | undefined;
374
374
  insurance?: boolean | undefined;
375
375
  insuranceCost?: number | undefined;
376
- shippingPaidBy?: "seller" | "buyer" | undefined;
376
+ shippingPaidBy?: "buyer" | "seller" | undefined;
377
377
  auctionEndDate?: string | Date | undefined;
378
378
  startingBid?: number | undefined;
379
379
  currentBid?: number | undefined;
@@ -512,7 +512,7 @@ export declare const productItemSchema: z.ZodObject<{
512
512
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | "graded" | undefined;
513
513
  insurance?: boolean | undefined;
514
514
  insuranceCost?: number | undefined;
515
- shippingPaidBy?: "seller" | "buyer" | undefined;
515
+ shippingPaidBy?: "buyer" | "seller" | undefined;
516
516
  auctionEndDate?: string | Date | undefined;
517
517
  startingBid?: number | undefined;
518
518
  currentBid?: number | undefined;
@@ -269,7 +269,7 @@ export declare const couponFirestoreSchema: z.ZodObject<{
269
269
  code: string;
270
270
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
271
271
  id: string;
272
- scope: "admin" | "seller";
272
+ scope: "seller" | "admin";
273
273
  name: string;
274
274
  createdAt: string | Date | z.objectOutputType<{
275
275
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
@@ -327,7 +327,7 @@ export declare const couponFirestoreSchema: z.ZodObject<{
327
327
  code: string;
328
328
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
329
329
  id: string;
330
- scope: "admin" | "seller";
330
+ scope: "seller" | "admin";
331
331
  name: string;
332
332
  createdAt: string | Date | z.objectInputType<{
333
333
  toDate: z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodDate>;
@@ -459,7 +459,7 @@ export declare const claimedCouponSnapshotSchema: z.ZodObject<{
459
459
  }>;
460
460
  }, "strip", z.ZodTypeAny, {
461
461
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
462
- scope: "admin" | "seller";
462
+ scope: "seller" | "admin";
463
463
  name: string;
464
464
  discount: {
465
465
  value: number;
@@ -478,7 +478,7 @@ export declare const claimedCouponSnapshotSchema: z.ZodObject<{
478
478
  description?: string | undefined;
479
479
  }, {
480
480
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
481
- scope: "admin" | "seller";
481
+ scope: "seller" | "admin";
482
482
  name: string;
483
483
  discount: {
484
484
  value: number;
@@ -545,7 +545,7 @@ export declare const claimedCouponFirestoreSchema: z.ZodObject<{
545
545
  }>;
546
546
  }, "strip", z.ZodTypeAny, {
547
547
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
548
- scope: "admin" | "seller";
548
+ scope: "seller" | "admin";
549
549
  name: string;
550
550
  discount: {
551
551
  value: number;
@@ -564,7 +564,7 @@ export declare const claimedCouponFirestoreSchema: z.ZodObject<{
564
564
  description?: string | undefined;
565
565
  }, {
566
566
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
567
- scope: "admin" | "seller";
567
+ scope: "seller" | "admin";
568
568
  name: string;
569
569
  discount: {
570
570
  value: number;
@@ -624,7 +624,7 @@ export declare const claimedCouponFirestoreSchema: z.ZodObject<{
624
624
  couponId: string;
625
625
  couponSnapshot: {
626
626
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
627
- scope: "admin" | "seller";
627
+ scope: "seller" | "admin";
628
628
  name: string;
629
629
  discount: {
630
630
  value: number;
@@ -664,7 +664,7 @@ export declare const claimedCouponFirestoreSchema: z.ZodObject<{
664
664
  couponId: string;
665
665
  couponSnapshot: {
666
666
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
667
- scope: "admin" | "seller";
667
+ scope: "seller" | "admin";
668
668
  name: string;
669
669
  discount: {
670
670
  value: number;
@@ -738,7 +738,7 @@ export declare const couponItemSchema: z.ZodObject<{
738
738
  code: string;
739
739
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
740
740
  id: string;
741
- scope: "admin" | "seller";
741
+ scope: "seller" | "admin";
742
742
  name: string;
743
743
  createdAt: string;
744
744
  updatedAt: string;
@@ -762,7 +762,7 @@ export declare const couponItemSchema: z.ZodObject<{
762
762
  code: string;
763
763
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
764
764
  id: string;
765
- scope: "admin" | "seller";
765
+ scope: "seller" | "admin";
766
766
  name: string;
767
767
  createdAt: string;
768
768
  updatedAt: string;
@@ -794,7 +794,7 @@ export declare const promotionsListParamsSchema: z.ZodObject<{
794
794
  }, "strip", z.ZodTypeAny, {
795
795
  sort?: string | undefined;
796
796
  page?: number | undefined;
797
- scope?: "admin" | "seller" | undefined;
797
+ scope?: "seller" | "admin" | undefined;
798
798
  pageSize?: number | undefined;
799
799
  storeId?: string | undefined;
800
800
  isActive?: boolean | undefined;
@@ -802,7 +802,7 @@ export declare const promotionsListParamsSchema: z.ZodObject<{
802
802
  }, {
803
803
  sort?: string | undefined;
804
804
  page?: number | undefined;
805
- scope?: "admin" | "seller" | undefined;
805
+ scope?: "seller" | "admin" | undefined;
806
806
  pageSize?: number | undefined;
807
807
  storeId?: string | undefined;
808
808
  isActive?: boolean | undefined;
@@ -1,4 +1,12 @@
1
1
  import { BaseRepository, parseSieveDateValue, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
2
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
3
+ /** Who/why for a scammer write that lands in `statusHistory`. */
4
+ export interface ScammerWriteContext {
5
+ actor?: HistoryActor;
6
+ trigger?: string;
7
+ reason?: string;
8
+ note?: string;
9
+ }
2
10
  import { type ScammerDocument, type ScammerIncidentDocument, type ScammerCommentDocument, type ScammerCreateInput, type ScammerAdminUpdateInput } from "../schemas/firestore";
3
11
  declare class ScammerRepository extends BaseRepository<ScammerDocument> {
4
12
  constructor();
@@ -53,7 +61,16 @@ declare class ScammerRepository extends BaseRepository<ScammerDocument> {
53
61
  listAll(model: SieveModel): Promise<FirebaseSieveResult<ScammerDocument>>;
54
62
  findBySeoSlug(seoSlug: string): Promise<ScammerDocument | null>;
55
63
  create(input: ScammerCreateInput): Promise<ScammerDocument>;
56
- adminUpdate(id: string, input: ScammerAdminUpdateInput): Promise<void>;
64
+ /**
65
+ * The single admin write path for a scammer profile.
66
+ *
67
+ * The PATCH route used to call `update()` directly, bypassing this method
68
+ * entirely — so a decision on a public accusation against a named person
69
+ * left no record beyond `verifiedBy`/`verifiedAt`, which the NEXT decision
70
+ * overwrites. `prior` comes from the route's own 404 lookup, so the
71
+ * timeline entry costs no second read (Rule #6).
72
+ */
73
+ adminUpdate(id: string, input: ScammerAdminUpdateInput, ctx?: ScammerWriteContext, prior?: ScammerDocument | null): Promise<void>;
57
74
  incrementViews(id: string): Promise<void>;
58
75
  /**
59
76
  * Search for profiles whose phones, upiIds, emails, or displayNames arrays
@@ -3,6 +3,8 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { serverLogger } from "../../../monitoring/server-logger";
5
5
  import { increment } from "../../../contracts/field-ops";
6
+ import { withHistory } from "../../../_internal/shared/history/index";
7
+ import { SCAMMER_TRACKED_FIELDS, SCAMMER_HISTORY_PII_FIELDS } from "../schemas/firestore";
6
8
  import { SCAMMER_COLLECTION, SCAMMER_FIELDS, SCAMMER_INCIDENTS_SUBCOLLECTION, SCAMMER_COMMENTS_SUBCOLLECTION, DEFAULT_SCAMMER_DATA, } from "../schemas/firestore";
7
9
  class ScammerRepository extends BaseRepository {
8
10
  constructor() {
@@ -68,9 +70,29 @@ class ScammerRepository extends BaseRepository {
68
70
  throw new DatabaseError(`Failed to create scammer profile: ${error instanceof Error ? error.message : String(error)}`);
69
71
  }
70
72
  }
71
- async adminUpdate(id, input) {
73
+ /**
74
+ * The single admin write path for a scammer profile.
75
+ *
76
+ * The PATCH route used to call `update()` directly, bypassing this method
77
+ * entirely — so a decision on a public accusation against a named person
78
+ * left no record beyond `verifiedBy`/`verifiedAt`, which the NEXT decision
79
+ * overwrites. `prior` comes from the route's own 404 lookup, so the
80
+ * timeline entry costs no second read (Rule #6).
81
+ */
82
+ async adminUpdate(id, input, ctx, prior) {
72
83
  try {
73
- const data = prepareForFirestore({ ...input, updatedAt: new Date() });
84
+ const current = prior !== undefined ? prior : await this.findById(id);
85
+ const withEntry = withHistory(current, { ...input, updatedAt: new Date() }, {
86
+ tracked: SCAMMER_TRACKED_FIELDS,
87
+ actor: ctx?.actor ?? { role: "system" },
88
+ trigger: ctx?.trigger ?? "adminUpdateScammer",
89
+ reason: ctx?.reason,
90
+ note: ctx?.note,
91
+ // A scammer profile is built entirely from identifying details, and
92
+ // `encryptPiiFields` never descends into arrays.
93
+ piiFields: SCAMMER_HISTORY_PII_FIELDS,
94
+ });
95
+ const data = prepareForFirestore(withEntry ?? { ...input, updatedAt: new Date() });
74
96
  await this.getCollection().doc(id).update(data);
75
97
  }
76
98
  catch (error) {
@@ -24,6 +24,7 @@
24
24
  * - If matches found → suggest linking to existing profile (new incident) instead of new profile
25
25
  * - SCAMMER_MATCH_FIELDS lists the Firestore array-contains fields used for this query
26
26
  */
27
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
27
28
  import type { ScamType } from "../constants/scam-types";
28
29
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
29
30
  export declare const ScammerStatusValues: {
@@ -159,6 +160,16 @@ export interface ScammerDocument extends BaseDocument {
159
160
  * Reset to false when all contests are reviewed.
160
161
  */
161
162
  isContested?: boolean;
163
+ /**
164
+ * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
165
+ *
166
+ * A scammer profile is a public accusation against a named person, so "who
167
+ * verified this and on what note" is the record that matters most on it.
168
+ * `verifiedBy`/`verifiedAt` only ever hold the LAST decision — a profile
169
+ * verified, then removed, then re-verified keeps no trace of the middle.
170
+ */
171
+ statusHistory?: StatusChangeEntry[];
172
+ statusHistoryTruncated?: number;
162
173
  }
163
174
  /**
164
175
  * One victim's report linked to an existing scammer profile.
@@ -358,3 +369,16 @@ export declare const SCAMMER_FIELDS: {
358
369
  export declare const SCAM_PLATFORM_LABELS: Record<ScamPlatform, string>;
359
370
  export declare const SOCIAL_PLATFORM_LABELS: Record<SocialPlatform, string>;
360
371
  export declare const SCAMMER_STATUS_LABELS: Record<ScammerStatus, string>;
372
+ /**
373
+ * The fields whose changes earn a timeline entry.
374
+ *
375
+ * `verificationNote` is included because it is the REASON attached to a
376
+ * decision, and it is overwritten by the next one.
377
+ */
378
+ export declare const SCAMMER_TRACKED_FIELDS: readonly ["status", "verifiedBy", "verifiedAt", "verificationNote", "isPubliclyVisible"];
379
+ /**
380
+ * PII on this document, for `withHistory`'s scrub. A scammer profile is built
381
+ * ENTIRELY out of identifying details, so this list is long on purpose —
382
+ * `encryptPiiFields` never descends into arrays, and history is an array.
383
+ */
384
+ export declare const SCAMMER_HISTORY_PII_FIELDS: readonly ["name", "displayName", "email", "phone", "phoneNumber", "upiId", "bankAccount", "accountNumber", "reporterEmail", "reporterName"];
@@ -231,3 +231,33 @@ export const SCAMMER_STATUS_LABELS = {
231
231
  rejected: "Rejected",
232
232
  removed: "Removed",
233
233
  };
234
+ /**
235
+ * The fields whose changes earn a timeline entry.
236
+ *
237
+ * `verificationNote` is included because it is the REASON attached to a
238
+ * decision, and it is overwritten by the next one.
239
+ */
240
+ export const SCAMMER_TRACKED_FIELDS = [
241
+ "status",
242
+ "verifiedBy",
243
+ "verifiedAt",
244
+ "verificationNote",
245
+ "isPubliclyVisible",
246
+ ];
247
+ /**
248
+ * PII on this document, for `withHistory`'s scrub. A scammer profile is built
249
+ * ENTIRELY out of identifying details, so this list is long on purpose —
250
+ * `encryptPiiFields` never descends into arrays, and history is an array.
251
+ */
252
+ export const SCAMMER_HISTORY_PII_FIELDS = [
253
+ "name",
254
+ "displayName",
255
+ "email",
256
+ "phone",
257
+ "phoneNumber",
258
+ "upiId",
259
+ "bankAccount",
260
+ "accountNumber",
261
+ "reporterEmail",
262
+ "reporterName",
263
+ ];