@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Category adapters — the single allow-list projection for every public
|
|
3
|
+
* category response.
|
|
4
|
+
*
|
|
5
|
+
* Every field of `CategoryDocument` is triaged into exactly one of
|
|
6
|
+
* `PUBLIC_CATEGORY_FIELDS` (mapped into the returned literal) or
|
|
7
|
+
* `PRIVATE_CATEGORY_FIELDS` (with a stated reason), mirroring the store and
|
|
8
|
+
* site-settings adapters. A field added to the schema and to neither list
|
|
9
|
+
* fails `scripts/audit-public-projection-parity.mjs`, so new fields are
|
|
10
|
+
* private by default.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists (2026-10-10): `GET /api/categories` returned `result.data`
|
|
13
|
+
* raw — the whole document — on an unauthenticated, hour-edge-cached route.
|
|
14
|
+
* That published `createdBy` (a uid), `createdByStoreId`, `createdByStoreName`
|
|
15
|
+
* and, worst, `metrics.productIds[]` / `metrics.auctionIds[]`: two UNBOUNDED
|
|
16
|
+
* arrays that grow with the catalogue. `CategoryInlineSelect` calls that route
|
|
17
|
+
* with `pageSize=200` on every seller-form open, so the payload scaled with
|
|
18
|
+
* both the number of categories and the number of products in each.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 There is an older `CATEGORIES_PUBLIC_FIELDS` in
|
|
21
|
+
* `features/categories/schemas/firestore.ts`. It has ZERO runtime consumers,
|
|
22
|
+
* predates `categoryType` / `highlights` / `faqs` / `itemCode` / the brand and
|
|
23
|
+
* bundle fields, and is a `.select()`-style dot-path list rather than a
|
|
24
|
+
* projection. It is deliberately not reused here; this module is the one the
|
|
25
|
+
* route actually calls.
|
|
26
|
+
*/
|
|
27
|
+
import type { CategoryDocument } from "../../../../features/categories/schemas/firestore";
|
|
28
|
+
import type { CategoryItem } from "../../../../features/categories/types/index";
|
|
29
|
+
/**
|
|
30
|
+
* Document fields that may reach an unauthenticated client. Keep in sync with
|
|
31
|
+
* the object literal in `toCategoryListItem` — the audit asserts both
|
|
32
|
+
* directions.
|
|
33
|
+
*/
|
|
34
|
+
export declare const PUBLIC_CATEGORY_FIELDS: readonly ["id", "name", "slug", "description", "rootId", "parentIds", "childrenIds", "tier", "path", "order", "isLeaf", "ancestors", "categoryType", "itemCode", "isFeatured", "featuredPriority", "seo", "display", "isActive", "highlights", "faqs", "brandWebsite", "brandCountry", "brandFounded", "metrics", "createdAt", "updatedAt"];
|
|
35
|
+
/**
|
|
36
|
+
* Fields withheld, each with the reason. Never remove one without replacing
|
|
37
|
+
* the reason — "it seemed unused" is how a field ends up public by accident.
|
|
38
|
+
*/
|
|
39
|
+
export declare const PRIVATE_CATEGORY_FIELDS: readonly ["position", "subtreeSize", "createdBy", "createdByType", "createdByStoreId", "createdByStoreName", "viewCount", "isBrand", "showOnHomepage", "isSearchable", "searchTxt", "aliases", "brandSlug", "bundleKind", "bundlePrice", "bundleStockStatus", "bundleProductIds", "bundleCategorySlugs", "bundleOriginalTotal", "bundleItemDetails", "contentBody", "descriptionTemplates", "productDefaults", "isTestData", "testDataExpiresAt", "bundleQueryRule", "bundleQueryResolvedAt"];
|
|
40
|
+
/**
|
|
41
|
+
* Project a stored category into the public shape.
|
|
42
|
+
*
|
|
43
|
+
* Built by NAMING each field, never by spreading and deleting — a deny-list
|
|
44
|
+
* publishes every field nobody thought to remove, including ones the
|
|
45
|
+
* interface does not declare (Root Cause #70).
|
|
46
|
+
*/
|
|
47
|
+
export declare function toCategoryListItem(doc: CategoryDocument): CategoryItem;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document fields that may reach an unauthenticated client. Keep in sync with
|
|
3
|
+
* the object literal in `toCategoryListItem` — the audit asserts both
|
|
4
|
+
* directions.
|
|
5
|
+
*/
|
|
6
|
+
export const PUBLIC_CATEGORY_FIELDS = [
|
|
7
|
+
"id",
|
|
8
|
+
"name",
|
|
9
|
+
"slug",
|
|
10
|
+
"description",
|
|
11
|
+
// Structure. The client builds its own tree and breadcrumbs from these.
|
|
12
|
+
"rootId",
|
|
13
|
+
"parentIds",
|
|
14
|
+
"childrenIds",
|
|
15
|
+
"tier",
|
|
16
|
+
"path",
|
|
17
|
+
"order",
|
|
18
|
+
"isLeaf",
|
|
19
|
+
"ancestors",
|
|
20
|
+
// Discriminator — public because every consumer must tell a listing
|
|
21
|
+
// category apart from a brand/bundle/sublisting row, and a plain category
|
|
22
|
+
// OMITS the field, so the test can only be done client-side.
|
|
23
|
+
"categoryType",
|
|
24
|
+
"itemCode",
|
|
25
|
+
// Presentation.
|
|
26
|
+
"isFeatured",
|
|
27
|
+
"featuredPriority",
|
|
28
|
+
"seo",
|
|
29
|
+
"display",
|
|
30
|
+
// Read by `AdminCategoriesView`'s Status column off this very route.
|
|
31
|
+
"isActive",
|
|
32
|
+
// Editorial copy — already rendered publicly by
|
|
33
|
+
// `CategoryHighlightsAndFaqSection` on the category and brand pages.
|
|
34
|
+
"highlights",
|
|
35
|
+
"faqs",
|
|
36
|
+
// Brand-row fields, rendered in the "About this brand" panel.
|
|
37
|
+
"brandWebsite",
|
|
38
|
+
"brandCountry",
|
|
39
|
+
"brandFounded",
|
|
40
|
+
// Rollup counts only — see `metrics` in the private list.
|
|
41
|
+
"metrics",
|
|
42
|
+
"createdAt",
|
|
43
|
+
"updatedAt",
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Fields withheld, each with the reason. Never remove one without replacing
|
|
47
|
+
* the reason — "it seemed unused" is how a field ends up public by accident.
|
|
48
|
+
*/
|
|
49
|
+
export const PRIVATE_CATEGORY_FIELDS = [
|
|
50
|
+
// Internal DFS coordinates. Useful only to `onCategoryWrite` and
|
|
51
|
+
// `positionsReconcile`; publishing them invites a client to depend on an
|
|
52
|
+
// ordering we renumber on every reseed.
|
|
53
|
+
"position",
|
|
54
|
+
"subtreeSize",
|
|
55
|
+
// Operator identity. `createdBy` is a raw uid.
|
|
56
|
+
"createdBy",
|
|
57
|
+
"createdByType",
|
|
58
|
+
"createdByStoreId",
|
|
59
|
+
"createdByStoreName",
|
|
60
|
+
// Analytics, not content.
|
|
61
|
+
"viewCount",
|
|
62
|
+
// @deprecated duplicate of `categoryType === "brand"`.
|
|
63
|
+
"isBrand",
|
|
64
|
+
/*
|
|
65
|
+
* The next four are withheld NOT because they are sensitive but because
|
|
66
|
+
* no client reads them off THIS route — verified by grep, per consumer:
|
|
67
|
+
*
|
|
68
|
+
* showOnHomepage — homepage curation runs server-side; `CategoryItem`
|
|
69
|
+
* declares no such field and nothing reads one.
|
|
70
|
+
* isSearchable — a query-time flag. No client branches on it.
|
|
71
|
+
* brandSlug — a bundle's own brand tag. Its two readers are
|
|
72
|
+
* `AdminBundleEditorView` (via ADMIN_ENDPOINTS.BUNDLES,
|
|
73
|
+
* a different route) and `BrandDetailPageView` (which
|
|
74
|
+
* holds the raw `CategoryDocument` server-side).
|
|
75
|
+
* bundle* — same story: every bundle surface is fed by
|
|
76
|
+
* ADMIN_ENDPOINTS.BUNDLES or by a server-side read.
|
|
77
|
+
*
|
|
78
|
+
* 🛑 If a bundle card is ever fed from `/api/categories`, promote the
|
|
79
|
+
* bundle fields HERE and add them to `CategoryItem` — do not reach for a
|
|
80
|
+
* cast at the call site, which is Root Cause #70's exact mechanism.
|
|
81
|
+
*/
|
|
82
|
+
"showOnHomepage",
|
|
83
|
+
"isSearchable",
|
|
84
|
+
/*
|
|
85
|
+
* The prefix-expanded search index. Withheld for two reasons, not one:
|
|
86
|
+
*
|
|
87
|
+
* - No client reads it. Searching is a SERVER concern — the term is pushed
|
|
88
|
+
* down as an `array-contains` on `CATEGORY_FIELDS.SEARCH_TXT`; a browser
|
|
89
|
+
* has no use for the token array.
|
|
90
|
+
* - It is 53–160 tokens per row (measured, cap 600). Publishing it would
|
|
91
|
+
* roughly double this payload on a route `CategoryInlineSelect` hits
|
|
92
|
+
* with `pageSize=200` on every seller-form open — the exact cost
|
|
93
|
+
* Root Cause #104 was about.
|
|
94
|
+
*/
|
|
95
|
+
"searchTxt",
|
|
96
|
+
/*
|
|
97
|
+
* `aliases` — B5. The alternate names a leaf is searched by ("Lost
|
|
98
|
+
* Longinus" for the row stored as "Luinor L2", plus its product code).
|
|
99
|
+
*
|
|
100
|
+
* Withheld from THIS route because nothing reads it here: the aliases exist
|
|
101
|
+
* to be folded into `searchTxt` at write time, and the matching happens
|
|
102
|
+
* server-side. `CategoryItem` declares no such field, and the only consumer
|
|
103
|
+
* of this route at scale is `CategoryInlineSelect` at `pageSize=200`, which
|
|
104
|
+
* shows a name and nothing else.
|
|
105
|
+
*
|
|
106
|
+
* 🛑 Not secret, and the distinction matters for the next person: every
|
|
107
|
+
* alias is ALREADY inferable from search results, because it is tokenised
|
|
108
|
+
* into the index this route helps query. If a detail page ever wants to
|
|
109
|
+
* render an "also known as" line — which would be genuinely useful on a
|
|
110
|
+
* named-model leaf — promote it here AND add it to `CategoryItem`, rather
|
|
111
|
+
* than casting at the call site (Root Cause #70's exact mechanism).
|
|
112
|
+
*/
|
|
113
|
+
"aliases",
|
|
114
|
+
"brandSlug",
|
|
115
|
+
"bundleKind",
|
|
116
|
+
"bundlePrice",
|
|
117
|
+
"bundleStockStatus",
|
|
118
|
+
"bundleProductIds",
|
|
119
|
+
"bundleCategorySlugs",
|
|
120
|
+
"bundleOriginalTotal",
|
|
121
|
+
"bundleItemDetails",
|
|
122
|
+
/*
|
|
123
|
+
* ── Category-owned content (B3) ──────────────────────────────────────────
|
|
124
|
+
*
|
|
125
|
+
* All three are PRIVATE on this route and that is a payload decision, not a
|
|
126
|
+
* secrecy one — they are genuinely public content, just not from here.
|
|
127
|
+
*
|
|
128
|
+
* This is the LIST projection, and `CategoryInlineSelect` calls it with
|
|
129
|
+
* `pageSize=200` on every seller-form open. `contentBody` alone is 400–900
|
|
130
|
+
* chars of HTML per row, so publishing it here would add ~180 KB to a
|
|
131
|
+
* response whose only job is to fill a picker — the Fast-Origin-Transfer
|
|
132
|
+
* shape of Root Cause #104, on the resource this project is most over on.
|
|
133
|
+
*
|
|
134
|
+
* Each has exactly one real reader and neither is this route:
|
|
135
|
+
*
|
|
136
|
+
* contentBody — the category DETAIL page, which holds the raw
|
|
137
|
+
* CategoryDocument server-side (getCategoryBySlug).
|
|
138
|
+
* descriptionTemplates — the seller form, via the narrow
|
|
139
|
+
* /api/categories/[id]/description-template
|
|
140
|
+
* endpoint (C2), one category at a time, edge-cached
|
|
141
|
+
* for an hour.
|
|
142
|
+
* productDefaults — `deriveTaxonomy`, server-side, on the product
|
|
143
|
+
* write path; plus the same narrow endpoint for the
|
|
144
|
+
* seller-facing price hint.
|
|
145
|
+
*
|
|
146
|
+
* 🛑 If a surface ever needs one of these from the list route, promote it
|
|
147
|
+
* here AND add it to `CategoryItem` AND to the literal in
|
|
148
|
+
* `toCategoryListItem` — do not cast at the call site, which is Root Cause
|
|
149
|
+
* #70's exact mechanism.
|
|
150
|
+
*/
|
|
151
|
+
"contentBody",
|
|
152
|
+
"descriptionTemplates",
|
|
153
|
+
"productDefaults",
|
|
154
|
+
// Test-data plumbing. `hidePublicTestData()` filters on these server-side;
|
|
155
|
+
// a client has no business branching on them.
|
|
156
|
+
"isTestData",
|
|
157
|
+
"testDataExpiresAt",
|
|
158
|
+
// The rule that resolves a dynamic bundle's members. Publishing it hands
|
|
159
|
+
// out the query, and the resolved `bundleProductIds` is already public.
|
|
160
|
+
"bundleQueryRule",
|
|
161
|
+
"bundleQueryResolvedAt",
|
|
162
|
+
];
|
|
163
|
+
/**
|
|
164
|
+
* Rollup counters only.
|
|
165
|
+
*
|
|
166
|
+
* 🛑 `metrics.productIds[]` and `metrics.auctionIds[]` are deliberately
|
|
167
|
+
* dropped. They are unbounded arrays — one entry per product in the category
|
|
168
|
+
* — and they were the bulk of the raw payload. Nothing client-side reads them
|
|
169
|
+
* that a query cannot answer, and a category page already fetches its own
|
|
170
|
+
* products.
|
|
171
|
+
*/
|
|
172
|
+
function toPublicMetrics(m) {
|
|
173
|
+
return {
|
|
174
|
+
productCount: m?.productCount ?? 0,
|
|
175
|
+
auctionCount: m?.auctionCount ?? 0,
|
|
176
|
+
totalProductCount: m?.totalProductCount ?? 0,
|
|
177
|
+
totalAuctionCount: m?.totalAuctionCount ?? 0,
|
|
178
|
+
totalItemCount: m?.totalItemCount ?? 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Project a stored category into the public shape.
|
|
183
|
+
*
|
|
184
|
+
* Built by NAMING each field, never by spreading and deleting — a deny-list
|
|
185
|
+
* publishes every field nobody thought to remove, including ones the
|
|
186
|
+
* interface does not declare (Root Cause #70).
|
|
187
|
+
*/
|
|
188
|
+
export function toCategoryListItem(doc) {
|
|
189
|
+
/*
|
|
190
|
+
* No `as CategoryItem` on the literal below, on purpose. The cast is what
|
|
191
|
+
* lets a shape drift silently; without it tsc checks every key, and it
|
|
192
|
+
* already caught `createdAt` being a Date where the contract wants a
|
|
193
|
+
* string.
|
|
194
|
+
*/
|
|
195
|
+
return {
|
|
196
|
+
id: doc.id,
|
|
197
|
+
name: doc.name,
|
|
198
|
+
slug: doc.slug,
|
|
199
|
+
description: doc.description,
|
|
200
|
+
rootId: doc.rootId,
|
|
201
|
+
parentIds: doc.parentIds,
|
|
202
|
+
childrenIds: doc.childrenIds,
|
|
203
|
+
tier: doc.tier,
|
|
204
|
+
path: doc.path,
|
|
205
|
+
order: doc.order,
|
|
206
|
+
isLeaf: doc.isLeaf,
|
|
207
|
+
ancestors: doc.ancestors,
|
|
208
|
+
categoryType: doc.categoryType,
|
|
209
|
+
itemCode: doc.itemCode,
|
|
210
|
+
isFeatured: doc.isFeatured,
|
|
211
|
+
featuredPriority: doc.featuredPriority,
|
|
212
|
+
seo: doc.seo,
|
|
213
|
+
display: doc.display,
|
|
214
|
+
isActive: doc.isActive,
|
|
215
|
+
highlights: doc.highlights,
|
|
216
|
+
faqs: doc.faqs,
|
|
217
|
+
brandWebsite: doc.brandWebsite,
|
|
218
|
+
brandCountry: doc.brandCountry,
|
|
219
|
+
brandFounded: doc.brandFounded,
|
|
220
|
+
metrics: toPublicMetrics(doc.metrics),
|
|
221
|
+
// 🛑 `CategoryDocument` stores Dates; `CategoryItem` declares strings —
|
|
222
|
+
// this projection is also the serialisation boundary. Converting here
|
|
223
|
+
// rather than casting is deliberate: `as unknown as CategoryItem` has no
|
|
224
|
+
// runtime effect and would have shipped raw `Timestamp`/`Date` objects
|
|
225
|
+
// into an RSC payload, which is Root Cause #70's exact mechanism.
|
|
226
|
+
createdAt: toIsoString(doc.createdAt),
|
|
227
|
+
updatedAt: toIsoString(doc.updatedAt),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Firestore hands back `Date`; the client contract is an ISO string. */
|
|
231
|
+
function toIsoString(value) {
|
|
232
|
+
if (!value)
|
|
233
|
+
return undefined;
|
|
234
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
235
|
+
}
|
|
@@ -13,6 +13,7 @@ import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../
|
|
|
13
13
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
14
14
|
import { serverLogger } from "../../../../monitoring";
|
|
15
15
|
import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
16
|
+
import { isIntraStateSupply } from "../../../shared/fees/calculator";
|
|
16
17
|
import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math";
|
|
17
18
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
18
19
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
@@ -411,16 +412,37 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
411
412
|
// unknown (digital-only carts have no shipping address).
|
|
412
413
|
let gstBreakdown;
|
|
413
414
|
if (gstSettings?.enabled && buyerState) {
|
|
414
|
-
|
|
415
|
+
/*
|
|
416
|
+
* 🛑 `isIntraStateSupply`, not `storeState === buyerState`. That raw
|
|
417
|
+
* comparison read "Karnataka" / "karnataka" / "KA" as three different
|
|
418
|
+
* states — the exact drift `constants/geo/subdivisions.ts` opens by
|
|
419
|
+
* documenting — and a store with no address resolved to `false`, i.e.
|
|
420
|
+
* silently inter-state. `null` means "cannot tell" and is logged rather
|
|
421
|
+
* than guessed; the AMOUNT is identical either way (IGST total ==
|
|
422
|
+
* CGST+SGST total), so what was wrong was the split on the invoice.
|
|
423
|
+
*/
|
|
424
|
+
const intra = isIntraStateSupply(storeState, buyerState);
|
|
425
|
+
if (intra === null) {
|
|
426
|
+
serverLogger.warn("[gst] place of supply undetermined — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
|
|
427
|
+
}
|
|
415
428
|
// Per-member, not per-line: a bundle or grouped line spans several products
|
|
416
429
|
// with several different rates, and taxing the whole line at one product's
|
|
417
430
|
// rate is wrong in both directions. Shared with previewCheckoutPricing so
|
|
418
431
|
// the figure shown and the figure charged cannot drift.
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
432
|
+
const summary = sumGroupGst(group.map(({ item }) => item), ctx.productById, intra ?? false);
|
|
433
|
+
/*
|
|
434
|
+
* The split is taken AS COMPUTED, per slice, by `calculateGst` inside
|
|
435
|
+
* `sumGroupGst`. It used to be re-derived here as
|
|
436
|
+
* `Math.round(gstAmount / 2)` — whole rupees, so ₹161.82 became
|
|
437
|
+
* 81.00/80.82 rather than 80.91/80.91, and a ₹0.52 total rounded to 0 and
|
|
438
|
+
* was then dropped off the invoice entirely by the `|| undefined` below.
|
|
439
|
+
*
|
|
440
|
+
* The guard is on EITHER base: an order of purely exempt goods has a real
|
|
441
|
+
* taxable value to report and zero tax, and skipping it left the invoice
|
|
442
|
+
* unable to reconcile.
|
|
443
|
+
*/
|
|
444
|
+
if (summary.taxableAmount > 0 || summary.exemptAmount > 0) {
|
|
445
|
+
gstBreakdown = summary;
|
|
424
446
|
}
|
|
425
447
|
}
|
|
426
448
|
const isCodLike = paymentMethod === "cod" || paymentMethod === "upi_manual" || paymentMethod === "cash";
|
|
@@ -458,7 +480,19 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
458
480
|
// The platform commission is now charged on EVERY payment method. It used to
|
|
459
481
|
// be added only on the online-payment path, so COD / UPI-manual / cash / EMI
|
|
460
482
|
// buyers silently never paid it.
|
|
461
|
-
const orderTotal =
|
|
483
|
+
const orderTotal =
|
|
484
|
+
/*
|
|
485
|
+
* 🛑 Product GST is NOT a term here, and its absence is the model.
|
|
486
|
+
*
|
|
487
|
+
* Prices are tax-INCLUSIVE: the GST is already inside `groupTotal`, backed
|
|
488
|
+
* out by `sumGroupGst` for reporting rather than added on top. A ₹999
|
|
489
|
+
* listing at 5% charges ₹999, of which ₹47.57 is tax we remit.
|
|
490
|
+
*
|
|
491
|
+
* `platformFeeGst` IS a term, and the asymmetry is correct: that is GST on
|
|
492
|
+
* OUR service fee to the seller, charged to the buyer on top of the fee —
|
|
493
|
+
* not tax on the goods.
|
|
494
|
+
*/
|
|
495
|
+
Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst + (emiSchedule?.surchargeAmount ?? 0);
|
|
462
496
|
const imageUrls = [
|
|
463
497
|
...new Set(group
|
|
464
498
|
.map(({ product }) => product.mainImage)
|
|
@@ -535,6 +569,20 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
535
569
|
shipmentProtectionAddon: !adminBypass && shipmentProtectionFee > 0 ? true : undefined,
|
|
536
570
|
shipmentProtectionFee: !adminBypass && shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
|
|
537
571
|
taxableAmount: gstBreakdown?.taxableAmount,
|
|
572
|
+
/* Exempt goods are a reportable nil-rated supply, kept OUT of
|
|
573
|
+
* `taxableAmount` so the taxed base stays the base the tax was charged on,
|
|
574
|
+
* and recorded here so the invoice's columns can reconcile. */
|
|
575
|
+
exemptAmount: gstBreakdown?.exemptAmount || undefined,
|
|
576
|
+
/* Our registration AS IT STANDS NOW. A tax invoice without the supplier's
|
|
577
|
+
* GSTIN is not a tax invoice, and the invoice page cannot read it from
|
|
578
|
+
* site settings — `gst` is in PRIVATE_SITE_FIELDS. See the schema. */
|
|
579
|
+
supplierGstin: gstSettings?.enabled ? gstSettings.gstin || undefined : undefined,
|
|
580
|
+
supplierLegalName: gstSettings?.enabled ? gstSettings.legalName || undefined : undefined,
|
|
581
|
+
supplierAddress: gstSettings?.enabled ? gstSettings.address || undefined : undefined,
|
|
582
|
+
/* Per-rate, for Rule 46. Computed by `sumGroupGst` rather than re-derived
|
|
583
|
+
* downstream — a mixed 5%+18% order cannot produce a compliant invoice
|
|
584
|
+
* from the aggregate triple alone. */
|
|
585
|
+
gstByRate: gstBreakdown?.byRate.length ? gstBreakdown.byRate : undefined,
|
|
538
586
|
/*
|
|
539
587
|
* 🛑 This must include the GST on the PLATFORM FEE, not just product GST.
|
|
540
588
|
*
|
|
@@ -985,11 +1033,18 @@ export async function previewCheckoutPricing(input) {
|
|
|
985
1033
|
const groupShipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissions);
|
|
986
1034
|
let groupGstAmount = 0;
|
|
987
1035
|
if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
|
|
988
|
-
|
|
1036
|
+
/*
|
|
1037
|
+
* `isIntraStateSupply`, matching `createOrderForGroup` exactly — this
|
|
1038
|
+
* was the same raw `===` and had to change with it, or the figure SHOWN
|
|
1039
|
+
* and the figure CHARGED would split on different state comparisons.
|
|
1040
|
+
* The total is unaffected by the answer (IGST total == CGST+SGST total),
|
|
1041
|
+
* but using one rule in both places is what keeps that true.
|
|
1042
|
+
*/
|
|
1043
|
+
const intra = isIntraStateSupply(storeState, resolvedAddress.state);
|
|
989
1044
|
// Same helper createOrderForGroup uses — the two disagreeing is the
|
|
990
1045
|
// recurring defect here (Root Cause #59), and it is the buyer seeing one
|
|
991
1046
|
// tax figure and being charged another.
|
|
992
|
-
groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById,
|
|
1047
|
+
groupGstAmount = sumGroupGst(group.map(({ item }) => item), resolvedProductById, intra ?? false).gstAmount;
|
|
993
1048
|
}
|
|
994
1049
|
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
995
1050
|
const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
@@ -1002,6 +1057,15 @@ export async function previewCheckoutPricing(input) {
|
|
|
1002
1057
|
whatsappNotifyFee: groupWhatsappFee,
|
|
1003
1058
|
giftWrapFee: groupGiftWrapFee,
|
|
1004
1059
|
shipmentProtectionFee: groupShipmentProtectionFee,
|
|
1060
|
+
/*
|
|
1061
|
+
* INFORMATIONAL — the tax already INSIDE these prices, so checkout can
|
|
1062
|
+
* render "incl. GST ₹47.57". 🛑 Deliberately NOT added to `total` below:
|
|
1063
|
+
* it was, under the old add-on-top math, and removing it from all five
|
|
1064
|
+
* money sites together is what the tax-inclusive model consists of. A
|
|
1065
|
+
* preview that still added it would show a total ₹47.57 higher than the
|
|
1066
|
+
* order it previews — the shown-vs-charged drift this field exists to
|
|
1067
|
+
* prevent, reintroduced from the other direction.
|
|
1068
|
+
*/
|
|
1005
1069
|
gstAmount: groupGstAmount,
|
|
1006
1070
|
couponDiscount: groupCouponDiscount,
|
|
1007
1071
|
total: Math.max(0, groupTotal - groupCouponDiscount) +
|
|
@@ -1009,8 +1073,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1009
1073
|
groupCodHandlingFee +
|
|
1010
1074
|
groupWhatsappFee +
|
|
1011
1075
|
groupGiftWrapFee +
|
|
1012
|
-
groupShipmentProtectionFee
|
|
1013
|
-
groupGstAmount,
|
|
1076
|
+
groupShipmentProtectionFee,
|
|
1014
1077
|
});
|
|
1015
1078
|
}
|
|
1016
1079
|
const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
|
|
@@ -1184,13 +1247,13 @@ async function refundDroppedItemsForPhonePeCheckout(input) {
|
|
|
1184
1247
|
* from the inline version this replaced.
|
|
1185
1248
|
*/
|
|
1186
1249
|
async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
1187
|
-
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, } = ctx;
|
|
1250
|
+
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, merchantOrderId, phonepeOrderId, transactionId, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, productById: phonepeProductById, buyerState, } = ctx;
|
|
1188
1251
|
const firstItem = group[0].item;
|
|
1189
1252
|
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1190
1253
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
1191
1254
|
// of re-implementing it inline — was two sequential findById calls per
|
|
1192
1255
|
// seller group here, duplicated from resolveShippingCost.
|
|
1193
|
-
const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
|
|
1256
|
+
const { shippingFee, storeOwnerId, storeState } = await resolveShippingCost(firstItem.storeId);
|
|
1194
1257
|
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
1195
1258
|
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
1196
1259
|
// Mirrors createOrderForGroup — without this the online-payment path wrote
|
|
@@ -1221,16 +1284,39 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1221
1284
|
const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
|
|
1222
1285
|
const giftWrapMessage = addons.giftWrapMessage;
|
|
1223
1286
|
const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, addons.shipmentProtectionAddon ?? false, commissionRates);
|
|
1287
|
+
/*
|
|
1288
|
+
* P-8 GST — now wired through this path. C0c defect 1.
|
|
1289
|
+
*
|
|
1290
|
+
* The comment that used to sit here deferred it, correctly identifying the
|
|
1291
|
+
* blocker: `expectedPaymentAmountRs` compares against what PhonePe already
|
|
1292
|
+
* captured, so adding product GST here WITHOUT adding it to the upstream
|
|
1293
|
+
* pre-payment amount would either fail the mismatch check or silently
|
|
1294
|
+
* under-charge. Both halves move in this change, plus the create-order route
|
|
1295
|
+
* that decides the captured amount.
|
|
1296
|
+
*
|
|
1297
|
+
* 🛑 What unblocked it: the GST TOTAL does not depend on the place of
|
|
1298
|
+
* supply. IGST total equals CGST+SGST total, so `/api/payment/create-order`
|
|
1299
|
+
* can compute the amount before the delivery address is even known — which
|
|
1300
|
+
* is the thing it genuinely cannot see. Measured across ~285k rate/base
|
|
1301
|
+
* combinations, the two differ by at most ₹0.01 (from `round(half × 2)` in
|
|
1302
|
+
* the intra branch), and the guard's tolerance is ₹1.
|
|
1303
|
+
*
|
|
1304
|
+
* Only the SPLIT needs the address, and this function has it.
|
|
1305
|
+
*/
|
|
1306
|
+
let phonepeGst;
|
|
1307
|
+
if (siteSettings?.gst?.enabled && buyerState) {
|
|
1308
|
+
const intra = isIntraStateSupply(storeState, buyerState);
|
|
1309
|
+
if (intra === null) {
|
|
1310
|
+
serverLogger.warn("[gst] place of supply undetermined on the online path — defaulting to inter-state (IGST)", { storeId: firstItem.storeId, hasStoreState: !!storeState });
|
|
1311
|
+
}
|
|
1312
|
+
const summary = sumGroupGst(group.map(({ item }) => item), phonepeProductById, intra ?? false);
|
|
1313
|
+
if (summary.taxableAmount > 0 || summary.exemptAmount > 0)
|
|
1314
|
+
phonepeGst = summary;
|
|
1315
|
+
}
|
|
1316
|
+
/* Product GST is not a term — prices are tax-inclusive, so it is already
|
|
1317
|
+
* inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
|
|
1318
|
+
* stays because that is GST on our fee, charged on top. */
|
|
1224
1319
|
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst;
|
|
1225
|
-
// P-8 GST — deliberately NOT wired into this PhonePe-verify path. The
|
|
1226
|
-
// amount-mismatch check above (expectedPaymentAmountRs) compares against
|
|
1227
|
-
// what the buyer already paid via the PhonePe order created earlier in
|
|
1228
|
-
// the flow; adding product GST here without also adding it to that
|
|
1229
|
-
// upstream pre-payment amount calculation would either fail the mismatch
|
|
1230
|
-
// check or silently under/over-charge. Wiring GST through the full
|
|
1231
|
-
// PhonePe create→confirm round-trip is separate follow-up work, tracked
|
|
1232
|
-
// alongside P-13 (PhonePe is disabled by default today, so this order
|
|
1233
|
-
// type doesn't currently carry a GST breakdown).
|
|
1234
1320
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
1235
1321
|
// Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
|
|
1236
1322
|
// the price rule (`isBundle ? item.price : product.price`) that — like the
|
|
@@ -1311,6 +1397,26 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1311
1397
|
giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
|
|
1312
1398
|
shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
|
|
1313
1399
|
shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
|
|
1400
|
+
/*
|
|
1401
|
+
* The tax breakdown, matching `createOrderForGroup` field for field.
|
|
1402
|
+
* This path recorded NONE of it before C0c defect 1 — so an online order
|
|
1403
|
+
* carried no `taxableAmount`, no `gstAmount` and no split, and the order
|
|
1404
|
+
* page's Tax row could not render even once GST was enabled. Both the
|
|
1405
|
+
* product GST and the platform-fee GST are summed into `gstAmount`, which
|
|
1406
|
+
* is what makes the order's own lines reconcile to its total.
|
|
1407
|
+
*/
|
|
1408
|
+
taxableAmount: phonepeGst?.taxableAmount,
|
|
1409
|
+
exemptAmount: phonepeGst?.exemptAmount || undefined,
|
|
1410
|
+
// Our registration at the moment of supply — see the schema. Mirrors the
|
|
1411
|
+
// COD/UPI path so both produce an invoiceable order.
|
|
1412
|
+
supplierGstin: siteSettings?.gst?.enabled ? siteSettings.gst.gstin || undefined : undefined,
|
|
1413
|
+
supplierLegalName: siteSettings?.gst?.enabled ? siteSettings.gst.legalName || undefined : undefined,
|
|
1414
|
+
supplierAddress: siteSettings?.gst?.enabled ? siteSettings.gst.address || undefined : undefined,
|
|
1415
|
+
gstByRate: phonepeGst?.byRate.length ? phonepeGst.byRate : undefined,
|
|
1416
|
+
gstAmount: (phonepeGst?.gstAmount ?? 0) + platformFeeGst || undefined,
|
|
1417
|
+
cgst: phonepeGst?.cgst || undefined,
|
|
1418
|
+
sgst: phonepeGst?.sgst || undefined,
|
|
1419
|
+
igst: phonepeGst?.igst || undefined,
|
|
1314
1420
|
couponCode: appliedDiscounts[0]?.code,
|
|
1315
1421
|
couponDiscount: couponDiscount > 0 ? couponDiscount : undefined,
|
|
1316
1422
|
appliedDiscounts: appliedDiscounts.length > 0 ? appliedDiscounts : undefined,
|
|
@@ -1563,6 +1669,35 @@ export async function verifyAndPlacePhonePeOrderAction(input) {
|
|
|
1563
1669
|
computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
|
|
1564
1670
|
computeShipmentProtectionFee(groupSubtotal, addons.shipmentProtectionAddon ?? false, commissionRates));
|
|
1565
1671
|
}, 0);
|
|
1672
|
+
/*
|
|
1673
|
+
* Product GST, matching what `/api/payment/create-order` charged.
|
|
1674
|
+
*
|
|
1675
|
+
* 🛑 This term's ABSENCE was half of C0c defect 1. The guard agreed with
|
|
1676
|
+
* the create path only because both omitted it, so an undercharge could
|
|
1677
|
+
* never trip the check — the two were consistently wrong together, which
|
|
1678
|
+
* is the worst available state for a guard.
|
|
1679
|
+
*
|
|
1680
|
+
* `intraState: false` is deliberate and is not a guess about the buyer's
|
|
1681
|
+
* address: the TOTAL does not depend on the place of supply (IGST total ==
|
|
1682
|
+
* CGST+SGST total), measured to within ₹0.01 across ~285k rate/base
|
|
1683
|
+
* combinations, and this comparison already carries a ₹1 tolerance.
|
|
1684
|
+
* Only the split needs the address, and the split is not checked here.
|
|
1685
|
+
*/
|
|
1686
|
+
/*
|
|
1687
|
+
* 🛑 No product-GST term, and the reason is the tax-inclusive model rather
|
|
1688
|
+
* than the omission this guard was fixed for an hour earlier.
|
|
1689
|
+
*
|
|
1690
|
+
* Defect 1's fix added `expectedProductGst` here, because the three money
|
|
1691
|
+
* sites had to AGREE with the preview and the preview added the tax. The
|
|
1692
|
+
* model then changed: every price carries its tax inside, so the correct
|
|
1693
|
+
* shared value became zero-added — at the capture route, at both order
|
|
1694
|
+
* paths, at the preview, and here. The agreement defect 1 established is
|
|
1695
|
+
* preserved; what changed is the figure all five agree on.
|
|
1696
|
+
*
|
|
1697
|
+
* `cartSubtotalRs` therefore already contains the GST, and adding it again
|
|
1698
|
+
* would make this guard demand ~₹580 more than the buyer owes on a
|
|
1699
|
+
* ₹5,547 cart — rejecting every correctly-charged order.
|
|
1700
|
+
*/
|
|
1566
1701
|
const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedAddonFees + expectedShippingFee;
|
|
1567
1702
|
// Reuses the order already fetched above to confirm COMPLETED — PhonePe's
|
|
1568
1703
|
// own amount is in its wire-format smallest unit. // audit-money-units-ok: describes the PhonePe boundary conversion this line performs, not our storage convention
|
|
@@ -1611,6 +1746,10 @@ export async function verifyAndPlacePhonePeOrderAction(input) {
|
|
|
1611
1746
|
phonepeOrderId: phonepeOrder.gatewayOrderId,
|
|
1612
1747
|
transactionId: phonepeOrder.transactionId,
|
|
1613
1748
|
shippingAddress,
|
|
1749
|
+
// For the CGST/SGST vs IGST split. The TOTAL does not depend on it
|
|
1750
|
+
// (see createPhonePeGroupOrder), which is what let the pre-payment
|
|
1751
|
+
// amount be computed before this address was known.
|
|
1752
|
+
buyerState: resolvedAddressPp?.state,
|
|
1614
1753
|
notes,
|
|
1615
1754
|
outOfStockPolicy,
|
|
1616
1755
|
unavailablePaid,
|
|
@@ -50,6 +50,29 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
50
50
|
// through `isFinalSale` so this is always an explicit boolean, even
|
|
51
51
|
// though the product field is optional and absent means true.
|
|
52
52
|
finalSale: isFinalSale(representative),
|
|
53
|
+
/*
|
|
54
|
+
* 🛑 The LEAF category, snapshotted — beside `hsnCode`, `gstRate` and
|
|
55
|
+
* `finalSale`, and for the same reason the first two are here.
|
|
56
|
+
*
|
|
57
|
+
* `OrderDocumentItem` carried no taxonomy field at all (`bundleCategorySlug`
|
|
58
|
+
* is a bundle key, not a category), so `priceIndexRollup` could only find
|
|
59
|
+
* a sale's category by joining every order item back to its product — a
|
|
60
|
+
* full products scan per run, and nothing at all for an item whose
|
|
61
|
+
* product has since been deleted.
|
|
62
|
+
*
|
|
63
|
+
* Snapshotting makes the join disappear for every future order. And a
|
|
64
|
+
* snapshot is the CORRECT answer rather than merely the cheap one: a
|
|
65
|
+
* product re-categorised next year must not retroactively move last
|
|
66
|
+
* year's sale into a band it was never part of.
|
|
67
|
+
*
|
|
68
|
+
* `categorySlugs[0]` is the leaf — the chain is stored self-first — and
|
|
69
|
+
* a sale rolls up to exactly one leaf, so there is nothing to
|
|
70
|
+
* double-count.
|
|
71
|
+
*/
|
|
72
|
+
...(representative.categorySlugs?.[0]
|
|
73
|
+
? { categorySlug: representative.categorySlugs[0] }
|
|
74
|
+
: {}),
|
|
75
|
+
...(representative.listingType ? { listingType: representative.listingType } : {}),
|
|
53
76
|
// Legacy collapsed bundle rows (no groupMembers) keep their old shape so
|
|
54
77
|
// orders already in flight and their UI stay untouched.
|
|
55
78
|
...(item.bundleCategorySlug && item.bundleProductIds
|
|
@@ -88,6 +111,17 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
88
111
|
// Kept on bundle rows so the pre-existing receipt grouping (which keys on
|
|
89
112
|
// this) keeps working for bundles without any UI change.
|
|
90
113
|
...(item.bundleCategorySlug ? { bundleCategorySlug: item.bundleCategorySlug } : {}),
|
|
114
|
+
/*
|
|
115
|
+
* Per MEMBER, like `finalSale` and the GST pair above it — a bundle
|
|
116
|
+
* genuinely spans categories, and attributing every member's sale to the
|
|
117
|
+
* bundle's own leaf would put a ₹1,299 bey's realised price into a
|
|
118
|
+
* sticker leaf's band.
|
|
119
|
+
*
|
|
120
|
+
* Falls back to the cart-line member snapshot when the product has since
|
|
121
|
+
* been deleted, which is the whole reason that snapshot exists.
|
|
122
|
+
*/
|
|
123
|
+
...(product?.categorySlugs?.[0] ? { categorySlug: product.categorySlugs[0] } : {}),
|
|
124
|
+
...(product?.listingType ? { listingType: product.listingType } : {}),
|
|
91
125
|
...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
|
|
92
126
|
};
|
|
93
127
|
});
|
|
@@ -60,6 +60,12 @@ export function orderDocumentToOrder(doc) {
|
|
|
60
60
|
// cannot tell a final-sale line from a returnable one and offers
|
|
61
61
|
// every reason on every order (Root Cause #57).
|
|
62
62
|
...(item.finalSale != null ? { finalSale: item.finalSale } : {}),
|
|
63
|
+
// Snapshotted tax terms. Without these the invoice cannot render an
|
|
64
|
+
// HSN or a rate column — the SAME omission as `finalSale` above, one
|
|
65
|
+
// field family over, and the reason the buyer-facing invoice showed
|
|
66
|
+
// only Item / Qty / Price (Root Cause #57).
|
|
67
|
+
...(item.hsnCode ? { hsnCode: item.hsnCode } : {}),
|
|
68
|
+
...(item.gstRate != null ? { gstRate: item.gstRate } : {}),
|
|
63
69
|
}))
|
|
64
70
|
: [
|
|
65
71
|
{
|
|
@@ -159,6 +165,21 @@ export function orderDocumentToOrder(doc) {
|
|
|
159
165
|
* GST-compliance problem, not a cosmetic one.
|
|
160
166
|
*/
|
|
161
167
|
tax: doc.gstAmount,
|
|
168
|
+
/*
|
|
169
|
+
* The rest of the breakdown. `tax` alone was all this adapter mapped, so
|
|
170
|
+
* the invoice had one aggregate "Tax (GST)" row and no way to show a
|
|
171
|
+
* CGST/SGST vs IGST split, a taxable value, or a per-rate table — i.e. it
|
|
172
|
+
* could not be Rule 46-compliant no matter how it was rendered.
|
|
173
|
+
*/
|
|
174
|
+
taxableAmount: doc.taxableAmount,
|
|
175
|
+
exemptAmount: doc.exemptAmount,
|
|
176
|
+
cgst: doc.cgst,
|
|
177
|
+
sgst: doc.sgst,
|
|
178
|
+
igst: doc.igst,
|
|
179
|
+
gstByRate: doc.gstByRate,
|
|
180
|
+
supplierGstin: doc.supplierGstin,
|
|
181
|
+
supplierLegalName: doc.supplierLegalName,
|
|
182
|
+
supplierAddress: doc.supplierAddress,
|
|
162
183
|
codHandlingFee: doc.codHandlingFee,
|
|
163
184
|
trackingNumber: doc.trackingNumber,
|
|
164
185
|
shippingCarrier: doc.shippingCarrier,
|
|
@@ -19,7 +19,7 @@ import { randomUUID } from "crypto";
|
|
|
19
19
|
import { getProviders } from "../../../../contracts/registry";
|
|
20
20
|
import { normalizeError } from "../../../../errors/normalize";
|
|
21
21
|
import { rupeesToPaise } from "../../../../core/money";
|
|
22
|
-
import { orderRepository } from "
|
|
22
|
+
import { orderRepository } from "../../../../features/orders/repository/orders.repository";
|
|
23
23
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
24
24
|
import { NotFoundError, ValidationError } from "../../../../errors";
|
|
25
25
|
import { serverLogger } from "../../../../monitoring";
|