@mohasinac/appkit 4.62.0 → 4.62.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 (204) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +80 -0
  3. package/dist/_internal/server/features/checkout/actions.js +542 -26
  4. package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
  5. package/dist/_internal/server/features/invoicing/allocate.js +162 -0
  6. package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
  7. package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
  8. package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
  9. package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
  10. package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
  11. package/dist/_internal/server/features/invoicing/issue.js +202 -0
  12. package/dist/_internal/server/features/orders/actions.d.ts +8 -1
  13. package/dist/_internal/server/features/orders/actions.js +81 -8
  14. package/dist/_internal/server/features/orders/adapters.js +33 -0
  15. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
  16. package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
  17. package/dist/_internal/server/features/points/award.js +46 -4
  18. package/dist/_internal/server/features/refunds/actions.js +26 -0
  19. package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
  20. package/dist/_internal/server/features/shipping/courier-block.js +239 -0
  21. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  22. package/dist/_internal/server/features/site-settings/adapters.js +15 -0
  23. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  24. package/dist/_internal/server/features/stores/adapters.js +51 -0
  25. package/dist/_internal/server/functions/scheduled.js +38 -1
  26. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +9 -2
  27. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
  28. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
  29. package/dist/_internal/server/jobs/core/index.d.ts +2 -1
  30. package/dist/_internal/server/jobs/core/index.js +2 -1
  31. package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
  32. package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
  33. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
  34. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +62 -0
  35. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
  36. package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
  37. package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
  38. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +10 -2
  39. package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
  40. package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
  41. package/dist/_internal/shared/actions/action-registry.js +28 -0
  42. package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
  43. package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
  44. package/dist/_internal/shared/checkout/order-math.d.ts +145 -0
  45. package/dist/_internal/shared/checkout/order-math.js +173 -0
  46. package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
  47. package/dist/_internal/shared/checkout/refundable-base.js +13 -1
  48. package/dist/_internal/shared/checkout/split.d.ts +87 -0
  49. package/dist/_internal/shared/checkout/split.js +100 -0
  50. package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
  51. package/dist/_internal/shared/content/disclaimers.js +217 -0
  52. package/dist/_internal/shared/content/published-values.d.ts +88 -0
  53. package/dist/_internal/shared/content/published-values.js +198 -0
  54. package/dist/_internal/shared/features/admin/integrations.d.ts +109 -0
  55. package/dist/_internal/shared/features/admin/integrations.js +253 -0
  56. package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
  57. package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
  58. package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
  59. package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
  60. package/dist/_internal/shared/features/points/accepts.d.ts +36 -0
  61. package/dist/_internal/shared/features/points/accepts.js +43 -0
  62. package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
  63. package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
  64. package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
  65. package/dist/_internal/shared/fees/calculator.d.ts +33 -1
  66. package/dist/_internal/shared/fees/calculator.js +32 -2
  67. package/dist/client.d.ts +11 -0
  68. package/dist/client.js +30 -0
  69. package/dist/constants/api-endpoints.d.ts +42 -0
  70. package/dist/constants/api-endpoints.js +14 -0
  71. package/dist/constants/field-names.d.ts +1 -0
  72. package/dist/constants/field-names.js +5 -0
  73. package/dist/contracts/index.d.ts +1 -1
  74. package/dist/contracts/shipping.d.ts +45 -0
  75. package/dist/contracts/shipping.js +15 -0
  76. package/dist/features/about/components/AboutView.js +1 -1
  77. package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
  78. package/dist/features/account/components/UserOrdersView.js +19 -33
  79. package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
  80. package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
  81. package/dist/features/admin/components/AdminInvoicingView.js +65 -0
  82. package/dist/features/admin/components/AdminOrderEditorView.d.ts +18 -1
  83. package/dist/features/admin/components/AdminOrderEditorView.js +11 -2
  84. package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
  85. package/dist/features/admin/components/AdminOrdersView.js +67 -5
  86. package/dist/features/admin/components/OwnerQuickActions.js +24 -2
  87. package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
  88. package/dist/features/admin/constants/filter-tabs.js +6 -0
  89. package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
  90. package/dist/features/admin/hooks/useAdminListingData.js +16 -0
  91. package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
  92. package/dist/features/admin/schemas/firestore.d.ts +30 -0
  93. package/dist/features/admin/schemas/firestore.js +5 -0
  94. package/dist/features/audit-log/schemas/firestore.d.ts +30 -0
  95. package/dist/features/audit-log/schemas/firestore.js +15 -0
  96. package/dist/features/auth/permissions/constants.js +13 -0
  97. package/dist/features/cart/components/CartPriceBreakdown.d.ts +13 -0
  98. package/dist/features/cart/components/CartPriceBreakdown.js +6 -2
  99. package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
  100. package/dist/features/cart/components/PointsTenderPicker.js +15 -0
  101. package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
  102. package/dist/features/cart/components/index.d.ts +2 -0
  103. package/dist/features/cart/components/index.js +1 -0
  104. package/dist/features/cart/repository/cart.repository.d.ts +19 -0
  105. package/dist/features/cart/repository/cart.repository.js +27 -0
  106. package/dist/features/cart/schemas/firestore.d.ts +92 -0
  107. package/dist/features/contact/email.d.ts +27 -10
  108. package/dist/features/contact/email.js +20 -3
  109. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +38 -0
  110. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +80 -0
  111. package/dist/features/dispute-reasons/schemas/firestore.d.ts +64 -0
  112. package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
  113. package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
  114. package/dist/features/faq/components/FAQAccordion.js +29 -4
  115. package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
  116. package/dist/features/invoicing/schemas/firestore.js +45 -0
  117. package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
  118. package/dist/features/invoicing/utils/financial-year.js +53 -0
  119. package/dist/features/jobs/actions/enqueue-job.js +18 -1
  120. package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
  121. package/dist/features/jobs/repository/jobs.repository.js +84 -1
  122. package/dist/features/jobs/schemas/firestore.d.ts +80 -0
  123. package/dist/features/jobs/schemas/firestore.js +37 -0
  124. package/dist/features/lottery/schemas/zod.d.ts +6 -6
  125. package/dist/features/media/MediaImage.js +22 -1
  126. package/dist/features/media/upload/ImageUpload.js +1 -1
  127. package/dist/features/obligations/constants/policies.js +24 -1
  128. package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
  129. package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
  130. package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
  131. package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
  132. package/dist/features/orders/components/RaiseDisputeForm.js +111 -0
  133. package/dist/features/orders/components/RefundRequestView.js +19 -1
  134. package/dist/features/orders/components/index.d.ts +4 -0
  135. package/dist/features/orders/components/index.js +2 -0
  136. package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
  137. package/dist/features/orders/constants/order-lane-tabs.js +88 -0
  138. package/dist/features/orders/repository/orders.repository.d.ts +57 -0
  139. package/dist/features/orders/repository/orders.repository.js +145 -0
  140. package/dist/features/orders/schemas/firestore.d.ts +196 -1
  141. package/dist/features/orders/schemas/index.d.ts +54 -6
  142. package/dist/features/orders/schemas/index.js +12 -0
  143. package/dist/features/orders/types/index.d.ts +26 -0
  144. package/dist/features/orders/utils/courier-block.d.ts +25 -0
  145. package/dist/features/orders/utils/courier-block.js +23 -0
  146. package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
  147. package/dist/features/payment-cases/schemas/firestore.js +1 -0
  148. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
  149. package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
  150. package/dist/features/products/components/ProductDetailPageView.js +2 -2
  151. package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
  152. package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
  153. package/dist/features/promotions/actions/coupon-stacking.js +21 -2
  154. package/dist/features/promotions/components/CouponCard.js +13 -0
  155. package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
  156. package/dist/features/promotions/constants/coupon-help.js +9 -1
  157. package/dist/features/promotions/schemas/firestore.d.ts +24 -0
  158. package/dist/features/promotions/schemas/firestore.js +4 -0
  159. package/dist/features/promotions/types/index.d.ts +19 -1
  160. package/dist/features/scams/components/ScamProfileView.js +2 -2
  161. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  162. package/dist/features/search/action-index/tab-entries.js +1 -1
  163. package/dist/features/seller/actions/seller-actions.js +33 -3
  164. package/dist/features/seller/components/SellerOrdersView.js +129 -27
  165. package/dist/features/seller/hooks/useSellerListingData.js +25 -0
  166. package/dist/features/shipments/schemas/validation.d.ts +4 -4
  167. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +43 -0
  168. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +90 -0
  169. package/dist/features/shipping-boxes/schemas/firestore.d.ts +82 -0
  170. package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
  171. package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
  172. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
  173. package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
  174. package/dist/features/stores/schemas/firestore.d.ts +71 -0
  175. package/dist/features/stores/types/index.d.ts +19 -0
  176. package/dist/features/support/schemas/index.d.ts +6 -6
  177. package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
  178. package/dist/index.d.ts +6 -1
  179. package/dist/index.js +14 -1
  180. package/dist/next/routing/route-map.d.ts +20 -0
  181. package/dist/next/routing/route-map.js +10 -0
  182. package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
  183. package/dist/providers/shipping-shiprocket/index.js +84 -1
  184. package/dist/schemas/registry.d.ts +47 -18
  185. package/dist/schemas/sieve.d.ts +2 -2
  186. package/dist/server-entry.d.ts +2 -2
  187. package/dist/server-entry.js +8 -1
  188. package/dist/server.d.ts +10 -1
  189. package/dist/server.js +39 -1
  190. package/dist/styles.css +64 -80
  191. package/dist/tailwind-utilities.css +1 -1
  192. package/dist/tokens/themes/default-dark.js +1 -0
  193. package/dist/tokens/themes/default-light.js +1 -0
  194. package/dist/tokens/themes/required.d.ts +1 -1
  195. package/dist/tokens/themes/required.js +1 -0
  196. package/dist/tokens/tokens.css +15 -0
  197. package/dist/ui/components/Disclaimer.d.ts +19 -0
  198. package/dist/ui/components/Disclaimer.js +74 -0
  199. package/dist/ui/components/tabs-overflow.js +64 -11
  200. package/dist/ui/index.d.ts +2 -0
  201. package/dist/ui/index.js +6 -0
  202. package/dist/ui/rich-text/RichText.style.css +48 -79
  203. package/package.json +1 -1
  204. package/scripts/seed-cli.mjs +1831 -1822
@@ -5,55 +5,88 @@
5
5
  * (CLAUDE.md Rule #6 — Vercel routes never do heavy work; they write a
6
6
  * `jobs` doc and return, this Function does the actual processing).
7
7
  *
8
- * Flow:
9
- * 1. Mark the job "processing".
10
- * 2. Look up a runner in `JOB_RUNNERS` by `job.jobType`.
11
- * 3. Run it, mark the job "done"/"failed", and best-effort ping the
12
- * `bulk_events/{jobId}` RTDB channel the client is subscribed to via
13
- * `useBulkEvent` — every RTDB write is its own try/catch so a ping
14
- * failure never masks the real Firestore result.
8
+ * ## 🛑 It no longer runs the job itself — `runJobOnce` does (Phase 8)
9
+ *
10
+ * The attempt, the failure classification and the retry scheduling all live in
11
+ * `runJobOnce`, which the retry sweep also calls. One dispatch path for both
12
+ * entry points, or the two drift the first time either learns something.
15
13
  */
16
- import { normalizeError } from "../../../../errors/normalize.js";
17
14
  import { jobsRepository } from "../../../../repositories/index.js";
18
- import { getAdminRealtimeDb } from "../../../../providers/db-firebase/index.js";
19
- import { RTDB_PATHS } from "../../../../providers/db-firebase/rtdb-paths.js";
20
- import { JOB_RUNNERS } from "./jobRunners.js";
21
- async function pingBulkEvent(jobId, payload, ctx) {
22
- try {
23
- await getAdminRealtimeDb()
24
- .ref(`${RTDB_PATHS.BULK_EVENTS}/${jobId}`)
25
- .set({ ...payload, updatedAt: Date.now() });
26
- }
27
- catch (err) {
28
- void normalizeError(err);
29
- ctx.logger.warn("onJobCreated: RTDB ping failed (non-fatal)", {
30
- jobId,
31
- error: err instanceof Error ? err.message : String(err),
32
- });
33
- }
34
- }
15
+ import { runJobOnce } from "./runJobOnce.js";
35
16
  export async function handleJobCreated(input, ctx) {
36
17
  const { jobId, job } = input;
37
- await jobsRepository.markProcessing(jobId);
38
- const runner = JOB_RUNNERS[job.jobType];
39
- if (!runner) {
40
- const error = `No runner registered for jobType: ${job.jobType}`;
41
- await jobsRepository.markFailed(jobId, error);
42
- await pingBulkEvent(jobId, { status: "failed", action: job.jobType, error }, ctx);
43
- ctx.logger.error("onJobCreated: unregistered jobType", null, { jobId, jobType: job.jobType });
44
- return;
45
- }
46
- try {
47
- const result = await runner(job.payload, ctx);
48
- await jobsRepository.markDone(jobId, result);
49
- await pingBulkEvent(jobId, { status: "success", action: job.jobType, ...result }, ctx);
50
- ctx.logger.info("onJobCreated: job complete", { jobId, jobType: job.jobType, summary: result.summary });
18
+ /*
19
+ * ── 🛑 A SCHEDULED RETRY MUST NOT RUN HERE ────────────────────────────────
20
+ *
21
+ * This guard is the whole reason Phase 8's backoff works, and its absence
22
+ * was a defect in this feature's own first draft.
23
+ *
24
+ * A retry is a NEW `jobs` document (it has to be — this trigger fires on
25
+ * CREATE, so flipping a job back to `pending` would never wake anything).
26
+ * But creating that document fires THIS trigger immediately — so without
27
+ * this check, every retry would run the instant it was scheduled, the
28
+ * 5-minute and 30-minute backoffs would be zero, and three attempts would
29
+ * burn in under a second against a fault that had not had time to clear.
30
+ * The backoff fields would still be written, so the database would read as
31
+ * though it had waited.
32
+ *
33
+ * `nextAttemptAt` in the future means "the sweep owns this one". Returning
34
+ * leaves the document `pending`, which is exactly what `getDueRetries`
35
+ * queries for.
36
+ */
37
+ if (job.nextAttemptAt) {
38
+ const due = new Date(job.nextAttemptAt).getTime();
39
+ if (Number.isFinite(due) && due > Date.now()) {
40
+ ctx.logger.info("onJobCreated: retry scheduled, deferring to the sweep", {
41
+ jobId,
42
+ jobType: job.jobType,
43
+ attempt: job.attempt ?? 1,
44
+ nextAttemptAt: new Date(due).toISOString(),
45
+ });
46
+ return;
47
+ }
51
48
  }
52
- catch (err) {
53
- void normalizeError(err);
54
- const message = err instanceof Error ? err.message : String(err);
55
- await jobsRepository.markFailed(jobId, message);
56
- await pingBulkEvent(jobId, { status: "failed", action: job.jobType, error: message }, ctx);
57
- ctx.logger.error("onJobCreated: job failed", err, { jobId, jobType: job.jobType });
49
+ await runJobOnce({ jobId, job }, ctx);
50
+ }
51
+ /**
52
+ * The retry sweep — runs every scheduled attempt whose time has come.
53
+ *
54
+ * ## 🛑 Folded into an EXISTING scheduled function, not a new one
55
+ *
56
+ * It is called from `paymentWindowTimeout` (every 5 minutes). Cloud Scheduler
57
+ * bills per registered job and this project already carries ~34 of them
58
+ * against 3 free; adding a 35th to run a usually-empty query is the cost this
59
+ * plan explicitly refuses. The same reasoning put the ledger reconciler inside
60
+ * `dailyDataCleanup` and R1 inside this same function.
61
+ *
62
+ * 🛑 And it is placed FIRST at its call site, before that function's early
63
+ * return — `paymentWindowTimeout` returns as soon as there are no expired
64
+ * payment windows, which is most ticks, so appending would run the sweep only
65
+ * on the minority of ticks with unrelated work (Root Cause #27).
66
+ *
67
+ * Sequential, not `Promise.all`. A job runner is heavy by definition — that is
68
+ * why it is a job — and twenty of them concurrently inside one 540s function
69
+ * is how a sweep exhausts its own budget. Twenty sequential attempts that run
70
+ * out of time simply leave the rest `pending` for the next tick, which is the
71
+ * safe failure.
72
+ */
73
+ export async function runDueJobRetries(ctx) {
74
+ const due = await jobsRepository.getDueRetries();
75
+ if (due.length === 0)
76
+ return { ran: 0 };
77
+ ctx.logger.info("job retries: running due attempts", { count: due.length });
78
+ let ran = 0;
79
+ for (const { id, data } of due) {
80
+ /*
81
+ * `runJobOnce` never throws — it records every outcome on the job itself.
82
+ * The `.catch` is belt-and-braces so one pathological job cannot abort the
83
+ * rest of the sweep, which would leave the remainder un-retried with no
84
+ * record of why.
85
+ */
86
+ await runJobOnce({ jobId: id, job: data }, ctx).catch((err) => {
87
+ ctx.logger.error("job retries: attempt threw", err, { jobId: id });
88
+ });
89
+ ran += 1;
58
90
  }
91
+ return { ran };
59
92
  }
@@ -6,9 +6,16 @@ export type OrderAfter = {
6
6
  userEmail: string;
7
7
  productTitle: string;
8
8
  trackingNumber?: string;
9
+ /**
10
+ * Phase 7 — the invoice is numbered on the PAYMENT transition, which is a
11
+ * different axis from `status` and is why these two fields are here.
12
+ */
13
+ paymentStatus?: string;
14
+ invoiceNumber?: string;
9
15
  };
10
16
  export type OrderBefore = {
11
17
  status: OrderStatus;
18
+ paymentStatus?: string;
12
19
  };
13
20
  export interface HandleOrderStatusChangeInput {
14
21
  orderId: string;
@@ -1,5 +1,9 @@
1
1
  import { decryptPii } from "../../../../security/index.js";
2
2
  import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
3
+ import { orderRepository } from "../../../../features/orders/repository/orders.repository.js";
4
+ /* Phase 7 — numbering only; the PDF renderer lives in `functions/` because
5
+ * `pdfkit` must not enter appkit's module graph. */
6
+ import { allocateSeriesNumber } from "../../features/invoicing/allocate.js";
3
7
  import { ORDER_MESSAGES } from "../handlers/messages.js";
4
8
  const STATUS_CONFIG = {
5
9
  confirmed: {
@@ -31,6 +35,64 @@ export async function handleOrderStatusChange(input, ctx) {
31
35
  const { orderId, before, after } = input;
32
36
  if (!before || !after)
33
37
  return;
38
+ /*
39
+ * ── PHASE 7: number the invoice on the PAYMENT transition ─────────────────
40
+ *
41
+ * 🛑 PLACED FIRST, before the `status`-unchanged early return. That return
42
+ * is correct for the notification work below it, and it is also why this
43
+ * cannot go after it: `paymentStatus: pending → paid` frequently happens
44
+ * with NO change to `status` at all (a COD order verified by an admin, a
45
+ * manual proof approved), so appending this below would have numbered only
46
+ * the minority of paid orders whose status happened to move in the same
47
+ * write. That is Root Cause #27's sweep-coverage trap, and R1 in
48
+ * `paymentWindowTimeout` had to be placed the same way for the same reason.
49
+ *
50
+ * 🛑 Extending THIS trigger rather than adding a second one on `orders`.
51
+ * A second Firestore trigger on the same collection wakes for every write
52
+ * and returns — which is how `onShipmentDeleted` burned a million
53
+ * invocations a day sitting correctly-guarded beside a neighbour's loop
54
+ * (Root Cause #92). The sticker-trace definitions carry the same 🛑.
55
+ *
56
+ * 🛑 And it is numbering ONLY — no PDF. `pdfkit` is a Functions dependency
57
+ * and must not enter appkit's module graph; the bytes are rendered once,
58
+ * lazily, by the serve route and stored from there. Numbering is the half
59
+ * that must be immediate and transactional, because it is the half that
60
+ * cannot be corrected: a number cannot be withdrawn without leaving a gap.
61
+ */
62
+ const becamePaid = before.paymentStatus !== "paid" && after.paymentStatus === "paid";
63
+ if (becamePaid && !after.invoiceNumber) {
64
+ try {
65
+ const allocation = await allocateSeriesNumber({
66
+ kind: "invoice",
67
+ subjectId: orderId,
68
+ orderId,
69
+ });
70
+ /*
71
+ * Only writes when THIS call allocated. A re-entry that found an
72
+ * existing claim skips the write entirely, which keeps a re-fired
73
+ * trigger from touching the order at all — the write that would
74
+ * otherwise re-fire this very trigger.
75
+ */
76
+ if (allocation.allocated) {
77
+ await orderRepository.update(orderId, {
78
+ invoiceNumber: allocation.number,
79
+ invoiceIssuedAt: new Date(),
80
+ });
81
+ ctx.logger.info(`Invoice ${allocation.number} allocated`, { orderId });
82
+ }
83
+ }
84
+ catch (error) {
85
+ /*
86
+ * Logged and swallowed, so a numbering failure cannot suppress the
87
+ * buyer's order notification below. The order stays un-invoiced, which
88
+ * is a visible, retryable state — the next write that touches
89
+ * `paymentStatus` re-attempts, and the serve route can allocate on
90
+ * demand. Throwing here would also make the Firestore trigger retry the
91
+ * whole handler, re-sending the notification.
92
+ */
93
+ ctx.logger.error("Invoice allocation failed", error, { orderId });
94
+ }
95
+ }
34
96
  if (before.status === after.status)
35
97
  return;
36
98
  const newStatus = after.status;
@@ -6,6 +6,9 @@ import { OrderStatusValues } from "../../../../features/orders/schemas/firestore
6
6
  import { PAYMENT_WINDOW_EXPIRED_REASON } from "../../../../features/orders/constants/payment-window.js";
7
7
  import { ORDER_MESSAGES } from "../handlers/messages.js";
8
8
  import { reconcileStuckGatewayEvents } from "./paymentReconcile.js";
9
+ /* Phase 8 — the job retry sweep. Folded in here rather than given its own
10
+ * Scheduler job; see its own docstring and the 🛑 at the call site. */
11
+ import { runDueJobRetries } from "./onJobCreated.js";
9
12
  /**
10
13
  * 15-minute payment window sweep — cancels + restocks any `upi_manual`/
11
14
  * `cash`/`emi` order still `pending`/`pending` with no proof uploaded past
@@ -53,6 +56,36 @@ export async function runPaymentWindowTimeout(ctx) {
53
56
  err,
54
57
  });
55
58
  }
59
+ /*
60
+ * ── Phase 8 rides along too: job retries whose backoff has elapsed ────────
61
+ *
62
+ * Same two reasons as R1 above, and the same placement rule: **FIRST, before
63
+ * the early return**. A retry is a `pending` job document with a future
64
+ * `nextAttemptAt`, and nothing else in the system will ever run it — the
65
+ * create trigger deliberately defers to this sweep, because creating the
66
+ * retry document fires that trigger instantly and would collapse the backoff
67
+ * to zero.
68
+ *
69
+ * So if this call sat after the early return, a retry would run only on a
70
+ * tick that happened to have an expired payment window — which is the
71
+ * minority — and a job scheduled on a quiet afternoon could wait hours.
72
+ * Root Cause #27 again, now with a second mechanism depending on it.
73
+ *
74
+ * Never throws, for the same reason R1 does not: a stuck job must not stop
75
+ * the payment-window cancellations this function exists for.
76
+ */
77
+ try {
78
+ const retries = await runDueJobRetries(ctx);
79
+ if (retries.ran > 0) {
80
+ ctx.logger.info("Phase 8: ran due job retries", { ...retries });
81
+ }
82
+ }
83
+ catch (err) {
84
+ void normalizeError(err);
85
+ ctx.logger.error("job retry sweep failed inside paymentWindowTimeout — continuing", {
86
+ err,
87
+ });
88
+ }
56
89
  ctx.logger.info("Scanning orders past their 15-minute payment deadline");
57
90
  const expired = await orderRepository.getExpiredPaymentDeadlines();
58
91
  if (expired.length === 0) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Run ONE attempt of a job, and decide what happens next (Phase 8).
3
+ *
4
+ * ## 🛑 ONE dispatch path, shared by the trigger and the retry sweep
5
+ *
6
+ * `handleJobCreated` is the `jobs/{jobId}` create trigger; `runDueJobRetries`
7
+ * is the sweep that picks up scheduled retries. Both call THIS, so there is
8
+ * exactly one place that knows how to run a job, classify its failure and
9
+ * schedule the next attempt. Two dispatch paths would drift the first time one
10
+ * of them learned something — which is the shape of every defect in this
11
+ * plan's § "Recurrent Root Cause Patterns" that begins "a fix applied to the
12
+ * route but never back-ported".
13
+ *
14
+ * ## 🛑 A retry is a NEW DOCUMENT
15
+ *
16
+ * `onJobCreated` fires on CREATE. Re-running a job by flipping its status back
17
+ * to `pending` would never wake the trigger, so the job would sit `pending`
18
+ * forever looking scheduled — a stall that reads as "queued". So a retry is a
19
+ * fresh `jobs` document carrying `attempt + 1`, `parentJobId` and a
20
+ * `nextAttemptAt`, and the sweep is what runs it when due.
21
+ *
22
+ * ## What is retryable
23
+ *
24
+ * `mapToHttpError(err).status >= 500`. Reused rather than invented: that
25
+ * function already encodes "is this our fault or the caller's" for every error
26
+ * class in the codebase, and a second classification would answer differently
27
+ * the first time either changed.
28
+ *
29
+ * A 4xx is terminal IMMEDIATELY — no backoff, no attempts burned. A validation
30
+ * failure does not become valid by waiting, and retrying it three times over
31
+ * 35 minutes just delays the case by 35 minutes.
32
+ */
33
+ import { type JobDocument } from "../../../../features/jobs/schemas/firestore";
34
+ import type { JobContext } from "../runtime/types";
35
+ export interface RunJobOnceInput {
36
+ jobId: string;
37
+ job: JobDocument;
38
+ }
39
+ /**
40
+ * Run one attempt. Marks the job done, schedules a retry, or declares it dead.
41
+ *
42
+ * Never throws: a job's failure is recorded on the job, and letting it escape
43
+ * would make the Firebase trigger retry the whole handler — a second,
44
+ * uncontrolled retry mechanism layered under this one, which is precisely the
45
+ * doubling that cost 55 sessions in Root Cause #91 (`run.mjs`'s internal retry
46
+ * multiplying the pool's).
47
+ */
48
+ export declare function runJobOnce(input: RunJobOnceInput, ctx: JobContext): Promise<void>;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Run ONE attempt of a job, and decide what happens next (Phase 8).
3
+ *
4
+ * ## 🛑 ONE dispatch path, shared by the trigger and the retry sweep
5
+ *
6
+ * `handleJobCreated` is the `jobs/{jobId}` create trigger; `runDueJobRetries`
7
+ * is the sweep that picks up scheduled retries. Both call THIS, so there is
8
+ * exactly one place that knows how to run a job, classify its failure and
9
+ * schedule the next attempt. Two dispatch paths would drift the first time one
10
+ * of them learned something — which is the shape of every defect in this
11
+ * plan's § "Recurrent Root Cause Patterns" that begins "a fix applied to the
12
+ * route but never back-ported".
13
+ *
14
+ * ## 🛑 A retry is a NEW DOCUMENT
15
+ *
16
+ * `onJobCreated` fires on CREATE. Re-running a job by flipping its status back
17
+ * to `pending` would never wake the trigger, so the job would sit `pending`
18
+ * forever looking scheduled — a stall that reads as "queued". So a retry is a
19
+ * fresh `jobs` document carrying `attempt + 1`, `parentJobId` and a
20
+ * `nextAttemptAt`, and the sweep is what runs it when due.
21
+ *
22
+ * ## What is retryable
23
+ *
24
+ * `mapToHttpError(err).status >= 500`. Reused rather than invented: that
25
+ * function already encodes "is this our fault or the caller's" for every error
26
+ * class in the codebase, and a second classification would answer differently
27
+ * the first time either changed.
28
+ *
29
+ * A 4xx is terminal IMMEDIATELY — no backoff, no attempts burned. A validation
30
+ * failure does not become valid by waiting, and retrying it three times over
31
+ * 35 minutes just delays the case by 35 minutes.
32
+ */
33
+ import { normalizeError } from "../../../../errors/normalize.js";
34
+ import { mapToHttpError } from "../../../../errors/error-mapping.js";
35
+ import { jobsRepository } from "../../../../repositories/index.js";
36
+ import { paymentCasesRepository } from "../../../../features/payment-cases/repository/payment-cases.repository.js";
37
+ import { getAdminRealtimeDb } from "../../../../providers/db-firebase/index.js";
38
+ import { RTDB_PATHS } from "../../../../providers/db-firebase/rtdb-paths.js";
39
+ import { JOB_BACKOFF_MS, JOB_MAX_ATTEMPTS, } from "../../../../features/jobs/schemas/firestore.js";
40
+ import { JOB_RUNNERS } from "./jobRunners.js";
41
+ async function pingBulkEvent(jobId, payload, ctx) {
42
+ try {
43
+ await getAdminRealtimeDb()
44
+ .ref(`${RTDB_PATHS.BULK_EVENTS}/${jobId}`)
45
+ .set({ ...payload, updatedAt: Date.now() });
46
+ }
47
+ catch (err) {
48
+ void normalizeError(err);
49
+ ctx.logger.warn("runJobOnce: RTDB ping failed (non-fatal)", {
50
+ jobId,
51
+ error: err instanceof Error ? err.message : String(err),
52
+ });
53
+ }
54
+ }
55
+ /**
56
+ * The delay before the next attempt.
57
+ *
58
+ * 🛑 Clamped to the LAST element rather than indexing past the end. An
59
+ * out-of-range index yields `undefined`, `new Date(Date.now() + undefined)` is
60
+ * `Invalid Date`, and the sweep's `nextAttemptAt <= now` comparison is false
61
+ * for an invalid date forever — so the retry would be scheduled and never run.
62
+ * A stall that looks like a queue is the exact failure this phase removes.
63
+ */
64
+ function backoffFor(attempt) {
65
+ const idx = Math.max(0, Math.min(attempt - 1, JOB_BACKOFF_MS.length - 1));
66
+ return JOB_BACKOFF_MS[idx];
67
+ }
68
+ /**
69
+ * Run one attempt. Marks the job done, schedules a retry, or declares it dead.
70
+ *
71
+ * Never throws: a job's failure is recorded on the job, and letting it escape
72
+ * would make the Firebase trigger retry the whole handler — a second,
73
+ * uncontrolled retry mechanism layered under this one, which is precisely the
74
+ * doubling that cost 55 sessions in Root Cause #91 (`run.mjs`'s internal retry
75
+ * multiplying the pool's).
76
+ */
77
+ export async function runJobOnce(input, ctx) {
78
+ const { jobId, job } = input;
79
+ const attempt = job.attempt ?? 1;
80
+ const maxAttempts = job.maxAttempts ?? JOB_MAX_ATTEMPTS;
81
+ await jobsRepository.markProcessing(jobId);
82
+ const runner = JOB_RUNNERS[job.jobType];
83
+ if (!runner) {
84
+ /*
85
+ * 🛑 Terminal immediately, and DEAD rather than failed. An unregistered
86
+ * jobType cannot become registered by waiting, and the thing that would
87
+ * fix it is a deploy — so this is the clearest possible case for a human,
88
+ * not a retry. It is also the failure mode of the seed-loader registry
89
+ * (Root Cause #90): a missing map entry whose symptom is silence.
90
+ */
91
+ const error = `No runner registered for jobType: ${job.jobType}`;
92
+ await jobsRepository.markDead(jobId, error);
93
+ await pingBulkEvent(jobId, { status: "failed", action: job.jobType, error }, ctx);
94
+ await openDeadJobCase(jobId, job, error, ctx);
95
+ ctx.logger.error("runJobOnce: unregistered jobType", null, { jobId, jobType: job.jobType });
96
+ return;
97
+ }
98
+ try {
99
+ const result = await runner(job.payload, ctx);
100
+ await jobsRepository.markDone(jobId, result);
101
+ await pingBulkEvent(jobId, { status: "success", action: job.jobType, ...result }, ctx);
102
+ ctx.logger.info("runJobOnce: job complete", {
103
+ jobId,
104
+ jobType: job.jobType,
105
+ attempt,
106
+ summary: result.summary,
107
+ });
108
+ return;
109
+ }
110
+ catch (err) {
111
+ void normalizeError(err);
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ /*
114
+ * 🛑 `mapToHttpError` rather than a bespoke test. It already decides "ours
115
+ * or theirs" for every error class here, so a 5xx is a transient fault
116
+ * worth retrying and a 4xx is a permanent one that will fail identically
117
+ * in five minutes.
118
+ */
119
+ const status = mapToHttpError(err).status;
120
+ const retryable = status >= 500;
121
+ const attemptsLeft = attempt < maxAttempts;
122
+ if (retryable && attemptsLeft) {
123
+ const nextAttemptAt = new Date(Date.now() + backoffFor(attempt));
124
+ /*
125
+ * 🛑 The NEW document is created BEFORE this one is marked failed. The
126
+ * reverse ordering leaves a window where the job reads as failed with no
127
+ * retry scheduled — and if the process dies in that window, nothing
128
+ * retries and nothing says so. This way the worst case is a scheduled
129
+ * retry whose parent still reads `processing`, which the sweep handles
130
+ * and a human can see.
131
+ */
132
+ const retryId = await jobsRepository.createRetry(jobId, job, {
133
+ attempt: attempt + 1,
134
+ maxAttempts,
135
+ nextAttemptAt,
136
+ });
137
+ await jobsRepository.markFailed(jobId, message);
138
+ /*
139
+ * The client is told "retrying", not "failed". `useBulkEvent` watches
140
+ * for a terminal state, and reporting failure here would stop a progress
141
+ * UI on a job that is still going.
142
+ */
143
+ await pingBulkEvent(jobId, { status: "retrying", action: job.jobType, error: message, retryJobId: retryId }, ctx);
144
+ ctx.logger.warn("runJobOnce: attempt failed, retry scheduled", {
145
+ jobId,
146
+ retryId,
147
+ jobType: job.jobType,
148
+ attempt,
149
+ status,
150
+ nextAttemptAt: nextAttemptAt.toISOString(),
151
+ });
152
+ return;
153
+ }
154
+ await jobsRepository.markDead(jobId, message);
155
+ await pingBulkEvent(jobId, { status: "failed", action: job.jobType, error: message }, ctx);
156
+ await openDeadJobCase(jobId, job, message, ctx);
157
+ ctx.logger.error("runJobOnce: job DEAD", err, {
158
+ jobId,
159
+ jobType: job.jobType,
160
+ attempt,
161
+ status,
162
+ reason: retryable ? "attempts exhausted" : "permanent failure",
163
+ });
164
+ }
165
+ }
166
+ /**
167
+ * A dead job is a case, not a log line.
168
+ *
169
+ * 🛑 Keyed on the ORIGINAL job id (`parentJobId ?? jobId`), so three exhausted
170
+ * attempts of one job produce ONE case rather than three. `openOrObserve`
171
+ * increments its own counter — the property that makes it safe to open a case
172
+ * from a hot path at all.
173
+ *
174
+ * Best-effort: a job is already dead by this point, and failing to file the
175
+ * case must not turn that into a thrown error from a trigger.
176
+ */
177
+ async function openDeadJobCase(jobId, job, error, ctx) {
178
+ const subjectId = job.parentJobId ?? jobId;
179
+ try {
180
+ await paymentCasesRepository().openOrObserve({
181
+ kind: "dead_job",
182
+ subjectId,
183
+ severity: "error",
184
+ entityType: "job",
185
+ entityId: subjectId,
186
+ title: `Job ${job.jobType} gave up after ${job.attempt ?? 1} attempt(s)`,
187
+ detail: `The last error was: ${error}. ` +
188
+ `Requested by ${job.requestedBy}. The job document holds the payload.`,
189
+ });
190
+ }
191
+ catch (err) {
192
+ void normalizeError(err);
193
+ ctx.logger.error("runJobOnce: could not open dead_job case", err, { jobId });
194
+ }
195
+ }
@@ -8,7 +8,7 @@ import { getDefaultCurrency } from "../../../../core/index.js";
8
8
  import { computePayoutDeduction } from "../../../shared/fees/calculator.js";
9
9
  import { payoutCutoff } from "../../../shared/fees/payout-hold.js";
10
10
  import { PROTECTION_CLAIM_WINDOW_HOURS } from "../../../shared/features/shipping/protections.js";
11
- import { ordersTaxableValue, ordersShippingReimbursement, } from "../../../shared/checkout/order-math.js";
11
+ import { ordersTaxableValue, ordersShippingReimbursement, ordersPointsBorneByStore, } from "../../../shared/checkout/order-math.js";
12
12
  export async function runWeeklyPayoutEligibility(ctx) {
13
13
  // `FEATURE_PAYOUTS` gate deleted 2026-10-19 — see `autoPayoutEligibility.ts`.
14
14
  const siteSettings = await siteSettingsRepository.getSingleton();
@@ -75,7 +75,15 @@ export async function runWeeklyPayoutEligibility(ctx) {
75
75
  * `{ id, data }` wrappers, and passing the wrapper would read every field
76
76
  * as undefined and silently reimburse zero.
77
77
  */
78
- ordersShippingReimbursement(orders.map((o) => o.data)));
78
+ ordersShippingReimbursement(orders.map((o) => o.data)),
79
+ /*
80
+ * D46/D47 — the store-funded points, both ends. Must match
81
+ * `autoPayoutEligibility` exactly, for the reason stated above
82
+ * `grossAmount`: the weekly run and the automatic one pay the same orders,
83
+ * and a term present in one and absent in the other is a seller paid two
84
+ * different amounts depending on which job got there first.
85
+ */
86
+ ordersPointsBorneByStore(orders.map((o) => o.data)));
79
87
  /*
80
88
  * 🛑 REVEALED before building a payout — see the identical note in
81
89
  * `autoPayoutEligibility.ts`. `findById` is ciphertext-by-default and
@@ -1,4 +1,21 @@
1
1
  import { handleOrderStatusChange, } from "../core/onOrderStatusChange.js";
2
+ /*
3
+ * 🛑 Bound as **`documentUpdated`**, which is what Phase 7's numbering needs
4
+ * and is already what this function uses — verified rather than assumed.
5
+ *
6
+ * `paymentStatus: pending → paid` is frequently the ONLY field that moves on a
7
+ * COD or manual order, and `documentUpdated` fires on any update, so the
8
+ * handler sees it. `documentCreated` would miss every one of them, and
9
+ * `documentWritten` would add nothing except a wake-up on create, where an
10
+ * order is `pending` and there is nothing to number.
11
+ *
12
+ * 🛑 What makes a write-back from this handler safe is the allocation claim:
13
+ * it updates `orders` only when IT allocated, so the re-fire caused by its own
14
+ * write finds the claim taken and writes nothing. That is the guard Root Cause
15
+ * #92's 12M-invocation loop lacked — there the no-op check compared Firestore
16
+ * maps with `JSON.stringify`, which is key-order sensitive and could never
17
+ * match, so every invocation wrote and every write re-triggered.
18
+ */
2
19
  export const onOrderStatusChangeHandler = async (event, ctx) => {
3
20
  await handleOrderStatusChange({ orderId: event.params.orderId, before: event.before, after: event.after }, ctx);
4
21
  };
@@ -10,10 +10,33 @@
10
10
  * its own outcome decides whether the circuit re-closes or re-opens.
11
11
  */
12
12
  /**
13
- * Email is the only outbound channel with a transport. "whatsapp" was the
14
- * other arm until 2026-10-20, when the Cloud API integration was removed.
13
+ * Email is the only outbound *notification* channel with a transport.
14
+ * "whatsapp" was the other arm until 2026-10-20, when the Cloud API
15
+ * integration was removed.
16
+ *
17
+ * ## 🛑 Phase 8 widened this to the RECONCILERS' reads — and only those
18
+ *
19
+ * `razorpay_read` and `shiprocket_read` name the one placement where "stop
20
+ * hammering a dead API" is the right behaviour. Two placements are **actively
21
+ * harmful** and must never be added:
22
+ *
23
+ * 1. **NOT the webhook ingest path.** An open breaker converts a transient
24
+ * blip into a DROPPED money event — and dropping is precisely what a
25
+ * breaker does. The ingest path's whole job is to persist first and ack
26
+ * honestly so the gateway retries; a breaker there would ack a 5xx we never
27
+ * recorded.
28
+ *
29
+ * 2. **NOT around `payment.refund()`.** A refund POST that timed out may have
30
+ * SUCCEEDED, so retrying it issues two refunds — and the idempotency claim
31
+ * does not help, because it is the same call with the same key making a
32
+ * second network attempt. The two-phase reserve/settle exists so a human
33
+ * can resolve that by reading the gateway, not so a breaker can guess.
34
+ *
35
+ * The names carry `_read` for exactly that reason: a reader is idempotent, so
36
+ * backing off costs only freshness. Anything that moves money is not a reader
37
+ * and does not belong in this union.
15
38
  */
16
- export type NotificationChannel = "email";
39
+ export type NotificationChannel = "email" | "razorpay_read" | "shiprocket_read";
17
40
  /**
18
41
  * Returns false only when the circuit is open and still within its cooldown
19
42
  * window. Any other state (closed, or open-but-cooldown-elapsed) is treated
@@ -3256,6 +3256,34 @@ export const ACTIONS = {
3256
3256
  confirmLabel: "Close ticket",
3257
3257
  },
3258
3258
  },
3259
+ /*
3260
+ * Raising a dispute on an order.
3261
+ *
3262
+ * Filed under SUPPORT rather than a new ORDER bucket: there is no such
3263
+ * bucket, and a dispute's destination is a support investigation. A first
3264
+ * draft of the form wrote `ACTIONS.ORDERS?.["raise-dispute"]`, where the
3265
+ * optional chain would have silently resolved to `undefined` and rendered
3266
+ * a button with no label, no aria-label and no confirmation — which is
3267
+ * precisely what Rule #7 exists to stop.
3268
+ *
3269
+ * NO `confirmation`, deliberately. It is not destructive: nothing is
3270
+ * cancelled, no money moves, and the action's own docstring says it "does
3271
+ * not itself reverse payment/order status". A confirm dialog in front of
3272
+ * "tell us something went wrong" adds friction to the one path a buyer
3273
+ * with a problem needs to be able to take.
3274
+ */
3275
+ "raise-dispute": {
3276
+ // `report`, which IS the Flag glyph (icon-registry.ts:133). `"flag"` is
3277
+ // not a registered key — and because `iconKey` is typed `IconKey` rather
3278
+ // than `string`, that would have been a compile error rather than the
3279
+ // silent no-op it used to be.
3280
+ iconKey: "report",
3281
+ id: "support.raise-dispute",
3282
+ label: "Report this",
3283
+ ariaLabel: "Report a problem with this order",
3284
+ description: "Raise a dispute on an order for support to investigate.",
3285
+ kind: "primary",
3286
+ },
3259
3287
  },
3260
3288
  TESTER: {
3261
3289
  "save-note": {