@mohasinac/appkit 4.43.0 → 4.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
- package/dist/_internal/server/features/categories/adapters.js +235 -0
- package/dist/_internal/server/features/checkout/actions.js +161 -22
- package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/refunds/actions.js +1 -1
- package/dist/_internal/server/features/seo/sitemap.js +54 -15
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +6 -0
- package/dist/_internal/server/functions/scheduled.d.ts +4 -2
- package/dist/_internal/server/functions/scheduled.js +64 -6
- package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
- package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +54 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
- package/dist/_internal/server/jobs/handlers/index.js +3 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
- package/dist/_internal/server/jobs/handlers/messages.js +5 -2
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
- package/dist/_internal/shared/actions/action-registry.js +34 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
- package/dist/_internal/shared/checkout/order-math.js +194 -7
- package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
- package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
- package/dist/_internal/shared/fees/calculator.d.ts +105 -0
- package/dist/_internal/shared/fees/calculator.js +143 -0
- package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
- package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
- package/dist/_internal/shared/templating/placeholders.js +73 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/admin/actions/admin-actions.d.ts +1 -1
- package/dist/features/admin/actions/admin-actions.js +1 -1
- package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
- package/dist/features/admin/components/AdminProductEditorView.js +8 -1
- package/dist/features/admin/components/AdminProductsView.js +78 -7
- package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
- package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
- package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
- package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
- package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
- package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
- package/dist/features/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
- package/dist/features/categories/api/[id]/description-template/route.js +252 -0
- package/dist/features/categories/api/route.js +156 -11
- package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
- package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
- package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
- package/dist/features/categories/constants/listing-categories.d.ts +17 -0
- package/dist/features/categories/constants/listing-categories.js +53 -0
- package/dist/features/categories/hooks/useCategoryTree.js +28 -2
- package/dist/features/categories/repository/categories.repository.d.ts +62 -11
- package/dist/features/categories/repository/categories.repository.js +178 -35
- package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
- package/dist/features/categories/repository/price-index.repository.js +85 -0
- package/dist/features/categories/schemas/category-content.d.ts +206 -0
- package/dist/features/categories/schemas/category-content.js +28 -0
- package/dist/features/categories/schemas/firestore.d.ts +74 -5
- package/dist/features/categories/schemas/firestore.js +74 -3
- package/dist/features/categories/schemas/index.d.ts +1047 -0
- package/dist/features/categories/schemas/index.js +96 -0
- package/dist/features/categories/server.d.ts +1 -0
- package/dist/features/categories/server.js +7 -0
- package/dist/features/categories/types/index.d.ts +9 -0
- package/dist/features/events/schemas/firestore.d.ts +2 -2
- package/dist/features/faq/schemas/firestore.d.ts +2 -1
- package/dist/features/faq/schemas/firestore.js +20 -8
- package/dist/features/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -0
- package/dist/features/products/columns/index.d.ts +0 -1
- package/dist/features/products/columns/index.js +5 -1
- package/dist/features/products/components/ProductFilters.d.ts +0 -9
- package/dist/features/products/components/ProductForm.d.ts +27 -1
- package/dist/features/products/components/ProductForm.js +26 -12
- package/dist/features/products/constants/sieve.d.ts +0 -6
- package/dist/features/products/constants/sieve.js +35 -2
- package/dist/features/products/repository/products.repository.d.ts +115 -1
- package/dist/features/products/repository/products.repository.js +491 -39
- package/dist/features/products/schemas/firestore.d.ts +65 -5
- package/dist/features/products/schemas/firestore.js +9 -0
- package/dist/features/products/schemas/product-features.d.ts +140 -2
- package/dist/features/products/schemas/product-features.js +62 -1
- package/dist/features/promotions/repository/coupons.repository.js +6 -1
- package/dist/features/promotions/schemas/firestore.d.ts +44 -1
- package/dist/features/promotions/schemas/firestore.js +76 -2
- package/dist/features/seller/actions/seller-actions.js +17 -2
- package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
- package/dist/features/seller/components/PrintCenterView.js +3 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
- package/dist/features/seller/components/SellerOrdersView.js +6 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
- package/dist/features/seller/components/SellerProductShell.js +101 -9
- package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
- package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +108 -3
- package/dist/features/seller/types/index.d.ts +17 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
- package/dist/features/seller/utils/product-draft-mapping.js +17 -0
- package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
- package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
- package/dist/features/stores/schemas/firestore.d.ts +31 -2
- package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
- package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
- package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
- package/dist/features/tax-codes/schemas/firestore.js +82 -0
- package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
- package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
- package/dist/features/tester/actions/index.d.ts +0 -1
- package/dist/features/tester/actions/index.js +0 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
- package/dist/features/tester/components/index.d.ts +0 -4
- package/dist/features/tester/components/index.js +0 -2
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
- package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
- package/dist/features/tester/schemas/firestore.d.ts +0 -6
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
- package/dist/features/tester/seed-data/authored/index.js +0 -2
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +8 -27
- package/dist/index.d.ts +21 -7
- package/dist/index.js +53 -7
- package/dist/next/routing/route-map.d.ts +2 -4
- package/dist/next/routing/route-map.js +1 -2
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +1 -0
- package/dist/schemas/registry.d.ts +506 -0
- package/dist/seed/_helpers/brand-rows.d.ts +35 -0
- package/dist/seed/_helpers/brand-rows.js +576 -0
- package/dist/seed/_helpers/category-content.d.ts +39 -0
- package/dist/seed/_helpers/category-content.js +634 -0
- package/dist/seed/_helpers/category-forest.d.ts +2 -0
- package/dist/seed/_helpers/category-forest.js +585 -243
- package/dist/seed/_helpers/category-models.d.ts +5 -0
- package/dist/seed/_helpers/category-models.js +1364 -0
- package/dist/seed/_helpers/category-tree.js +17 -1
- package/dist/seed/_helpers/character-features.d.ts +10 -0
- package/dist/seed/_helpers/character-features.js +118 -0
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
- package/dist/seed/categories-seed-data.js +147 -169
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/manifest.d.ts +1 -1
- package/dist/seed/manifest.js +5 -0
- package/dist/seed/product-features-seed-data.d.ts +1 -8
- package/dist/seed/product-features-seed-data.js +358 -154
- package/dist/seed/products-art-seed-data.js +16 -4
- package/dist/seed/products-auctions-seed-data.js +6 -6
- package/dist/seed/products-classifieds-seed-data.js +17 -7
- package/dist/seed/products-digital-codes-seed-data.js +12 -12
- package/dist/seed/products-preorders-seed-data.js +8 -8
- package/dist/seed/products-prize-draws-seed-data.js +25 -8
- package/dist/seed/products-standard-seed-data.js +10 -60
- package/dist/seed/products-stickers-seed-data.js +6 -6
- package/dist/seed/site-settings-seed-data.js +24 -0
- package/dist/seed/store-extensions-seed-data.js +10 -4
- package/dist/seed/tax-codes-seed-data.d.ts +34 -0
- package/dist/seed/tax-codes-seed-data.js +100 -0
- package/dist/seed/test-data-ttl.d.ts +29 -0
- package/dist/seed/test-data-ttl.js +31 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +6 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +5 -2
- package/dist/styles.css +40 -0
- package/dist/ui/components/PaginatedSelect.d.ts +14 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/PaginatedSelect.style.css +40 -0
- package/dist/utils/physical-location.d.ts +23 -0
- package/dist/utils/physical-location.js +52 -0
- package/dist/utils/search-txt-builders.d.ts +20 -0
- package/dist/utils/search-txt-builders.js +36 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -1
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
- package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
- package/dist/features/tester/actions/leaderboard-actions.js +0 -5
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
- package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
- package/dist/features/tester/components/TesterHubView.d.ts +0 -5
- package/dist/features/tester/components/TesterHubView.js +0 -110
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
- package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
- package/dist/features/tester/seed-data/tester-ttl.js +0 -6
|
@@ -49,6 +49,19 @@ import type { SellerProductDraft } from "../components/SellerProductShell";
|
|
|
49
49
|
export interface StoredProductBlocks {
|
|
50
50
|
finalSale?: boolean;
|
|
51
51
|
returnPolicy?: string;
|
|
52
|
+
authenticity?: "original" | "reproduction" | "unverified";
|
|
53
|
+
specifications?: {
|
|
54
|
+
name: string;
|
|
55
|
+
value: string;
|
|
56
|
+
unit?: string;
|
|
57
|
+
}[];
|
|
58
|
+
features?: string[];
|
|
59
|
+
weightG?: number;
|
|
60
|
+
dimensionsCm?: {
|
|
61
|
+
l: number;
|
|
62
|
+
w: number;
|
|
63
|
+
h: number;
|
|
64
|
+
};
|
|
52
65
|
classified?: {
|
|
53
66
|
meetupArea?: {
|
|
54
67
|
city?: string;
|
|
@@ -120,6 +120,23 @@ export function productToDraft(product) {
|
|
|
120
120
|
*/
|
|
121
121
|
finalSale: product.finalSale,
|
|
122
122
|
returnPolicy: product.returnPolicy,
|
|
123
|
+
/*
|
|
124
|
+
* The inherited-but-editable fields (C2c). Same category as `finalSale`:
|
|
125
|
+
* flat, stored, and absent from the ten hand-written `initialValues`
|
|
126
|
+
* literals — so the Details section would open EMPTY on a listing that
|
|
127
|
+
* already carries them, and a seller saving anything else would appear to
|
|
128
|
+
* have cleared their own specifications.
|
|
129
|
+
*
|
|
130
|
+
* (The write is partial, so an untouched save does not actually clear
|
|
131
|
+
* them. But a form that shows a listing's authenticity as unanswered when
|
|
132
|
+
* it is answered is telling the seller something untrue, which is exactly
|
|
133
|
+
* the argument `finalSale` above is making.)
|
|
134
|
+
*/
|
|
135
|
+
authenticity: product.authenticity,
|
|
136
|
+
specifications: product.specifications,
|
|
137
|
+
features: product.features,
|
|
138
|
+
weightG: product.weightG,
|
|
139
|
+
dimensionsCm: product.dimensionsCm,
|
|
123
140
|
classifiedCity: classified?.meetupArea?.city,
|
|
124
141
|
classifiedLocality: classified?.meetupArea?.locality,
|
|
125
142
|
classifiedPincode: classified?.meetupArea?.pincode,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE alert-metric vocabulary — C3a.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect this closes
|
|
5
|
+
*
|
|
6
|
+
* `AnalyticsAlertDocument.metric` was `z.string()`, and the two places that
|
|
7
|
+
* produce one shared **ZERO tokens**:
|
|
8
|
+
*
|
|
9
|
+
* the seller UI offered daily_revenue · daily_orders · low_stock ·
|
|
10
|
+
* cancelled_orders · pending_payouts · avg_rating ·
|
|
11
|
+
* reviews_count (7, snake_case)
|
|
12
|
+
* the seed wrote min-stock · orders-window · api-error-rate ·
|
|
13
|
+
* reports-1h (4, kebab-case)
|
|
14
|
+
*
|
|
15
|
+
* Not a near-miss — no value in one list appears in the other. And because
|
|
16
|
+
* nothing validated the field, every seeded alert rendered its raw string in
|
|
17
|
+
* the seller's list (`METRIC_OPTIONS.find(...)?.label ?? alert.metric`) while
|
|
18
|
+
* every alert a seller created was equally unrecognised by anything else.
|
|
19
|
+
*
|
|
20
|
+
* It stayed invisible for one reason: **nothing read either value**. There was
|
|
21
|
+
* no evaluator anywhere — zero references across all 59 job files and
|
|
22
|
+
* `functions/` — so `lastTriggeredAt` was declared and written nowhere, and
|
|
23
|
+
* sellers configured alerts that could never fire.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 The rule that keeps it closed
|
|
26
|
+
*
|
|
27
|
+
* **A metric is in this map only if the evaluator computes it.** Offering one
|
|
28
|
+
* it cannot is how the original dead promise was built — a dropdown is a
|
|
29
|
+
* commitment, and `low_stock` sitting in a picker with no evaluator behind it
|
|
30
|
+
* is worse than its absence, because the seller stops watching their stock.
|
|
31
|
+
*
|
|
32
|
+
* So `ALERT_METRICS` is a `Record<AlertMetric, AlertMetricDef>`: adding a
|
|
33
|
+
* union member without a definition is a compile error, and the evaluator's
|
|
34
|
+
* own switch is exhaustive over the same union, so adding a member without an
|
|
35
|
+
* implementation is a compile error too. Root Cause #61 — a `Record<Union, T>`
|
|
36
|
+
* is compile-safe where an array literal is not, and this vocabulary existed
|
|
37
|
+
* as two array literals.
|
|
38
|
+
*/
|
|
39
|
+
/** Who may set an alert on this metric. */
|
|
40
|
+
export type AlertMetricScope = "seller" | "admin";
|
|
41
|
+
/** How to render the threshold the seller types. */
|
|
42
|
+
export type AlertMetricUnit = "count" | "currency" | "rating";
|
|
43
|
+
export interface AlertMetricDef {
|
|
44
|
+
label: string;
|
|
45
|
+
/** What the number means, so the form can suffix it honestly. */
|
|
46
|
+
unit: AlertMetricUnit;
|
|
47
|
+
/** Which portals may offer it. Seller metrics are evaluated per store. */
|
|
48
|
+
scopes: readonly AlertMetricScope[];
|
|
49
|
+
/**
|
|
50
|
+
* Plain-English statement of what is counted, shown as helper text.
|
|
51
|
+
*
|
|
52
|
+
* Carried because "Daily Orders" does not say whether a cancelled order
|
|
53
|
+
* counts, and an alert whose definition the seller has to guess at is an
|
|
54
|
+
* alert they will not trust when it fires.
|
|
55
|
+
*/
|
|
56
|
+
describes: string;
|
|
57
|
+
}
|
|
58
|
+
export declare const ALERT_METRIC_VALUES: readonly ["low_stock", "out_of_stock", "orders_count", "revenue_total", "cancelled_orders", "pending_payout_total", "avg_rating", "reviews_count", "open_reports", "pending_moderation"];
|
|
59
|
+
export type AlertMetric = (typeof ALERT_METRIC_VALUES)[number];
|
|
60
|
+
/**
|
|
61
|
+
* 🛑 `Record<AlertMetric, …>`, not a partial and not an array. A union member
|
|
62
|
+
* with no definition does not compile, and the evaluator switches over the
|
|
63
|
+
* same union exhaustively — so the picker, the evaluator and the stored value
|
|
64
|
+
* cannot drift the way the two array literals did.
|
|
65
|
+
*/
|
|
66
|
+
export declare const ALERT_METRICS: Record<AlertMetric, AlertMetricDef>;
|
|
67
|
+
/** Narrow an arbitrary stored string. */
|
|
68
|
+
export declare function isAlertMetric(value: unknown): value is AlertMetric;
|
|
69
|
+
/**
|
|
70
|
+
* The metrics one portal may offer, derived — never a second hand-written list.
|
|
71
|
+
*
|
|
72
|
+
* That is the whole defect this file closes: the seller picker WAS a
|
|
73
|
+
* hand-written list, and it agreed with nothing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function alertMetricsForScope(scope: AlertMetricScope): {
|
|
76
|
+
value: AlertMetric;
|
|
77
|
+
def: AlertMetricDef;
|
|
78
|
+
}[];
|
|
79
|
+
/**
|
|
80
|
+
* The stock level `low_stock` counts at or below.
|
|
81
|
+
*
|
|
82
|
+
* Exported so the alert, the products-list filter and the dashboard tile agree
|
|
83
|
+
* on what "low" means. Three surfaces each picking their own number is how a
|
|
84
|
+
* seller gets a warning from one and a clean bill from another.
|
|
85
|
+
*/
|
|
86
|
+
export declare const LOW_STOCK_THRESHOLD = 5;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE alert-metric vocabulary — C3a.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect this closes
|
|
5
|
+
*
|
|
6
|
+
* `AnalyticsAlertDocument.metric` was `z.string()`, and the two places that
|
|
7
|
+
* produce one shared **ZERO tokens**:
|
|
8
|
+
*
|
|
9
|
+
* the seller UI offered daily_revenue · daily_orders · low_stock ·
|
|
10
|
+
* cancelled_orders · pending_payouts · avg_rating ·
|
|
11
|
+
* reviews_count (7, snake_case)
|
|
12
|
+
* the seed wrote min-stock · orders-window · api-error-rate ·
|
|
13
|
+
* reports-1h (4, kebab-case)
|
|
14
|
+
*
|
|
15
|
+
* Not a near-miss — no value in one list appears in the other. And because
|
|
16
|
+
* nothing validated the field, every seeded alert rendered its raw string in
|
|
17
|
+
* the seller's list (`METRIC_OPTIONS.find(...)?.label ?? alert.metric`) while
|
|
18
|
+
* every alert a seller created was equally unrecognised by anything else.
|
|
19
|
+
*
|
|
20
|
+
* It stayed invisible for one reason: **nothing read either value**. There was
|
|
21
|
+
* no evaluator anywhere — zero references across all 59 job files and
|
|
22
|
+
* `functions/` — so `lastTriggeredAt` was declared and written nowhere, and
|
|
23
|
+
* sellers configured alerts that could never fire.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 The rule that keeps it closed
|
|
26
|
+
*
|
|
27
|
+
* **A metric is in this map only if the evaluator computes it.** Offering one
|
|
28
|
+
* it cannot is how the original dead promise was built — a dropdown is a
|
|
29
|
+
* commitment, and `low_stock` sitting in a picker with no evaluator behind it
|
|
30
|
+
* is worse than its absence, because the seller stops watching their stock.
|
|
31
|
+
*
|
|
32
|
+
* So `ALERT_METRICS` is a `Record<AlertMetric, AlertMetricDef>`: adding a
|
|
33
|
+
* union member without a definition is a compile error, and the evaluator's
|
|
34
|
+
* own switch is exhaustive over the same union, so adding a member without an
|
|
35
|
+
* implementation is a compile error too. Root Cause #61 — a `Record<Union, T>`
|
|
36
|
+
* is compile-safe where an array literal is not, and this vocabulary existed
|
|
37
|
+
* as two array literals.
|
|
38
|
+
*/
|
|
39
|
+
export const ALERT_METRIC_VALUES = [
|
|
40
|
+
"low_stock",
|
|
41
|
+
"out_of_stock",
|
|
42
|
+
"orders_count",
|
|
43
|
+
"revenue_total",
|
|
44
|
+
"cancelled_orders",
|
|
45
|
+
"pending_payout_total",
|
|
46
|
+
"avg_rating",
|
|
47
|
+
"reviews_count",
|
|
48
|
+
"open_reports",
|
|
49
|
+
"pending_moderation",
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* 🛑 `Record<AlertMetric, …>`, not a partial and not an array. A union member
|
|
53
|
+
* with no definition does not compile, and the evaluator switches over the
|
|
54
|
+
* same union exhaustively — so the picker, the evaluator and the stored value
|
|
55
|
+
* cannot drift the way the two array literals did.
|
|
56
|
+
*/
|
|
57
|
+
export const ALERT_METRICS = {
|
|
58
|
+
low_stock: {
|
|
59
|
+
label: "Products low on stock",
|
|
60
|
+
unit: "count",
|
|
61
|
+
scopes: ["seller", "admin"],
|
|
62
|
+
describes: "How many of your published listings have available stock at or below 5. With 76–93% of competitor catalogues sold out, restock is the main lifecycle event.",
|
|
63
|
+
},
|
|
64
|
+
out_of_stock: {
|
|
65
|
+
label: "Products out of stock",
|
|
66
|
+
unit: "count",
|
|
67
|
+
scopes: ["seller", "admin"],
|
|
68
|
+
describes: "How many published listings have zero available stock and cannot be bought.",
|
|
69
|
+
},
|
|
70
|
+
orders_count: {
|
|
71
|
+
label: "Orders in the window",
|
|
72
|
+
unit: "count",
|
|
73
|
+
scopes: ["seller", "admin"],
|
|
74
|
+
describes: "Orders placed in the window, whatever their status. Set this to `== 0` to be told when nothing is selling.",
|
|
75
|
+
},
|
|
76
|
+
revenue_total: {
|
|
77
|
+
label: "Revenue in the window",
|
|
78
|
+
unit: "currency",
|
|
79
|
+
scopes: ["seller", "admin"],
|
|
80
|
+
describes: "Goods value of delivered, paid, unrefunded orders in the window — the same base a payout is computed from, not the grand total.",
|
|
81
|
+
},
|
|
82
|
+
cancelled_orders: {
|
|
83
|
+
label: "Cancelled orders in the window",
|
|
84
|
+
unit: "count",
|
|
85
|
+
scopes: ["seller", "admin"],
|
|
86
|
+
describes: "Orders cancelled in the window.",
|
|
87
|
+
},
|
|
88
|
+
pending_payout_total: {
|
|
89
|
+
label: "Pending payout amount",
|
|
90
|
+
unit: "currency",
|
|
91
|
+
scopes: ["seller", "admin"],
|
|
92
|
+
describes: "Total of payouts not yet marked paid. Not window-scoped — a backlog is a backlog.",
|
|
93
|
+
},
|
|
94
|
+
avg_rating: {
|
|
95
|
+
label: "Average rating",
|
|
96
|
+
unit: "rating",
|
|
97
|
+
scopes: ["seller"],
|
|
98
|
+
describes: "Mean of approved reviews across the whole store. Set this to `< 4` to hear about a slide early.",
|
|
99
|
+
},
|
|
100
|
+
reviews_count: {
|
|
101
|
+
label: "New reviews in the window",
|
|
102
|
+
unit: "count",
|
|
103
|
+
scopes: ["seller"],
|
|
104
|
+
describes: "Approved reviews published in the window.",
|
|
105
|
+
},
|
|
106
|
+
open_reports: {
|
|
107
|
+
label: "Open reports",
|
|
108
|
+
unit: "count",
|
|
109
|
+
scopes: ["admin"],
|
|
110
|
+
describes: "Reports awaiting moderation. Replaces the seeded `reports-1h`, which nothing read.",
|
|
111
|
+
},
|
|
112
|
+
pending_moderation: {
|
|
113
|
+
label: "Listings pending review",
|
|
114
|
+
unit: "count",
|
|
115
|
+
scopes: ["admin"],
|
|
116
|
+
describes: "Listings in `in_review`, waiting on a moderator.",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/** Narrow an arbitrary stored string. */
|
|
120
|
+
export function isAlertMetric(value) {
|
|
121
|
+
return typeof value === "string" && ALERT_METRIC_VALUES.includes(value);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The metrics one portal may offer, derived — never a second hand-written list.
|
|
125
|
+
*
|
|
126
|
+
* That is the whole defect this file closes: the seller picker WAS a
|
|
127
|
+
* hand-written list, and it agreed with nothing.
|
|
128
|
+
*/
|
|
129
|
+
export function alertMetricsForScope(scope) {
|
|
130
|
+
return ALERT_METRIC_VALUES.filter((m) => ALERT_METRICS[m].scopes.includes(scope)).map((m) => ({
|
|
131
|
+
value: m,
|
|
132
|
+
def: ALERT_METRICS[m],
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The stock level `low_stock` counts at or below.
|
|
137
|
+
*
|
|
138
|
+
* Exported so the alert, the products-list filter and the dashboard tile agree
|
|
139
|
+
* on what "low" means. Three surfaces each picking their own number is how a
|
|
140
|
+
* seller gets a warning from one and a clean bill from another.
|
|
141
|
+
*/
|
|
142
|
+
export const LOW_STOCK_THRESHOLD = 5;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AlertMetric } from "../constants/alert-metrics";
|
|
1
2
|
import type { FirestoreDocument } from "@mohasinac/appkit";
|
|
2
3
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
3
4
|
/**
|
|
@@ -76,13 +77,30 @@ export interface AnalyticsAlertDocument extends BaseDocument {
|
|
|
76
77
|
scope: AnalyticsScope;
|
|
77
78
|
ownerId: string;
|
|
78
79
|
label: string;
|
|
79
|
-
|
|
80
|
+
/**
|
|
81
|
+
* 🛑 Was `string`, and that is what let the two producers share ZERO tokens:
|
|
82
|
+
* the seller picker offered seven snake_case values and the seed wrote four
|
|
83
|
+
* kebab-case ones, with no overlap and nothing to notice. Narrowed to the
|
|
84
|
+
* one vocabulary in `constants/alert-metrics.ts`, which the picker derives
|
|
85
|
+
* from and the evaluator switches over exhaustively.
|
|
86
|
+
*/
|
|
87
|
+
metric: AlertMetric;
|
|
80
88
|
operator: AlertOperator;
|
|
81
89
|
threshold: number;
|
|
82
90
|
windowHours: number;
|
|
83
91
|
isActive: boolean;
|
|
84
92
|
notifyChannels: Array<"in-app" | "email" | "whatsapp">;
|
|
93
|
+
/**
|
|
94
|
+
* When this alert last fired.
|
|
95
|
+
*
|
|
96
|
+
* Declared from the start and written NOWHERE until C3b, because there was
|
|
97
|
+
* no evaluator — which is also why nobody noticed the metric vocabulary was
|
|
98
|
+
* two disjoint lists. It is now the re-notify guard: an alert that is still
|
|
99
|
+
* true must not mail every run.
|
|
100
|
+
*/
|
|
85
101
|
lastTriggeredAt?: Date;
|
|
102
|
+
/** The value observed at `lastTriggeredAt`, so the message can quote it. */
|
|
103
|
+
lastObservedValue?: number;
|
|
86
104
|
}
|
|
87
105
|
export declare const ANALYTICS_ALERTS_COLLECTION: "analyticsAlerts";
|
|
88
106
|
export declare const ANALYTICS_ALERT_INDEXED_FIELDS: readonly ["scope", "ownerId", "isActive", "metric", "createdAt"];
|
|
@@ -53,9 +53,9 @@ export interface StoreDocument extends BaseDocument {
|
|
|
53
53
|
searchTxt?: string[];
|
|
54
54
|
storeSlug: string;
|
|
55
55
|
ownerId: string;
|
|
56
|
-
/**
|
|
56
|
+
/** Test-data flag — set on the shared admin-seeded test store; swept by testDataCleanup. */
|
|
57
57
|
isTestData?: boolean;
|
|
58
|
-
/** When isTestData, the cutoff after which
|
|
58
|
+
/** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
|
|
59
59
|
testDataExpiresAt?: Date;
|
|
60
60
|
storeName: string;
|
|
61
61
|
storeDescription?: string;
|
|
@@ -120,6 +120,35 @@ export interface StoreDocument extends BaseDocument {
|
|
|
120
120
|
* true. Default false — sellers must explicitly opt in.
|
|
121
121
|
*/
|
|
122
122
|
emiEnabled?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* How this seller THINKS about the prices they type. An authoring
|
|
125
|
+
* convenience, and nothing more.
|
|
126
|
+
*
|
|
127
|
+
* 🛑 **Every stored `price` in this system is tax-INCLUSIVE** — it is what
|
|
128
|
+
* the buyer pays, with GST inside it, remitted from it. No money path
|
|
129
|
+
* branches on this flag, and that is deliberate: a mode read in five places
|
|
130
|
+
* is five places to drift, which is the defect family C0/C0c spent its
|
|
131
|
+
* whole length removing.
|
|
132
|
+
*
|
|
133
|
+
* `true` (the default) — the seller types the buyer-facing price. ₹999 at 5%
|
|
134
|
+
* means the buyer pays ₹999 and the seller earns from a ₹951.43 taxable
|
|
135
|
+
* value.
|
|
136
|
+
*
|
|
137
|
+
* `false` — the seller types an EX-TAX price, and `ProductRepository`
|
|
138
|
+
* grosses it up **once, at write time**: ₹999 at 5% is stored as ₹1,048.95,
|
|
139
|
+
* so the buyer pays ₹1,048.95 and the seller earns from ₹999.00.
|
|
140
|
+
*
|
|
141
|
+
* Default `true`, which matters twice. It is the Indian B2C convention
|
|
142
|
+
* (and what worldhobbyshop asserts in its own JSON-LD via
|
|
143
|
+
* `valueAddedTaxIncluded: true`); and it means enabling GST changes no
|
|
144
|
+
* displayed price, where defaulting to `false` would raise every listing in
|
|
145
|
+
* the catalogue the moment the switch was flipped.
|
|
146
|
+
*
|
|
147
|
+
* 🛑 Flipping this does NOT re-price existing listings. The gross-up happens
|
|
148
|
+
* at write, so a seller who switches must re-save (or have re-derived) the
|
|
149
|
+
* products they want converted — see `ProductRepository`.
|
|
150
|
+
*/
|
|
151
|
+
pricesIncludeGst?: boolean;
|
|
123
152
|
whatsappConfig?: {
|
|
124
153
|
/** Seller's WhatsApp Business phone number, digits-only with country code e.g. "919876543210" */
|
|
125
154
|
phoneNumber?: string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
2
|
+
import { type TaxCodeCreateInput, type TaxCodeDocument, type TaxCodeUpdateInput } from "../schemas/firestore";
|
|
3
|
+
/**
|
|
4
|
+
* The `taxCodes` collection.
|
|
5
|
+
*
|
|
6
|
+
* Small, read-mostly and read on a hot path — `deriveTaxonomy` resolves a
|
|
7
|
+
* category's `taxCodeId` on every product create and on any update that names
|
|
8
|
+
* a taxonomy field. So the two reads below are single-document and
|
|
9
|
+
* single-field-equality shaped on purpose; neither needs a composite index.
|
|
10
|
+
*/
|
|
11
|
+
export declare class TaxCodesRepository extends BaseRepository<TaxCodeDocument> {
|
|
12
|
+
constructor();
|
|
13
|
+
list(model: SieveModel): Promise<FirebaseSieveResult<TaxCodeDocument>>;
|
|
14
|
+
/**
|
|
15
|
+
* Every active code, chapter-then-rate ordered, for the picker.
|
|
16
|
+
*
|
|
17
|
+
* Unpaginated deliberately — this is ~10 rows and will be a few dozen at
|
|
18
|
+
* most. If it ever needs paging, the picker needs `searchTxt` first, and
|
|
19
|
+
* that is a different change.
|
|
20
|
+
*/
|
|
21
|
+
listActive(): Promise<TaxCodeDocument[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Every code including inactive ones, for the admin list.
|
|
24
|
+
*
|
|
25
|
+
* 🛑 EXPLICITLY bounded, even though the collection is five rows. Two
|
|
26
|
+
* reasons: `BaseRepository.findAll()` relies on a cap several layers away
|
|
27
|
+
* (`base.ts` clamps perPage at 500), and a route that reads "all of a
|
|
28
|
+
* collection" with no visible bound is the shape that has to be re-audited
|
|
29
|
+
* every time somebody wonders whether it is safe. 200 is far above any
|
|
30
|
+
* plausible number of HSN codes and far below anything that costs.
|
|
31
|
+
*/
|
|
32
|
+
listAllBounded(): Promise<TaxCodeDocument[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a code for derivation.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 Returns `null` for an INACTIVE code as well as a missing one, and the
|
|
37
|
+
* caller must treat both the same way: leave the product's `gstRate` and
|
|
38
|
+
* `hsnCode` alone rather than writing a zero. Deactivating a code must not
|
|
39
|
+
* silently re-rate the next listing to 0% — that is indistinguishable from a
|
|
40
|
+
* deliberate exemption, which is the whole reason `gstRate` distinguishes
|
|
41
|
+
* `undefined` from `0`.
|
|
42
|
+
*/
|
|
43
|
+
findResolvable(id: string): Promise<TaxCodeDocument | null>;
|
|
44
|
+
/**
|
|
45
|
+
* Create with a DERIVED id, so the same HSN cannot exist twice.
|
|
46
|
+
*
|
|
47
|
+
* Two rows for one HSN is the failure worth preventing structurally: an
|
|
48
|
+
* admin edits one, categories point at the other, and the rate that reaches
|
|
49
|
+
* an invoice is whichever the data happens to reference. A 409 here is the
|
|
50
|
+
* right answer — the request is well-formed and the world already has it.
|
|
51
|
+
*/
|
|
52
|
+
create(input: TaxCodeCreateInput): Promise<TaxCodeDocument>;
|
|
53
|
+
update(id: string, data: TaxCodeUpdateInput): Promise<TaxCodeDocument>;
|
|
54
|
+
}
|
|
55
|
+
export declare const taxCodesRepository: TaxCodesRepository;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
2
|
+
import { DatabaseError, ConflictError, ValidationError } from "../../../errors";
|
|
3
|
+
import { BaseRepository, } from "../../../providers/db-firebase";
|
|
4
|
+
import { TAX_CODES_COLLECTION, TAX_CODE_FIELDS, TAX_CODE_SIEVE_FIELDS, createTaxCodeId, isValidHsnCode, } from "../schemas/firestore";
|
|
5
|
+
function failureMessage(prefix, error) {
|
|
6
|
+
return `${prefix}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The `taxCodes` collection.
|
|
10
|
+
*
|
|
11
|
+
* Small, read-mostly and read on a hot path — `deriveTaxonomy` resolves a
|
|
12
|
+
* category's `taxCodeId` on every product create and on any update that names
|
|
13
|
+
* a taxonomy field. So the two reads below are single-document and
|
|
14
|
+
* single-field-equality shaped on purpose; neither needs a composite index.
|
|
15
|
+
*/
|
|
16
|
+
export class TaxCodesRepository extends BaseRepository {
|
|
17
|
+
constructor() {
|
|
18
|
+
super(TAX_CODES_COLLECTION);
|
|
19
|
+
}
|
|
20
|
+
async list(model) {
|
|
21
|
+
return this.sieveQuery(model, TAX_CODE_SIEVE_FIELDS);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every active code, chapter-then-rate ordered, for the picker.
|
|
25
|
+
*
|
|
26
|
+
* Unpaginated deliberately — this is ~10 rows and will be a few dozen at
|
|
27
|
+
* most. If it ever needs paging, the picker needs `searchTxt` first, and
|
|
28
|
+
* that is a different change.
|
|
29
|
+
*/
|
|
30
|
+
async listActive() {
|
|
31
|
+
try {
|
|
32
|
+
const snap = await this.getCollection()
|
|
33
|
+
.where(TAX_CODE_FIELDS.IS_ACTIVE, "==", true)
|
|
34
|
+
.get();
|
|
35
|
+
return snap.docs
|
|
36
|
+
.map((d) => this.mapDoc(d))
|
|
37
|
+
.sort((a, b) => (a.chapter ?? "").localeCompare(b.chapter ?? "") ||
|
|
38
|
+
a.gstRate - b.gstRate ||
|
|
39
|
+
a.label.localeCompare(b.label));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
void normalizeError(error);
|
|
43
|
+
throw new DatabaseError(failureMessage("Failed to list tax codes", error));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Every code including inactive ones, for the admin list.
|
|
48
|
+
*
|
|
49
|
+
* 🛑 EXPLICITLY bounded, even though the collection is five rows. Two
|
|
50
|
+
* reasons: `BaseRepository.findAll()` relies on a cap several layers away
|
|
51
|
+
* (`base.ts` clamps perPage at 500), and a route that reads "all of a
|
|
52
|
+
* collection" with no visible bound is the shape that has to be re-audited
|
|
53
|
+
* every time somebody wonders whether it is safe. 200 is far above any
|
|
54
|
+
* plausible number of HSN codes and far below anything that costs.
|
|
55
|
+
*/
|
|
56
|
+
async listAllBounded() {
|
|
57
|
+
try {
|
|
58
|
+
const snap = await this.getCollection().limit(200).get();
|
|
59
|
+
return snap.docs
|
|
60
|
+
.map((d) => this.mapDoc(d))
|
|
61
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
void normalizeError(error);
|
|
65
|
+
throw new DatabaseError(failureMessage("Failed to list tax codes", error));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a code for derivation.
|
|
70
|
+
*
|
|
71
|
+
* 🛑 Returns `null` for an INACTIVE code as well as a missing one, and the
|
|
72
|
+
* caller must treat both the same way: leave the product's `gstRate` and
|
|
73
|
+
* `hsnCode` alone rather than writing a zero. Deactivating a code must not
|
|
74
|
+
* silently re-rate the next listing to 0% — that is indistinguishable from a
|
|
75
|
+
* deliberate exemption, which is the whole reason `gstRate` distinguishes
|
|
76
|
+
* `undefined` from `0`.
|
|
77
|
+
*/
|
|
78
|
+
async findResolvable(id) {
|
|
79
|
+
try {
|
|
80
|
+
const snap = await this.getCollection().doc(id).get();
|
|
81
|
+
if (!snap.exists)
|
|
82
|
+
return null;
|
|
83
|
+
const doc = this.mapDoc(snap);
|
|
84
|
+
return doc.isActive ? doc : null;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
void normalizeError(error);
|
|
88
|
+
throw new DatabaseError(failureMessage("Failed to read tax code", error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create with a DERIVED id, so the same HSN cannot exist twice.
|
|
93
|
+
*
|
|
94
|
+
* Two rows for one HSN is the failure worth preventing structurally: an
|
|
95
|
+
* admin edits one, categories point at the other, and the rate that reaches
|
|
96
|
+
* an invoice is whichever the data happens to reference. A 409 here is the
|
|
97
|
+
* right answer — the request is well-formed and the world already has it.
|
|
98
|
+
*/
|
|
99
|
+
async create(input) {
|
|
100
|
+
const hsn = input.hsnCode?.trim() ?? "";
|
|
101
|
+
if (hsn !== "" && !isValidHsnCode(hsn)) {
|
|
102
|
+
throw new ValidationError("An HSN code must be exactly 4, 6 or 8 digits.", { hsnCode: "Must be 4, 6 or 8 digits." });
|
|
103
|
+
}
|
|
104
|
+
if (hsn === "" && input.gstRate !== 0) {
|
|
105
|
+
throw new ValidationError("An HSN code is required for any rate above 0%.", { hsnCode: "Required unless the rate is 0%." });
|
|
106
|
+
}
|
|
107
|
+
const id = createTaxCodeId(hsn);
|
|
108
|
+
const existing = await this.getCollection().doc(id).get();
|
|
109
|
+
if (existing.exists) {
|
|
110
|
+
throw new ConflictError(`A tax code for HSN ${hsn || "(exempt)"} already exists. Edit it instead of creating a second one.`);
|
|
111
|
+
}
|
|
112
|
+
return this.createWithId(id, { ...input, hsnCode: hsn });
|
|
113
|
+
}
|
|
114
|
+
async update(id, data) {
|
|
115
|
+
/*
|
|
116
|
+
* 🛑 `hsnCode` is editable but the id is NOT re-derived from it. Changing
|
|
117
|
+
* the id would orphan every `CategoryProductDefaults.taxCodeId` pointing
|
|
118
|
+
* here — the mirror-drift shape of Root Cause #42, with an invoice at the
|
|
119
|
+
* end of it. To correct a wrong HSN: deactivate this row, create the right
|
|
120
|
+
* one, and repoint the categories.
|
|
121
|
+
*/
|
|
122
|
+
if (data.hsnCode !== undefined) {
|
|
123
|
+
const hsn = data.hsnCode.trim();
|
|
124
|
+
if (hsn !== "" && !isValidHsnCode(hsn)) {
|
|
125
|
+
throw new ValidationError("An HSN code must be exactly 4, 6 or 8 digits.", { hsnCode: "Must be 4, 6 or 8 digits." });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return super.update(id, data);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const taxCodesRepository = new TaxCodesRepository();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tax codes — Firestore schema
|
|
3
|
+
*
|
|
4
|
+
* An HSN code and its GST rate, as an ENTITY rather than two fields repeated
|
|
5
|
+
* across every category. Changing a rate becomes one edit instead of ~330.
|
|
6
|
+
*
|
|
7
|
+
* ## 🛑 The reference is for AUTHORING. The scalars stay for CORRECTNESS.
|
|
8
|
+
*
|
|
9
|
+
* `CategoryProductDefaults.taxCodeId` points here, and `deriveTaxonomy`
|
|
10
|
+
* resolves it to `ProductDocument.gstRate` / `.hsnCode` **on the product**;
|
|
11
|
+
* the order item then snapshots from the product, exactly as it already does.
|
|
12
|
+
*
|
|
13
|
+
* That layering is not redundancy. Without it, editing a tax code would
|
|
14
|
+
* retroactively change the GST on orders already invoiced — a compliance
|
|
15
|
+
* problem, not a modelling preference. It is the same reason
|
|
16
|
+
* `OrderDocumentItem` already snapshots `hsnCode`/`gstRate`.
|
|
17
|
+
*
|
|
18
|
+
* a category edit -> changes future listings
|
|
19
|
+
* a tax-code edit -> changes future derivations
|
|
20
|
+
* nothing -> rewrites a past sale
|
|
21
|
+
*
|
|
22
|
+
* ## Why this exists at all
|
|
23
|
+
*
|
|
24
|
+
* GST has never been charged on goods here. Recorded in our own source
|
|
25
|
+
* (`_internal/server/features/checkout/actions.ts`): *"0 of 40 live orders had
|
|
26
|
+
* a `gstAmount`, and the order page's Tax row had never rendered."* And
|
|
27
|
+
* `gstRate` is set on zero of the current products.
|
|
28
|
+
*
|
|
29
|
+
* id === slug, prefix "tax-".
|
|
30
|
+
*/
|
|
31
|
+
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
32
|
+
export declare const TAX_CODES_COLLECTION: "taxCodes";
|
|
33
|
+
export declare const TAX_CODE_PREFIX: "tax-";
|
|
34
|
+
/**
|
|
35
|
+
* The GST slabs. A closed union because a rate outside it is a data error, not
|
|
36
|
+
* a new option — and because `ProductDocument.gstRate` already declares this
|
|
37
|
+
* exact set, so a wider type here would not survive the derivation.
|
|
38
|
+
*/
|
|
39
|
+
export type GstRate = 0 | 5 | 12 | 18 | 28;
|
|
40
|
+
export declare const GST_RATE_VALUES: readonly [0, 5, 12, 18, 28];
|
|
41
|
+
/**
|
|
42
|
+
* HSN chapters this catalogue touches.
|
|
43
|
+
*
|
|
44
|
+
* 🛑 Only **9503** is verified. 4911 (printed cards/stickers/posters) and 9504
|
|
45
|
+
* (playing cards, board games) are the correct chapters for that merchandise
|
|
46
|
+
* and their CURRENT RATES ARE NOT CONFIRMED — the toys rates themselves moved
|
|
47
|
+
* in the 2025 revision. Verify before seeding a rate against either, because a
|
|
48
|
+
* wrong HSN on a real invoice is worse than an absent one.
|
|
49
|
+
*/
|
|
50
|
+
export type HsnChapter = "9503" | "4911" | "9504" | "exempt";
|
|
51
|
+
export interface TaxCodeDocument extends BaseDocument {
|
|
52
|
+
/** 4, 6 or 8 digits. Empty for the exemption row, which has no HSN. */
|
|
53
|
+
hsnCode: string;
|
|
54
|
+
/** Picker label, e.g. "Toys, non-electronic (HSN 9503 0020) — 5%". */
|
|
55
|
+
label: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
gstRate: GstRate;
|
|
58
|
+
/** Groups the picker. ~10 rows today, more once 4911/9504 are verified. */
|
|
59
|
+
chapter?: HsnChapter;
|
|
60
|
+
/**
|
|
61
|
+
* When the CURRENT rate took effect. Informational — it does NOT rewrite
|
|
62
|
+
* history, because history lives in the per-product and per-order-item
|
|
63
|
+
* snapshots. Recorded so an admin can tell a rate that has always been 5%
|
|
64
|
+
* from one that changed.
|
|
65
|
+
*/
|
|
66
|
+
effectiveFrom?: Date;
|
|
67
|
+
isActive: boolean;
|
|
68
|
+
/** Why this rate, and what it covers — the thing a future admin will want. */
|
|
69
|
+
notes?: string;
|
|
70
|
+
}
|
|
71
|
+
export type TaxCodeCreateInput = Omit<TaxCodeDocument, "id" | "createdAt" | "updatedAt">;
|
|
72
|
+
export declare const TAX_CODE_UPDATABLE_FIELDS: readonly ["hsnCode", "label", "description", "gstRate", "chapter", "effectiveFrom", "isActive", "notes"];
|
|
73
|
+
export type TaxCodeUpdateInput = Partial<Pick<TaxCodeDocument, (typeof TAX_CODE_UPDATABLE_FIELDS)[number]>>;
|
|
74
|
+
export declare const TAX_CODE_FIELDS: {
|
|
75
|
+
readonly ID: "id";
|
|
76
|
+
readonly HSN_CODE: "hsnCode";
|
|
77
|
+
readonly LABEL: "label";
|
|
78
|
+
readonly GST_RATE: "gstRate";
|
|
79
|
+
readonly CHAPTER: "chapter";
|
|
80
|
+
readonly IS_ACTIVE: "isActive";
|
|
81
|
+
readonly EFFECTIVE_FROM: "effectiveFrom";
|
|
82
|
+
readonly CREATED_AT: "createdAt";
|
|
83
|
+
readonly UPDATED_AT: "updatedAt";
|
|
84
|
+
};
|
|
85
|
+
export declare const TAX_CODE_SIEVE_FIELDS: {
|
|
86
|
+
readonly label: {
|
|
87
|
+
readonly canFilter: true;
|
|
88
|
+
readonly canSort: true;
|
|
89
|
+
};
|
|
90
|
+
readonly hsnCode: {
|
|
91
|
+
readonly canFilter: true;
|
|
92
|
+
readonly canSort: true;
|
|
93
|
+
};
|
|
94
|
+
readonly gstRate: {
|
|
95
|
+
readonly canFilter: true;
|
|
96
|
+
readonly canSort: true;
|
|
97
|
+
};
|
|
98
|
+
readonly chapter: {
|
|
99
|
+
readonly canFilter: true;
|
|
100
|
+
readonly canSort: false;
|
|
101
|
+
};
|
|
102
|
+
readonly isActive: {
|
|
103
|
+
readonly canFilter: true;
|
|
104
|
+
readonly canSort: false;
|
|
105
|
+
};
|
|
106
|
+
readonly createdAt: {
|
|
107
|
+
readonly canFilter: false;
|
|
108
|
+
readonly canSort: true;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Derive the document id from the HSN code.
|
|
113
|
+
*
|
|
114
|
+
* Deterministic, so a seed run is idempotent (Root Cause #25) and so the same
|
|
115
|
+
* HSN cannot be entered twice under two ids — which would give two rows one
|
|
116
|
+
* admin edits and the other serves.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createTaxCodeId(hsnCode: string): string;
|
|
119
|
+
/**
|
|
120
|
+
* India's HSN codes are 4, 6 or 8 digits. Checked rather than trusted because
|
|
121
|
+
* the inline-create path in the seller form reaches the same route as the
|
|
122
|
+
* admin editor, and a malformed code propagates onto every product derived
|
|
123
|
+
* from the category that points at it.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isValidHsnCode(hsnCode: string): boolean;
|