@open-mercato/core 0.10.1-develop.7723.1.3b2ea82ea7 → 0.10.1-develop.7724.1.95e4e7d38e

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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/sales_order/index.js +2 -0
  3. package/dist/generated/entities/sales_order/index.js.map +2 -2
  4. package/dist/generated/entities/sales_order_line/index.js +2 -0
  5. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/sales/api/documents/factory.js +3 -0
  9. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  10. package/dist/modules/sales/api/order-lines/route.js +2 -1
  11. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  12. package/dist/modules/sales/backend/sales/documents/[id]/page.js +83 -6
  13. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  14. package/dist/modules/sales/commands/documents.js +141 -18
  15. package/dist/modules/sales/commands/documents.js.map +2 -2
  16. package/dist/modules/sales/commands/returns.js +24 -7
  17. package/dist/modules/sales/commands/returns.js.map +2 -2
  18. package/dist/modules/sales/components/documents/AdjustmentsSection.js +17 -11
  19. package/dist/modules/sales/components/documents/AdjustmentsSection.js.map +2 -2
  20. package/dist/modules/sales/components/documents/DocumentTotals.js +21 -3
  21. package/dist/modules/sales/components/documents/DocumentTotals.js.map +2 -2
  22. package/dist/modules/sales/components/documents/ItemsSection.js +23 -12
  23. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  24. package/dist/modules/sales/data/entities.js +10 -0
  25. package/dist/modules/sales/data/entities.js.map +2 -2
  26. package/dist/modules/sales/data/validators.js +9 -1
  27. package/dist/modules/sales/data/validators.js.map +2 -2
  28. package/dist/modules/sales/lib/calculations.js +85 -6
  29. package/dist/modules/sales/lib/calculations.js.map +2 -2
  30. package/dist/modules/sales/lib/externalAmounts.js +143 -0
  31. package/dist/modules/sales/lib/externalAmounts.js.map +7 -0
  32. package/dist/modules/sales/lib/lineSnapshots.js +6 -1
  33. package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
  34. package/dist/modules/sales/lib/makeSalesLineRoute.js +8 -2
  35. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  36. package/dist/modules/sales/lib/providers/totals.js +3 -2
  37. package/dist/modules/sales/lib/providers/totals.js.map +2 -2
  38. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js +19 -0
  39. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js.map +7 -0
  40. package/generated/entities/sales_order/index.ts +1 -0
  41. package/generated/entities/sales_order_line/index.ts +1 -0
  42. package/generated/entity-fields-registry.ts +2 -0
  43. package/package.json +7 -7
  44. package/src/modules/sales/api/documents/factory.ts +3 -0
  45. package/src/modules/sales/api/order-lines/route.ts +2 -1
  46. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +96 -0
  47. package/src/modules/sales/commands/documents.ts +219 -44
  48. package/src/modules/sales/commands/returns.ts +27 -3
  49. package/src/modules/sales/components/documents/AdjustmentsSection.tsx +34 -25
  50. package/src/modules/sales/components/documents/DocumentTotals.tsx +26 -2
  51. package/src/modules/sales/components/documents/ItemsSection.tsx +27 -12
  52. package/src/modules/sales/data/entities.ts +14 -0
  53. package/src/modules/sales/data/validators.ts +10 -0
  54. package/src/modules/sales/i18n/de.json +12 -0
  55. package/src/modules/sales/i18n/en.json +12 -0
  56. package/src/modules/sales/i18n/es.json +12 -0
  57. package/src/modules/sales/i18n/ko.json +12 -0
  58. package/src/modules/sales/i18n/pl.json +12 -0
  59. package/src/modules/sales/lib/calculations.ts +99 -4
  60. package/src/modules/sales/lib/externalAmounts.ts +174 -0
  61. package/src/modules/sales/lib/lineSnapshots.ts +6 -1
  62. package/src/modules/sales/lib/makeSalesLineRoute.ts +8 -0
  63. package/src/modules/sales/lib/providers/totals.ts +5 -2
  64. package/src/modules/sales/lib/types.ts +19 -2
  65. package/src/modules/sales/migrations/.snapshot-open-mercato.json +33 -1
  66. package/src/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.ts +25 -0
@@ -0,0 +1,174 @@
1
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import type { SalesAmountsMode } from '../data/entities'
4
+ import { EXTERNAL_HEADER_FIELDS } from './calculations'
5
+ import type { SalesDocumentAmounts } from './types'
6
+
7
+ export type ExternalHeaderField = (typeof EXTERNAL_HEADER_FIELDS)[number]
8
+
9
+ // A mirrored document that charges no shipping or surcharge has nothing to say
10
+ // about them, so they default to zero instead of being required.
11
+ const OPTIONAL_EXTERNAL_HEADER_FIELDS: ReadonlySet<ExternalHeaderField> = new Set([
12
+ 'shippingNetAmount',
13
+ 'shippingGrossAmount',
14
+ 'surchargeTotalAmount',
15
+ ])
16
+
17
+ export const REQUIRED_EXTERNAL_HEADER_FIELDS: readonly ExternalHeaderField[] =
18
+ EXTERNAL_HEADER_FIELDS.filter((field) => !OPTIONAL_EXTERNAL_HEADER_FIELDS.has(field))
19
+
20
+ /**
21
+ * `unitPriceNet` is required because the derived discount consumes it: without it
22
+ * the line's whole net would be stored as a discount, indistinguishable from a markup.
23
+ */
24
+ export const REQUIRED_EXTERNAL_LINE_FIELDS = [
25
+ 'unitPriceNet',
26
+ 'totalNetAmount',
27
+ 'totalGrossAmount',
28
+ 'taxAmount',
29
+ ] as const
30
+
31
+ export type ExternalLineField = (typeof REQUIRED_EXTERNAL_LINE_FIELDS)[number]
32
+
33
+ function toFiniteNumber(value: unknown): number | null {
34
+ if (typeof value === 'number' && Number.isFinite(value)) return value
35
+ if (typeof value === 'string' && value.trim() !== '') {
36
+ const parsed = Number(value)
37
+ return Number.isFinite(parsed) ? parsed : null
38
+ }
39
+ return null
40
+ }
41
+
42
+ type HeaderSource = Record<string, unknown> | null | undefined
43
+
44
+ export function collectSuppliedHeaderTotals(source: HeaderSource): Partial<SalesDocumentAmounts> {
45
+ const totals: Partial<SalesDocumentAmounts> = {}
46
+ if (!source) return totals
47
+ for (const field of EXTERNAL_HEADER_FIELDS) {
48
+ const value = toFiniteNumber(source[field])
49
+ if (value !== null) totals[field] = value
50
+ }
51
+ return totals
52
+ }
53
+
54
+ export function hasAnySuppliedHeaderTotal(source: HeaderSource): boolean {
55
+ if (!source) return false
56
+ return EXTERNAL_HEADER_FIELDS.some((field) => toFiniteNumber(source[field]) !== null)
57
+ }
58
+
59
+ function missingHeaderFields(source: HeaderSource): ExternalHeaderField[] {
60
+ return REQUIRED_EXTERNAL_HEADER_FIELDS.filter(
61
+ (field) => toFiniteNumber(source?.[field]) === null,
62
+ )
63
+ }
64
+
65
+ function missingLineFields(line: Record<string, unknown>): ExternalLineField[] {
66
+ return REQUIRED_EXTERNAL_LINE_FIELDS.filter((field) => toFiniteNumber(line[field]) === null)
67
+ }
68
+
69
+ /** The supplied header with its optional fields defaulted to zero. */
70
+ export function buildExternalHeaderTotals(source: HeaderSource): Partial<SalesDocumentAmounts> {
71
+ const supplied = collectSuppliedHeaderTotals(source)
72
+ for (const field of OPTIONAL_EXTERNAL_HEADER_FIELDS) {
73
+ supplied[field] ??= 0
74
+ }
75
+ return supplied
76
+ }
77
+
78
+ export async function assertExternalHeaderComplete(source: HeaderSource): Promise<void> {
79
+ const missing = missingHeaderFields(source)
80
+ if (!missing.length) return
81
+ const { translate } = await resolveTranslations()
82
+ throw new CrudHttpError(400, {
83
+ error: translate(
84
+ 'sales.errors.externalTotalsRequired',
85
+ 'An order with external amounts must supply its own document totals: {{fields}}.',
86
+ { fields: missing.join(', ') },
87
+ ),
88
+ })
89
+ }
90
+
91
+ export async function assertExternalLineComplete(
92
+ line: Record<string, unknown>,
93
+ lineNumber: number,
94
+ ): Promise<void> {
95
+ const missing = missingLineFields(line)
96
+ if (!missing.length) return
97
+ const { translate } = await resolveTranslations()
98
+ throw new CrudHttpError(400, {
99
+ error: translate(
100
+ 'sales.errors.externalAmountsIncomplete',
101
+ 'Line {{line}} has external amounts but is missing: {{fields}}. Partial specification is not a mode.',
102
+ { line: String(lineNumber), fields: missing.join(', ') },
103
+ ),
104
+ })
105
+ }
106
+
107
+ export async function assertUniformAmountsMode(
108
+ documentMode: SalesAmountsMode,
109
+ lines: Array<{ amountsMode?: SalesAmountsMode | null }>,
110
+ ): Promise<void> {
111
+ const conflicting = lines.some(
112
+ (line) => line.amountsMode != null && line.amountsMode !== documentMode,
113
+ )
114
+ if (!conflicting) return
115
+ const { translate } = await resolveTranslations()
116
+ throw new CrudHttpError(400, {
117
+ error: translate(
118
+ 'sales.errors.externalModeMixed',
119
+ 'An order and all of its lines must share the same amounts mode.',
120
+ { mode: documentMode },
121
+ ),
122
+ })
123
+ }
124
+
125
+ export async function refuseOnExternalOrder(): Promise<never> {
126
+ const { translate } = await resolveTranslations()
127
+ throw new CrudHttpError(409, {
128
+ error: translate(
129
+ 'sales.errors.externalAdjustmentRefused',
130
+ 'This order carries amounts from an external system, so adjustments cannot be added or removed. Switch it to computed amounts first.',
131
+ ),
132
+ })
133
+ }
134
+
135
+ export async function requireOrderTotalsForExternalWrite(source: HeaderSource): Promise<void> {
136
+ if (!hasAnySuppliedHeaderTotal(source)) {
137
+ const { translate } = await resolveTranslations()
138
+ throw new CrudHttpError(400, {
139
+ error: translate(
140
+ 'sales.errors.externalTotalsRequired',
141
+ 'An order with external amounts must supply its own document totals: {{fields}}.',
142
+ { fields: REQUIRED_EXTERNAL_HEADER_FIELDS.join(', ') },
143
+ ),
144
+ })
145
+ }
146
+ await assertExternalHeaderComplete(source)
147
+ }
148
+
149
+ export function readPersistedHeaderTotals(
150
+ order: Record<ExternalHeaderField, string | number | null | undefined>,
151
+ ): Partial<SalesDocumentAmounts> {
152
+ const totals: Partial<SalesDocumentAmounts> = {}
153
+ for (const field of EXTERNAL_HEADER_FIELDS) {
154
+ totals[field] = toFiniteNumber(order[field]) ?? 0
155
+ }
156
+ return totals
157
+ }
158
+
159
+ export async function assertAmountsModeUnsupportedOnQuote(
160
+ payloads:
161
+ | Array<{ amountsMode?: SalesAmountsMode | null; totalsMode?: SalesAmountsMode | null }>
162
+ | null
163
+ | undefined,
164
+ ): Promise<void> {
165
+ // Both reach quote commands, through schemas shared with orders.
166
+ if (!payloads?.some((payload) => payload.amountsMode != null || payload.totalsMode != null)) return
167
+ const { translate } = await resolveTranslations()
168
+ throw new CrudHttpError(400, {
169
+ error: translate(
170
+ 'sales.errors.externalModeUnsupportedOnQuote',
171
+ 'Quotes cannot carry an amounts mode; they always use computed amounts.',
172
+ ),
173
+ })
174
+ }
@@ -80,7 +80,12 @@ function mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesPersisted
80
80
  * the return flows kept the discount defect after the order flows were fixed.
81
81
  */
82
82
  export function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesPersistedLineSnapshot {
83
- return mapPersistedLine(line)
83
+ return {
84
+ ...mapPersistedLine(line),
85
+ // Who owns the amounts, not where they came from — never conflate it with the
86
+ // origin flags above. Order-only: quote lines have no such column.
87
+ amountsMode: line.amountsMode ?? 'computed',
88
+ }
84
89
  }
85
90
 
86
91
  export function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesPersistedLineSnapshot {
@@ -28,6 +28,8 @@ interface SalesLineRouteConfig {
28
28
  parentFkColumn: string
29
29
  parentFkParam: string
30
30
  createSchema: z.ZodObject<z.ZodRawShape>
31
+ /** Extra fields accepted by the write payloads on top of `createSchema`. */
32
+ writeExtensionShape?: z.ZodRawShape
31
33
  features: { view: string; manage: string }
32
34
  commandPrefix: string
33
35
  openApi: {
@@ -110,6 +112,7 @@ export function makeSalesLineRoute(config: SalesLineRouteConfig) {
110
112
  parentFkColumn,
111
113
  parentFkParam,
112
114
  createSchema,
115
+ writeExtensionShape,
113
116
  features,
114
117
  commandPrefix,
115
118
  } = config
@@ -127,11 +130,13 @@ export function makeSalesLineRoute(config: SalesLineRouteConfig) {
127
130
 
128
131
  const upsertSchema = createSchema.extend({
129
132
  id: z.string().uuid().optional(),
133
+ ...(writeExtensionShape ?? {}),
130
134
  })
131
135
 
132
136
  const deleteSchema = z.object({
133
137
  id: z.string().uuid(),
134
138
  [parentFkParam]: z.string().uuid(),
139
+ ...(writeExtensionShape ?? {}),
135
140
  })
136
141
 
137
142
  const routeMetadata = {
@@ -199,6 +204,8 @@ export function makeSalesLineRoute(config: SalesLineRouteConfig) {
199
204
  ]
200
205
  const returnedQuantity = F['returned_quantity']
201
206
  if (typeof returnedQuantity === 'string') fields.push(returnedQuantity)
207
+ const amountsMode = F['amounts_mode']
208
+ if (typeof amountsMode === 'string') fields.push(amountsMode)
202
209
  return fields
203
210
  })(),
204
211
  sortFieldMap: {
@@ -321,6 +328,7 @@ export function makeSalesLineRoute(config: SalesLineRouteConfig) {
321
328
  promotion_snapshot: z.record(z.string(), z.unknown()).nullable().optional(),
322
329
  metadata: z.record(z.string(), z.unknown()).nullable().optional(),
323
330
  custom_field_set_id: z.string().uuid().nullable().optional(),
331
+ amounts_mode: z.enum(['computed', 'external']).optional(),
324
332
  created_at: z.string(),
325
333
  updated_at: z.string(),
326
334
  })
@@ -1,4 +1,4 @@
1
- import { registerSalesTotalsCalculator, rebuildDocumentResult } from '../calculations'
1
+ import { isExternalAmountsMode, registerSalesTotalsCalculator, rebuildDocumentResult } from '../calculations'
2
2
  import type { SalesAdjustmentDraft, SalesDocumentCalculationResult } from '../types'
3
3
  import {
4
4
  getPaymentProvider,
@@ -180,7 +180,10 @@ export function ensureProviderTotalsCalculator() {
180
180
  if (totalsRegistered) return
181
181
  totalsRegistered = true
182
182
 
183
- registerSalesTotalsCalculator(async ({ documentKind, lines, context, current, eventBus }) => {
183
+ registerSalesTotalsCalculator(async ({ documentKind, lines, context, current, eventBus, totalsMode }) => {
184
+ // A provider charge here could not move the caller's header, so it would show
185
+ // in the breakdown and be absent from the total.
186
+ if (isExternalAmountsMode(totalsMode)) return current
184
187
  const metadata = (context.metadata ?? {}) as Record<string, unknown>
185
188
  const shippingMethod = (metadata.shippingMethod ?? null) as ShippingMethodContext | null
186
189
  const paymentMethod = (metadata.paymentMethod ?? null) as PaymentMethodContext | null
@@ -1,8 +1,17 @@
1
1
  import type { EventBus } from '@open-mercato/events'
2
2
  import type { ReferenceUnitCode } from '@open-mercato/shared/lib/units/unitCodes'
3
- import type { SalesAdjustmentKind, SalesDocumentKind, SalesLineKind } from '../data/entities'
3
+ import type {
4
+ SalesAdjustmentKind,
5
+ SalesAmountsMode,
6
+ SalesDocumentKind,
7
+ SalesLineKind,
8
+ } from '../data/entities'
4
9
 
5
- export type { SalesAdjustmentKind, SalesDocumentKind, SalesLineKind }
10
+ /**
11
+ * `SalesAmountsMode`: `computed` means core derives the amounts; `external` means
12
+ * they are the caller's, mirrored from another system, and are never recomputed.
13
+ */
14
+ export type { SalesAdjustmentKind, SalesAmountsMode, SalesDocumentKind, SalesLineKind }
6
15
 
7
16
  export type NumericLike = number | string
8
17
 
@@ -89,6 +98,8 @@ export type SalesLineSnapshot = {
89
98
  metadata?: Record<string, unknown> | null
90
99
  customFieldSetId?: string | null
91
100
  customFields?: Record<string, unknown> | null
101
+ /** `external`: net, gross and tax are returned verbatim, never derived. Omitted means `computed`. */
102
+ amountsMode?: SalesAmountsMode | null
92
103
  }
93
104
 
94
105
  export type SalesAdjustmentDraft = {
@@ -155,6 +166,8 @@ export type SalesTotalsCalculationHook = (params: {
155
166
  context: SalesCalculationContext
156
167
  current: SalesDocumentCalculationResult
157
168
  eventBus?: EventBus | null
169
+ /** `external`: the supplied header is re-applied after every calculator, so none can move it. */
170
+ totalsMode?: SalesAmountsMode | null
158
171
  }) => SalesDocumentCalculationResult | Promise<SalesDocumentCalculationResult>
159
172
 
160
173
  export type SalesCalculationContext = {
@@ -182,4 +195,8 @@ export type CalculateDocumentOptions = {
182
195
  refundedTotalAmount?: number | null
183
196
  }
184
197
  eventBus?: EventBus | null
198
+ /** `external` makes {@link CalculateDocumentOptions.suppliedTotals} authoritative for the header. */
199
+ totalsMode?: SalesAmountsMode | null
200
+ /** The caller-asserted header, honoured verbatim when `totalsMode` is `external`. */
201
+ suppliedTotals?: Partial<SalesDocumentAmounts> | null
185
202
  }
@@ -4222,6 +4222,22 @@
4222
4222
  "name": "sales_order_lines",
4223
4223
  "schema": "public",
4224
4224
  "columns": {
4225
+ "amounts_mode": {
4226
+ "name": "amounts_mode",
4227
+ "type": "text",
4228
+ "unsigned": false,
4229
+ "autoincrement": false,
4230
+ "primary": false,
4231
+ "nullable": false,
4232
+ "unique": false,
4233
+ "length": null,
4234
+ "precision": null,
4235
+ "scale": null,
4236
+ "default": "'computed'",
4237
+ "comment": null,
4238
+ "enumItems": [],
4239
+ "mappedType": "text"
4240
+ },
4225
4241
  "catalog_snapshot": {
4226
4242
  "name": "catalog_snapshot",
4227
4243
  "type": "jsonb",
@@ -5892,6 +5908,22 @@
5892
5908
  "enumItems": [],
5893
5909
  "mappedType": "uuid"
5894
5910
  },
5911
+ "totals_mode": {
5912
+ "name": "totals_mode",
5913
+ "type": "text",
5914
+ "unsigned": false,
5915
+ "autoincrement": false,
5916
+ "primary": false,
5917
+ "nullable": false,
5918
+ "unique": false,
5919
+ "length": null,
5920
+ "precision": null,
5921
+ "scale": null,
5922
+ "default": "'computed'",
5923
+ "comment": null,
5924
+ "enumItems": [],
5925
+ "mappedType": "text"
5926
+ },
5895
5927
  "totals_snapshot": {
5896
5928
  "name": "totals_snapshot",
5897
5929
  "type": "jsonb",
@@ -10943,4 +10975,4 @@
10943
10975
  ],
10944
10976
  "views": [],
10945
10977
  "nativeEnums": {}
10946
- }
10978
+ }
@@ -0,0 +1,25 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // Opt-in `external` amounts mode for sales orders: the row-level statement that
4
+ // the stored amounts are a caller's assertion mirrored from an external book of
5
+ // record, not something core derived. Two defaulted columns, no backfill — every
6
+ // existing row takes 'computed', which is exactly today's behaviour, so the
7
+ // change is observably inert until a caller sets the mode.
8
+ //
9
+ // PostgreSQL has not rewritten a table for a defaulted column add since 11, so
10
+ // both statements are metadata-only regardless of table size.
11
+ export class Migration20260910120000_sales_external_amounts_mode extends Migration {
12
+ override async up(): Promise<void> {
13
+ this.addSql(
14
+ `alter table "sales_orders" add column if not exists "totals_mode" text not null default 'computed';`,
15
+ );
16
+ this.addSql(
17
+ `alter table "sales_order_lines" add column if not exists "amounts_mode" text not null default 'computed';`,
18
+ );
19
+ }
20
+
21
+ override async down(): Promise<void> {
22
+ this.addSql(`alter table "sales_order_lines" drop column if exists "amounts_mode";`);
23
+ this.addSql(`alter table "sales_orders" drop column if exists "totals_mode";`);
24
+ }
25
+ }