@mohasinac/appkit 4.61.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 (281) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/features/checkout/actions.d.ts +80 -0
  6. package/dist/_internal/server/features/checkout/actions.js +542 -26
  7. package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
  8. package/dist/_internal/server/features/invoicing/allocate.js +162 -0
  9. package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
  10. package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
  11. package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
  12. package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
  13. package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
  14. package/dist/_internal/server/features/invoicing/issue.js +202 -0
  15. package/dist/_internal/server/features/orders/actions.d.ts +8 -1
  16. package/dist/_internal/server/features/orders/actions.js +81 -8
  17. package/dist/_internal/server/features/orders/adapters.js +33 -0
  18. package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
  19. package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
  20. package/dist/_internal/server/features/points/award.js +46 -4
  21. package/dist/_internal/server/features/refunds/actions.js +26 -0
  22. package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
  23. package/dist/_internal/server/features/shipping/courier-block.js +239 -0
  24. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  25. package/dist/_internal/server/features/site-settings/adapters.js +15 -0
  26. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  27. package/dist/_internal/server/features/stores/adapters.js +51 -0
  28. package/dist/_internal/server/functions/scheduled.js +38 -1
  29. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +9 -2
  30. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
  31. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
  32. package/dist/_internal/server/jobs/core/index.d.ts +2 -1
  33. package/dist/_internal/server/jobs/core/index.js +2 -1
  34. package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
  35. package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
  36. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
  37. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +62 -0
  38. package/dist/_internal/server/jobs/core/paymentReconcile.js +87 -97
  39. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
  40. package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
  41. package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
  42. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +10 -2
  43. package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
  45. package/dist/_internal/shared/actions/action-registry.js +177 -9
  46. package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
  47. package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
  48. package/dist/_internal/shared/checkout/order-math.d.ts +145 -0
  49. package/dist/_internal/shared/checkout/order-math.js +173 -0
  50. package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
  51. package/dist/_internal/shared/checkout/refundable-base.js +13 -1
  52. package/dist/_internal/shared/checkout/split.d.ts +87 -0
  53. package/dist/_internal/shared/checkout/split.js +100 -0
  54. package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
  55. package/dist/_internal/shared/content/disclaimers.js +217 -0
  56. package/dist/_internal/shared/content/published-values.d.ts +88 -0
  57. package/dist/_internal/shared/content/published-values.js +198 -0
  58. package/dist/_internal/shared/features/admin/integrations.d.ts +109 -0
  59. package/dist/_internal/shared/features/admin/integrations.js +253 -0
  60. package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
  61. package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
  62. package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
  63. package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
  64. package/dist/_internal/shared/features/points/accepts.d.ts +36 -0
  65. package/dist/_internal/shared/features/points/accepts.js +43 -0
  66. package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
  67. package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
  68. package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
  69. package/dist/_internal/shared/fees/calculator.d.ts +33 -1
  70. package/dist/_internal/shared/fees/calculator.js +32 -2
  71. package/dist/_internal/shared/media/limits.d.ts +10 -0
  72. package/dist/_internal/shared/media/limits.js +10 -0
  73. package/dist/_internal/shared/types/action-result.js +15 -1
  74. package/dist/client/api/surface-error.js +16 -2
  75. package/dist/client/api/useApiMutation.js +17 -1
  76. package/dist/client.d.ts +12 -1
  77. package/dist/client.js +31 -1
  78. package/dist/constants/api-endpoints.d.ts +45 -0
  79. package/dist/constants/api-endpoints.js +15 -0
  80. package/dist/constants/field-names.d.ts +18 -0
  81. package/dist/constants/field-names.js +28 -0
  82. package/dist/contracts/index.d.ts +1 -1
  83. package/dist/contracts/shipping.d.ts +45 -0
  84. package/dist/contracts/shipping.js +15 -0
  85. package/dist/core/server-action.js +19 -3
  86. package/dist/errors/error-mapping.js +26 -1
  87. package/dist/features/about/components/AboutView.js +1 -1
  88. package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
  89. package/dist/features/account/components/UserOffersPanel.js +5 -3
  90. package/dist/features/account/components/UserOrdersView.js +19 -33
  91. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  92. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  93. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  94. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  95. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  96. package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
  97. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  98. package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
  99. package/dist/features/admin/components/AdminInvoicingView.js +65 -0
  100. package/dist/features/admin/components/AdminMediaView.js +36 -3
  101. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  102. package/dist/features/admin/components/AdminOffersView.js +2 -1
  103. package/dist/features/admin/components/AdminOrderEditorView.d.ts +18 -1
  104. package/dist/features/admin/components/AdminOrderEditorView.js +11 -2
  105. package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
  106. package/dist/features/admin/components/AdminOrdersView.js +67 -5
  107. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  108. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  109. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  110. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  111. package/dist/features/admin/components/OwnerQuickActions.js +24 -2
  112. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  113. package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
  114. package/dist/features/admin/constants/filter-tabs.js +6 -0
  115. package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
  116. package/dist/features/admin/hooks/useAdminListingData.js +16 -0
  117. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  118. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  119. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  120. package/dist/features/admin/repository/notification.repository.js +32 -0
  121. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  122. package/dist/features/admin/schemas/action-index-form.js +115 -0
  123. package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
  124. package/dist/features/admin/schemas/firestore.d.ts +30 -0
  125. package/dist/features/admin/schemas/firestore.js +5 -0
  126. package/dist/features/audit-log/schemas/firestore.d.ts +30 -0
  127. package/dist/features/audit-log/schemas/firestore.js +15 -0
  128. package/dist/features/auth/components/RegisterForm.js +2 -1
  129. package/dist/features/auth/permissions/constants.js +13 -0
  130. package/dist/features/cart/components/CartPriceBreakdown.d.ts +13 -0
  131. package/dist/features/cart/components/CartPriceBreakdown.js +6 -2
  132. package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
  133. package/dist/features/cart/components/PointsTenderPicker.js +15 -0
  134. package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
  135. package/dist/features/cart/components/index.d.ts +2 -0
  136. package/dist/features/cart/components/index.js +1 -0
  137. package/dist/features/cart/repository/cart.repository.d.ts +19 -0
  138. package/dist/features/cart/repository/cart.repository.js +27 -0
  139. package/dist/features/cart/schemas/firestore.d.ts +92 -0
  140. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  141. package/dist/features/contact/email.d.ts +27 -10
  142. package/dist/features/contact/email.js +20 -3
  143. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +38 -0
  144. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +80 -0
  145. package/dist/features/dispute-reasons/schemas/firestore.d.ts +64 -0
  146. package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
  147. package/dist/features/events/components/AdminEventEditorView.js +8 -6
  148. package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
  149. package/dist/features/faq/components/FAQAccordion.js +29 -4
  150. package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
  151. package/dist/features/invoicing/schemas/firestore.js +45 -0
  152. package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
  153. package/dist/features/invoicing/utils/financial-year.js +53 -0
  154. package/dist/features/jobs/actions/enqueue-job.js +18 -1
  155. package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
  156. package/dist/features/jobs/repository/jobs.repository.js +84 -1
  157. package/dist/features/jobs/schemas/firestore.d.ts +80 -0
  158. package/dist/features/jobs/schemas/firestore.js +37 -0
  159. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  160. package/dist/features/lottery/schemas/zod.d.ts +6 -6
  161. package/dist/features/media/MediaImage.js +22 -1
  162. package/dist/features/media/upload/ImageUpload.js +1 -1
  163. package/dist/features/obligations/constants/policies.js +24 -1
  164. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  165. package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
  166. package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
  167. package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
  168. package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
  169. package/dist/features/orders/components/RaiseDisputeForm.js +111 -0
  170. package/dist/features/orders/components/RefundRequestView.js +22 -4
  171. package/dist/features/orders/components/index.d.ts +4 -0
  172. package/dist/features/orders/components/index.js +2 -0
  173. package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
  174. package/dist/features/orders/constants/order-lane-tabs.js +88 -0
  175. package/dist/features/orders/repository/orders.repository.d.ts +57 -0
  176. package/dist/features/orders/repository/orders.repository.js +145 -0
  177. package/dist/features/orders/schemas/firestore.d.ts +196 -1
  178. package/dist/features/orders/schemas/index.d.ts +60 -12
  179. package/dist/features/orders/schemas/index.js +12 -0
  180. package/dist/features/orders/types/index.d.ts +26 -0
  181. package/dist/features/orders/utils/courier-block.d.ts +25 -0
  182. package/dist/features/orders/utils/courier-block.js +23 -0
  183. package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
  184. package/dist/features/payment-cases/schemas/firestore.js +1 -0
  185. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  186. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
  187. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  188. package/dist/features/products/components/ProductDetailActions.js +2 -1
  189. package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
  190. package/dist/features/products/components/ProductDetailPageView.js +2 -2
  191. package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
  192. package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
  193. package/dist/features/promotions/actions/coupon-stacking.js +21 -2
  194. package/dist/features/promotions/components/CouponCard.js +13 -0
  195. package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
  196. package/dist/features/promotions/constants/coupon-help.js +9 -1
  197. package/dist/features/promotions/schemas/firestore.d.ts +24 -0
  198. package/dist/features/promotions/schemas/firestore.js +4 -0
  199. package/dist/features/promotions/types/index.d.ts +19 -1
  200. package/dist/features/scams/components/ScamProfileView.js +2 -2
  201. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  202. package/dist/features/search/action-index/tab-entries.js +1 -1
  203. package/dist/features/seller/actions/seller-actions.js +33 -3
  204. package/dist/features/seller/components/FulfillmentView.js +3 -2
  205. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  206. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  207. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  208. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  209. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  210. package/dist/features/seller/components/SellerOrdersView.js +129 -27
  211. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  212. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  213. package/dist/features/seller/components/SellerProductsView.js +5 -1
  214. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  215. package/dist/features/seller/components/SellerShippingView.js +2 -1
  216. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  217. package/dist/features/seller/components/SellerSupportView.js +69 -2
  218. package/dist/features/seller/hooks/useSellerListingData.js +25 -0
  219. package/dist/features/shell/SectionForm.js +20 -16
  220. package/dist/features/shell/build-sections.js +14 -1
  221. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  222. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  223. package/dist/features/shipments/schemas/validation.d.ts +4 -4
  224. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +43 -0
  225. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +90 -0
  226. package/dist/features/shipping-boxes/schemas/firestore.d.ts +82 -0
  227. package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
  228. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  229. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  230. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  231. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  232. package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
  233. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
  234. package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
  235. package/dist/features/stores/schemas/firestore.d.ts +71 -0
  236. package/dist/features/stores/types/index.d.ts +19 -0
  237. package/dist/features/support/schemas/index.d.ts +6 -6
  238. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  239. package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
  240. package/dist/index.d.ts +11 -2
  241. package/dist/index.js +17 -2
  242. package/dist/next/routing/route-map.d.ts +24 -0
  243. package/dist/next/routing/route-map.js +19 -0
  244. package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
  245. package/dist/providers/shipping-shiprocket/index.js +84 -1
  246. package/dist/schemas/registry.d.ts +51 -22
  247. package/dist/schemas/sieve.d.ts +5 -5
  248. package/dist/server-entry.d.ts +2 -2
  249. package/dist/server-entry.js +8 -1
  250. package/dist/server.d.ts +11 -2
  251. package/dist/server.js +40 -2
  252. package/dist/styles.css +64 -80
  253. package/dist/tailwind-utilities.css +1 -1
  254. package/dist/tokens/themes/default-dark.js +1 -0
  255. package/dist/tokens/themes/default-light.js +1 -0
  256. package/dist/tokens/themes/required.d.ts +1 -1
  257. package/dist/tokens/themes/required.js +1 -0
  258. package/dist/tokens/tokens.css +15 -0
  259. package/dist/ui/components/Checkbox.js +10 -2
  260. package/dist/ui/components/DateInput.js +8 -1
  261. package/dist/ui/components/Disclaimer.d.ts +19 -0
  262. package/dist/ui/components/Disclaimer.js +74 -0
  263. package/dist/ui/components/Input.js +22 -2
  264. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  265. package/dist/ui/components/PaginatedSelect.js +43 -9
  266. package/dist/ui/components/Radio.js +8 -1
  267. package/dist/ui/components/Select.js +10 -2
  268. package/dist/ui/components/TagInput.d.ts +8 -1
  269. package/dist/ui/components/TagInput.js +4 -3
  270. package/dist/ui/components/Textarea.js +10 -2
  271. package/dist/ui/components/tabs-overflow.js +64 -11
  272. package/dist/ui/forms/FieldCheckbox.js +1 -1
  273. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  274. package/dist/ui/forms/FormErrorList.js +34 -1
  275. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  276. package/dist/ui/forms/apply-zod-issues.js +23 -1
  277. package/dist/ui/index.d.ts +2 -0
  278. package/dist/ui/index.js +6 -0
  279. package/dist/ui/rich-text/RichText.style.css +48 -79
  280. package/package.json +1 -1
  281. 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;
@@ -138,81 +138,7 @@ export async function reconcileStuckGatewayEvents(ctx) {
138
138
  }
139
139
  return { redispatched, escalated, reads: stuck.length };
140
140
  }
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
+ /* ──────────────── R2: the gateway has it — do we? ───────────────────────── */
216
142
  async function reconcileGatewayPayments(ctx) {
217
143
  const payment = getProviders().payment;
218
144
  let examined = 0;
@@ -298,28 +224,9 @@ async function reconcileGatewayPayments(ctx) {
298
224
  continue;
299
225
  }
300
226
  for (const order of orders) {
301
- /*
302
- * Amount check. Compared against the order's own recorded total rather
303
- * than recomputed from its lines — recomputing would re-derive the
304
- * pricing pipeline here and disagree with it the day a rate changes,
305
- * which is a sixth copy of the money rule (Root Cause #75).
306
- */
307
- const recorded = order.totalPrice ?? 0;
308
- if (Math.abs(recorded - record.amount) > MONEY_EPSILON) {
309
- amountMismatch += 1;
310
- await openAmountMismatchCase(record, order.id, recorded, ctx.now);
311
- }
312
- /*
313
- * Refund check — the gateway's `amount_refunded` against our own
314
- * non-failed refund events. `committedRefundTotal` is the same helper
315
- * the refund cap uses, so "what we think we refunded" has ONE
316
- * definition rather than a second one written here.
317
- */
318
- const ours = committedRefundTotal(order);
319
- if (Math.abs(ours - record.amountRefunded) > MONEY_EPSILON) {
320
- refundMismatch += 1;
321
- await openRefundMismatchCase(record, order.id, ours, refundableBase(order), ctx.now);
322
- }
227
+ const outcome = await reconcilePaymentAgainstOrder(record, order, ctx);
228
+ amountMismatch += outcome.amountMismatch;
229
+ refundMismatch += outcome.refundMismatch;
323
230
  }
324
231
  }
325
232
  catch (err) {
@@ -331,6 +238,89 @@ async function reconcileGatewayPayments(ctx) {
331
238
  }
332
239
  return { examined, missing, amountMismatch, refundMismatch, reads };
333
240
  }
241
+ /**
242
+ * The two per-order checks R2 runs against one gateway record.
243
+ *
244
+ * Extracted so each `openOrObserve` call is a statement in a function body
245
+ * rather than five braces deep inside `for record -> try -> for order -> if`.
246
+ * Returns the counts rather than mutating the caller's accumulators, so the
247
+ * loop above reads as a sum rather than as two side effects.
248
+ */
249
+ async function reconcilePaymentAgainstOrder(record, order, ctx) {
250
+ let amountMismatch = 0;
251
+ let refundMismatch = 0;
252
+ /*
253
+ * Amount check. Compared against the order's own recorded total rather
254
+ * than recomputed from its lines — recomputing would re-derive the
255
+ * pricing pipeline here and disagree with it the day a rate changes,
256
+ * which is a sixth copy of the money rule (Root Cause #75).
257
+ */
258
+ const recorded = order.totalPrice ?? 0;
259
+ if (Math.abs(recorded - record.amount) > MONEY_EPSILON) {
260
+ amountMismatch += 1;
261
+ await paymentCasesRepository().openOrObserve({
262
+ kind: "amount_mismatch",
263
+ subjectId: `${record.id}:${order.id}`,
264
+ severity: "error",
265
+ title: `Captured ${record.amount} but recorded ${recorded} — ${order.id}`,
266
+ detail: [
267
+ `The gateway captured a different amount from the one the order records.`,
268
+ `Either the buyer was over-charged or the order under-states what was paid.`,
269
+ "",
270
+ `Gateway: ${record.gateway}`,
271
+ `Payment: ${record.id}`,
272
+ `Order: ${order.id}`,
273
+ `Captured: ${record.amount} ${record.currency}`,
274
+ `Order total: ${recorded}`,
275
+ `Difference: ${(record.amount - recorded).toFixed(2)}`,
276
+ ].join("\n"),
277
+ entityType: "order",
278
+ entityId: order.id,
279
+ amountAtRisk: Math.abs(record.amount - recorded),
280
+ orderIds: [order.id],
281
+ gatewayPaymentId: record.id,
282
+ now: ctx.now,
283
+ });
284
+ }
285
+ /*
286
+ * Refund check — the gateway's `amount_refunded` against our own
287
+ * non-failed refund events. `committedRefundTotal` is the same helper
288
+ * the refund cap uses, so "what we think we refunded" has ONE
289
+ * definition rather than a second one written here.
290
+ */
291
+ const ours = committedRefundTotal(order);
292
+ if (Math.abs(ours - record.amountRefunded) > MONEY_EPSILON) {
293
+ refundMismatch += 1;
294
+ await paymentCasesRepository().openOrObserve({
295
+ kind: "refund_mismatch",
296
+ subjectId: `${record.id}:refunds`,
297
+ severity: record.amountRefunded > ours ? "error" : "warning",
298
+ title: `Refund total disagrees with the gateway — ${order.id}`,
299
+ detail: [
300
+ record.amountRefunded > ours
301
+ ? `🛑 The gateway has refunded MORE than our records show. Money has left that we did not record.`
302
+ : `Our records show more refunded than the gateway did — a refund may have been reserved and never completed.`,
303
+ "",
304
+ `Gateway: ${record.gateway}`,
305
+ `Payment: ${record.id}`,
306
+ `Order: ${order.id}`,
307
+ `Gateway refunded: ${record.amountRefunded}`,
308
+ `Our committed refunds: ${ours}`,
309
+ `Refundable base: ${refundableBase(order)}`,
310
+ record.refundStatus ? `Gateway refund status: ${record.refundStatus}` : null,
311
+ ]
312
+ .filter((l) => l !== null)
313
+ .join("\n"),
314
+ entityType: "order",
315
+ entityId: order.id,
316
+ amountAtRisk: Math.abs(record.amountRefunded - ours),
317
+ orderIds: [order.id],
318
+ gatewayPaymentId: record.id,
319
+ now: ctx.now,
320
+ });
321
+ }
322
+ return { amountMismatch, refundMismatch };
323
+ }
334
324
  /* ──────────────── R3: refund claimed, never settled ─────────────────────── */
335
325
  /**
336
326
  * A `refundClaims` row still `claimed` an hour later is a crash between
@@ -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>;