@asteby/metacore-runtime-react 47.0.0 → 48.0.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.
Files changed (178) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/audit-info.d.ts +54 -0
  5. package/dist/audit-info.d.ts.map +1 -0
  6. package/dist/audit-info.js +142 -0
  7. package/dist/business/catalog-product-search.d.ts +25 -0
  8. package/dist/business/catalog-product-search.d.ts.map +1 -0
  9. package/dist/business/catalog-product-search.js +57 -0
  10. package/dist/business/catalog.d.ts.map +1 -1
  11. package/dist/business/catalog.js +2 -0
  12. package/dist/business/document-editor-model.d.ts +107 -0
  13. package/dist/business/document-editor-model.d.ts.map +1 -0
  14. package/dist/business/document-editor-model.js +279 -0
  15. package/dist/business/document-editor-panels.d.ts +64 -0
  16. package/dist/business/document-editor-panels.d.ts.map +1 -0
  17. package/dist/business/document-editor-panels.js +58 -0
  18. package/dist/business/document-editor.d.ts +27 -0
  19. package/dist/business/document-editor.d.ts.map +1 -0
  20. package/dist/business/document-editor.js +445 -0
  21. package/dist/business/document-lines.d.ts.map +1 -1
  22. package/dist/business/document-lines.js +5 -1
  23. package/dist/business/index.d.ts +5 -0
  24. package/dist/business/index.d.ts.map +1 -1
  25. package/dist/business/index.js +5 -0
  26. package/dist/business/line-items.d.ts +7 -0
  27. package/dist/business/line-items.d.ts.map +1 -1
  28. package/dist/business/line-items.js +6 -1
  29. package/dist/business/payment-allocator.d.ts +19 -0
  30. package/dist/business/payment-allocator.d.ts.map +1 -0
  31. package/dist/business/payment-allocator.js +31 -0
  32. package/dist/business/product-search.d.ts +4 -0
  33. package/dist/business/product-search.d.ts.map +1 -1
  34. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.js +19 -8
  36. package/dist/document-form-dialog.d.ts +31 -2
  37. package/dist/document-form-dialog.d.ts.map +1 -1
  38. package/dist/document-form-dialog.js +43 -11
  39. package/dist/dynamic-columns-helpers.d.ts +9 -0
  40. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  41. package/dist/dynamic-columns-helpers.js +19 -0
  42. package/dist/dynamic-columns.d.ts +8 -2
  43. package/dist/dynamic-columns.d.ts.map +1 -1
  44. package/dist/dynamic-columns.js +21 -5
  45. package/dist/dynamic-crud-page.d.ts +6 -0
  46. package/dist/dynamic-crud-page.d.ts.map +1 -1
  47. package/dist/dynamic-crud-page.js +14 -5
  48. package/dist/dynamic-form-schema.d.ts.map +1 -1
  49. package/dist/dynamic-form-schema.js +15 -3
  50. package/dist/dynamic-form.d.ts +16 -1
  51. package/dist/dynamic-form.d.ts.map +1 -1
  52. package/dist/dynamic-form.js +81 -20
  53. package/dist/dynamic-kanban.d.ts +9 -3
  54. package/dist/dynamic-kanban.d.ts.map +1 -1
  55. package/dist/dynamic-kanban.js +39 -13
  56. package/dist/dynamic-line-items.d.ts.map +1 -1
  57. package/dist/dynamic-line-items.js +3 -0
  58. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  59. package/dist/dynamic-multi-select-field.js +2 -0
  60. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  61. package/dist/dynamic-relation-helpers.js +8 -0
  62. package/dist/dynamic-relation.js +1 -1
  63. package/dist/dynamic-select-field.d.ts.map +1 -1
  64. package/dist/dynamic-select-field.js +4 -0
  65. package/dist/index.d.ts +8 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -2
  68. package/dist/installed-addons-context.d.ts +15 -0
  69. package/dist/installed-addons-context.d.ts.map +1 -0
  70. package/dist/installed-addons-context.js +39 -0
  71. package/dist/option-filter.d.ts +31 -0
  72. package/dist/option-filter.d.ts.map +1 -0
  73. package/dist/option-filter.js +49 -0
  74. package/dist/org-runtime-context.d.ts +8 -0
  75. package/dist/org-runtime-context.d.ts.map +1 -1
  76. package/dist/org-runtime-context.js +8 -0
  77. package/dist/org-runtime-provider.d.ts +3 -1
  78. package/dist/org-runtime-provider.d.ts.map +1 -1
  79. package/dist/org-runtime-provider.js +3 -3
  80. package/dist/primitives/aging.d.ts +34 -0
  81. package/dist/primitives/aging.d.ts.map +1 -0
  82. package/dist/primitives/aging.js +48 -0
  83. package/dist/primitives/allocation.d.ts +42 -0
  84. package/dist/primitives/allocation.d.ts.map +1 -0
  85. package/dist/primitives/allocation.js +59 -0
  86. package/dist/primitives/approvals.d.ts +71 -0
  87. package/dist/primitives/approvals.d.ts.map +1 -0
  88. package/dist/primitives/approvals.js +48 -0
  89. package/dist/primitives/contributions.d.ts +82 -0
  90. package/dist/primitives/contributions.d.ts.map +1 -0
  91. package/dist/primitives/contributions.js +126 -0
  92. package/dist/primitives/document-kinds.d.ts +25 -0
  93. package/dist/primitives/document-kinds.d.ts.map +1 -0
  94. package/dist/primitives/document-kinds.js +26 -0
  95. package/dist/primitives/index.d.ts +8 -0
  96. package/dist/primitives/index.d.ts.map +1 -0
  97. package/dist/primitives/index.js +23 -0
  98. package/dist/primitives/panels.d.ts +68 -0
  99. package/dist/primitives/panels.d.ts.map +1 -0
  100. package/dist/primitives/panels.js +3 -0
  101. package/dist/primitives/use-contributions.d.ts +15 -0
  102. package/dist/primitives/use-contributions.d.ts.map +1 -0
  103. package/dist/primitives/use-contributions.js +30 -0
  104. package/dist/record-prefill-bar.d.ts +11 -0
  105. package/dist/record-prefill-bar.d.ts.map +1 -0
  106. package/dist/record-prefill-bar.js +33 -0
  107. package/dist/types.d.ts +142 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/use-options-resolver.d.ts +8 -0
  110. package/dist/use-options-resolver.d.ts.map +1 -1
  111. package/dist/use-options-resolver.js +8 -2
  112. package/package.json +5 -5
  113. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  114. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  115. package/src/__tests__/audit-info.test.tsx +103 -0
  116. package/src/__tests__/catalog-product-search.test.ts +61 -0
  117. package/src/__tests__/dependent-options.test.tsx +12 -5
  118. package/src/__tests__/document-editor-model.test.ts +167 -0
  119. package/src/__tests__/document-editor.test.tsx +261 -0
  120. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  121. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  122. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  123. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  124. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  125. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  126. package/src/__tests__/line-item-totals.test.ts +28 -0
  127. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  128. package/src/__tests__/option-filter.test.tsx +115 -0
  129. package/src/__tests__/primitives-federated.test.tsx +156 -0
  130. package/src/__tests__/primitives.test.ts +224 -0
  131. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  132. package/src/__tests__/structured-view-value.test.tsx +5 -1
  133. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  134. package/src/action-modal-dispatcher.tsx +47 -4
  135. package/src/audit-info.tsx +219 -0
  136. package/src/business/catalog-product-search.ts +87 -0
  137. package/src/business/catalog.ts +2 -0
  138. package/src/business/document-editor-model.ts +325 -0
  139. package/src/business/document-editor-panels.tsx +164 -0
  140. package/src/business/document-editor.tsx +727 -0
  141. package/src/business/document-lines.ts +5 -1
  142. package/src/business/index.ts +5 -0
  143. package/src/business/line-items.ts +15 -1
  144. package/src/business/payment-allocator.tsx +110 -0
  145. package/src/business/product-search.ts +4 -0
  146. package/src/dialogs/dynamic-record.tsx +42 -6
  147. package/src/document-form-dialog.tsx +92 -12
  148. package/src/dropdown-menu-entry.test.ts +23 -0
  149. package/src/dynamic-columns-helpers.ts +22 -0
  150. package/src/dynamic-columns.tsx +23 -6
  151. package/src/dynamic-crud-page.tsx +22 -3
  152. package/src/dynamic-form-schema.ts +15 -2
  153. package/src/dynamic-form.tsx +120 -39
  154. package/src/dynamic-kanban.tsx +41 -10
  155. package/src/dynamic-line-items.tsx +3 -0
  156. package/src/dynamic-multi-select-field.tsx +2 -0
  157. package/src/dynamic-relation-helpers.ts +9 -0
  158. package/src/dynamic-relation.tsx +7 -2
  159. package/src/dynamic-select-field.tsx +4 -0
  160. package/src/index.ts +34 -1
  161. package/src/installed-addons-context.tsx +58 -0
  162. package/src/option-filter.ts +88 -0
  163. package/src/org-runtime-context.ts +10 -0
  164. package/src/org-runtime-provider.tsx +7 -2
  165. package/src/primitives/aging.ts +76 -0
  166. package/src/primitives/allocation.ts +104 -0
  167. package/src/primitives/approvals.ts +103 -0
  168. package/src/primitives/contributions.ts +208 -0
  169. package/src/primitives/document-kinds.ts +62 -0
  170. package/src/primitives/index.ts +23 -0
  171. package/src/primitives/panels.ts +59 -0
  172. package/src/primitives/use-contributions.ts +42 -0
  173. package/src/record-prefill-bar.tsx +62 -0
  174. package/src/row-action-stage-gate.test.ts +33 -0
  175. package/src/types.ts +142 -0
  176. package/src/use-options-resolver.ts +19 -1
  177. package/vitest.config.ts +1 -0
  178. package/vitest.setup.ts +7 -0
@@ -0,0 +1,58 @@
1
+ // InstalledAddonsProvider / useAddonInstalled / useCapabilityProvided —
2
+ // primitivo ÚNICO de "¿qué hay instalado?" para hosts y addons.
3
+ //
4
+ // Hoy cada addon lo resuelve a su manera: POS lee `installedAddons` del host
5
+ // (lib/host.ts addonKnownMissing), sondea `/options/<Model>` (use-model-available:
6
+ // 403/404 = ausente) o hardcodea claves (caja-policy useAnyFiscalAddonInstalled);
7
+ // `useInstalledAddons` vive sólo en @asteby/metacore-marketplace. Esto lo sube a
8
+ // runtime-react con dos niveles:
9
+ // - addon key (useAddonInstalled('fiscal_mexico'))
10
+ // - capability (useCapabilityProvided('fiscal.invoice_issuer')) — contrato
11
+ // independiente del país/vertical: fiscal_mexico, fiscal_colombia… lo proveen
12
+ // vía manifest `provides_capabilities`; caja pregunta por la capacidad,
13
+ // no por la clave del addon.
14
+ // Fuente: el host monta el provider con lo que ya trae su bootstrap
15
+ // (installed addons + provides_capabilities del manifest). Sin provider:
16
+ // `undefined` = desconocido (el consumidor decide; NUNCA asume ausente).
17
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
18
+
19
+ export interface InstalledAddonsValue {
20
+ addons: ReadonlySet<string>
21
+ capabilities: ReadonlySet<string>
22
+ }
23
+
24
+ const Ctx = createContext<InstalledAddonsValue | null>(null)
25
+
26
+ export function InstalledAddonsProvider({
27
+ addons,
28
+ capabilities = [],
29
+ children,
30
+ }: {
31
+ addons: readonly string[]
32
+ capabilities?: readonly string[]
33
+ children: ReactNode
34
+ }) {
35
+ const aKey = [...addons].sort().join(',')
36
+ const cKey = [...capabilities].sort().join(',')
37
+ const value = useMemo<InstalledAddonsValue>(
38
+ () => ({ addons: new Set(addons), capabilities: new Set(capabilities) }),
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ [aKey, cKey],
41
+ )
42
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>
43
+ }
44
+
45
+ /** true/false si el host lo sabe; undefined si no hay provider. */
46
+ export function useAddonInstalled(key: string): boolean | undefined {
47
+ const v = useContext(Ctx)
48
+ return v ? v.addons.has(key) : undefined
49
+ }
50
+
51
+ export function useCapabilityProvided(capability: string): boolean | undefined {
52
+ const v = useContext(Ctx)
53
+ return v ? v.capabilities.has(capability) : undefined
54
+ }
55
+
56
+ export function useInstalledAddons(): InstalledAddonsValue | null {
57
+ return useContext(Ctx)
58
+ }
@@ -0,0 +1,88 @@
1
+ // Option filter — lets a manifest HIDE options of a relation / dynamic picker
2
+ // (e.g. keep cancelled invoices out of the "abonos" invoice selector, PIT-059).
3
+ //
4
+ // The filter runs client-side over the options the resolver already fetched, so
5
+ // it needs no kernel change: it reads the extra scalar columns the options
6
+ // endpoint returns (they land in `option.meta`) plus the standard `label`,
7
+ // `name`, `description`, `color`, `icon` keys. Fully opt-in: a field without
8
+ // `option_filter` / `optionFilter` behaves exactly as before.
9
+ import type { ResolvedOption } from './use-options-resolver'
10
+
11
+ export interface OptionFilterRule {
12
+ /** Option property to test: a `meta` key (e.g. `status`) or `id`/`value`/`label`/`name`/`description`/`color`/`icon`. */
13
+ field: string
14
+ /** Keep only options whose value equals this. */
15
+ equals?: string | number | boolean
16
+ /** snake_case alias of `notEquals`. */
17
+ not_equals?: string | number | boolean
18
+ /** Hide options whose value equals this. */
19
+ notEquals?: string | number | boolean
20
+ /** Keep only options whose value is one of these. */
21
+ in?: Array<string | number | boolean>
22
+ /** Hide options whose value is one of these. */
23
+ not_in?: Array<string | number | boolean>
24
+ /** camelCase alias of `not_in`. */
25
+ notIn?: Array<string | number | boolean>
26
+ }
27
+
28
+ /** One rule, or a list of rules that must ALL pass (AND). */
29
+ export type OptionFilter = OptionFilterRule | OptionFilterRule[]
30
+
31
+ type FilterCarrier = { optionFilter?: unknown; option_filter?: unknown }
32
+
33
+ /** Reads the filter from a field (camelCase or the snake_case the kernel serves). */
34
+ export function getOptionFilter(field: unknown): OptionFilterRule[] {
35
+ if (!field || typeof field !== 'object') return []
36
+ const f = field as FilterCarrier
37
+ const raw = f.optionFilter ?? f.option_filter
38
+ const list = Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? [raw] : []
39
+ return list.filter(
40
+ (r): r is OptionFilterRule =>
41
+ !!r && typeof r === 'object' && typeof (r as OptionFilterRule).field === 'string' && (r as OptionFilterRule).field !== '',
42
+ )
43
+ }
44
+
45
+ function norm(v: unknown): string {
46
+ return String(v).trim().toLowerCase()
47
+ }
48
+
49
+ function readProp(opt: ResolvedOption, key: string): unknown {
50
+ if (key === 'id' || key === 'value' || key === 'label' || key === 'name' || key === 'description' || key === 'color' || key === 'icon' || key === 'image') {
51
+ return (opt as unknown as Record<string, unknown>)[key]
52
+ }
53
+ return opt.meta?.[key]
54
+ }
55
+
56
+ /** True when the option satisfies the rule. Comparison is trimmed + case-insensitive. */
57
+ export function optionPassesRule(opt: ResolvedOption, rule: OptionFilterRule): boolean {
58
+ const raw = readProp(opt, rule.field)
59
+ const known = raw !== undefined && raw !== null
60
+ const v = known ? norm(raw) : ''
61
+ const notEquals = rule.notEquals ?? rule.not_equals
62
+ const notIn = rule.notIn ?? rule.not_in
63
+ // Positive rules need the property to exist; negative rules cannot exclude
64
+ // what they cannot see, so an option lacking the property is kept.
65
+ if (rule.equals !== undefined && !(known && v === norm(rule.equals))) return false
66
+ if (Array.isArray(rule.in) && !(known && rule.in.some((x) => norm(x) === v))) return false
67
+ if (notEquals !== undefined && known && v === norm(notEquals)) return false
68
+ if (Array.isArray(notIn) && known && notIn.some((x) => norm(x) === v)) return false
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * Applies the field's filter to resolved options. Returns the SAME array when
74
+ * there is nothing to filter. `keepValue` (the current selection) is never
75
+ * hidden, so an existing record keeps showing its label after the option
76
+ * became ineligible.
77
+ */
78
+ export function applyOptionFilter(
79
+ options: ResolvedOption[],
80
+ rules: OptionFilterRule[],
81
+ keepValue?: unknown,
82
+ ): ResolvedOption[] {
83
+ if (rules.length === 0) return options
84
+ const keep = keepValue === undefined || keepValue === null || keepValue === '' ? null : String(keepValue)
85
+ return options.filter(
86
+ (o) => (keep !== null && String(o.id) === keep) || rules.every((r) => optionPassesRule(o, r)),
87
+ )
88
+ }
@@ -26,3 +26,13 @@ export const CurrencyContext = createContext<string | undefined>(undefined)
26
26
 
27
27
  /** Reads the nearest org currency (undefined outside a provider). */
28
28
  export const useCurrency = () => useContext(CurrencyContext)
29
+
30
+ /**
31
+ * Org default tax rate as a fraction (e.g. `0.16`), from the org's fiscal
32
+ * config. Document line editors use it when the picked product carries no rate
33
+ * of its own. `undefined` outside a provider → lines start at 0.
34
+ */
35
+ export const TaxRateContext = createContext<number | undefined>(undefined)
36
+
37
+ /** Reads the nearest org default tax rate (undefined outside a provider). */
38
+ export const useOrgTaxRate = () => useContext(TaxRateContext)
@@ -12,13 +12,15 @@ import {
12
12
  identityImageUrl,
13
13
  type GetImageUrl,
14
14
  } from './image-url-context'
15
- import { CurrencyContext, TimeZoneContext } from './org-runtime-context'
15
+ import { CurrencyContext, TaxRateContext, TimeZoneContext } from './org-runtime-context'
16
16
 
17
17
  export interface OrgRuntimeProviderProps {
18
18
  /** Org IANA timezone (e.g. `America/Mexico_City`). */
19
19
  timeZone?: string
20
20
  /** Org ISO-4217 currency (e.g. `MXN`). */
21
21
  currency?: string
22
+ /** Org default tax rate as a fraction (e.g. `0.16`) for document lines. */
23
+ taxRate?: number
22
24
  /** Resolver turning a stored path into a fetchable URL. Defaults to identity. */
23
25
  getImageUrl?: GetImageUrl
24
26
  children: ReactNode
@@ -27,6 +29,7 @@ export interface OrgRuntimeProviderProps {
27
29
  export function OrgRuntimeProvider({
28
30
  timeZone,
29
31
  currency,
32
+ taxRate,
30
33
  getImageUrl = identityImageUrl,
31
34
  children,
32
35
  }: OrgRuntimeProviderProps) {
@@ -34,7 +37,9 @@ export function OrgRuntimeProvider({
34
37
  <ImageUrlContext.Provider value={getImageUrl}>
35
38
  <TimeZoneContext.Provider value={timeZone}>
36
39
  <CurrencyContext.Provider value={currency}>
37
- {children}
40
+ <TaxRateContext.Provider value={taxRate}>
41
+ {children}
42
+ </TaxRateContext.Provider>
38
43
  </CurrencyContext.Provider>
39
44
  </TimeZoneContext.Provider>
40
45
  </ImageUrlContext.Provider>
@@ -0,0 +1,76 @@
1
+ // AgingTable — antigüedad de saldos (CxC y CxP) como función pura.
2
+ //
3
+ // customers ya guarda aging_1_30 … aging_90_plus por cliente (refresh_credit_file).
4
+ // Esta función es la MISMA regla para la UI (drill-down por documento, CxP que
5
+ // hoy no la tiene) y para reportes: un solo lugar decide qué es «corriente».
6
+ import { roundMoney } from '../business/format'
7
+
8
+ export interface AgingBucket {
9
+ key: string
10
+ label: string
11
+ /** Días vencidos desde (inclusive). `current` usa -Infinity..0. */
12
+ from: number
13
+ /** Días vencidos hasta (inclusive). `null` = sin tope. */
14
+ to: number | null
15
+ }
16
+
17
+ export const DEFAULT_AGING_BUCKETS: AgingBucket[] = [
18
+ { key: 'current', label: 'Corriente', from: Number.NEGATIVE_INFINITY, to: 0 },
19
+ { key: 'd1_30', label: '1–30', from: 1, to: 30 },
20
+ { key: 'd31_60', label: '31–60', from: 31, to: 60 },
21
+ { key: 'd61_90', label: '61–90', from: 61, to: 90 },
22
+ { key: 'd90_plus', label: '90+', from: 91, to: null },
23
+ ]
24
+
25
+ export interface AgingDoc {
26
+ party_id: string
27
+ party_name?: string
28
+ due_at: string | null // ISO date; null = vence al emitir
29
+ issued_at: string
30
+ balance: number
31
+ }
32
+
33
+ export interface AgingRow {
34
+ party_id: string
35
+ party_name?: string
36
+ buckets: Record<string, number>
37
+ total: number
38
+ overdue: number
39
+ oldest_overdue_days: number
40
+ }
41
+
42
+ const DAY = 86_400_000
43
+
44
+ /** Días vencidos al corte (negativo = falta para vencer). Fechas en ISO (día). */
45
+ export function daysOverdue(dueISO: string, asOfISO: string): number {
46
+ const due = Date.parse(dueISO.slice(0, 10) + 'T00:00:00Z')
47
+ const asOf = Date.parse(asOfISO.slice(0, 10) + 'T00:00:00Z')
48
+ return Math.round((asOf - due) / DAY)
49
+ }
50
+
51
+ export function bucketFor(days: number, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): string {
52
+ for (const b of buckets) if (days >= b.from && (b.to === null || days <= b.to)) return b.key
53
+ return buckets[buckets.length - 1].key
54
+ }
55
+
56
+ /** Agrupa por contraparte y tramo. Saldos ≤ 0 se ignoran (anticipos van aparte). */
57
+ export function computeAging(docs: readonly AgingDoc[], asOfISO: string, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): { rows: AgingRow[]; totals: Record<string, number>; total: number } {
58
+ const rows = new Map<string, AgingRow>()
59
+ const totals: Record<string, number> = Object.fromEntries(buckets.map(b => [b.key, 0]))
60
+ for (const d of docs) {
61
+ if (d.balance <= 0) continue
62
+ const days = daysOverdue(d.due_at ?? d.issued_at, asOfISO)
63
+ const key = bucketFor(days, buckets)
64
+ const row = rows.get(d.party_id) ?? { party_id: d.party_id, party_name: d.party_name, buckets: Object.fromEntries(buckets.map(b => [b.key, 0])), total: 0, overdue: 0, oldest_overdue_days: 0 }
65
+ row.buckets[key] = roundMoney(row.buckets[key] + d.balance)
66
+ row.total = roundMoney(row.total + d.balance)
67
+ if (days > 0) {
68
+ row.overdue = roundMoney(row.overdue + d.balance)
69
+ row.oldest_overdue_days = Math.max(row.oldest_overdue_days, days)
70
+ }
71
+ totals[key] = roundMoney(totals[key] + d.balance)
72
+ rows.set(d.party_id, row)
73
+ }
74
+ const list = [...rows.values()].sort((a, b) => b.overdue - a.overdue || b.total - a.total)
75
+ return { rows: list, totals, total: roundMoney(list.reduce((s, r) => s + r.total, 0)) }
76
+ }
@@ -0,0 +1,104 @@
1
+ // PaymentAllocator — reglas puras para aplicar un pago a varios documentos
2
+ // abiertos (CxC: facturas del cliente; CxP: facturas del proveedor).
3
+ //
4
+ // Lo usan: cobro multi-factura + REP (customers / fiscal_mexico), CxC
5
+ // «Registrar abono», CxP «Aplicar pago», abonos de apartado. Sin React y sin
6
+ // host: la UI (PaymentAllocator) y el backend (wasm) pueden validar con lo mismo.
7
+ import { roundMoney } from '../business/format'
8
+
9
+ /** Documento abierto con saldo. `installments_paid` = parcialidades previas vigentes. */
10
+ export interface OpenDocument {
11
+ id: string
12
+ number: string
13
+ issued_at: string // ISO date
14
+ due_at?: string | null
15
+ total: number
16
+ balance: number
17
+ currency: string
18
+ /** `PPD` / `PUE` u otro: el núcleo no lo interpreta; fiscal_mexico sí. */
19
+ payment_method?: string | null
20
+ installments_paid?: number
21
+ /** Extensiones de país (UUID, serie…). Pasan intactas a la línea. */
22
+ extra?: Record<string, unknown>
23
+ }
24
+
25
+ export interface Allocation {
26
+ document_id: string
27
+ amount: number
28
+ /** Saldo anterior / pagado / insoluto: lo que pide el DoctoRelacionado del REP y el estado de cuenta. */
29
+ balance_before: number
30
+ balance_after: number
31
+ /** Número de parcialidad de ESTE pago para el documento (1-based). */
32
+ installment: number
33
+ }
34
+
35
+ export type AllocationStrategy = 'oldest_due_first' | 'oldest_issued_first' | 'manual'
36
+
37
+ export interface AllocationResult {
38
+ allocations: Allocation[]
39
+ applied: number
40
+ unapplied: number
41
+ }
42
+
43
+ function sortKey(d: OpenDocument, s: AllocationStrategy): string {
44
+ return s === 'oldest_issued_first' ? d.issued_at : (d.due_at ?? d.issued_at)
45
+ }
46
+
47
+ /** Reparte `amount` sobre `docs` según la estrategia. `manual` respeta `manual[id]`. */
48
+ export function allocatePayment(
49
+ amount: number,
50
+ docs: readonly OpenDocument[],
51
+ strategy: AllocationStrategy = 'oldest_due_first',
52
+ manual: Readonly<Record<string, number>> = {},
53
+ ): AllocationResult {
54
+ let left = roundMoney(Math.max(0, amount))
55
+ const ordered = strategy === 'manual' ? [...docs] : [...docs].sort((a, b) => sortKey(a, strategy).localeCompare(sortKey(b, strategy)))
56
+ const allocations: Allocation[] = []
57
+ for (const d of ordered) {
58
+ if (left <= 0) break
59
+ const balance = roundMoney(d.balance)
60
+ if (balance <= 0) continue
61
+ const want = strategy === 'manual' ? roundMoney(manual[d.id] ?? 0) : balance
62
+ const pay = roundMoney(Math.min(want, balance, left))
63
+ if (pay <= 0) continue
64
+ allocations.push({
65
+ document_id: d.id,
66
+ amount: pay,
67
+ balance_before: balance,
68
+ balance_after: roundMoney(balance - pay),
69
+ installment: (d.installments_paid ?? 0) + 1,
70
+ })
71
+ left = roundMoney(left - pay)
72
+ }
73
+ const applied = roundMoney(allocations.reduce((s, a) => s + a.amount, 0))
74
+ return { allocations, applied, unapplied: roundMoney(Math.max(0, amount) - applied) }
75
+ }
76
+
77
+ export interface AllocationIssue {
78
+ code: 'over_balance' | 'over_amount' | 'currency_mismatch' | 'unapplied' | 'non_positive'
79
+ document_id?: string
80
+ severity: 'error' | 'warning'
81
+ }
82
+
83
+ /** Revisión previa a guardar (la UI la pinta en ValidationChecklist; el backend la repite). */
84
+ export function validateAllocation(
85
+ amount: number,
86
+ currency: string,
87
+ docs: readonly OpenDocument[],
88
+ allocations: readonly Allocation[],
89
+ opts: { allowUnapplied?: boolean } = {},
90
+ ): AllocationIssue[] {
91
+ const byId = new Map(docs.map(d => [d.id, d]))
92
+ const issues: AllocationIssue[] = []
93
+ if (amount <= 0) issues.push({ code: 'non_positive', severity: 'error' })
94
+ for (const a of allocations) {
95
+ const d = byId.get(a.document_id)
96
+ if (!d) continue
97
+ if (a.amount > roundMoney(d.balance) + 0.005) issues.push({ code: 'over_balance', document_id: d.id, severity: 'error' })
98
+ if (d.currency !== currency) issues.push({ code: 'currency_mismatch', document_id: d.id, severity: 'error' })
99
+ }
100
+ const applied = roundMoney(allocations.reduce((s, a) => s + a.amount, 0))
101
+ if (applied > roundMoney(amount) + 0.005) issues.push({ code: 'over_amount', severity: 'error' })
102
+ else if (applied < roundMoney(amount) - 0.005) issues.push({ code: 'unapplied', severity: opts.allowUnapplied ? 'warning' : 'error' })
103
+ return issues
104
+ }
@@ -0,0 +1,103 @@
1
+ // ApprovalInbox — tipos y cliente de la bandeja de aprobaciones del kernel
2
+ // (dynamic/approvals.go, rutas /approvals*). Una sola bandeja para TODO lo que
3
+ // declare `supervisor_policy`, `approval` o un guard `on_violation:
4
+ // request_approval`, sin importar el addon (descuentos, crédito, OC por monto,
5
+ // cancelación CFDI, ajustes negativos…).
6
+ import type { ApiClient } from '../api-context'
7
+
8
+ /** Espejo de dynamic.ApprovalRequest (JSON). jsonb llega como string. */
9
+ export interface ApprovalRequestDTO {
10
+ id: string
11
+ addon_key: string
12
+ model_key: string
13
+ record_id: string
14
+ action_key: string
15
+ constraint_key: string
16
+ kind: 'constraint' | 'action' | 'explicit' | 'pin'
17
+ label: string
18
+ status: 'pending' | 'approved' | 'applied' | 'failed' | 'rejected' | 'expired'
19
+ requested_by: string
20
+ requested_by_role: string
21
+ requested_at: string
22
+ expires_at: string | null
23
+ roles: string | string[]
24
+ reason_required: boolean
25
+ payload: string | Record<string, unknown>
26
+ snapshot: string | Record<string, unknown>
27
+ violation: string | Record<string, unknown>
28
+ decided_by: string | null
29
+ decided_at: string | null
30
+ reason: string
31
+ error: string
32
+ }
33
+
34
+ export interface ApprovalListQuery {
35
+ status?: ApprovalRequestDTO['status']
36
+ kind?: ApprovalRequestDTO['kind']
37
+ model?: string
38
+ /** Solo las que yo pedí. */
39
+ mine?: boolean
40
+ /** Solo las que mis roles pueden decidir (default de la bandeja). */
41
+ for_me?: boolean
42
+ page?: number
43
+ per_page?: number
44
+ }
45
+
46
+ /** Categoría visible en la bandeja. El mapeo policy → categoría es extensible por addons. */
47
+ export interface ApprovalCategory {
48
+ key: string
49
+ label: string
50
+ icon?: string
51
+ /** Coincide si devuelve true (p. ej. por action_key, constraint_key o model_key). */
52
+ match: (r: ApprovalRequestDTO) => boolean
53
+ }
54
+
55
+ const categories: ApprovalCategory[] = []
56
+
57
+ /** Los addons registran su categoría al cargar (purchases: «Compras», fiscal_mexico: «Cancelación CFDI»…). */
58
+ export function registerApprovalCategory(c: ApprovalCategory): () => void {
59
+ categories.push(c)
60
+ return () => {
61
+ const i = categories.indexOf(c)
62
+ if (i >= 0) categories.splice(i, 1)
63
+ }
64
+ }
65
+
66
+ export function categorize(r: ApprovalRequestDTO): string {
67
+ return categories.find(c => c.match(r))?.key ?? 'other'
68
+ }
69
+
70
+ export function groupApprovals(items: readonly ApprovalRequestDTO[]): Record<string, ApprovalRequestDTO[]> {
71
+ const out: Record<string, ApprovalRequestDTO[]> = {}
72
+ for (const r of items) (out[categorize(r)] ??= []).push(r)
73
+ return out
74
+ }
75
+
76
+ export function parseJSONField<T = Record<string, unknown>>(v: string | T | null | undefined): T | null {
77
+ if (v == null || v === '') return null
78
+ if (typeof v !== 'string') return v
79
+ try {
80
+ return JSON.parse(v) as T
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** Cliente fino sobre las rutas del kernel; la UI lo usa vía useApi(). */
87
+ export function approvalsClient(api: ApiClient) {
88
+ const qs = (q: ApprovalListQuery) =>
89
+ Object.entries(q)
90
+ .filter(([, v]) => v !== undefined && v !== '')
91
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
92
+ .join('&')
93
+ return {
94
+ list: async (q: ApprovalListQuery = { status: 'pending', for_me: true }) => {
95
+ const res = await api.get(`/approvals?${qs(q)}`)
96
+ return { items: (res.data?.data ?? []) as ApprovalRequestDTO[], total: Number(res.data?.meta?.total ?? 0) }
97
+ },
98
+ count: async () => Number((await api.get('/approvals/count')).data?.data?.count ?? 0),
99
+ approve: (id: string, reason?: string) => api.post(`/approvals/${id}/approve`, { reason }),
100
+ reject: (id: string, reason: string) => api.post(`/approvals/${id}/reject`, { reason }),
101
+ approveWithPin: (id: string, pin: string, reason: string) => api.post(`/approvals/${id}/approve-pin`, { pin, reason }),
102
+ }
103
+ }