@open-mercato/core 0.10.1-develop.7723.1.3b2ea82ea7 → 0.10.1-develop.7726.1.a447cc5525

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 (81) 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/planner/api/availability-date-specific.js +2 -7
  9. package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
  10. package/dist/modules/planner/commands/availability-date-specific.js +14 -31
  11. package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
  12. package/dist/modules/planner/commands/availability-weekly.js +8 -22
  13. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  14. package/dist/modules/planner/lib/availabilityMerge.js +10 -18
  15. package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
  16. package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
  17. package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
  18. package/dist/modules/sales/api/documents/factory.js +3 -0
  19. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  20. package/dist/modules/sales/api/order-lines/route.js +2 -1
  21. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  22. package/dist/modules/sales/backend/sales/documents/[id]/page.js +83 -6
  23. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  24. package/dist/modules/sales/commands/documents.js +141 -18
  25. package/dist/modules/sales/commands/documents.js.map +2 -2
  26. package/dist/modules/sales/commands/returns.js +24 -7
  27. package/dist/modules/sales/commands/returns.js.map +2 -2
  28. package/dist/modules/sales/components/documents/AdjustmentsSection.js +17 -11
  29. package/dist/modules/sales/components/documents/AdjustmentsSection.js.map +2 -2
  30. package/dist/modules/sales/components/documents/DocumentTotals.js +21 -3
  31. package/dist/modules/sales/components/documents/DocumentTotals.js.map +2 -2
  32. package/dist/modules/sales/components/documents/ItemsSection.js +23 -12
  33. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  34. package/dist/modules/sales/data/entities.js +10 -0
  35. package/dist/modules/sales/data/entities.js.map +2 -2
  36. package/dist/modules/sales/data/validators.js +9 -1
  37. package/dist/modules/sales/data/validators.js.map +2 -2
  38. package/dist/modules/sales/lib/calculations.js +85 -6
  39. package/dist/modules/sales/lib/calculations.js.map +2 -2
  40. package/dist/modules/sales/lib/externalAmounts.js +143 -0
  41. package/dist/modules/sales/lib/externalAmounts.js.map +7 -0
  42. package/dist/modules/sales/lib/lineSnapshots.js +6 -1
  43. package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
  44. package/dist/modules/sales/lib/makeSalesLineRoute.js +8 -2
  45. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  46. package/dist/modules/sales/lib/providers/totals.js +3 -2
  47. package/dist/modules/sales/lib/providers/totals.js.map +2 -2
  48. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js +19 -0
  49. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js.map +7 -0
  50. package/generated/entities/sales_order/index.ts +1 -0
  51. package/generated/entities/sales_order_line/index.ts +1 -0
  52. package/generated/entity-fields-registry.ts +2 -0
  53. package/package.json +8 -7
  54. package/src/modules/planner/api/availability-date-specific.ts +5 -10
  55. package/src/modules/planner/commands/availability-date-specific.ts +14 -33
  56. package/src/modules/planner/commands/availability-weekly.ts +15 -24
  57. package/src/modules/planner/lib/availabilityMerge.ts +13 -19
  58. package/src/modules/planner/lib/availabilityTimezone.ts +242 -0
  59. package/src/modules/sales/api/documents/factory.ts +3 -0
  60. package/src/modules/sales/api/order-lines/route.ts +2 -1
  61. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +96 -0
  62. package/src/modules/sales/commands/documents.ts +219 -44
  63. package/src/modules/sales/commands/returns.ts +27 -3
  64. package/src/modules/sales/components/documents/AdjustmentsSection.tsx +34 -25
  65. package/src/modules/sales/components/documents/DocumentTotals.tsx +26 -2
  66. package/src/modules/sales/components/documents/ItemsSection.tsx +27 -12
  67. package/src/modules/sales/data/entities.ts +14 -0
  68. package/src/modules/sales/data/validators.ts +10 -0
  69. package/src/modules/sales/i18n/de.json +12 -0
  70. package/src/modules/sales/i18n/en.json +12 -0
  71. package/src/modules/sales/i18n/es.json +12 -0
  72. package/src/modules/sales/i18n/ko.json +12 -0
  73. package/src/modules/sales/i18n/pl.json +12 -0
  74. package/src/modules/sales/lib/calculations.ts +99 -4
  75. package/src/modules/sales/lib/externalAmounts.ts +174 -0
  76. package/src/modules/sales/lib/lineSnapshots.ts +6 -1
  77. package/src/modules/sales/lib/makeSalesLineRoute.ts +8 -0
  78. package/src/modules/sales/lib/providers/totals.ts +5 -2
  79. package/src/modules/sales/lib/types.ts +19 -2
  80. package/src/modules/sales/migrations/.snapshot-open-mercato.json +33 -1
  81. package/src/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.ts +25 -0
@@ -0,0 +1,242 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+
3
+ const logger = createLogger('planner').child({ component: 'availability' })
4
+
5
+ const FALLBACK_TIME_ZONE = 'UTC'
6
+ const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
7
+
8
+ /**
9
+ * Availability rows store `timezone` as free-form `text`, so a malformed value
10
+ * must not turn a read or a write into a 500. Unknown zones degrade to UTC,
11
+ * which is already how the expander interprets every stored instant.
12
+ */
13
+ export function resolveTimeZone(value: string | null | undefined): string {
14
+ if (!isResolvableTimeZone(value)) return FALLBACK_TIME_ZONE
15
+ return value.trim()
16
+ }
17
+
18
+ export function isResolvableTimeZone(value: string | null | undefined): value is string {
19
+ if (typeof value !== 'string' || !value.trim().length) return false
20
+ try {
21
+ new Intl.DateTimeFormat('en-US', { timeZone: value.trim() })
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The replace endpoints anchor `DTSTART` in the declared zone, so an
30
+ * unresolvable value is persisted as a UTC-anchored instant under a label
31
+ * nothing can resolve. Rejecting it with a 400 would narrow the replace
32
+ * schemas (a `data/validators.ts` contract surface), so for now the write is
33
+ * accepted and flagged; the 400 is announced in UPGRADE_NOTES.md.
34
+ */
35
+ export function warnOnUnresolvableTimeZone(value: string, commandId: string): void {
36
+ if (isResolvableTimeZone(value)) return
37
+ logger.warn('[deprecated] Unresolvable availability timezone anchored to UTC; this will be rejected with 400 in a future minor release', {
38
+ commandId,
39
+ timezone: value,
40
+ fallback: FALLBACK_TIME_ZONE,
41
+ })
42
+ }
43
+
44
+ function parseTimeOfDay(value: string): { hours: number; minutes: number } | null {
45
+ const [hours, minutes] = String(value).split(':').map((part) => Number(part))
46
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
47
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
48
+ return { hours, minutes }
49
+ }
50
+
51
+ type WallClock = { year: number; month: number; day: number; hours: number; minutes: number; seconds: number }
52
+
53
+ const MINUTE_MS = 60 * 1000
54
+ const DAY_MS = 24 * 60 * MINUTE_MS
55
+ const formatterCache = new Map<string, Intl.DateTimeFormat>()
56
+
57
+ function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
58
+ let formatter = formatterCache.get(timeZone)
59
+ if (!formatter) {
60
+ formatter = new Intl.DateTimeFormat('en-US', {
61
+ timeZone,
62
+ hourCycle: 'h23',
63
+ year: 'numeric',
64
+ month: '2-digit',
65
+ day: '2-digit',
66
+ hour: '2-digit',
67
+ minute: '2-digit',
68
+ second: '2-digit',
69
+ })
70
+ formatterCache.set(timeZone, formatter)
71
+ }
72
+ return formatter
73
+ }
74
+
75
+ /**
76
+ * Wall clock of an instant in `timeZone`, read through `Intl` only. `date-fns-tz`
77
+ * seeds its offset search with host-local getters, so around DST transitions
78
+ * the same input resolved differently depending on the serving machine's `TZ`.
79
+ */
80
+ function zonedWallClock(epochMs: number, timeZone: string): WallClock {
81
+ const values: Record<string, number> = {}
82
+ for (const part of zonedFormatter(timeZone).formatToParts(new Date(epochMs))) {
83
+ if (part.type !== 'literal') values[part.type] = Number(part.value)
84
+ }
85
+ return {
86
+ year: values.year,
87
+ month: values.month,
88
+ day: values.day,
89
+ hours: values.hour,
90
+ minutes: values.minute,
91
+ seconds: values.second,
92
+ }
93
+ }
94
+
95
+ function wallClockAsUtc(clock: WallClock): number {
96
+ return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hours, clock.minutes, clock.seconds)
97
+ }
98
+
99
+ function offsetAt(epochMs: number, timeZone: string): number {
100
+ const flooredToMinute = Math.floor(epochMs / MINUTE_MS) * MINUTE_MS
101
+ return wallClockAsUtc(zonedWallClock(flooredToMinute, timeZone)) - flooredToMinute
102
+ }
103
+
104
+ function parseDateKey(dateKey: string): { year: number; month: number; day: number } | null {
105
+ if (!DATE_KEY_PATTERN.test(String(dateKey))) return null
106
+ const [year, month, day] = dateKey.split('-').map((part) => Number(part))
107
+ const probe = new Date(Date.UTC(year, month - 1, day))
108
+ if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null
109
+ return { year, month, day }
110
+ }
111
+
112
+ function formatUtcDateKey(epochMs: number): string {
113
+ return new Date(epochMs).toISOString().slice(0, 10)
114
+ }
115
+
116
+ /**
117
+ * `2026-06-15` + `09:00` + `Europe/Warsaw` → the absolute instant that wall
118
+ * time denotes. Replaces `new Date(y, m, d, h, min)`, whose result depended on
119
+ * the host process's `TZ` rather than on the zone the caller declared (#5862).
120
+ *
121
+ * DST transitions follow the `compatible` disambiguation of RFC 5545 §3.3.5 and
122
+ * Temporal: a repeated wall time resolves to its EARLIER occurrence, and a
123
+ * skipped one moves forward by the length of the gap (`Africa/Cairo` has no
124
+ * 2026-04-24 00:00, so it becomes 01:00 — the first instant of that date).
125
+ */
126
+ export function zonedWallTimeToInstant(dateKey: string, time: string, timeZone: string): Date | null {
127
+ const date = parseDateKey(dateKey)
128
+ const parsed = parseTimeOfDay(time)
129
+ if (!date || !parsed) return null
130
+ const wall = new Date(Date.UTC(date.year, date.month - 1, date.day, parsed.hours, parsed.minutes))
131
+ return zonedWallDateToInstant(wall, timeZone)
132
+ }
133
+
134
+ /** Resolve a UTC-encoded wall clock with compatible DST disambiguation. */
135
+ export function zonedWallDateToInstant(wall: Date, timeZone: string): Date {
136
+ const zone = resolveTimeZone(timeZone)
137
+ const wallAsUtc = wall.getTime()
138
+ const offsetBefore = offsetAt(wallAsUtc - DAY_MS, zone)
139
+ const offsetAfter = offsetAt(wallAsUtc + DAY_MS, zone)
140
+ if (offsetBefore === offsetAfter) return new Date(wallAsUtc - offsetBefore)
141
+ const candidates = [wallAsUtc - offsetBefore, wallAsUtc - offsetAfter].sort((left, right) => left - right)
142
+ const matching = candidates.find((candidate) => candidate + offsetAt(candidate, zone) === wallAsUtc)
143
+ return new Date(matching ?? wallAsUtc - offsetBefore)
144
+ }
145
+
146
+ /**
147
+ * The span a full-day block covers: from the first instant of `dateKey` to the
148
+ * first instant of the following date, both in `timeZone`. That is 23 or 25
149
+ * hours on DST days, so the block neither bleeds into nor falls short of its
150
+ * neighbours.
151
+ */
152
+ export function zonedDayBounds(dateKey: string, timeZone: string): { start: Date; end: Date } | null {
153
+ const date = parseDateKey(dateKey)
154
+ if (!date) return null
155
+ const nextDateKey = formatUtcDateKey(Date.UTC(date.year, date.month - 1, date.day) + DAY_MS)
156
+ const start = zonedWallTimeToInstant(dateKey, '00:00', timeZone)
157
+ const end = zonedWallTimeToInstant(nextDateKey, '00:00', timeZone)
158
+ if (!start || !end || end <= start) return null
159
+ return { start, end }
160
+ }
161
+
162
+ /**
163
+ * The calendar date an instant falls on, as seen from `timeZone`.
164
+ *
165
+ * Which day an existing rule belongs to has to be read in that rule's own zone,
166
+ * the same zone its `DTSTART` was anchored in. Derived from the host clock
167
+ * instead, replace-by-date selected a different set of rows — and the 403 gate
168
+ * in `api/availability-date-specific.ts` admitted or refused the same request —
169
+ * depending on which machine served it.
170
+ */
171
+ export function zonedDateKey(instant: Date, timeZone: string): string {
172
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
173
+ return formatUtcDateKey(Date.UTC(clock.year, clock.month - 1, clock.day))
174
+ }
175
+
176
+ /** `Date.getDay()` index (0 = Sunday) of an instant, as seen from `timeZone`. */
177
+ export function zonedWeekday(instant: Date, timeZone: string): number {
178
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
179
+ return new Date(Date.UTC(clock.year, clock.month - 1, clock.day)).getUTCDay()
180
+ }
181
+
182
+ /**
183
+ * The next occurrence of `weekday` (0 = Sunday) at or after `from`, as a
184
+ * calendar date key in `timeZone`. The zone matters: at 12:00 UTC on a Monday
185
+ * it is already Tuesday in `Pacific/Auckland`, which shifted the whole series
186
+ * by a week before this was zone-aware.
187
+ */
188
+ export function nextWeekdayDateKey(weekday: number, timeZone: string, from: Date = new Date()): string {
189
+ const clock = zonedWallClock(from.getTime(), resolveTimeZone(timeZone))
190
+ const localMidnight = Date.UTC(clock.year, clock.month - 1, clock.day)
191
+ const diff = (weekday - new Date(localMidnight).getUTCDay() + 7) % 7
192
+ return formatUtcDateKey(localMidnight + diff * DAY_MS)
193
+ }
194
+
195
+ /**
196
+ * The start and end instants of the occurrence a weekly window is anchored on.
197
+ *
198
+ * The anchor's exact length becomes the rule's `DURATION`, which every reader
199
+ * applies to every week. An anchor that spans a DST transition (Europe/Warsaw
200
+ * Sunday 00:00–08:00 on 2026-10-25 lasts 9 hours) or starts in a gap would
201
+ * therefore misstate the window for all ordinary weeks, so the upcoming
202
+ * occurrence is used only when both local endpoints and its exact length match
203
+ * the request; otherwise the previous week's occurrence anchors the series. Taking
204
+ * the earlier week keeps the upcoming occurrence inside the series for readers
205
+ * that expand forward from `DTSTART`.
206
+ *
207
+ * `from` is threaded in rather than defaulted per call: resolving a window's
208
+ * start and end against two separate `new Date()` reads can straddle midnight
209
+ * in the declared zone, which lands the anchors seven days apart.
210
+ */
211
+ export function zonedWeeklyAnchor(
212
+ weekday: number,
213
+ startTime: string,
214
+ endTime: string,
215
+ timeZone: string,
216
+ from: Date,
217
+ ): { start: Date; end: Date } | null {
218
+ const startClock = parseTimeOfDay(startTime)
219
+ const endClock = parseTimeOfDay(endTime)
220
+ if (!startClock || !endClock) return null
221
+ const zone = resolveTimeZone(timeZone)
222
+ const startMinutes = startClock.hours * 60 + startClock.minutes
223
+ const endMinutes = endClock.hours * 60 + endClock.minutes
224
+ const localLengthMs = (endMinutes - startMinutes) * MINUTE_MS
225
+ const upcoming = parseDateKey(nextWeekdayDateKey(weekday, timeZone, from))
226
+ if (!upcoming) return null
227
+ const upcomingMidnight = Date.UTC(upcoming.year, upcoming.month - 1, upcoming.day)
228
+ let fallback: { start: Date; end: Date } | null = null
229
+ for (const weekOffset of [0, -1]) {
230
+ const midnight = upcomingMidnight + weekOffset * 7 * DAY_MS
231
+ const dateKey = formatUtcDateKey(midnight)
232
+ const start = zonedWallTimeToInstant(dateKey, startTime, timeZone)
233
+ const end = zonedWallTimeToInstant(dateKey, endTime, timeZone)
234
+ if (!start || !end) return null
235
+ const startWall = wallClockAsUtc(zonedWallClock(start.getTime(), zone))
236
+ const endWall = wallClockAsUtc(zonedWallClock(end.getTime(), zone))
237
+ if (startWall !== midnight + startMinutes * MINUTE_MS || endWall !== midnight + endMinutes * MINUTE_MS) continue
238
+ if (end.getTime() - start.getTime() === localLengthMs) return { start, end }
239
+ fallback ??= { start, end }
240
+ }
241
+ return fallback
242
+ }
@@ -424,6 +424,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
424
424
 
425
425
  const orderOnlyFields = [
426
426
  'expected_delivery_at',
427
+ 'totals_mode',
427
428
  'shipping_net_amount',
428
429
  'shipping_gross_amount',
429
430
  'surcharge_total_amount',
@@ -535,6 +536,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
535
536
  paidTotalAmount: toNumber(item.paid_total_amount),
536
537
  refundedTotalAmount: toNumber(item.refunded_total_amount),
537
538
  outstandingAmount: toNumber(item.outstanding_amount),
539
+ ...(binding.kind === 'order' ? { totalsMode: item.totals_mode ?? 'computed' } : {}),
538
540
  customerSnapshot: normalizeJsonRecord(item.customer_snapshot),
539
541
  billingAddressSnapshot: normalizeJsonRecord(item.billing_address_snapshot),
540
542
  shippingAddressSnapshot: normalizeJsonRecord(item.shipping_address_snapshot),
@@ -665,6 +667,7 @@ export function buildDocumentOpenApi(binding: DocumentBinding) {
665
667
  paidTotalAmount: z.number().nullable().optional(),
666
668
  refundedTotalAmount: z.number().nullable().optional(),
667
669
  outstandingAmount: z.number().nullable().optional(),
670
+ totalsMode: z.enum(['computed', 'external']).optional(),
668
671
  createdAt: z.string(),
669
672
  updatedAt: z.string(),
670
673
  customFields: z.record(z.string(), z.unknown()).optional(),
@@ -1,5 +1,5 @@
1
1
  import { SalesOrderLine } from "../../data/entities";
2
- import { orderLineCreateSchema } from "../../data/validators";
2
+ import { orderHeaderTotalsSchema, orderLineCreateSchema } from "../../data/validators";
3
3
  import { E } from "#generated/entities.ids.generated";
4
4
  import * as F from "#generated/entities/sales_order_line";
5
5
  import { makeSalesLineRoute } from "../../lib/makeSalesLineRoute";
@@ -11,6 +11,7 @@ const route = makeSalesLineRoute({
11
11
  parentFkColumn: "order_id",
12
12
  parentFkParam: "orderId",
13
13
  createSchema: orderLineCreateSchema,
14
+ writeExtensionShape: { orderTotals: orderHeaderTotalsSchema.optional() },
14
15
  features: { view: "sales.orders.view", manage: "sales.orders.manage" },
15
16
  commandPrefix: "sales.orders.lines",
16
17
  openApi: {
@@ -77,6 +77,7 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
77
77
  import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
78
78
  import { useSalesChannelsEnabled } from '@open-mercato/core/modules/sales/components/useSalesChannelsEnabled'
79
79
  import { createLogger } from '@open-mercato/shared/lib/logger'
80
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
80
81
 
81
82
  const logger = createLogger('sales')
82
83
 
@@ -899,6 +900,7 @@ type DocumentRecord = {
899
900
  paidTotalAmount?: number | null
900
901
  refundedTotalAmount?: number | null
901
902
  outstandingAmount?: number | null
903
+ totalsMode?: 'computed' | 'external' | null
902
904
  createdAt?: string
903
905
  updatedAt?: string
904
906
  metadata?: Record<string, unknown> | null
@@ -1478,6 +1480,7 @@ function MethodInlineEditor({
1478
1480
  emptyResultsLabel,
1479
1481
  selectedHint,
1480
1482
  icon,
1483
+ note,
1481
1484
  allowClear = true,
1482
1485
  }: {
1483
1486
  label: string
@@ -1494,6 +1497,7 @@ function MethodInlineEditor({
1494
1497
  emptyResultsLabel: string
1495
1498
  selectedHint: (id: string) => string
1496
1499
  icon: React.ReactNode
1500
+ note?: string | null
1497
1501
  allowClear?: boolean
1498
1502
  }) {
1499
1503
  const t = useT()
@@ -1607,6 +1611,7 @@ function MethodInlineEditor({
1607
1611
  <div className="flex items-start justify-between gap-2">
1608
1612
  <div className="flex-1 min-w-0">
1609
1613
  <p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
1614
+ {note ? <p className="mt-1 text-xs text-muted-foreground">{note}</p> : null}
1610
1615
  {editing ? (
1611
1616
  <div
1612
1617
  className="mt-2 space-y-2"
@@ -1937,6 +1942,7 @@ export default function SalesDocumentDetailPage({
1937
1942
  const [generating, setGenerating] = React.useState(false)
1938
1943
  const [converting, setConverting] = React.useState(false)
1939
1944
  const [deleting, setDeleting] = React.useState(false)
1945
+ const [switchingToComputedAmounts, setSwitchingToComputedAmounts] = React.useState(false)
1940
1946
  const [sending, setSending] = React.useState(false)
1941
1947
  const [sendOpen, setSendOpen] = React.useState(false)
1942
1948
  const [validForDays, setValidForDays] = React.useState(DEFAULT_QUOTE_VALID_FOR_DAYS)
@@ -2844,6 +2850,8 @@ export default function SalesDocumentDetailPage({
2844
2850
  },
2845
2851
  [t]
2846
2852
  )
2853
+ const amountsAreExternal = kind === 'order' && record?.totalsMode === 'external'
2854
+
2847
2855
  const totalsItems = React.useMemo(() => {
2848
2856
  if (!record) return []
2849
2857
  const items: { key: string; label: string; amount: number | null | undefined; emphasize?: boolean }[] = [
@@ -3791,6 +3799,48 @@ export default function SalesDocumentDetailPage({
3791
3799
  }
3792
3800
  }, [fetchDocumentByKind, kind, record, runMutationWithContext, t, validForDays])
3793
3801
 
3802
+ const handleSwitchToComputedAmounts = React.useCallback(async () => {
3803
+ if (!record || kind !== 'order') return
3804
+ const ok = await confirm({
3805
+ title: t(
3806
+ 'sales.documents.amountsSwitchConfirm',
3807
+ 'Recompute this order\u2019s amounts from its lines? The totals supplied by the source system will be replaced and cannot be restored from here.',
3808
+ ),
3809
+ variant: 'default',
3810
+ })
3811
+ if (!ok) return
3812
+ setSwitchingToComputedAmounts(true)
3813
+ try {
3814
+ await runMutationWithContext(async () => {
3815
+ await withScopedApiRequestHeaders(buildOptimisticLockHeader(record.updatedAt), () =>
3816
+ apiCallOrThrow('/api/sales/orders', {
3817
+ method: 'PUT',
3818
+ headers: { 'Content-Type': 'application/json' },
3819
+ body: JSON.stringify({ id: record.id, totalsMode: 'computed' }),
3820
+ }),
3821
+ )
3822
+ }, { id: record.id, totalsMode: 'computed' })
3823
+ flash(t('sales.documents.amountsSwitchDone', 'Amounts are now computed from the lines.'), 'success')
3824
+ await refreshDocumentTotals()
3825
+ } catch (err) {
3826
+ logger.error('sales.documents.amounts.switchToComputed', { err })
3827
+ try {
3828
+ getTelemetryRuntime()?.reportError(err, {
3829
+ module: 'sales',
3830
+ code: 'sales.amounts_switch_to_computed_failed',
3831
+ attributes: { orderId: record.id },
3832
+ })
3833
+ } catch (reportFailure) {
3834
+ logger.warn('sales.documents.amounts.switchToComputed.reportFailed', { err: reportFailure })
3835
+ }
3836
+ if (!handleDocumentMutationError(err, t, () => setReloadKey((prev) => prev + 1))) {
3837
+ flash(t('sales.documents.amountsSwitchFailed', 'Could not switch to computed amounts.'), 'error')
3838
+ }
3839
+ } finally {
3840
+ setSwitchingToComputedAmounts(false)
3841
+ }
3842
+ }, [confirm, kind, record, refreshDocumentTotals, runMutationWithContext, t])
3843
+
3794
3844
  const handleDelete = React.useCallback(async () => {
3795
3845
  if (!record) return
3796
3846
  const ok = await confirm({
@@ -3851,6 +3901,14 @@ export default function SalesDocumentDetailPage({
3851
3901
  t('sales.documents.detail.shippingMethod.selected', 'Selected shipping method: {{id}}', { id })
3852
3902
  }
3853
3903
  icon={<Truck className="h-5 w-5 text-muted-foreground" />}
3904
+ note={
3905
+ amountsAreExternal
3906
+ ? t(
3907
+ 'sales.documents.amountsExternalMethodNote',
3908
+ 'The method is recorded, but no charge is added \u2014 this order\u2019s totals come from the source system.',
3909
+ )
3910
+ : null
3911
+ }
3854
3912
  />
3855
3913
  ),
3856
3914
  },
@@ -3877,6 +3935,14 @@ export default function SalesDocumentDetailPage({
3877
3935
  t('sales.documents.detail.paymentMethod.selected', 'Selected payment method: {{id}}', { id })
3878
3936
  }
3879
3937
  icon={<CreditCard className="h-5 w-5 text-muted-foreground" />}
3938
+ note={
3939
+ amountsAreExternal
3940
+ ? t(
3941
+ 'sales.documents.amountsExternalMethodNote',
3942
+ 'The method is recorded, but no charge is added \u2014 this order\u2019s totals come from the source system.',
3943
+ )
3944
+ : null
3945
+ }
3880
3946
  />
3881
3947
  ),
3882
3948
  },
@@ -3949,6 +4015,7 @@ export default function SalesDocumentDetailPage({
3949
4015
  )
3950
4016
  return fields
3951
4017
  }, [
4018
+ amountsAreExternal,
3952
4019
  handleUpdateComment,
3953
4020
  handleUpdateCustomerReference,
3954
4021
  handleUpdateExpectedDeliveryAt,
@@ -4266,6 +4333,9 @@ export default function SalesDocumentDetailPage({
4266
4333
  if (activeTab === 'items') {
4267
4334
  return (
4268
4335
  <SalesDocumentItemsSection
4336
+ // Remount on a mode flip: the switch rewrites every line server-side, and the
4337
+ // section loads its lines only once per `documentId`, which does not change.
4338
+ key={`items:${amountsAreExternal ? 'external' : 'computed'}`}
4269
4339
  documentId={record.id}
4270
4340
  kind={kind}
4271
4341
  currencyCode={record.currencyCode ?? null}
@@ -4274,6 +4344,7 @@ export default function SalesDocumentDetailPage({
4274
4344
  tenantId={(record as any)?.tenantId ?? (record as any)?.tenant_id ?? null}
4275
4345
  onActionChange={handleSectionActionChange}
4276
4346
  onItemsChange={(items) => setHasItems(items.length > 0)}
4347
+ amountsReadOnly={amountsAreExternal}
4277
4348
  />
4278
4349
  )
4279
4350
  }
@@ -4332,6 +4403,7 @@ export default function SalesDocumentDetailPage({
4332
4403
  tenantId={(record as any)?.tenantId ?? (record as any)?.tenant_id ?? null}
4333
4404
  onActionChange={handleSectionActionChange}
4334
4405
  onRowsChange={setAdjustmentRows}
4406
+ amountsReadOnly={amountsAreExternal}
4335
4407
  />
4336
4408
  )
4337
4409
  }
@@ -4914,6 +4986,30 @@ export default function SalesDocumentDetailPage({
4914
4986
  title={t('sales.documents.detail.totals.title', 'Totals')}
4915
4987
  currency={record.currencyCode ?? null}
4916
4988
  items={totalsItems}
4989
+ sourceBadge={
4990
+ amountsAreExternal
4991
+ ? {
4992
+ label: t('sales.documents.amountsExternal', 'Amounts from source'),
4993
+ hint: t(
4994
+ 'sales.documents.amountsExternalHint',
4995
+ 'Totals on this document come from an external system and are stored as supplied.',
4996
+ ),
4997
+ }
4998
+ : null
4999
+ }
5000
+ footerAction={
5001
+ amountsAreExternal ? (
5002
+ <Button
5003
+ type="button"
5004
+ variant="outline"
5005
+ size="sm"
5006
+ disabled={switchingToComputedAmounts}
5007
+ onClick={handleSwitchToComputedAmounts}
5008
+ >
5009
+ {t('sales.documents.amountsSwitchToComputed', 'Switch to computed amounts')}
5010
+ </Button>
5011
+ ) : null
5012
+ }
4917
5013
  />
4918
5014
 
4919
5015
  <div className="space-y-4" ref={detailSectionRef}>