@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/checkout/actions.d.ts +80 -0
- package/dist/_internal/server/features/checkout/actions.js +542 -26
- package/dist/_internal/server/features/invoicing/allocate.d.ts +63 -0
- package/dist/_internal/server/features/invoicing/allocate.js +162 -0
- package/dist/_internal/server/features/invoicing/credit-note.d.ts +57 -0
- package/dist/_internal/server/features/invoicing/credit-note.js +126 -0
- package/dist/_internal/server/features/invoicing/gstr1.d.ts +74 -0
- package/dist/_internal/server/features/invoicing/gstr1.js +251 -0
- package/dist/_internal/server/features/invoicing/issue.d.ts +96 -0
- package/dist/_internal/server/features/invoicing/issue.js +202 -0
- package/dist/_internal/server/features/orders/actions.d.ts +8 -1
- package/dist/_internal/server/features/orders/actions.js +81 -8
- package/dist/_internal/server/features/orders/adapters.js +33 -0
- package/dist/_internal/server/features/orders/cancellation-refund.d.ts +15 -9
- package/dist/_internal/server/features/orders/cancellation-refund.js +15 -9
- package/dist/_internal/server/features/points/award.js +46 -4
- package/dist/_internal/server/features/refunds/actions.js +26 -0
- package/dist/_internal/server/features/shipping/courier-block.d.ts +69 -0
- package/dist/_internal/server/features/shipping/courier-block.js +239 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +15 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +51 -0
- package/dist/_internal/server/functions/scheduled.js +38 -1
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +9 -2
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +4 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +19 -2
- package/dist/_internal/server/jobs/core/index.d.ts +2 -1
- package/dist/_internal/server/jobs/core/index.js +2 -1
- package/dist/_internal/server/jobs/core/onJobCreated.d.ts +30 -7
- package/dist/_internal/server/jobs/core/onJobCreated.js +78 -45
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +7 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +62 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +33 -0
- package/dist/_internal/server/jobs/core/runJobOnce.d.ts +48 -0
- package/dist/_internal/server/jobs/core/runJobOnce.js +195 -0
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +10 -2
- package/dist/_internal/server/jobs/handlers/onOrderStatusChange.js +17 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +26 -3
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/checkout/coupon-discount.d.ts +43 -1
- package/dist/_internal/shared/checkout/coupon-discount.js +26 -1
- package/dist/_internal/shared/checkout/order-math.d.ts +145 -0
- package/dist/_internal/shared/checkout/order-math.js +173 -0
- package/dist/_internal/shared/checkout/refundable-base.d.ts +25 -0
- package/dist/_internal/shared/checkout/refundable-base.js +13 -1
- package/dist/_internal/shared/checkout/split.d.ts +87 -0
- package/dist/_internal/shared/checkout/split.js +100 -0
- package/dist/_internal/shared/content/disclaimers.d.ts +88 -0
- package/dist/_internal/shared/content/disclaimers.js +217 -0
- package/dist/_internal/shared/content/published-values.d.ts +88 -0
- package/dist/_internal/shared/content/published-values.js +198 -0
- package/dist/_internal/shared/features/admin/integrations.d.ts +109 -0
- package/dist/_internal/shared/features/admin/integrations.js +253 -0
- package/dist/_internal/shared/features/orders/dispute-reasons.d.ts +113 -0
- package/dist/_internal/shared/features/orders/dispute-reasons.js +181 -0
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +1 -1
- package/dist/_internal/shared/features/orders/refund-copy.js +18 -1
- package/dist/_internal/shared/features/points/accepts.d.ts +36 -0
- package/dist/_internal/shared/features/points/accepts.js +43 -0
- package/dist/_internal/shared/features/shipping/shared-purchase.d.ts +91 -0
- package/dist/_internal/shared/features/shipping/shared-purchase.js +108 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +11 -0
- package/dist/_internal/shared/fees/calculator.d.ts +33 -1
- package/dist/_internal/shared/fees/calculator.js +32 -2
- package/dist/client.d.ts +11 -0
- package/dist/client.js +30 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/constants/field-names.d.ts +1 -0
- package/dist/constants/field-names.js +5 -0
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/shipping.d.ts +45 -0
- package/dist/contracts/shipping.js +15 -0
- package/dist/features/about/components/AboutView.js +1 -1
- package/dist/features/account/components/BuyerAuctionsGuideView.js +4 -1
- package/dist/features/account/components/UserOrdersView.js +19 -33
- package/dist/features/admin/components/AdminCouponEditorView.js +17 -0
- package/dist/features/admin/components/AdminInvoicingView.d.ts +2 -0
- package/dist/features/admin/components/AdminInvoicingView.js +65 -0
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +18 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +11 -2
- package/dist/features/admin/components/AdminOrdersGuideView.js +23 -2
- package/dist/features/admin/components/AdminOrdersView.js +67 -5
- package/dist/features/admin/components/OwnerQuickActions.js +24 -2
- package/dist/features/admin/constants/filter-tabs.d.ts +6 -0
- package/dist/features/admin/constants/filter-tabs.js +6 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +1 -1
- package/dist/features/admin/hooks/useAdminListingData.js +16 -0
- package/dist/features/admin/schemas/admin-user-form.d.ts +2 -2
- package/dist/features/admin/schemas/firestore.d.ts +30 -0
- package/dist/features/admin/schemas/firestore.js +5 -0
- package/dist/features/audit-log/schemas/firestore.d.ts +30 -0
- package/dist/features/audit-log/schemas/firestore.js +15 -0
- package/dist/features/auth/permissions/constants.js +13 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +13 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +6 -2
- package/dist/features/cart/components/PointsTenderPicker.d.ts +61 -0
- package/dist/features/cart/components/PointsTenderPicker.js +15 -0
- package/dist/features/cart/components/StoreAddonsPicker.js +2 -1
- package/dist/features/cart/components/index.d.ts +2 -0
- package/dist/features/cart/components/index.js +1 -0
- package/dist/features/cart/repository/cart.repository.d.ts +19 -0
- package/dist/features/cart/repository/cart.repository.js +27 -0
- package/dist/features/cart/schemas/firestore.d.ts +92 -0
- package/dist/features/contact/email.d.ts +27 -10
- package/dist/features/contact/email.js +20 -3
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +38 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +80 -0
- package/dist/features/dispute-reasons/schemas/firestore.d.ts +64 -0
- package/dist/features/dispute-reasons/schemas/firestore.js +40 -0
- package/dist/features/faq/components/FAQAccordion.d.ts +13 -1
- package/dist/features/faq/components/FAQAccordion.js +29 -4
- package/dist/features/invoicing/schemas/firestore.d.ts +112 -0
- package/dist/features/invoicing/schemas/firestore.js +45 -0
- package/dist/features/invoicing/utils/financial-year.d.ts +43 -0
- package/dist/features/invoicing/utils/financial-year.js +53 -0
- package/dist/features/jobs/actions/enqueue-job.js +18 -1
- package/dist/features/jobs/repository/jobs.repository.d.ts +62 -1
- package/dist/features/jobs/repository/jobs.repository.js +84 -1
- package/dist/features/jobs/schemas/firestore.d.ts +80 -0
- package/dist/features/jobs/schemas/firestore.js +37 -0
- package/dist/features/lottery/schemas/zod.d.ts +6 -6
- package/dist/features/media/MediaImage.js +22 -1
- package/dist/features/media/upload/ImageUpload.js +1 -1
- package/dist/features/obligations/constants/policies.js +24 -1
- package/dist/features/orders/components/OrderPaymentSummary.js +10 -1
- package/dist/features/orders/components/OrderProvenanceCard.d.ts +8 -0
- package/dist/features/orders/components/OrderProvenanceCard.js +185 -0
- package/dist/features/orders/components/RaiseDisputeForm.d.ts +14 -0
- package/dist/features/orders/components/RaiseDisputeForm.js +111 -0
- package/dist/features/orders/components/RefundRequestView.js +19 -1
- package/dist/features/orders/components/index.d.ts +4 -0
- package/dist/features/orders/components/index.js +2 -0
- package/dist/features/orders/constants/order-lane-tabs.d.ts +69 -0
- package/dist/features/orders/constants/order-lane-tabs.js +88 -0
- package/dist/features/orders/repository/orders.repository.d.ts +57 -0
- package/dist/features/orders/repository/orders.repository.js +145 -0
- package/dist/features/orders/schemas/firestore.d.ts +196 -1
- package/dist/features/orders/schemas/index.d.ts +54 -6
- package/dist/features/orders/schemas/index.js +12 -0
- package/dist/features/orders/types/index.d.ts +26 -0
- package/dist/features/orders/utils/courier-block.d.ts +25 -0
- package/dist/features/orders/utils/courier-block.js +23 -0
- package/dist/features/payment-cases/schemas/firestore.d.ts +26 -1
- package/dist/features/payment-cases/schemas/firestore.js +1 -0
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -2
- package/dist/features/products/components/ProductDetailPageView.d.ts +17 -1
- package/dist/features/products/components/ProductDetailPageView.js +2 -2
- package/dist/features/products/schemas/product-features.validators.d.ts +10 -10
- package/dist/features/promotions/actions/coupon-stacking.d.ts +30 -3
- package/dist/features/promotions/actions/coupon-stacking.js +21 -2
- package/dist/features/promotions/components/CouponCard.js +13 -0
- package/dist/features/promotions/constants/coupon-help.d.ts +2 -2
- package/dist/features/promotions/constants/coupon-help.js +9 -1
- package/dist/features/promotions/schemas/firestore.d.ts +24 -0
- package/dist/features/promotions/schemas/firestore.js +4 -0
- package/dist/features/promotions/types/index.d.ts +19 -1
- package/dist/features/scams/components/ScamProfileView.js +2 -2
- package/dist/features/scams/components/ScamRegistryView.js +2 -2
- package/dist/features/search/action-index/tab-entries.js +1 -1
- package/dist/features/seller/actions/seller-actions.js +33 -3
- package/dist/features/seller/components/SellerOrdersView.js +129 -27
- package/dist/features/seller/hooks/useSellerListingData.js +25 -0
- package/dist/features/shipments/schemas/validation.d.ts +4 -4
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +43 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +90 -0
- package/dist/features/shipping-boxes/schemas/firestore.d.ts +82 -0
- package/dist/features/shipping-boxes/schemas/firestore.js +53 -0
- package/dist/features/store-extensions/schemas/report-create-form.d.ts +2 -2
- package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +2 -2
- package/dist/features/stores/components/StoreOrdersGuideView.js +6 -1
- package/dist/features/stores/schemas/firestore.d.ts +71 -0
- package/dist/features/stores/types/index.d.ts +19 -0
- package/dist/features/support/schemas/index.d.ts +6 -6
- package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +14 -1
- package/dist/next/routing/route-map.d.ts +20 -0
- package/dist/next/routing/route-map.js +10 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +27 -1
- package/dist/providers/shipping-shiprocket/index.js +84 -1
- package/dist/schemas/registry.d.ts +47 -18
- package/dist/schemas/sieve.d.ts +2 -2
- package/dist/server-entry.d.ts +2 -2
- package/dist/server-entry.js +8 -1
- package/dist/server.d.ts +10 -1
- package/dist/server.js +39 -1
- package/dist/styles.css +64 -80
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/default-dark.js +1 -0
- package/dist/tokens/themes/default-light.js +1 -0
- package/dist/tokens/themes/required.d.ts +1 -1
- package/dist/tokens/themes/required.js +1 -0
- package/dist/tokens/tokens.css +15 -0
- package/dist/ui/components/Disclaimer.d.ts +19 -0
- package/dist/ui/components/Disclaimer.js +74 -0
- package/dist/ui/components/tabs-overflow.js +64 -11
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +6 -0
- package/dist/ui/rich-text/RichText.style.css +48 -79
- package/package.json +1 -1
- 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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
14
|
-
* other arm until 2026-10-20, when the Cloud API
|
|
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": {
|