@mohasinac/appkit 4.60.0 → 4.61.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 (122) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  3. package/dist/_internal/server/features/auctions/actions.js +12 -1
  4. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  5. package/dist/_internal/server/features/blog/actions.js +12 -1
  6. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  7. package/dist/_internal/server/features/brands/actions.js +12 -1
  8. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  9. package/dist/_internal/server/features/cart/actions.js +12 -1
  10. package/dist/_internal/server/features/checkout/actions.js +0 -1
  11. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/events/actions.js +12 -1
  13. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  14. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/history/actions.js +12 -1
  16. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  17. package/dist/_internal/server/features/lottery/actions.js +12 -1
  18. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  19. package/dist/_internal/server/features/orders/actions.js +12 -1
  20. package/dist/_internal/server/features/orders/adapters.d.ts +20 -2
  21. package/dist/_internal/server/features/orders/adapters.js +18 -0
  22. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  23. package/dist/_internal/server/features/orders/service.js +31 -1
  24. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  25. package/dist/_internal/server/features/payouts/actions.js +12 -1
  26. package/dist/_internal/server/features/points/award.js +9 -1
  27. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  28. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  29. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  30. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  31. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  32. package/dist/_internal/server/features/products/actions.js +12 -1
  33. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  34. package/dist/_internal/server/features/promotions/actions.js +12 -1
  35. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  36. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  37. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  38. package/dist/_internal/server/features/raffle/actions.js +12 -1
  39. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  40. package/dist/_internal/server/features/refunds/actions.js +14 -2
  41. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  42. package/dist/_internal/server/features/reviews/actions.js +12 -1
  43. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  44. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  45. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  46. package/dist/_internal/server/jobs/core/paymentReconcile.js +84 -57
  47. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  48. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  49. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  50. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  51. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  52. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  53. package/dist/_internal/shared/tester/window.d.ts +25 -0
  54. package/dist/_internal/shared/tester/window.js +66 -0
  55. package/dist/client.d.ts +1 -0
  56. package/dist/client.js +3 -0
  57. package/dist/core/unit-of-work.js +15 -1
  58. package/dist/errors/firestore-codes.d.ts +37 -0
  59. package/dist/errors/firestore-codes.js +52 -0
  60. package/dist/errors/index.d.ts +0 -1
  61. package/dist/errors/index.js +12 -2
  62. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  63. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  64. package/dist/features/admin/components/DataListingView.js +2 -0
  65. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  66. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  67. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  68. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  69. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  70. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  71. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  72. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  73. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  74. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  75. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  76. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  77. package/dist/features/events/actions/event-actions.js +4 -1
  78. package/dist/features/events/components/AdminEventEditorView.js +72 -45
  79. package/dist/features/events/components/EventsListPageView.js +2 -1
  80. package/dist/features/events/schemas/firestore.d.ts +17 -0
  81. package/dist/features/events/schemas/firestore.js +1 -0
  82. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  83. package/dist/features/events/utils/sort-pinned.js +46 -0
  84. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  85. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  86. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  87. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  88. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  89. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  90. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  91. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  92. package/dist/features/orders/schemas/firestore.js +19 -0
  93. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  94. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  95. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  96. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  97. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  98. package/dist/features/products/components/ProductForm.js +2 -2
  99. package/dist/features/products/components/ProductGrid.js +1 -1
  100. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  101. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  102. package/dist/features/promotions/schemas/firestore.js +22 -0
  103. package/dist/features/promotions/schemas/index.d.ts +479 -6
  104. package/dist/features/promotions/schemas/index.js +153 -0
  105. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  106. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  107. package/dist/features/seller/schemas/coupon-form.js +18 -0
  108. package/dist/features/shell/SectionForm.js +31 -9
  109. package/dist/features/sticker-trace/server.d.ts +2 -0
  110. package/dist/http/api-handler.js +4 -1
  111. package/dist/index.d.ts +3 -3
  112. package/dist/index.js +3 -3
  113. package/dist/next/routing/route-map.d.ts +0 -2
  114. package/dist/next/routing/route-map.js +10 -1
  115. package/dist/schemas/registry.d.ts +11 -6
  116. package/dist/server.js +7 -0
  117. package/dist/styles.css +51 -0
  118. package/dist/ui/components/PaginatedSelect.js +0 -0
  119. package/package.json +1 -1
  120. package/scripts/seed-cli.mjs +1822 -1812
  121. package/dist/features/tester/utils/phases.d.ts +0 -48
  122. package/dist/features/tester/utils/phases.js +0 -139
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
2
+ /*
3
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
4
+ *
5
+ * appkit importing its own public server barrel is a self-reference that drags
6
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
7
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
8
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
9
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
10
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
11
+ * .wasm files, naming none of the responsible files. Root Cause #18.
12
+ */
13
+ import { wrapAction } from "../../../shared/types/action-result.js";
3
14
  /**
4
15
  * processRefundAction — append a refund event to an order.
5
16
  *
@@ -235,7 +246,8 @@ export async function processRefundAction(input) {
235
246
  const isFull = wouldBeFullRefund(order, input.amount, siteSettings?.commissions?.gstPercent);
236
247
  const event = {
237
248
  refundId,
238
- type: input.type,
249
+ // Derived from the authoritative `isFull` above, never from a caller.
250
+ type: isFull ? "full" : "partial",
239
251
  amount: input.amount,
240
252
  ...(input.itemIds?.length ? { itemIds: input.itemIds } : {}),
241
253
  reasonCode: input.reasonCode,
@@ -1,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../shared/types/action-result";
2
2
  export declare function createReviewAction(input: unknown): Promise<ActionResult<unknown>>;
3
3
  export declare function replyToReviewAction(input: unknown): Promise<ActionResult<unknown>>;
4
4
  export declare function deleteReviewAction(input: unknown): Promise<ActionResult<unknown>>;
@@ -1,5 +1,16 @@
1
1
  "use server";
2
- import { wrapAction } from "@mohasinac/appkit/server";
2
+ /*
3
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
4
+ *
5
+ * appkit importing its own public server barrel is a self-reference that drags
6
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
7
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
8
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
9
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
10
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
11
+ * .wasm files, naming none of the responsible files. Root Cause #18.
12
+ */
13
+ import { wrapAction } from "../../../shared/types/action-result.js";
3
14
  import { requireRoleUser } from "../../../../providers/auth-firebase/helpers.js";
4
15
  import { isAdminUser } from "../../../../features/auth/role-predicates.js";
5
16
  import { reviewRepository, productRepository, userRepository, } from "../../../../repositories/index.js";
@@ -1,4 +1,4 @@
1
- import { type ActionResult } from "@mohasinac/appkit/server";
1
+ import { type ActionResult } from "../../../shared/types/action-result";
2
2
  import { type WishlistItem } from "../../../../repositories";
3
3
  export declare function addToWishlistAction(productId: string, extras?: {
4
4
  productType?: WishlistItem["productType"];
@@ -1,6 +1,17 @@
1
1
  "use server";
2
2
  import { normalizeError } from "../../../../errors/normalize.js";
3
- import { wrapAction } from "@mohasinac/appkit/server";
3
+ /*
4
+ * 🛑 From the DEFINING module, never "@mohasinac/appkit/server".
5
+ *
6
+ * appkit importing its own public server barrel is a self-reference that drags
7
+ * the whole server surface in behind it — `dist/server.js` reaches next/og,
8
+ * next/navigation, next/headers and driver.js. Twenty `actions.ts` files did
9
+ * this for two symbols, and because `obligationSweep` reaches one of them, the
10
+ * Firebase Functions tsup bundle (noExternal: appkit) had to resolve Next:
11
+ * it failed on two `react-server-dom-webpack/*` requires and two @vercel/og
12
+ * .wasm files, naming none of the responsible files. Root Cause #18.
13
+ */
14
+ import { wrapAction } from "../../../shared/types/action-result.js";
4
15
  import { requireRoleUser } from "../../../../providers/auth-firebase/helpers.js";
5
16
  import { wishlistRepository, WishlistFullError, } from "../../../../repositories/index.js";
6
17
  import { WISHLIST_MAX } from "../../../shared/features/wishlist/config.js";
@@ -279,7 +279,6 @@ async function performAction(ctx, obligation, order) {
279
279
  }
280
280
  const result = await processRefundAction({
281
281
  orderId: order.id,
282
- type: "full",
283
282
  amount: capacity.remaining,
284
283
  reasonCode: order.returnReasonCode ?? "not_as_described",
285
284
  reasonNote: `Auto-resolved in the buyer's favour — ${policy.describe}`,
@@ -36,7 +36,8 @@ import { gatewayEventsRepository } from "../../../../features/gateway-events/rep
36
36
  import { GATEWAY_EVENT_MAX_ATTEMPTS, } from "../../../../features/gateway-events/schemas/firestore.js";
37
37
  import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
38
38
  import { orderRepository } from "../../../../features/orders/repository/orders.repository.js";
39
- import { REFUND_CLAIMS_COLLECTION } from "../../../../features/orders/schemas/firestore.js";
39
+ import { REFUND_CLAIMS_COLLECTION, REFUND_CLAIM_FIELDS, ORDER_COLLECTION, } from "../../../../features/orders/schemas/firestore.js";
40
+ import { ORDER_FIELDS } from "../../../../constants/field-names.js";
40
41
  import { reconcileDisputes } from "../../features/payment-cases/disputes.js";
41
42
  import { refundableBase, committedRefundTotal, MONEY_EPSILON, } from "../../../shared/checkout/refundable-base.js";
42
43
  /** How far back R2 looks. Two days of overlap, so a run that fails is covered by the next. */
@@ -137,7 +138,81 @@ export async function reconcileStuckGatewayEvents(ctx) {
137
138
  }
138
139
  return { redispatched, escalated, reads: stuck.length };
139
140
  }
140
- /* ──────────────── R2: the gateway has it — do we? ───────────────────────── */
141
+ /**
142
+ * "The gateway captured a different amount from the one the order records."
143
+ *
144
+ * Extracted from the sweep below, where it sat inside
145
+ * fn → `for record` → `for order` → `if` and the whole case literal was a
146
+ * brace too deep. Pure construction — no branching, and `recorded` is passed
147
+ * in rather than re-derived, so the one money rule still lives at the call
148
+ * site (Root Cause #75).
149
+ */
150
+ async function openAmountMismatchCase(record, orderId, recorded, now) {
151
+ await paymentCasesRepository().openOrObserve({
152
+ kind: "amount_mismatch",
153
+ subjectId: `${record.id}:${orderId}`,
154
+ severity: "error",
155
+ title: `Captured ${record.amount} but recorded ${recorded} — ${orderId}`,
156
+ detail: [
157
+ `The gateway captured a different amount from the one the order records.`,
158
+ `Either the buyer was over-charged or the order under-states what was paid.`,
159
+ "",
160
+ `Gateway: ${record.gateway}`,
161
+ `Payment: ${record.id}`,
162
+ `Order: ${orderId}`,
163
+ `Captured: ${record.amount} ${record.currency}`,
164
+ `Order total: ${recorded}`,
165
+ `Difference: ${(record.amount - recorded).toFixed(2)}`,
166
+ ].join("\n"),
167
+ entityType: "order",
168
+ entityId: orderId,
169
+ amountAtRisk: Math.abs(record.amount - recorded),
170
+ orderIds: [orderId],
171
+ gatewayPaymentId: record.id,
172
+ now,
173
+ });
174
+ }
175
+ /**
176
+ * "Our refund total disagrees with the gateway's."
177
+ *
178
+ * Same extraction as `openAmountMismatchCase`. `ours` and `base` are both
179
+ * computed by the caller from the shared helpers, so this never re-derives a
180
+ * refund figure of its own.
181
+ *
182
+ * 🛑 The severity split is the whole point and must not be flattened: the
183
+ * gateway having refunded MORE than we recorded is money that left without a
184
+ * record (`error`); our side showing more is a refund reserved and never
185
+ * completed (`warning`).
186
+ */
187
+ async function openRefundMismatchCase(record, orderId, ours, base, now) {
188
+ await paymentCasesRepository().openOrObserve({
189
+ kind: "refund_mismatch",
190
+ subjectId: `${record.id}:refunds`,
191
+ severity: record.amountRefunded > ours ? "error" : "warning",
192
+ title: `Refund total disagrees with the gateway — ${orderId}`,
193
+ detail: [
194
+ record.amountRefunded > ours
195
+ ? `🛑 The gateway has refunded MORE than our records show. Money has left that we did not record.`
196
+ : `Our records show more refunded than the gateway did — a refund may have been reserved and never completed.`,
197
+ "",
198
+ `Gateway: ${record.gateway}`,
199
+ `Payment: ${record.id}`,
200
+ `Order: ${orderId}`,
201
+ `Gateway refunded: ${record.amountRefunded}`,
202
+ `Our committed refunds: ${ours}`,
203
+ `Refundable base: ${base}`,
204
+ record.refundStatus ? `Gateway refund status: ${record.refundStatus}` : null,
205
+ ]
206
+ .filter((l) => l !== null)
207
+ .join("\n"),
208
+ entityType: "order",
209
+ entityId: orderId,
210
+ amountAtRisk: Math.abs(record.amountRefunded - ours),
211
+ orderIds: [orderId],
212
+ gatewayPaymentId: record.id,
213
+ now,
214
+ });
215
+ }
141
216
  async function reconcileGatewayPayments(ctx) {
142
217
  const payment = getProviders().payment;
143
218
  let examined = 0;
@@ -232,29 +307,7 @@ async function reconcileGatewayPayments(ctx) {
232
307
  const recorded = order.totalPrice ?? 0;
233
308
  if (Math.abs(recorded - record.amount) > MONEY_EPSILON) {
234
309
  amountMismatch += 1;
235
- await paymentCasesRepository().openOrObserve({
236
- kind: "amount_mismatch",
237
- subjectId: `${record.id}:${order.id}`,
238
- severity: "error",
239
- title: `Captured ${record.amount} but recorded ${recorded} — ${order.id}`,
240
- detail: [
241
- `The gateway captured a different amount from the one the order records.`,
242
- `Either the buyer was over-charged or the order under-states what was paid.`,
243
- "",
244
- `Gateway: ${record.gateway}`,
245
- `Payment: ${record.id}`,
246
- `Order: ${order.id}`,
247
- `Captured: ${record.amount} ${record.currency}`,
248
- `Order total: ${recorded}`,
249
- `Difference: ${(record.amount - recorded).toFixed(2)}`,
250
- ].join("\n"),
251
- entityType: "order",
252
- entityId: order.id,
253
- amountAtRisk: Math.abs(record.amount - recorded),
254
- orderIds: [order.id],
255
- gatewayPaymentId: record.id,
256
- now: ctx.now,
257
- });
310
+ await openAmountMismatchCase(record, order.id, recorded, ctx.now);
258
311
  }
259
312
  /*
260
313
  * Refund check — the gateway's `amount_refunded` against our own
@@ -265,33 +318,7 @@ async function reconcileGatewayPayments(ctx) {
265
318
  const ours = committedRefundTotal(order);
266
319
  if (Math.abs(ours - record.amountRefunded) > MONEY_EPSILON) {
267
320
  refundMismatch += 1;
268
- await paymentCasesRepository().openOrObserve({
269
- kind: "refund_mismatch",
270
- subjectId: `${record.id}:refunds`,
271
- severity: record.amountRefunded > ours ? "error" : "warning",
272
- title: `Refund total disagrees with the gateway — ${order.id}`,
273
- detail: [
274
- record.amountRefunded > ours
275
- ? `🛑 The gateway has refunded MORE than our records show. Money has left that we did not record.`
276
- : `Our records show more refunded than the gateway did — a refund may have been reserved and never completed.`,
277
- "",
278
- `Gateway: ${record.gateway}`,
279
- `Payment: ${record.id}`,
280
- `Order: ${order.id}`,
281
- `Gateway refunded: ${record.amountRefunded}`,
282
- `Our committed refunds: ${ours}`,
283
- `Refundable base: ${refundableBase(order)}`,
284
- record.refundStatus ? `Gateway refund status: ${record.refundStatus}` : null,
285
- ]
286
- .filter((l) => l !== null)
287
- .join("\n"),
288
- entityType: "order",
289
- entityId: order.id,
290
- amountAtRisk: Math.abs(record.amountRefunded - ours),
291
- orderIds: [order.id],
292
- gatewayPaymentId: record.id,
293
- now: ctx.now,
294
- });
321
+ await openRefundMismatchCase(record, order.id, ours, refundableBase(order), ctx.now);
295
322
  }
296
323
  }
297
324
  }
@@ -321,8 +348,8 @@ async function reconcileStaleRefundClaims(ctx) {
321
348
  try {
322
349
  const snap = await ctx.db
323
350
  .collection(REFUND_CLAIMS_COLLECTION)
324
- .where("state", "==", "claimed")
325
- .where("claimedAt", "<", cutoff)
351
+ .where(REFUND_CLAIM_FIELDS.STATE, "==", "claimed")
352
+ .where(REFUND_CLAIM_FIELDS.CLAIMED_AT, "<", cutoff)
326
353
  .limit(50)
327
354
  .get();
328
355
  for (const doc of snap.docs) {
@@ -404,9 +431,9 @@ async function reconcileUnpaidCancellations(ctx) {
404
431
  let stale = 0;
405
432
  try {
406
433
  const snap = await ctx.db
407
- .collection("orders")
408
- .where("status", "==", "cancelled")
409
- .where("refundStatus", "==", "pending")
434
+ .collection(ORDER_COLLECTION)
435
+ .where(ORDER_FIELDS.STATUS, "==", "cancelled")
436
+ .where(ORDER_FIELDS.REFUND_STATUS, "==", "pending")
410
437
  .limit(100)
411
438
  .get();
412
439
  /*
@@ -14,7 +14,7 @@ import type { JobContext } from "../runtime/types";
14
14
  * (This used to be shared with testerSandboxRefresh.ts, which was removed when
15
15
  * the tester runner took over fixture state — see scheduled.ts.)
16
16
  */
17
- export declare const TEST_DATA_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events"];
17
+ export declare const TEST_DATA_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events", "orders", "obligations", "protectionClaims"];
18
18
  export interface TestDataCleanupOptions {
19
19
  /** true = wipe every isTestData:true doc regardless of testDataExpiresAt (manual force-purge). */
20
20
  force?: boolean;
@@ -41,7 +41,28 @@ import { BID_FIELDS } from "../../../../constants/field-names.js";
41
41
  * is both `test` AND `isTestData`. `audit-seed-purpose` R6 now asserts every
42
42
  * entry here declares the field, so the `offers` shape cannot return.
43
43
  */
44
- export const TEST_DATA_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events"];
44
+ /*
45
+ * 🛑 Every schema that declares `isTestData` must appear here, or the field is
46
+ * WRITE-ONLY and the row is never swept — a flag that reads as "this will be
47
+ * cleaned up" and never is.
48
+ *
49
+ * `orders`, `obligations` and `protectionClaims` were added 2026-10-22 after
50
+ * `audit-seed-purpose`'s inverse rule found all three declaring the field with
51
+ * no entry. They are the propagation chain: an order carries the flag, writing
52
+ * an obligation copies it (`orders.repository.ts` → `repo.open({ …isTestData
53
+ * })`), and a claim inherits it in turn — so a test order left three
54
+ * generations of descendants behind that nothing would ever collect.
55
+ */
56
+ export const TEST_DATA_COLLECTIONS = [
57
+ "categories",
58
+ "stores",
59
+ "products",
60
+ "blogPosts",
61
+ "events",
62
+ "orders",
63
+ "obligations",
64
+ "protectionClaims",
65
+ ];
45
66
  const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
46
67
  export async function runTestDataCleanup(ctx, opts = {}) {
47
68
  const cutoff = opts.force ? null : new Date();
@@ -1,5 +1,6 @@
1
1
  import { allocateShipmentCosts } from "../../../../features/shipments/utils/cost-allocation.js";
2
2
  import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
3
+ import { safeRead } from "../../../../errors/safe-read.js";
3
4
  /*
4
5
  * ── D58: the admin saw 6 while this used 4 ───────────────────────────────
5
6
  *
@@ -97,10 +98,21 @@ async function recomputeShipmentAllocation(shipmentId, ctx) {
97
98
  * single shipment, not a sweep, so the read is bounded — and the
98
99
  * alternative (keep ignoring the admin control) is the defect.
99
100
  */
100
- const laborSettings = await siteSettingsRepository
101
- .getSingleton()
102
- .then((s) => s.laborRate)
103
- .catch(() => undefined);
101
+ /*
102
+ * `safeRead`, not `.catch(() => undefined)`.
103
+ *
104
+ * The fallback is unchanged — a settings read that fails must not stop the
105
+ * allocation, and `settingsMaxHours` being absent simply means "no admin
106
+ * ceiling". What changes is that the failure is now RECORDED: a bare catch
107
+ * returning `undefined` is indistinguishable from an admin who never set a
108
+ * ceiling, so the labour cap could silently stop being enforced with
109
+ * nothing anywhere to say so. `installDegradedReadReporter` persists it.
110
+ */
111
+ const laborSettings = await safeRead(() => siteSettingsRepository.getSingleton().then((s) => s.laborRate), {
112
+ route: "job:onShipmentAllocationSync",
113
+ key: "siteSettings:laborRate",
114
+ fallback: undefined,
115
+ });
104
116
  const settingsMaxHours = laborSettings?.maxHoursPerDay;
105
117
  const { perLot, totals } = allocateShipmentCosts({
106
118
  lots: allocationInputs,
@@ -36,6 +36,25 @@ export declare const createCouponSchema: z.ZodObject<{
36
36
  minPurchase?: number | undefined;
37
37
  maxDiscount?: number | undefined;
38
38
  }>;
39
+ bxgy: z.ZodOptional<z.ZodObject<{
40
+ buyQuantity: z.ZodNumber;
41
+ getQuantity: z.ZodNumber;
42
+ maxItemPrice: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodNumber, number, number>, number, number>>;
43
+ applicableProducts: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
44
+ applicableCategories: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
45
+ }, "strip", z.ZodTypeAny, {
46
+ buyQuantity: number;
47
+ getQuantity: number;
48
+ maxItemPrice?: number | undefined;
49
+ applicableProducts?: string[] | undefined;
50
+ applicableCategories?: string[] | undefined;
51
+ }, {
52
+ buyQuantity: number;
53
+ getQuantity: number;
54
+ maxItemPrice?: number | undefined;
55
+ applicableProducts?: string[] | undefined;
56
+ applicableCategories?: string[] | undefined;
57
+ }>>;
39
58
  usage: z.ZodObject<{
40
59
  totalLimit: z.ZodOptional<z.ZodNumber>;
41
60
  perUserLimit: z.ZodDefault<z.ZodNumber>;
@@ -91,6 +110,13 @@ export declare const createCouponSchema: z.ZodObject<{
91
110
  endDate: string;
92
111
  startDate: string;
93
112
  };
113
+ bxgy?: {
114
+ buyQuantity: number;
115
+ getQuantity: number;
116
+ maxItemPrice?: number | undefined;
117
+ applicableProducts?: string[] | undefined;
118
+ applicableCategories?: string[] | undefined;
119
+ } | undefined;
94
120
  restrictions?: {
95
121
  firstTimeUserOnly: boolean;
96
122
  applicableProducts?: string[] | undefined;
@@ -115,6 +141,13 @@ export declare const createCouponSchema: z.ZodObject<{
115
141
  startDate: string;
116
142
  isActive?: boolean | undefined;
117
143
  };
144
+ bxgy?: {
145
+ buyQuantity: number;
146
+ getQuantity: number;
147
+ maxItemPrice?: number | undefined;
148
+ applicableProducts?: string[] | undefined;
149
+ applicableCategories?: string[] | undefined;
150
+ } | undefined;
118
151
  restrictions?: {
119
152
  firstTimeUserOnly?: boolean | undefined;
120
153
  applicableProducts?: string[] | undefined;
@@ -139,6 +172,25 @@ export declare const updateCouponSchema: z.ZodObject<Omit<{
139
172
  minPurchase?: number | undefined;
140
173
  maxDiscount?: number | undefined;
141
174
  }>>;
175
+ bxgy: z.ZodOptional<z.ZodOptional<z.ZodObject<{
176
+ buyQuantity: z.ZodNumber;
177
+ getQuantity: z.ZodNumber;
178
+ maxItemPrice: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodNumber, number, number>, number, number>>;
179
+ applicableProducts: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
180
+ applicableCategories: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
181
+ }, "strip", z.ZodTypeAny, {
182
+ buyQuantity: number;
183
+ getQuantity: number;
184
+ maxItemPrice?: number | undefined;
185
+ applicableProducts?: string[] | undefined;
186
+ applicableCategories?: string[] | undefined;
187
+ }, {
188
+ buyQuantity: number;
189
+ getQuantity: number;
190
+ maxItemPrice?: number | undefined;
191
+ applicableProducts?: string[] | undefined;
192
+ applicableCategories?: string[] | undefined;
193
+ }>>>;
142
194
  usage: z.ZodOptional<z.ZodObject<{
143
195
  totalLimit: z.ZodOptional<z.ZodNumber>;
144
196
  perUserLimit: z.ZodDefault<z.ZodNumber>;
@@ -184,6 +236,13 @@ export declare const updateCouponSchema: z.ZodObject<Omit<{
184
236
  minPurchase: number;
185
237
  maxDiscount?: number | undefined;
186
238
  } | undefined;
239
+ bxgy?: {
240
+ buyQuantity: number;
241
+ getQuantity: number;
242
+ maxItemPrice?: number | undefined;
243
+ applicableProducts?: string[] | undefined;
244
+ applicableCategories?: string[] | undefined;
245
+ } | undefined;
187
246
  usage?: {
188
247
  perUserLimit: number;
189
248
  totalLimit?: number | undefined;
@@ -207,6 +266,13 @@ export declare const updateCouponSchema: z.ZodObject<Omit<{
207
266
  minPurchase?: number | undefined;
208
267
  maxDiscount?: number | undefined;
209
268
  } | undefined;
269
+ bxgy?: {
270
+ buyQuantity: number;
271
+ getQuantity: number;
272
+ maxItemPrice?: number | undefined;
273
+ applicableProducts?: string[] | undefined;
274
+ applicableCategories?: string[] | undefined;
275
+ } | undefined;
210
276
  usage?: {
211
277
  totalLimit?: number | undefined;
212
278
  perUserLimit?: number | undefined;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { COUPON_CODE_PATTERN } from "./config.js";
3
+ import { bxgyConfigSchema } from "../../../../features/promotions/schemas/index.js";
3
4
  export const applyCouponSchema = z.object({
4
5
  code: z.string().regex(COUPON_CODE_PATTERN, "Invalid coupon code format").toUpperCase(),
5
6
  cartTotal: z.number().min(0),
@@ -17,6 +18,12 @@ export const createCouponSchema = z.object({
17
18
  maxDiscount: z.number().min(0).optional(),
18
19
  minPurchase: z.number().min(0).default(0),
19
20
  }),
21
+ /*
22
+ * 🛑 Without this, `z.object()` strips the config the `buy_x_get_y` member
23
+ * of the enum above depends on — see the fuller note on `bxgyConfigSchema`.
24
+ * Imported rather than restated so all five write paths move together.
25
+ */
26
+ bxgy: bxgyConfigSchema.optional(),
20
27
  usage: z.object({
21
28
  totalLimit: z.number().int().min(1).optional(),
22
29
  perUserLimit: z.number().int().min(1).default(1),
@@ -33,7 +33,17 @@
33
33
  import { addBusinessDays } from "../time/business-days.js";
34
34
  import { OBLIGATION_POLICIES, durationToMinutes, } from "../../../features/obligations/constants/policies.js";
35
35
  import { ALL_OBLIGATION_KINDS, } from "../../../features/obligations/schemas/firestore.js";
36
- import { windowMinutes } from "../../../features/tester/seed-data/tester-window.js";
36
+ /*
37
+ * 🛑 From _internal/shared/tester, NOT features/tester/seed-data.
38
+ *
39
+ * appkit/package.json excludes "!dist/features/tester/seed-data" from the
40
+ * published tarball, so this production module importing from there left the
41
+ * package with a DANGLING import: it resolved locally (the dir exists in a
42
+ * local build) and broke the consumer Turbopack build, which installs the
43
+ * tarball — "Module not found: tester-window.js". Nothing in appkit could see
44
+ * it; only a real deploy could.
45
+ */
46
+ import { windowMinutes } from "../tester/window.js";
37
47
  /**
38
48
  * How much a test fixture's deadlines are compressed.
39
49
  *
@@ -0,0 +1,25 @@
1
+ /** Reproduces the historical 1h/2h/3h auction stagger when nothing overrides it. */
2
+ export declare const DEFAULT_TESTER_WINDOW_MINUTES = 180;
3
+ /** Below this nothing is observable — a page load alone can outlast it. */
4
+ export declare const MIN_TESTER_WINDOW_MINUTES = 5;
5
+ /** The sandbox TTL is 7 days; a window longer than that outlives its own fixtures. */
6
+ export declare const MAX_TESTER_WINDOW_MINUTES: number;
7
+ /**
8
+ * Length of the testing window, in minutes.
9
+ *
10
+ * Read at call time rather than captured at module scope so a caller that sets the
11
+ * env var before importing a seed file still wins. Anything unparseable, non-finite
12
+ * or out of range falls back to the default rather than throwing: a malformed env
13
+ * var must not be able to brick `appkit-seed load`.
14
+ */
15
+ export declare function windowMinutes(): number;
16
+ /**
17
+ * A point `fraction` of the way through the window, measured from now.
18
+ *
19
+ * `windowOffset(0)` is now, `windowOffset(1)` is the window's end. Fractions above 1
20
+ * are allowed and mean "after this run" — that is how a fixture says "still open when
21
+ * testing finishes" without hard-coding a duration.
22
+ */
23
+ export declare function windowOffset(fraction: number): Date;
24
+ /** The mirror of windowOffset, into the past — for fixtures that must already be closed. */
25
+ export declare function windowAgo(fraction: number): Date;
@@ -0,0 +1,66 @@
1
+ /*
2
+ * WHY: Every time-bound tester fixture — auction end dates, coupon validity, event
3
+ * start/end, prize-draw reveal windows, offer expiry, lottery draw windows —
4
+ * must open and close INSIDE one testing session. Hand-written offsets like
5
+ * `Date.now() + 2 * 60 * 60 * 1000` cannot do that: they bake in a duration
6
+ * nobody can shorten, so a case reading "watch the auction actually end" is
7
+ * untestable without sitting there for two hours.
8
+ *
9
+ * This file is the one knob. Fixtures express WHEN as a fraction of the run
10
+ * window; the window's real length comes from TESTER_WINDOW_MINUTES.
11
+ *
12
+ * WHAT: windowMinutes() / windowOffset(fraction) / windowAgo(fraction).
13
+ *
14
+ * 🛑 The default is 180 minutes ON PURPOSE — it reproduces the pre-existing 1h/2h/3h
15
+ * auction stagger exactly (fractions 1/3, 2/3, 1), so an ordinary `appkit-seed load`
16
+ * behaves as it always has. Only the automated runner shortens it. Changing this
17
+ * default silently re-times every fixture for human testers too.
18
+ *
19
+ * @tag domain:tester
20
+ * @tag layer:seed
21
+ * @tag pattern:none
22
+ * @tag access:server-only
23
+ * @tag consumers:seed-data/*-tester-seed-data.ts
24
+ * @tag sideEffects:none
25
+ */
26
+ /** Reproduces the historical 1h/2h/3h auction stagger when nothing overrides it. */
27
+ export const DEFAULT_TESTER_WINDOW_MINUTES = 180;
28
+ /** Below this nothing is observable — a page load alone can outlast it. */
29
+ export const MIN_TESTER_WINDOW_MINUTES = 5;
30
+ /** The sandbox TTL is 7 days; a window longer than that outlives its own fixtures. */
31
+ export const MAX_TESTER_WINDOW_MINUTES = 7 * 24 * 60;
32
+ /**
33
+ * Length of the testing window, in minutes.
34
+ *
35
+ * Read at call time rather than captured at module scope so a caller that sets the
36
+ * env var before importing a seed file still wins. Anything unparseable, non-finite
37
+ * or out of range falls back to the default rather than throwing: a malformed env
38
+ * var must not be able to brick `appkit-seed load`.
39
+ */
40
+ export function windowMinutes() {
41
+ const raw = process.env.TESTER_WINDOW_MINUTES;
42
+ if (!raw)
43
+ return DEFAULT_TESTER_WINDOW_MINUTES;
44
+ const parsed = Number(raw);
45
+ if (!Number.isFinite(parsed))
46
+ return DEFAULT_TESTER_WINDOW_MINUTES;
47
+ if (parsed < MIN_TESTER_WINDOW_MINUTES)
48
+ return MIN_TESTER_WINDOW_MINUTES;
49
+ if (parsed > MAX_TESTER_WINDOW_MINUTES)
50
+ return MAX_TESTER_WINDOW_MINUTES;
51
+ return parsed;
52
+ }
53
+ /**
54
+ * A point `fraction` of the way through the window, measured from now.
55
+ *
56
+ * `windowOffset(0)` is now, `windowOffset(1)` is the window's end. Fractions above 1
57
+ * are allowed and mean "after this run" — that is how a fixture says "still open when
58
+ * testing finishes" without hard-coding a duration.
59
+ */
60
+ export function windowOffset(fraction) {
61
+ return new Date(Date.now() + fraction * windowMinutes() * 60000);
62
+ }
63
+ /** The mirror of windowOffset, into the past — for fixtures that must already be closed. */
64
+ export function windowAgo(fraction) {
65
+ return windowOffset(-fraction);
66
+ }
package/dist/client.d.ts CHANGED
@@ -31,6 +31,7 @@ export { BUILT_IN_THEMES, DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, REQUIRED_GRAD
31
31
  export type { GradientKey, RequiredThemeToken, ThemeMode, ThemeRecord, } from "./tokens/themes";
32
32
  export { ConfirmDeleteModal } from "./ui/components/ConfirmDeleteModal";
33
33
  export { EmptyState } from "./ui/components/EmptyState";
34
+ export { FilterChipGroup } from "./ui/components/FilterChipGroup";
34
35
  export { Skeleton } from "./ui/components/Skeleton";
35
36
  export { IconBox } from "./ui/components/IconBox";
36
37
  export type { IconBoxProps, IconBoxRounded, IconBoxSize, IconBoxTone, } from "./ui/components/IconBox";
package/dist/client.js CHANGED
@@ -52,6 +52,9 @@ export { BUILT_IN_THEMES, DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, REQUIRED_GRAD
52
52
  export { ConfirmDeleteModal } from "./ui/components/ConfirmDeleteModal.js";
53
53
  // S-STORE — pure-UI primitives + Seller view consumed by new dashboard pages
54
54
  export { EmptyState } from "./ui/components/EmptyState.js";
55
+ /* FilterChipGroup — a client UI primitive that existed, was used by
56
+ `ProtectionClaimsClient`, and was never exported here. */
57
+ export { FilterChipGroup } from "./ui/components/FilterChipGroup.js";
55
58
  export { Skeleton } from "./ui/components/Skeleton.js";
56
59
  // IconBox — square-icon container primitive; was exported from index.ts only,
57
60
  // missing here even though CLAUDE.md documents it as a client-usable pattern.
@@ -13,7 +13,21 @@ import { normalizeError } from "../errors/normalize.js";
13
13
  // db-firebase reaches firebase-admin only through `import type` plus its own
14
14
  // bare-specifier runtime requires. See CLAUDE.md Root Cause #24.
15
15
  import { getAdminDb } from "../providers/db-firebase/index.js";
16
- import { DatabaseError } from "../errors/index.js";
16
+ /*
17
+ * 🛑 From `errors/database-error`, NEVER the `../errors` barrel.
18
+ *
19
+ * That barrel re-exports `error-handler`, which does
20
+ * `import { NextResponse } from "next/server.js"` — and its own comment above
21
+ * that line says "only import in server/API-route contexts". This module is
22
+ * reachable from the Firebase Functions entry
23
+ * (`jobs.js → repositories → unit-of-work`), so the barrel dragged Next's
24
+ * whole server runtime into the tsup bundle. It failed with four errors that
25
+ * name neither this file nor the barrel: two unresolved
26
+ * `react-server-dom-webpack/*` requires and two `.wasm` files from
27
+ * `@vercel/og`. Nothing in appkit's own typecheck can see it; only
28
+ * `npm --prefix functions run build` can. Root Cause #18.
29
+ */
30
+ import { DatabaseError } from "../errors/database-error.js";
17
31
  import { userRepository, tokenRepository, sessionRepository, } from "../features/auth/repository/index.js";
18
32
  import { addressesRepository } from "../features/addresses/repository/addresses.repository.js";
19
33
  import { cartRepository } from "../features/cart/repository/cart.repository.js";