@open-mercato/core 0.6.7-develop.6751.1.ac823a3d26 → 0.6.7-develop.6758.1.697eade236

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/route.js +16 -2
  3. package/dist/modules/attachments/api/route.js.map +2 -2
  4. package/dist/modules/attachments/lib/upload-limits.js +33 -0
  5. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/currencies/di.js +4 -0
  9. package/dist/modules/currencies/di.js.map +2 -2
  10. package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
  11. package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
  12. package/dist/modules/customers/analytics.js +3 -1
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
  15. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/summary/route.js +5 -4
  17. package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
  18. package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
  19. package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
  20. package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
  21. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  22. package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
  23. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  24. package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
  25. package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
  26. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
  27. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
  28. package/dist/modules/dashboards/lib/aggregations.js +17 -0
  29. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  30. package/dist/modules/dashboards/lib/formatters.js +84 -11
  31. package/dist/modules/dashboards/lib/formatters.js.map +2 -2
  32. package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
  33. package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
  34. package/dist/modules/dashboards/services/widgetDataService.js +86 -6
  35. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  36. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
  37. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
  38. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
  39. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
  40. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
  41. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  42. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
  43. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
  44. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
  45. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
  46. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
  47. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
  48. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
  49. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
  50. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
  51. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
  52. package/dist/modules/notifications/api/route.js +62 -3
  53. package/dist/modules/notifications/api/route.js.map +2 -2
  54. package/dist/modules/sales/analytics.js +7 -3
  55. package/dist/modules/sales/analytics.js.map +2 -2
  56. package/dist/modules/sales/api/document-history/route.js +2 -1
  57. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  58. package/dist/modules/sales/lib/historyHelpers.js +69 -1
  59. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  60. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
  61. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/attachments/api/route.ts +16 -2
  64. package/src/modules/attachments/lib/upload-limits.ts +35 -0
  65. package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
  66. package/src/modules/currencies/di.ts +4 -1
  67. package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
  68. package/src/modules/customers/analytics.ts +2 -0
  69. package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
  70. package/src/modules/customers/api/deals/summary/route.ts +5 -4
  71. package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
  72. package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
  73. package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
  74. package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
  75. package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
  76. package/src/modules/dashboards/i18n/de.json +2 -0
  77. package/src/modules/dashboards/i18n/en.json +2 -0
  78. package/src/modules/dashboards/i18n/es.json +2 -0
  79. package/src/modules/dashboards/i18n/pl.json +2 -0
  80. package/src/modules/dashboards/lib/aggregations.ts +35 -0
  81. package/src/modules/dashboards/lib/formatters.ts +137 -12
  82. package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
  83. package/src/modules/dashboards/services/widgetDataService.ts +110 -4
  84. package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
  85. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
  86. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
  87. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
  88. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
  89. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
  90. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
  91. package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
  92. package/src/modules/notifications/api/route.ts +88 -3
  93. package/src/modules/sales/analytics.ts +4 -0
  94. package/src/modules/sales/api/document-history/route.ts +1 -0
  95. package/src/modules/sales/i18n/de.json +19 -0
  96. package/src/modules/sales/i18n/en.json +19 -0
  97. package/src/modules/sales/i18n/es.json +19 -0
  98. package/src/modules/sales/i18n/pl.json +19 -0
  99. package/src/modules/sales/lib/historyHelpers.ts +88 -1
  100. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +64 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/dashboards/api/widgets/data/schema.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\n\nexport const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])\nexport const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])\nexport const dateRangePresetSchema = z.enum([\n 'today',\n 'yesterday',\n 'this_week',\n 'last_week',\n 'this_month',\n 'last_month',\n 'this_quarter',\n 'last_quarter',\n 'this_year',\n 'last_year',\n 'last_7_days',\n 'last_30_days',\n 'last_90_days',\n])\n\nexport const filterOperatorSchema = z.enum([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'in',\n 'not_in',\n 'is_null',\n 'is_not_null',\n])\n\nexport const widgetDataRequestSchema = z.object({\n entityType: z.string().min(1),\n metric: z.object({\n field: z.string().min(1),\n aggregate: aggregateFunctionSchema,\n }),\n groupBy: z\n .object({\n field: z.string().min(1),\n granularity: dateGranularitySchema.optional(),\n limit: z.number().int().min(1).max(100).optional(),\n resolveLabels: z.boolean().optional(),\n })\n .optional(),\n filters: z\n .array(\n z.object({\n field: z.string().min(1),\n operator: filterOperatorSchema,\n value: z.unknown().optional(),\n }),\n )\n .optional(),\n dateRange: z\n .object({\n field: z.string().min(1),\n preset: dateRangePresetSchema,\n })\n .optional(),\n comparison: z\n .object({\n type: z.enum(['previous_period', 'previous_year']),\n })\n .optional(),\n})\n\nexport const widgetDataItemSchema = z.object({\n groupKey: z.unknown(),\n groupLabel: z.string().optional(),\n value: z.number().nullable(),\n})\n\nexport const widgetDataResponseSchema = z.object({\n value: z.number().nullable(),\n data: z.array(widgetDataItemSchema),\n comparison: z\n .object({\n value: z.number().nullable(),\n change: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n })\n .optional(),\n metadata: z.object({\n fetchedAt: z.string(),\n recordCount: z.number(),\n }),\n})\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,0BAA0B,EAAE,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK,CAAC;AAC5E,MAAM,wBAAwB,EAAE,KAAK,CAAC,OAAO,QAAQ,SAAS,WAAW,MAAM,CAAC;AAChF,MAAM,wBAAwB,EAAE,KAAK;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,uBAAuB,EAAE,KAAK;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,0BAA0B,EAAE,OAAO;AAAA,EAC9C,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC5B,QAAQ,EAAE,OAAO;AAAA,IACf,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,WAAW;AAAA,EACb,CAAC;AAAA,EACD,SAAS,EACN,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,aAAa,sBAAsB,SAAS;AAAA,IAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,IACjD,eAAe,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,CAAC,EACA,SAAS;AAAA,EACZ,SAAS,EACN;AAAA,IACC,EAAE,OAAO;AAAA,MACP,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACvB,UAAU;AAAA,MACV,OAAO,EAAE,QAAQ,EAAE,SAAS;AAAA,IAC9B,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,WAAW,EACR,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,QAAQ;AAAA,EACV,CAAC,EACA,SAAS;AAAA,EACZ,YAAY,EACT,OAAO;AAAA,IACN,MAAM,EAAE,KAAK,CAAC,mBAAmB,eAAe,CAAC;AAAA,EACnD,CAAC,EACA,SAAS;AACd,CAAC;AAEM,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAEM,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,oBAAoB;AAAA,EAClC,YAAY,EACT,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,IAC3B,QAAQ,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,KAAK,CAAC,MAAM,QAAQ,WAAW,CAAC;AAAA,EAC/C,CAAC,EACA,SAAS;AAAA,EACZ,UAAU,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,OAAO;AAAA,IACpB,aAAa,EAAE,OAAO;AAAA,EACxB,CAAC;AACH,CAAC;",
4
+ "sourcesContent": ["import { z } from 'zod'\n\nexport const aggregateFunctionSchema = z.enum(['count', 'sum', 'avg', 'min', 'max'])\nexport const dateGranularitySchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])\nexport const dateRangePresetSchema = z.enum([\n 'today',\n 'yesterday',\n 'this_week',\n 'last_week',\n 'this_month',\n 'last_month',\n 'this_quarter',\n 'last_quarter',\n 'this_year',\n 'last_year',\n 'last_7_days',\n 'last_30_days',\n 'last_90_days',\n])\n\nexport const filterOperatorSchema = z.enum([\n 'eq',\n 'neq',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'in',\n 'not_in',\n 'is_null',\n 'is_not_null',\n])\n\nexport const widgetDataRequestSchema = z.object({\n entityType: z.string().min(1),\n metric: z.object({\n field: z.string().min(1),\n aggregate: aggregateFunctionSchema,\n }),\n groupBy: z\n .object({\n field: z.string().min(1),\n granularity: dateGranularitySchema.optional(),\n limit: z.number().int().min(1).max(100).optional(),\n resolveLabels: z.boolean().optional(),\n })\n .optional(),\n filters: z\n .array(\n z.object({\n field: z.string().min(1),\n operator: filterOperatorSchema,\n value: z.unknown().optional(),\n }),\n )\n .optional(),\n dateRange: z\n .object({\n field: z.string().min(1),\n preset: dateRangePresetSchema,\n })\n .optional(),\n comparison: z\n .object({\n type: z.enum(['previous_period', 'previous_year']),\n })\n .optional(),\n})\n\nexport const widgetDataItemSchema = z.object({\n groupKey: z.unknown(),\n groupLabel: z.string().optional(),\n value: z.number().nullable(),\n})\n\nexport const widgetDataResponseSchema = z.object({\n value: z.number().nullable(),\n data: z.array(widgetDataItemSchema),\n comparison: z\n .object({\n value: z.number().nullable(),\n change: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n })\n .optional(),\n metadata: z.object({\n fetchedAt: z.string(),\n recordCount: z.number(),\n currency: z.string().nullable().optional(),\n }),\n})\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,0BAA0B,EAAE,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK,CAAC;AAC5E,MAAM,wBAAwB,EAAE,KAAK,CAAC,OAAO,QAAQ,SAAS,WAAW,MAAM,CAAC;AAChF,MAAM,wBAAwB,EAAE,KAAK;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,uBAAuB,EAAE,KAAK;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,0BAA0B,EAAE,OAAO;AAAA,EAC9C,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC5B,QAAQ,EAAE,OAAO;AAAA,IACf,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,WAAW;AAAA,EACb,CAAC;AAAA,EACD,SAAS,EACN,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,aAAa,sBAAsB,SAAS;AAAA,IAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,IACjD,eAAe,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,CAAC,EACA,SAAS;AAAA,EACZ,SAAS,EACN;AAAA,IACC,EAAE,OAAO;AAAA,MACP,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACvB,UAAU;AAAA,MACV,OAAO,EAAE,QAAQ,EAAE,SAAS;AAAA,IAC9B,CAAC;AAAA,EACH,EACC,SAAS;AAAA,EACZ,WAAW,EACR,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,IACvB,QAAQ;AAAA,EACV,CAAC,EACA,SAAS;AAAA,EACZ,YAAY,EACT,OAAO;AAAA,IACN,MAAM,EAAE,KAAK,CAAC,mBAAmB,eAAe,CAAC;AAAA,EACnD,CAAC,EACA,SAAS;AACd,CAAC;AAEM,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAEM,MAAM,2BAA2B,EAAE,OAAO;AAAA,EAC/C,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,oBAAoB;AAAA,EAClC,YAAY,EACT,OAAO;AAAA,IACN,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA,IAC3B,QAAQ,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,KAAK,CAAC,MAAM,QAAQ,WAAW,CAAC;AAAA,EAC/C,CAAC,EACA,SAAS;AAAA,EACZ,UAAU,EAAE,OAAO;AAAA,IACjB,WAAW,EAAE,OAAO;AAAA,IACpB,aAAa,EAAE,OAAO;AAAA,IACtB,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Info } from "lucide-react";
4
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
5
+ const UnlabelledAmountNotice = ({
6
+ currency,
7
+ loading,
8
+ error,
9
+ className
10
+ }) => {
11
+ const t = useT();
12
+ if (loading || error || currency) return null;
13
+ return /* @__PURE__ */ jsxs(
14
+ "p",
15
+ {
16
+ className: `mt-2 flex items-center gap-1 text-xs text-muted-foreground${className ? ` ${className}` : ""}`,
17
+ title: t(
18
+ "dashboards.analytics.currency.unlabelledHint",
19
+ "A reliable currency could not be determined, so the amount is shown without one."
20
+ ),
21
+ children: [
22
+ /* @__PURE__ */ jsx(Info, { className: "size-3 shrink-0", "aria-hidden": "true" }),
23
+ t("dashboards.analytics.currency.unlabelled", "Amounts shown without a currency")
24
+ ]
25
+ }
26
+ );
27
+ };
28
+ var UnlabelledAmountNotice_default = UnlabelledAmountNotice;
29
+ export {
30
+ UnlabelledAmountNotice,
31
+ UnlabelledAmountNotice_default as default
32
+ };
33
+ //# sourceMappingURL=UnlabelledAmountNotice.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/dashboards/components/UnlabelledAmountNotice.tsx"],
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Info } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type UnlabelledAmountNoticeProps = {\n currency: string | null | undefined\n loading?: boolean\n error?: string | null\n className?: string\n}\n\n/**\n * Explains why a money widget renders bare numbers. Without it a missing currency label\n * is indistinguishable from a broken widget, which is the gap #4676 closes: the amounts\n * are unlabelled on purpose because the service could not determine one reliably.\n */\nexport const UnlabelledAmountNotice: React.FC<UnlabelledAmountNoticeProps> = ({\n currency,\n loading,\n error,\n className,\n}) => {\n const t = useT()\n\n if (loading || error || currency) return null\n\n return (\n <p\n className={`mt-2 flex items-center gap-1 text-xs text-muted-foreground${className ? ` ${className}` : ''}`}\n title={t(\n 'dashboards.analytics.currency.unlabelledHint',\n 'A reliable currency could not be determined, so the amount is shown without one.',\n )}\n >\n <Info className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n {t('dashboards.analytics.currency.unlabelled', 'Amounts shown without a currency')}\n </p>\n )\n}\n\nexport default UnlabelledAmountNotice\n"],
5
+ "mappings": ";AA6BI,SAOE,KAPF;AA1BJ,SAAS,YAAY;AACrB,SAAS,YAAY;AAcd,MAAM,yBAAgE,CAAC;AAAA,EAC5E;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,IAAI,KAAK;AAEf,MAAI,WAAW,SAAS,SAAU,QAAO;AAEzC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,6DAA6D,YAAY,IAAI,SAAS,KAAK,EAAE;AAAA,MACxG,OAAO;AAAA,QACL;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,QAAK,WAAU,mBAAkB,eAAY,QAAO;AAAA,QACpD,EAAE,4CAA4C,kCAAkC;AAAA;AAAA;AAAA,EACnF;AAEJ;AAEA,IAAO,iCAAQ;",
6
+ "names": []
7
+ }
@@ -140,6 +140,22 @@ function buildWhereClause(options) {
140
140
  function resolveTableName(config) {
141
141
  return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`;
142
142
  }
143
+ function buildDistinctCurrencyQuery(options) {
144
+ const { registry } = options;
145
+ const config = registry.getEntityTypeConfig(options.entityType);
146
+ if (!config?.currencyField) return null;
147
+ const currencyMapping = registry.getFieldMapping(options.entityType, config.currencyField);
148
+ if (!currencyMapping || !isSafeIdentifier(currencyMapping.dbColumn)) return null;
149
+ const where = buildWhereClause(options);
150
+ const normalizedCurrencyExpression = `UPPER(NULLIF(BTRIM(${currencyMapping.dbColumn}), ''))`;
151
+ const sql = [
152
+ `SELECT DISTINCT ${normalizedCurrencyExpression} AS code`,
153
+ `FROM ${resolveTableName(config)}`,
154
+ where.clause,
155
+ "LIMIT 2"
156
+ ].filter(Boolean).join(" ");
157
+ return { sql, params: where.params };
158
+ }
143
159
  function buildAggregationQuery(options) {
144
160
  const { registry } = options;
145
161
  const config = registry.getEntityTypeConfig(options.entityType);
@@ -189,6 +205,7 @@ export {
189
205
  buildAggregateExpression,
190
206
  buildAggregationQuery,
191
207
  buildDateTruncExpression,
208
+ buildDistinctCurrencyQuery,
192
209
  buildGroupSourceRowsQuery,
193
210
  buildJsonbFieldExpression,
194
211
  isValidAggregate,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/dashboards/lib/aggregations.ts"],
4
- "sourcesContent": ["import type { AnalyticsRegistry } from '../services/analyticsRegistry'\nimport type { AnalyticsEntityTypeConfig, AnalyticsFieldMapping } from '@open-mercato/shared/modules/analytics'\n\nexport type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'\nexport type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nconst VALID_GRANULARITIES: readonly DateGranularity[] = ['day', 'week', 'month', 'quarter', 'year']\nconst VALID_AGGREGATES: readonly AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']\nconst SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/\n\nexport function isValidGranularity(value: unknown): value is DateGranularity {\n return typeof value === 'string' && VALID_GRANULARITIES.includes(value as DateGranularity)\n}\n\nexport function isValidAggregate(value: unknown): value is AggregateFunction {\n return typeof value === 'string' && VALID_AGGREGATES.includes(value as AggregateFunction)\n}\n\nfunction isSafeIdentifier(value: string): boolean {\n return SAFE_IDENTIFIER_PATTERN.test(value)\n}\n\n// Re-export types from shared module for convenience\nexport type EntityTypeConfig = AnalyticsEntityTypeConfig\nexport type FieldMapping = AnalyticsFieldMapping\n\nexport function buildAggregateExpression(aggregate: AggregateFunction, column: string): string {\n switch (aggregate) {\n case 'count':\n return column === 'id' ? 'COUNT(*)' : `COUNT(${column})`\n case 'sum':\n return `COALESCE(SUM(${column}::numeric), 0)`\n case 'avg':\n return `COALESCE(AVG(${column}::numeric), 0)`\n case 'min':\n return `MIN(${column}::numeric)`\n case 'max':\n return `MAX(${column}::numeric)`\n default:\n return `COUNT(*)`\n }\n}\n\nexport function buildDateTruncExpression(column: string, granularity: DateGranularity): string {\n if (!isValidGranularity(granularity)) {\n throw new Error(`Invalid granularity: ${granularity}`)\n }\n return `DATE_TRUNC('${granularity}', ${column})`\n}\n\nexport function buildJsonbFieldExpression(column: string, path: string): string {\n const parts = path.split('.')\n for (const part of parts) {\n if (!isSafeIdentifier(part)) {\n throw new Error(`Invalid JSONB path part: ${part}`)\n }\n }\n if (parts.length === 1) {\n return `${column}->>'${parts[0]}'`\n }\n const intermediate = parts.slice(0, -1).map((p) => `'${p}'`).join('->')\n const lastPart = parts[parts.length - 1]\n return `${column}->${intermediate}->>'${lastPart}'`\n}\n\nexport type AggregationQuery = {\n sql: string\n params: unknown[]\n}\n\nexport type BuildAggregationQueryOptions = {\n entityType: string\n metric: {\n field: string\n aggregate: AggregateFunction\n }\n groupBy?: {\n field: string\n granularity?: DateGranularity\n limit?: number\n }\n dateRange?: {\n field: string\n start: Date\n end: Date\n }\n filters?: Array<{\n field: string\n operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'\n value?: unknown\n }>\n scope: {\n tenantId: string\n organizationIds?: string[]\n }\n /** Analytics registry for resolving entity and field configurations */\n registry: AnalyticsRegistry\n}\n\nexport type ResolvedGroupExpression = {\n expression: string\n /** Column the group expression reads from \u2014 the encryption map is keyed by this. */\n dbColumn: string\n /** JSONB path below `dbColumn`, when the groupBy field used path notation. */\n jsonPath: string | null\n}\n\n/**\n * Resolves the SQL expression a groupBy field maps to, together with the underlying column so\n * callers can decide whether the source is encrypted at rest before grouping over it (#4622).\n */\nexport function resolveGroupExpression(\n registry: AnalyticsRegistry,\n entityType: string,\n groupBy: { field: string; granularity?: DateGranularity },\n): ResolvedGroupExpression | null {\n const groupMapping = registry.getFieldMapping(entityType, groupBy.field)\n\n // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)\n if (!groupMapping && groupBy.field.includes('.')) {\n const [baseField, ...pathParts] = groupBy.field.split('.')\n const baseMapping = registry.getFieldMapping(entityType, baseField)\n if (baseMapping?.type !== 'jsonb') return null\n const jsonPath = pathParts.join('.')\n return {\n expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),\n dbColumn: baseMapping.dbColumn,\n jsonPath,\n }\n }\n\n if (!groupMapping) return null\n\n if (groupMapping.type === 'timestamp' && groupBy.granularity) {\n return {\n expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),\n dbColumn: groupMapping.dbColumn,\n jsonPath: null,\n }\n }\n\n return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }\n}\n\nfunction buildWhereClause(\n options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,\n): { clause: string; params: unknown[] } {\n const { registry } = options\n const params: unknown[] = []\n const whereClauses: string[] = []\n\n whereClauses.push(`tenant_id = ?`)\n params.push(options.scope.tenantId)\n\n if (options.scope.organizationIds && options.scope.organizationIds.length > 0) {\n whereClauses.push(`organization_id = ANY(?::uuid[])`)\n params.push(`{${options.scope.organizationIds.join(',')}}`)\n }\n\n whereClauses.push(`deleted_at IS NULL`)\n\n if (options.dateRange) {\n const dateMapping = registry.getFieldMapping(options.entityType, options.dateRange.field)\n if (dateMapping) {\n whereClauses.push(`${dateMapping.dbColumn} >= ?`)\n params.push(options.dateRange.start)\n whereClauses.push(`${dateMapping.dbColumn} <= ?`)\n params.push(options.dateRange.end)\n }\n }\n\n if (options.filters) {\n for (const filter of options.filters) {\n const filterMapping = registry.getFieldMapping(options.entityType, filter.field)\n if (!filterMapping) continue\n\n switch (filter.operator) {\n case 'eq':\n whereClauses.push(`${filterMapping.dbColumn} = ?`)\n params.push(filter.value)\n break\n case 'neq':\n whereClauses.push(`${filterMapping.dbColumn} != ?`)\n params.push(filter.value)\n break\n case 'gt':\n whereClauses.push(`${filterMapping.dbColumn} > ?`)\n params.push(filter.value)\n break\n case 'gte':\n whereClauses.push(`${filterMapping.dbColumn} >= ?`)\n params.push(filter.value)\n break\n case 'lt':\n whereClauses.push(`${filterMapping.dbColumn} < ?`)\n params.push(filter.value)\n break\n case 'lte':\n whereClauses.push(`${filterMapping.dbColumn} <= ?`)\n params.push(filter.value)\n break\n case 'in':\n whereClauses.push(`${filterMapping.dbColumn} = ANY(?)`)\n params.push(filter.value)\n break\n case 'not_in':\n whereClauses.push(`${filterMapping.dbColumn} != ALL(?)`)\n params.push(filter.value)\n break\n case 'is_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NULL`)\n break\n case 'is_not_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)\n break\n }\n }\n }\n\n return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }\n}\n\nfunction resolveTableName(config: EntityTypeConfig): string {\n return config.schema ? `\"${config.schema}\".\"${config.tableName}\"` : `\"${config.tableName}\"`\n}\n\nexport function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n const tableName = resolveTableName(config)\n const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)\n\n let selectClause = `SELECT ${aggregateExpr} AS value`\n let groupByClause = ''\n let orderByClause = ''\n let limitClause = ''\n\n if (options.groupBy) {\n const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)\n\n if (resolved) {\n selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`\n groupByClause = `GROUP BY ${resolved.expression}`\n // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit\n // keeps the highest-value buckets instead of the empty ones, and so the application-side\n // encrypted path can mirror the same ordering (#4622).\n orderByClause = `ORDER BY value DESC NULLS LAST`\n\n if (options.groupBy.limit && options.groupBy.limit > 0) {\n limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`\n }\n }\n }\n\n const where = buildWhereClause(options)\n\n const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {\n /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */\n groupColumn: string\n /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */\n rowLimit: number\n}\n\n/**\n * Builds a per-row query for group sources that cannot be grouped in SQL because the column is\n * encrypted at rest. Returns the raw group source alongside the metric value so the caller can\n * decrypt, then aggregate in application code (#4622).\n */\nexport function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n if (!isSafeIdentifier(options.groupColumn)) {\n throw new Error(`Invalid group column: ${options.groupColumn}`)\n }\n\n const where = buildWhereClause(options)\n const sql = [\n `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { AnalyticsRegistry } from '../services/analyticsRegistry'\nimport type { AnalyticsEntityTypeConfig, AnalyticsFieldMapping } from '@open-mercato/shared/modules/analytics'\n\nexport type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'\nexport type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nconst VALID_GRANULARITIES: readonly DateGranularity[] = ['day', 'week', 'month', 'quarter', 'year']\nconst VALID_AGGREGATES: readonly AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']\nconst SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/\n\nexport function isValidGranularity(value: unknown): value is DateGranularity {\n return typeof value === 'string' && VALID_GRANULARITIES.includes(value as DateGranularity)\n}\n\nexport function isValidAggregate(value: unknown): value is AggregateFunction {\n return typeof value === 'string' && VALID_AGGREGATES.includes(value as AggregateFunction)\n}\n\nfunction isSafeIdentifier(value: string): boolean {\n return SAFE_IDENTIFIER_PATTERN.test(value)\n}\n\n// Re-export types from shared module for convenience\nexport type EntityTypeConfig = AnalyticsEntityTypeConfig\nexport type FieldMapping = AnalyticsFieldMapping\n\nexport function buildAggregateExpression(aggregate: AggregateFunction, column: string): string {\n switch (aggregate) {\n case 'count':\n return column === 'id' ? 'COUNT(*)' : `COUNT(${column})`\n case 'sum':\n return `COALESCE(SUM(${column}::numeric), 0)`\n case 'avg':\n return `COALESCE(AVG(${column}::numeric), 0)`\n case 'min':\n return `MIN(${column}::numeric)`\n case 'max':\n return `MAX(${column}::numeric)`\n default:\n return `COUNT(*)`\n }\n}\n\nexport function buildDateTruncExpression(column: string, granularity: DateGranularity): string {\n if (!isValidGranularity(granularity)) {\n throw new Error(`Invalid granularity: ${granularity}`)\n }\n return `DATE_TRUNC('${granularity}', ${column})`\n}\n\nexport function buildJsonbFieldExpression(column: string, path: string): string {\n const parts = path.split('.')\n for (const part of parts) {\n if (!isSafeIdentifier(part)) {\n throw new Error(`Invalid JSONB path part: ${part}`)\n }\n }\n if (parts.length === 1) {\n return `${column}->>'${parts[0]}'`\n }\n const intermediate = parts.slice(0, -1).map((p) => `'${p}'`).join('->')\n const lastPart = parts[parts.length - 1]\n return `${column}->${intermediate}->>'${lastPart}'`\n}\n\nexport type AggregationQuery = {\n sql: string\n params: unknown[]\n}\n\nexport type BuildAggregationQueryOptions = {\n entityType: string\n metric: {\n field: string\n aggregate: AggregateFunction\n }\n groupBy?: {\n field: string\n granularity?: DateGranularity\n limit?: number\n }\n dateRange?: {\n field: string\n start: Date\n end: Date\n }\n filters?: Array<{\n field: string\n operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'\n value?: unknown\n }>\n scope: {\n tenantId: string\n organizationIds?: string[]\n }\n /** Analytics registry for resolving entity and field configurations */\n registry: AnalyticsRegistry\n}\n\nexport type ResolvedGroupExpression = {\n expression: string\n /** Column the group expression reads from \u2014 the encryption map is keyed by this. */\n dbColumn: string\n /** JSONB path below `dbColumn`, when the groupBy field used path notation. */\n jsonPath: string | null\n}\n\n/**\n * Resolves the SQL expression a groupBy field maps to, together with the underlying column so\n * callers can decide whether the source is encrypted at rest before grouping over it (#4622).\n */\nexport function resolveGroupExpression(\n registry: AnalyticsRegistry,\n entityType: string,\n groupBy: { field: string; granularity?: DateGranularity },\n): ResolvedGroupExpression | null {\n const groupMapping = registry.getFieldMapping(entityType, groupBy.field)\n\n // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)\n if (!groupMapping && groupBy.field.includes('.')) {\n const [baseField, ...pathParts] = groupBy.field.split('.')\n const baseMapping = registry.getFieldMapping(entityType, baseField)\n if (baseMapping?.type !== 'jsonb') return null\n const jsonPath = pathParts.join('.')\n return {\n expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),\n dbColumn: baseMapping.dbColumn,\n jsonPath,\n }\n }\n\n if (!groupMapping) return null\n\n if (groupMapping.type === 'timestamp' && groupBy.granularity) {\n return {\n expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),\n dbColumn: groupMapping.dbColumn,\n jsonPath: null,\n }\n }\n\n return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }\n}\n\nfunction buildWhereClause(\n options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,\n): { clause: string; params: unknown[] } {\n const { registry } = options\n const params: unknown[] = []\n const whereClauses: string[] = []\n\n whereClauses.push(`tenant_id = ?`)\n params.push(options.scope.tenantId)\n\n if (options.scope.organizationIds && options.scope.organizationIds.length > 0) {\n whereClauses.push(`organization_id = ANY(?::uuid[])`)\n params.push(`{${options.scope.organizationIds.join(',')}}`)\n }\n\n whereClauses.push(`deleted_at IS NULL`)\n\n if (options.dateRange) {\n const dateMapping = registry.getFieldMapping(options.entityType, options.dateRange.field)\n if (dateMapping) {\n whereClauses.push(`${dateMapping.dbColumn} >= ?`)\n params.push(options.dateRange.start)\n whereClauses.push(`${dateMapping.dbColumn} <= ?`)\n params.push(options.dateRange.end)\n }\n }\n\n if (options.filters) {\n for (const filter of options.filters) {\n const filterMapping = registry.getFieldMapping(options.entityType, filter.field)\n if (!filterMapping) continue\n\n switch (filter.operator) {\n case 'eq':\n whereClauses.push(`${filterMapping.dbColumn} = ?`)\n params.push(filter.value)\n break\n case 'neq':\n whereClauses.push(`${filterMapping.dbColumn} != ?`)\n params.push(filter.value)\n break\n case 'gt':\n whereClauses.push(`${filterMapping.dbColumn} > ?`)\n params.push(filter.value)\n break\n case 'gte':\n whereClauses.push(`${filterMapping.dbColumn} >= ?`)\n params.push(filter.value)\n break\n case 'lt':\n whereClauses.push(`${filterMapping.dbColumn} < ?`)\n params.push(filter.value)\n break\n case 'lte':\n whereClauses.push(`${filterMapping.dbColumn} <= ?`)\n params.push(filter.value)\n break\n case 'in':\n whereClauses.push(`${filterMapping.dbColumn} = ANY(?)`)\n params.push(filter.value)\n break\n case 'not_in':\n whereClauses.push(`${filterMapping.dbColumn} != ALL(?)`)\n params.push(filter.value)\n break\n case 'is_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NULL`)\n break\n case 'is_not_null':\n whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)\n break\n }\n }\n }\n\n return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }\n}\n\nfunction resolveTableName(config: EntityTypeConfig): string {\n return config.schema ? `\"${config.schema}\".\"${config.tableName}\"` : `\"${config.tableName}\"`\n}\n\nexport type BuildDistinctCurrencyQueryOptions = Pick<\n BuildAggregationQueryOptions,\n 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'\n>\n\n/**\n * Builds the query that reads the distinct per-row currencies of the rows an aggregation\n * would sum, over exactly the same tenant/organization scope, date range and filters.\n * Returns `null` when the entity declares no `currencyField`, which means its amounts are\n * not per-row denominated and there is nothing to verify (#4676).\n */\nexport function buildDistinctCurrencyQuery(\n options: BuildDistinctCurrencyQueryOptions,\n): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config?.currencyField) return null\n\n const currencyMapping = registry.getFieldMapping(options.entityType, config.currencyField)\n if (!currencyMapping || !isSafeIdentifier(currencyMapping.dbColumn)) return null\n\n const where = buildWhereClause(options)\n const normalizedCurrencyExpression = `UPPER(NULLIF(BTRIM(${currencyMapping.dbColumn}), ''))`\n const sql = [\n `SELECT DISTINCT ${normalizedCurrencyExpression} AS code`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n 'LIMIT 2',\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n const tableName = resolveTableName(config)\n const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)\n\n let selectClause = `SELECT ${aggregateExpr} AS value`\n let groupByClause = ''\n let orderByClause = ''\n let limitClause = ''\n\n if (options.groupBy) {\n const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)\n\n if (resolved) {\n selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`\n groupByClause = `GROUP BY ${resolved.expression}`\n // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit\n // keeps the highest-value buckets instead of the empty ones, and so the application-side\n // encrypted path can mirror the same ordering (#4622).\n orderByClause = `ORDER BY value DESC NULLS LAST`\n\n if (options.groupBy.limit && options.groupBy.limit > 0) {\n limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`\n }\n }\n }\n\n const where = buildWhereClause(options)\n\n const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n\nexport type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {\n /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */\n groupColumn: string\n /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */\n rowLimit: number\n}\n\n/**\n * Builds a per-row query for group sources that cannot be grouped in SQL because the column is\n * encrypted at rest. Returns the raw group source alongside the metric value so the caller can\n * decrypt, then aggregate in application code (#4622).\n */\nexport function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {\n const { registry } = options\n const config = registry.getEntityTypeConfig(options.entityType)\n if (!config) return null\n\n const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)\n if (!metricMapping) return null\n\n if (!isSafeIdentifier(options.groupColumn)) {\n throw new Error(`Invalid group column: ${options.groupColumn}`)\n }\n\n const where = buildWhereClause(options)\n const sql = [\n `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,\n `FROM ${resolveTableName(config)}`,\n where.clause,\n `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,\n ]\n .filter(Boolean)\n .join(' ')\n\n return { sql, params: where.params }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,31 +1,104 @@
1
- function formatCurrency(value, options = {}) {
2
- const { currency = "USD", minimumFractionDigits = 0, maximumFractionDigits = 0 } = options;
3
- return new Intl.NumberFormat(void 0, {
4
- style: "currency",
5
- currency,
1
+ const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
2
+ const NUMBER_FORMATTER_CACHE_LIMIT = 128;
3
+ const numberFormatterCache = /* @__PURE__ */ new Map();
4
+ function normalizeCurrencyCode(currency) {
5
+ if (typeof currency !== "string") return null;
6
+ const trimmed = currency.trim();
7
+ if (!CURRENCY_CODE_PATTERN.test(trimmed)) return null;
8
+ return trimmed.toUpperCase();
9
+ }
10
+ function getNumberFormatter(locale, options) {
11
+ const key = `${locale ?? ""}:${JSON.stringify(options)}`;
12
+ const cached = numberFormatterCache.get(key);
13
+ if (cached) return cached;
14
+ const formatter = new Intl.NumberFormat(locale, options);
15
+ if (numberFormatterCache.size >= NUMBER_FORMATTER_CACHE_LIMIT) {
16
+ const oldest = numberFormatterCache.keys().next().value;
17
+ if (oldest) numberFormatterCache.delete(oldest);
18
+ }
19
+ numberFormatterCache.set(key, formatter);
20
+ return formatter;
21
+ }
22
+ function formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale) {
23
+ return getNumberFormatter(locale, {
24
+ style: "decimal",
6
25
  minimumFractionDigits,
7
26
  maximumFractionDigits
8
27
  }).format(value);
9
28
  }
29
+ function formatCurrency(value, options = {}) {
30
+ const { currency, locale, minimumFractionDigits = 0, maximumFractionDigits = 0 } = options;
31
+ const code = normalizeCurrencyCode(currency);
32
+ if (!code) return formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale);
33
+ try {
34
+ return getNumberFormatter(locale, {
35
+ style: "currency",
36
+ currency: code,
37
+ minimumFractionDigits,
38
+ maximumFractionDigits
39
+ }).format(value);
40
+ } catch {
41
+ return `${formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)} ${code}`;
42
+ }
43
+ }
10
44
  function formatCurrencyWithDecimals(value, options = {}) {
11
45
  return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options });
12
46
  }
13
- function formatCurrencyCompact(value, currencySymbol = "$") {
47
+ function formatWithLiteralSymbol(value, symbol) {
14
48
  if (Math.abs(value) >= 1e6) {
15
- return `${currencySymbol}${(value / 1e6).toFixed(1)}M`;
49
+ return `${symbol}${(value / 1e6).toFixed(1)}M`;
16
50
  }
17
51
  if (Math.abs(value) >= 1e3) {
18
- return `${currencySymbol}${(value / 1e3).toFixed(1)}K`;
52
+ return `${symbol}${(value / 1e3).toFixed(1)}K`;
19
53
  }
20
- return `${currencySymbol}${value.toFixed(0)}`;
54
+ return `${symbol}${value.toFixed(0)}`;
21
55
  }
22
- function formatCurrencySafe(value, fallback = "--") {
56
+ function formatCurrencyCompact(value, currencyOrOptions = {}) {
57
+ if (typeof currencyOrOptions === "string" && !normalizeCurrencyCode(currencyOrOptions)) {
58
+ return formatWithLiteralSymbol(value, currencyOrOptions);
59
+ }
60
+ const options = typeof currencyOrOptions === "string" ? { currency: currencyOrOptions } : currencyOrOptions ?? {};
61
+ const code = normalizeCurrencyCode(options.currency);
62
+ const compact = Math.abs(value) >= 1e3;
63
+ const formatOptions = {
64
+ style: code ? "currency" : "decimal",
65
+ notation: compact ? "compact" : "standard",
66
+ compactDisplay: "short",
67
+ minimumFractionDigits: compact ? 1 : 0,
68
+ maximumFractionDigits: compact ? 1 : 0
69
+ };
70
+ if (code) formatOptions.currency = code;
71
+ try {
72
+ return getNumberFormatter(options.locale, formatOptions).format(value);
73
+ } catch {
74
+ const formatted = getNumberFormatter(options.locale, {
75
+ style: "decimal",
76
+ notation: compact ? "compact" : "standard",
77
+ compactDisplay: "short",
78
+ minimumFractionDigits: compact ? 1 : 0,
79
+ maximumFractionDigits: compact ? 1 : 0
80
+ }).format(value);
81
+ return code ? `${formatted} ${code}` : formatted;
82
+ }
83
+ }
84
+ function formatCurrencySafe(value, fallback = "--", options = {}) {
23
85
  if (value === null || value === void 0) return fallback;
24
86
  const num = Number(value);
25
87
  if (!Number.isFinite(num)) return fallback;
26
- return formatCurrency(num);
88
+ return formatCurrency(num, options);
89
+ }
90
+ function createCurrencyFormatters(currency, fallback = "--", locale) {
91
+ const code = normalizeCurrencyCode(currency);
92
+ return {
93
+ currency: code,
94
+ format: (value) => formatCurrency(value, { currency: code, locale }),
95
+ formatWithDecimals: (value) => formatCurrencyWithDecimals(value, { currency: code, locale }),
96
+ formatCompact: (value) => formatCurrencyCompact(value, { currency: code, locale }),
97
+ formatSafe: (value) => formatCurrencySafe(value, fallback, { currency: code, locale })
98
+ };
27
99
  }
28
100
  export {
101
+ createCurrencyFormatters,
29
102
  formatCurrency,
30
103
  formatCurrencyCompact,
31
104
  formatCurrencySafe,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/dashboards/lib/formatters.ts"],
4
- "sourcesContent": ["export type FormatCurrencyOptions = {\n currency?: string\n minimumFractionDigits?: number\n maximumFractionDigits?: number\n}\n\nexport function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {\n const { currency = 'USD', minimumFractionDigits = 0, maximumFractionDigits = 0 } = options\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency,\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(value)\n}\n\nexport function formatCurrencyWithDecimals(value: number, options: FormatCurrencyOptions = {}): string {\n return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options })\n}\n\nexport function formatCurrencyCompact(value: number, currencySymbol = '$'): string {\n if (Math.abs(value) >= 1_000_000) {\n return `${currencySymbol}${(value / 1_000_000).toFixed(1)}M`\n }\n if (Math.abs(value) >= 1_000) {\n return `${currencySymbol}${(value / 1_000).toFixed(1)}K`\n }\n return `${currencySymbol}${value.toFixed(0)}`\n}\n\nexport function formatCurrencySafe(value: unknown, fallback = '--'): string {\n if (value === null || value === undefined) return fallback\n const num = Number(value)\n if (!Number.isFinite(num)) return fallback\n return formatCurrency(num)\n}\n"],
5
- "mappings": "AAMO,SAAS,eAAe,OAAe,UAAiC,CAAC,GAAW;AACzF,QAAM,EAAE,WAAW,OAAO,wBAAwB,GAAG,wBAAwB,EAAE,IAAI;AACnF,SAAO,IAAI,KAAK,aAAa,QAAW;AAAA,IACtC,OAAO;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EAAE,OAAO,KAAK;AACjB;AAEO,SAAS,2BAA2B,OAAe,UAAiC,CAAC,GAAW;AACrG,SAAO,eAAe,OAAO,EAAE,uBAAuB,GAAG,uBAAuB,GAAG,GAAG,QAAQ,CAAC;AACjG;AAEO,SAAS,sBAAsB,OAAe,iBAAiB,KAAa;AACjF,MAAI,KAAK,IAAI,KAAK,KAAK,KAAW;AAChC,WAAO,GAAG,cAAc,IAAI,QAAQ,KAAW,QAAQ,CAAC,CAAC;AAAA,EAC3D;AACA,MAAI,KAAK,IAAI,KAAK,KAAK,KAAO;AAC5B,WAAO,GAAG,cAAc,IAAI,QAAQ,KAAO,QAAQ,CAAC,CAAC;AAAA,EACvD;AACA,SAAO,GAAG,cAAc,GAAG,MAAM,QAAQ,CAAC,CAAC;AAC7C;AAEO,SAAS,mBAAmB,OAAgB,WAAW,MAAc;AAC1E,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,QAAM,MAAM,OAAO,KAAK;AACxB,MAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,SAAO,eAAe,GAAG;AAC3B;",
4
+ "sourcesContent": ["export type FormatCurrencyOptions = {\n currency?: string | null\n locale?: string\n minimumFractionDigits?: number\n maximumFractionDigits?: number\n}\n\nexport type FormatCurrencyCompactOptions = {\n currency?: string | null\n locale?: string\n}\n\nconst CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/\nconst NUMBER_FORMATTER_CACHE_LIMIT = 128\nconst numberFormatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction normalizeCurrencyCode(currency?: string | null): string | null {\n if (typeof currency !== 'string') return null\n const trimmed = currency.trim()\n if (!CURRENCY_CODE_PATTERN.test(trimmed)) return null\n return trimmed.toUpperCase()\n}\n\nfunction getNumberFormatter(\n locale: string | undefined,\n options: Intl.NumberFormatOptions,\n): Intl.NumberFormat {\n const key = `${locale ?? ''}:${JSON.stringify(options)}`\n const cached = numberFormatterCache.get(key)\n if (cached) return cached\n const formatter = new Intl.NumberFormat(locale, options)\n if (numberFormatterCache.size >= NUMBER_FORMATTER_CACHE_LIMIT) {\n const oldest = numberFormatterCache.keys().next().value\n if (oldest) numberFormatterCache.delete(oldest)\n }\n numberFormatterCache.set(key, formatter)\n return formatter\n}\n\nfunction formatDecimal(\n value: number,\n minimumFractionDigits: number,\n maximumFractionDigits: number,\n locale?: string,\n): string {\n return getNumberFormatter(locale, {\n style: 'decimal',\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(value)\n}\n\n/**\n * Formats a monetary amount. Without a currency code the amount renders as a plain\n * number: analytics values carry no currency of their own, and labelling them with a\n * guessed one (see #4620) is worse than leaving them unlabelled.\n */\nexport function formatCurrency(value: number, options: FormatCurrencyOptions = {}): string {\n const { currency, locale, minimumFractionDigits = 0, maximumFractionDigits = 0 } = options\n const code = normalizeCurrencyCode(currency)\n if (!code) return formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)\n try {\n return getNumberFormatter(locale, {\n style: 'currency',\n currency: code,\n minimumFractionDigits,\n maximumFractionDigits,\n }).format(value)\n } catch {\n return `${formatDecimal(value, minimumFractionDigits, maximumFractionDigits, locale)} ${code}`\n }\n}\n\nexport function formatCurrencyWithDecimals(value: number, options: FormatCurrencyOptions = {}): string {\n return formatCurrency(value, { minimumFractionDigits: 2, maximumFractionDigits: 2, ...options })\n}\n\nfunction formatWithLiteralSymbol(value: number, symbol: string): string {\n if (Math.abs(value) >= 1_000_000) {\n return `${symbol}${(value / 1_000_000).toFixed(1)}M`\n }\n if (Math.abs(value) >= 1_000) {\n return `${symbol}${(value / 1_000).toFixed(1)}K`\n }\n return `${symbol}${value.toFixed(0)}`\n}\n\nexport function formatCurrencyCompact(\n value: number,\n currencyOrOptions: string | null | FormatCurrencyCompactOptions = {},\n): string {\n if (typeof currencyOrOptions === 'string' && !normalizeCurrencyCode(currencyOrOptions)) {\n return formatWithLiteralSymbol(value, currencyOrOptions)\n }\n\n const options = typeof currencyOrOptions === 'string'\n ? { currency: currencyOrOptions }\n : (currencyOrOptions ?? {})\n const code = normalizeCurrencyCode(options.currency)\n const compact = Math.abs(value) >= 1_000\n const formatOptions: Intl.NumberFormatOptions = {\n style: code ? 'currency' : 'decimal',\n notation: compact ? 'compact' : 'standard',\n compactDisplay: 'short',\n minimumFractionDigits: compact ? 1 : 0,\n maximumFractionDigits: compact ? 1 : 0,\n }\n if (code) formatOptions.currency = code\n\n try {\n return getNumberFormatter(options.locale, formatOptions).format(value)\n } catch {\n const formatted = getNumberFormatter(options.locale, {\n style: 'decimal',\n notation: compact ? 'compact' : 'standard',\n compactDisplay: 'short',\n minimumFractionDigits: compact ? 1 : 0,\n maximumFractionDigits: compact ? 1 : 0,\n }).format(value)\n return code ? `${formatted} ${code}` : formatted\n }\n}\n\nexport function formatCurrencySafe(\n value: unknown,\n fallback = '--',\n options: FormatCurrencyOptions = {},\n): string {\n if (value === null || value === undefined) return fallback\n const num = Number(value)\n if (!Number.isFinite(num)) return fallback\n return formatCurrency(num, options)\n}\n\nexport type CurrencyFormatters = {\n currency: string | null\n format: (value: number) => string\n formatWithDecimals: (value: number) => string\n formatCompact: (value: number) => string\n formatSafe: (value: unknown) => string\n}\n\n/**\n * Binds the resolved currency once so widgets can hand chart and KPI components a\n * stable single-argument formatter \u2014 passing `formatCurrency` by reference is what\n * made its currency option unreachable in the first place (#4620).\n */\nexport function createCurrencyFormatters(\n currency?: string | null,\n fallback = '--',\n locale?: string,\n): CurrencyFormatters {\n const code = normalizeCurrencyCode(currency)\n return {\n currency: code,\n format: (value: number) => formatCurrency(value, { currency: code, locale }),\n formatWithDecimals: (value: number) => formatCurrencyWithDecimals(value, { currency: code, locale }),\n formatCompact: (value: number) => formatCurrencyCompact(value, { currency: code, locale }),\n formatSafe: (value: unknown) => formatCurrencySafe(value, fallback, { currency: code, locale }),\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,11 @@
1
+ function resolveOptionalBaseCurrencyResolver(container) {
2
+ try {
3
+ return container.resolve("baseCurrencyService");
4
+ } catch {
5
+ return void 0;
6
+ }
7
+ }
8
+ export {
9
+ resolveOptionalBaseCurrencyResolver
10
+ };
11
+ //# sourceMappingURL=optionalBaseCurrency.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/dashboards/lib/optionalBaseCurrency.ts"],
4
+ "sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\n\nexport type BaseCurrencyResolution =\n | { status: 'resolved'; code: string }\n | { status: 'missing' | 'ambiguous' | 'unavailable' }\n\nexport type BaseCurrencyResolver = {\n resolveBaseCurrency(scope: {\n tenantId: string\n organizationIds: string[]\n }): Promise<BaseCurrencyResolution>\n}\n\nexport function resolveOptionalBaseCurrencyResolver(\n container: AppContainer,\n): BaseCurrencyResolver | undefined {\n try {\n return container.resolve<BaseCurrencyResolver>('baseCurrencyService')\n } catch {\n return undefined\n }\n}\n"],
5
+ "mappings": "AAaO,SAAS,oCACd,WACkC;AAClC,MAAI;AACF,WAAO,UAAU,QAA8B,qBAAqB;AAAA,EACtE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
6
+ "names": []
7
+ }
@@ -15,6 +15,7 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
15
15
  import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
16
16
  import {
17
17
  buildAggregationQuery,
18
+ buildDistinctCurrencyQuery,
18
19
  buildGroupSourceRowsQuery,
19
20
  resolveGroupExpression
20
21
  } from "../lib/aggregations.js";
@@ -25,12 +26,12 @@ import {
25
26
  exactDecimalToNumber,
26
27
  parseExactDecimal
27
28
  } from "../lib/exactDecimal.js";
29
+ const logger = createLogger("dashboards").child({ component: "widget-data-service" });
28
30
  const WIDGET_DATA_CACHE_TTL = 12e4;
29
31
  const WIDGET_DATA_SEGMENT_TTL = 864e5;
30
32
  const WIDGET_DATA_SEGMENT_KEY = "widget-data:__segment__";
31
33
  const ENCRYPTED_GROUP_SCAN_LIMIT = 2e4;
32
34
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
33
- const logger = createLogger("dashboards").child({ component: "widget-data" });
34
35
  class WidgetDataValidationError extends Error {
35
36
  constructor(message) {
36
37
  super(message);
@@ -103,6 +104,7 @@ class WidgetDataService {
103
104
  this.scope = options.scope;
104
105
  this.registry = options.registry;
105
106
  this.cache = options.cache;
107
+ this.baseCurrencyResolver = options.baseCurrencyResolver;
106
108
  }
107
109
  buildCacheKey(request) {
108
110
  const hash = createHash("sha256");
@@ -134,16 +136,19 @@ class WidgetDataService {
134
136
  }
135
137
  }
136
138
  const shouldFetchComparison = Boolean(comparisonRange && request.dateRange);
137
- const [mainResult, comparisonResult] = await Promise.all([
139
+ const aggregatedRanges = shouldFetchComparison && comparisonRange ? [dateRangeResolved, comparisonRange] : [dateRangeResolved];
140
+ const [mainResult, comparisonResult, currency] = await Promise.all([
138
141
  this.executeQuery(request, dateRangeResolved),
139
- shouldFetchComparison && comparisonRange ? this.executeQuery(request, comparisonRange) : Promise.resolve(void 0)
142
+ shouldFetchComparison && comparisonRange ? this.executeQuery(request, comparisonRange) : Promise.resolve(void 0),
143
+ this.resolveCurrencyLabel(request, aggregatedRanges)
140
144
  ]);
141
145
  const response = {
142
146
  value: mainResult.value,
143
147
  data: mainResult.data,
144
148
  metadata: {
145
149
  fetchedAt: now.toISOString(),
146
- recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0)
150
+ recordCount: mainResult.data.length || (mainResult.value !== null ? 1 : 0),
151
+ currency
147
152
  }
148
153
  };
149
154
  if (comparisonResult && mainResult.value !== null && comparisonResult.value !== null) {
@@ -168,6 +173,81 @@ class WidgetDataService {
168
173
  }
169
174
  return response;
170
175
  }
176
+ /**
177
+ * Resolves the currency the response may be labelled with. The base currency of the
178
+ * scope is only half the answer: it describes how the organizations are configured, not
179
+ * what the aggregated rows are actually denominated in. A PLN-based organization holding
180
+ * both PLN and EUR orders would otherwise sum them and present the total as PLN (#4676),
181
+ * so the base currency survives only when every aggregated row carries exactly that code.
182
+ *
183
+ * The check is deliberately conservative — anything it cannot prove resolves to `null`
184
+ * and the widgets render an explicitly unlabelled number, which is recoverable, while a
185
+ * confident wrong label is not.
186
+ */
187
+ async resolveCurrencyLabel(request, aggregatedRanges) {
188
+ const baseCurrency = await this.resolveBaseCurrency();
189
+ if (!baseCurrency) return null;
190
+ try {
191
+ for (const range of aggregatedRanges) {
192
+ const uniform = await this.rowsShareCurrency(request, range, baseCurrency);
193
+ if (!uniform) return null;
194
+ }
195
+ return baseCurrency;
196
+ } catch (err) {
197
+ logger.warn("Row-currency uniformity check failed; leaving the amount unlabelled", {
198
+ err,
199
+ entityType: request.entityType
200
+ });
201
+ return null;
202
+ }
203
+ }
204
+ /**
205
+ * Reads the distinct per-row currencies of the rows one aggregation range would sum.
206
+ * Entities that declare no `currencyField` carry no per-row currency to contradict the
207
+ * base one, so they pass. An empty range has nothing to mislabel and also passes.
208
+ *
209
+ * A missing currency cannot prove the row uses the scope's base currency, so it fails
210
+ * closed just like a conflicting code. An empty range has no row to mislabel and passes.
211
+ */
212
+ async rowsShareCurrency(request, dateRange, expectedCode) {
213
+ const query = buildDistinctCurrencyQuery({
214
+ entityType: request.entityType,
215
+ dateRange: dateRange && request.dateRange ? { field: request.dateRange.field, ...dateRange } : void 0,
216
+ filters: request.filters,
217
+ scope: this.scope,
218
+ registry: this.registry
219
+ });
220
+ if (!query) return true;
221
+ const rows = await this.em.getConnection().execute(query.sql, query.params);
222
+ const resultRows = Array.isArray(rows) ? rows : [];
223
+ const recordedCodes = /* @__PURE__ */ new Set();
224
+ for (const row of resultRows) {
225
+ const code = typeof row.code === "string" ? row.code.trim().toUpperCase() : "";
226
+ if (!code) return false;
227
+ recordedCodes.add(code);
228
+ }
229
+ if (recordedCodes.size === 0) return true;
230
+ if (recordedCodes.size > 1) return false;
231
+ return recordedCodes.has(expectedCode);
232
+ }
233
+ /**
234
+ * Resolves the base currency of the current scope so money widgets can label amounts
235
+ * with the tenant's own currency instead of a hard-coded default (#4620). The lookup
236
+ * is soft: a scope spanning organizations with different base currencies, a missing
237
+ * base currency, or an unavailable currencies module all resolve to `null`, and the
238
+ * widgets then render unlabelled numbers rather than a wrong currency.
239
+ */
240
+ resolveBaseCurrency() {
241
+ if (!this.baseCurrencyPromise) {
242
+ const organizationIds = [...new Set(this.scope.organizationIds ?? [])];
243
+ if (!this.baseCurrencyResolver || organizationIds.length === 0) {
244
+ this.baseCurrencyPromise = Promise.resolve(null);
245
+ } else {
246
+ this.baseCurrencyPromise = this.baseCurrencyResolver.resolveBaseCurrency({ tenantId: this.scope.tenantId, organizationIds }).then((result) => result.status === "resolved" ? result.code : null).catch(() => null);
247
+ }
248
+ }
249
+ return this.baseCurrencyPromise;
250
+ }
171
251
  validateRequest(request) {
172
252
  if (!this.registry.isValidEntityType(request.entityType)) {
173
253
  throw new WidgetDataValidationError(`Invalid entity type: ${request.entityType}`);
@@ -553,8 +633,8 @@ class WidgetDataService {
553
633
  return decryptWithAesGcm(first, dek) ?? first;
554
634
  }
555
635
  }
556
- function createWidgetDataService(em, scope, registry, cache) {
557
- return new WidgetDataService({ em, scope, registry, cache });
636
+ function createWidgetDataService(em, scope, registry, cache, baseCurrencyResolver) {
637
+ return new WidgetDataService({ em, scope, registry, cache, baseCurrencyResolver });
558
638
  }
559
639
  export {
560
640
  WidgetDataEncryptionUnavailableError,