@open-mercato/core 0.7.1-develop.7120.1.a0154ac412 → 0.7.1-develop.7121.1.734c263bda

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 (27) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/sales/commands/documents.js +26 -62
  3. package/dist/modules/sales/commands/documents.js.map +2 -2
  4. package/dist/modules/sales/commands/returns.js +1 -30
  5. package/dist/modules/sales/commands/returns.js.map +2 -2
  6. package/dist/modules/sales/commands/shared.js +1 -4
  7. package/dist/modules/sales/commands/shared.js.map +2 -2
  8. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
  9. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
  10. package/dist/modules/sales/data/validators.js +3 -0
  11. package/dist/modules/sales/data/validators.js.map +2 -2
  12. package/dist/modules/sales/lib/calculations.js +14 -2
  13. package/dist/modules/sales/lib/calculations.js.map +2 -2
  14. package/dist/modules/sales/lib/json.js +8 -0
  15. package/dist/modules/sales/lib/json.js.map +7 -0
  16. package/dist/modules/sales/lib/lineSnapshots.js +66 -0
  17. package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
  18. package/package.json +7 -7
  19. package/src/modules/sales/commands/documents.ts +32 -66
  20. package/src/modules/sales/commands/returns.ts +1 -31
  21. package/src/modules/sales/commands/shared.ts +1 -4
  22. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
  23. package/src/modules/sales/data/validators.ts +3 -0
  24. package/src/modules/sales/lib/calculations.ts +33 -7
  25. package/src/modules/sales/lib/json.ts +4 -0
  26. package/src/modules/sales/lib/lineSnapshots.ts +98 -0
  27. package/src/modules/sales/lib/types.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.7.1-develop.7120.1.a0154ac412",
3
+ "version": "0.7.1-develop.7121.1.734c263bda",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -252,16 +252,16 @@
252
252
  "zod": "^4.4.3"
253
253
  },
254
254
  "peerDependencies": {
255
- "@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
256
- "@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
257
- "@open-mercato/ui": "0.7.1-develop.7120.1.a0154ac412",
255
+ "@open-mercato/ai-assistant": "0.7.1-develop.7121.1.734c263bda",
256
+ "@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
257
+ "@open-mercato/ui": "0.7.1-develop.7121.1.734c263bda",
258
258
  "react": "^19.0.0",
259
259
  "react-dom": "^19.0.0"
260
260
  },
261
261
  "devDependencies": {
262
- "@open-mercato/ai-assistant": "0.7.1-develop.7120.1.a0154ac412",
263
- "@open-mercato/shared": "0.7.1-develop.7120.1.a0154ac412",
264
- "@open-mercato/ui": "0.7.1-develop.7120.1.a0154ac412",
262
+ "@open-mercato/ai-assistant": "0.7.1-develop.7121.1.734c263bda",
263
+ "@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
264
+ "@open-mercato/ui": "0.7.1-develop.7121.1.734c263bda",
265
265
  "@testing-library/dom": "^10.4.1",
266
266
  "@testing-library/jest-dom": "^7.0.0",
267
267
  "@testing-library/react": "^16.3.1",
@@ -130,6 +130,11 @@ import {
130
130
  type SalesLineCalculationResult,
131
131
  type SalesDocumentCalculationResult,
132
132
  } from "../lib/types";
133
+ import {
134
+ mapOrderLineEntityToSnapshot,
135
+ mapQuoteLineEntityToSnapshot,
136
+ resolveUpsertDiscountFields,
137
+ } from "../lib/lineSnapshots";
133
138
  import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
134
139
  import { resolveDictionaryEntryValue, resolveCachedDictionaryEntryValue } from "../lib/dictionaries";
135
140
  import type { CacheStrategy } from "@open-mercato/cache";
@@ -2970,68 +2975,6 @@ function buildCalculationContext(params: {
2970
2975
  };
2971
2976
  }
2972
2977
 
2973
- function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
2974
- return {
2975
- id: line.id,
2976
- lineNumber: line.lineNumber,
2977
- kind: line.kind,
2978
- productId: line.productId ?? null,
2979
- productVariantId: line.productVariantId ?? null,
2980
- name: line.name ?? null,
2981
- description: line.description ?? null,
2982
- comment: line.comment ?? null,
2983
- quantity: toNumeric(line.quantity),
2984
- quantityUnit: line.quantityUnit ?? null,
2985
- normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
2986
- normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
2987
- uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
2988
- currencyCode: line.currencyCode,
2989
- unitPriceNet: toNumeric(line.unitPriceNet),
2990
- unitPriceGross: toNumeric(line.unitPriceGross),
2991
- discountAmount: toNumeric(line.discountAmount),
2992
- discountPercent: toNumeric(line.discountPercent),
2993
- taxRate: toNumeric(line.taxRate),
2994
- taxAmount: toNumeric(line.taxAmount),
2995
- totalNetAmount: toNumeric(line.totalNetAmount),
2996
- totalGrossAmount: toNumeric(line.totalGrossAmount),
2997
- configuration: line.configuration ? cloneJson(line.configuration) : null,
2998
- promotionCode: line.promotionCode ?? null,
2999
- metadata: line.metadata ? cloneJson(line.metadata) : null,
3000
- customFieldSetId: line.customFieldSetId ?? null,
3001
- };
3002
- }
3003
-
3004
- function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {
3005
- return {
3006
- id: line.id,
3007
- lineNumber: line.lineNumber,
3008
- kind: line.kind,
3009
- productId: line.productId ?? null,
3010
- productVariantId: line.productVariantId ?? null,
3011
- name: line.name ?? null,
3012
- description: line.description ?? null,
3013
- comment: line.comment ?? null,
3014
- quantity: toNumeric(line.quantity),
3015
- quantityUnit: line.quantityUnit ?? null,
3016
- normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
3017
- normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
3018
- uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
3019
- currencyCode: line.currencyCode,
3020
- unitPriceNet: toNumeric(line.unitPriceNet),
3021
- unitPriceGross: toNumeric(line.unitPriceGross),
3022
- discountAmount: toNumeric(line.discountAmount),
3023
- discountPercent: toNumeric(line.discountPercent),
3024
- taxRate: toNumeric(line.taxRate),
3025
- taxAmount: toNumeric(line.taxAmount),
3026
- totalNetAmount: toNumeric(line.totalNetAmount),
3027
- totalGrossAmount: toNumeric(line.totalGrossAmount),
3028
- configuration: line.configuration ? cloneJson(line.configuration) : null,
3029
- promotionCode: line.promotionCode ?? null,
3030
- metadata: line.metadata ? cloneJson(line.metadata) : null,
3031
- customFieldSetId: line.customFieldSetId ?? null,
3032
- };
3033
- }
3034
-
3035
2978
  function mapOrderAdjustmentToDraft(
3036
2979
  adjustment: SalesOrderAdjustment,
3037
2980
  ): SalesAdjustmentDraft {
@@ -3088,6 +3031,13 @@ async function emitTotalsCalculated(
3088
3031
  await eventBus.emitEvent("sales.document.totals.calculated", payload);
3089
3032
  }
3090
3033
 
3034
+ function isStoredRowSourcedLine(line: DocumentLineCreateInput): boolean {
3035
+ return (
3036
+ (line as Pick<SalesLineSnapshot, "discountAmountFromStoredRow">)
3037
+ .discountAmountFromStoredRow === true
3038
+ );
3039
+ }
3040
+
3091
3041
  function createLineSnapshotFromInput(
3092
3042
  line: DocumentLineCreateInput,
3093
3043
  lineNumber: number,
@@ -3119,6 +3069,16 @@ function createLineSnapshotFromInput(
3119
3069
  unitPriceNet: line.unitPriceNet ?? null,
3120
3070
  unitPriceGross: line.unitPriceGross ?? null,
3121
3071
  discountAmount: line.discountAmount ?? null,
3072
+ // Several callers re-run an already-mapped snapshot through here — the line
3073
+ // upsert and delete paths rebuild every line of the document, not just the
3074
+ // one being edited. Those inputs already carry a line total from a stored
3075
+ // row, so their origin must survive rather than be overwritten with the
3076
+ // caller default, or the untouched lines get re-inflated by quantity on
3077
+ // every write. Exactly one of the two origin fields is ever set, which is
3078
+ // the invariant the calculation engine relies on.
3079
+ ...(isStoredRowSourcedLine(line)
3080
+ ? { discountAmountFromStoredRow: true }
3081
+ : { discountAmountBasis: line.discountAmountBasis ?? "unit" }),
3122
3082
  discountPercent: line.discountPercent ?? null,
3123
3083
  taxRate: line.taxRate ?? null,
3124
3084
  taxAmount: line.taxAmount ?? null,
@@ -7267,8 +7227,11 @@ const orderLineUpsertCommand: CommandHandler<
7267
7227
  order.currencyCode,
7268
7228
  unitPriceNet: unitPriceNet ?? 0,
7269
7229
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7270
- discountAmount:
7271
- parsed.discountAmount ?? existingSnapshot?.discountAmount ?? 0,
7230
+ ...resolveUpsertDiscountFields(
7231
+ parsed.discountAmount,
7232
+ parsed.discountAmountBasis,
7233
+ existingSnapshot,
7234
+ ),
7272
7235
  discountPercent:
7273
7236
  parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
7274
7237
  taxRate: taxRate ?? 0,
@@ -7761,8 +7724,11 @@ const quoteLineUpsertCommand: CommandHandler<
7761
7724
  quote.currencyCode,
7762
7725
  unitPriceNet: unitPriceNet ?? 0,
7763
7726
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7764
- discountAmount:
7765
- parsed.discountAmount ?? existingSnapshot?.discountAmount ?? 0,
7727
+ ...resolveUpsertDiscountFields(
7728
+ parsed.discountAmount,
7729
+ parsed.discountAmountBasis,
7730
+ existingSnapshot,
7731
+ ),
7766
7732
  discountPercent:
7767
7733
  parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
7768
7734
  taxRate: taxRate ?? 0,
@@ -16,6 +16,7 @@ import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationR
16
16
  import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
17
17
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
18
18
  import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
19
+ import { mapOrderLineEntityToSnapshot } from '../lib/lineSnapshots'
19
20
  import { loadShippedQuantityByLine } from '../lib/shipments/snapshots'
20
21
  import { computeAvailableReturnQuantity } from '../lib/returnQuantity'
21
22
  import {
@@ -134,37 +135,6 @@ function applyOrderTotals(order: SalesOrder, totals: SalesDocumentCalculationRes
134
135
  order.lineItemCount = lineCount
135
136
  }
136
137
 
137
- function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
138
- return {
139
- id: line.id,
140
- lineNumber: line.lineNumber,
141
- kind: line.kind,
142
- productId: line.productId ?? null,
143
- productVariantId: line.productVariantId ?? null,
144
- name: line.name ?? null,
145
- description: line.description ?? null,
146
- comment: line.comment ?? null,
147
- quantity: toNumeric(line.quantity),
148
- quantityUnit: line.quantityUnit ?? null,
149
- normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
150
- normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
151
- uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
152
- currencyCode: line.currencyCode,
153
- unitPriceNet: toNumeric(line.unitPriceNet),
154
- unitPriceGross: toNumeric(line.unitPriceGross),
155
- discountAmount: toNumeric(line.discountAmount),
156
- discountPercent: toNumeric(line.discountPercent),
157
- taxRate: toNumeric(line.taxRate),
158
- taxAmount: toNumeric(line.taxAmount),
159
- totalNetAmount: toNumeric(line.totalNetAmount),
160
- totalGrossAmount: toNumeric(line.totalGrossAmount),
161
- configuration: line.configuration ? cloneJson(line.configuration) : null,
162
- promotionCode: line.promotionCode ?? null,
163
- metadata: line.metadata ? cloneJson(line.metadata) : null,
164
- customFieldSetId: line.customFieldSetId ?? null,
165
- }
166
- }
167
-
168
138
  function mapOrderAdjustmentToDraft(adjustment: SalesOrderAdjustment): SalesAdjustmentDraft {
169
139
  return {
170
140
  id: adjustment.id,
@@ -49,10 +49,7 @@ export async function enforceSalesDocumentOptimisticLock(
49
49
  })
50
50
  }
51
51
 
52
- export function cloneJson<T>(value: T): T {
53
- if (value === null || value === undefined) return value
54
- return JSON.parse(JSON.stringify(value)) as T
55
- }
52
+ export { cloneJson } from '../lib/json'
56
53
 
57
54
  export function toNumericString(value: number | null | undefined): string | null {
58
55
  if (value === undefined || value === null) return null
@@ -49,6 +49,17 @@ export function createSalesOrderLineDraft(
49
49
  const catalogSnapshot = payload.catalogSnapshot && typeof payload.catalogSnapshot === 'object'
50
50
  ? payload.catalogSnapshot as Record<string, unknown>
51
51
  : null
52
+ // `payload` is forwarded verbatim to the create API, where a supplied
53
+ // `discountAmount` is read per unit unless the caller says otherwise, while
54
+ // `record` below drives the grid, which shows the discount for the whole
55
+ // line. The multiplication is therefore the conversion between the two, not a
56
+ // second copy of the per-unit misreading the calculation engine used to hold
57
+ // — but it has to stop when the caller has explicitly said the amount is
58
+ // already a line total, or the grid would show the discount quantity times
59
+ // over while the persisted order shows it once.
60
+ const draftDiscountAmount = payload.discountAmountBasis === 'line'
61
+ ? normalizeNumber(payload.discountAmount, 0)
62
+ : normalizeNumber(payload.discountAmount, 0) * quantity
52
63
 
53
64
  return {
54
65
  id,
@@ -65,7 +76,7 @@ export function createSalesOrderLineDraft(
65
76
  currencyCode: typeof payload.currencyCode === 'string' ? payload.currencyCode : null,
66
77
  unitPriceNet,
67
78
  unitPriceGross,
68
- discountAmount: normalizeNumber(payload.discountAmount, 0) * quantity,
79
+ discountAmount: draftDiscountAmount,
69
80
  discountPercent: normalizeNumber(payload.discountPercent, 0),
70
81
  taxRate,
71
82
  totalNet,
@@ -340,6 +340,9 @@ const linePricingSchema = z.object({
340
340
  priceMode: z.enum(['net', 'gross']).optional(),
341
341
  taxRateId: uuid().optional(),
342
342
  discountAmount: decimal({ min: 0 }).optional(),
343
+ // How to read a supplied `discountAmount`. Omitting it reproduces the meaning
344
+ // the API has always documented — per unit — so no existing caller changes.
345
+ discountAmountBasis: z.enum(['unit', 'line']).optional(),
343
346
  discountPercent: percentage().optional(),
344
347
  taxRate: percentage().optional(),
345
348
  taxAmount: decimal({ min: 0 }).optional(),
@@ -77,6 +77,35 @@ function resolveAdjustmentAmounts(
77
77
  })
78
78
  }
79
79
 
80
+ // `discount_amount` stores the discount for the WHOLE line, while
81
+ // `discount_percent` records the operator's intent. The percentage therefore
82
+ // wins whenever it is set: a stored amount is only ever its cached result, and
83
+ // because the column is NOT NULL DEFAULT '0' a stored 0 cannot be told apart
84
+ // from "no discount supplied" — so it counts as absent rather than as a
85
+ // suppressing value. That is what makes recalculation idempotent, and what lets
86
+ // a row whose amount the old engine dropped or re-inflated heal itself on the
87
+ // next pass. Spec: .ai/specs/2026-08-07-sales-line-discount-amount-contract.md.
88
+ function resolveLineDiscountTotal(
89
+ line: SalesLineSnapshot,
90
+ netSubtotalBeforeDiscount: number,
91
+ quantity: number,
92
+ ): number {
93
+ const percent = toNumber(line.discountPercent, 0)
94
+ if (line.discountPercent !== null && line.discountPercent !== undefined && percent !== 0) {
95
+ return (percent / 100) * netSubtotalBeforeDiscount
96
+ }
97
+
98
+ const amount = toNumber(line.discountAmount, 0)
99
+ if (line.discountAmount === null || line.discountAmount === undefined || amount === 0) return 0
100
+
101
+ // A snapshot rebuilt from a persisted row already holds a line total, so it
102
+ // is never multiplied out again. Anything else came from a caller and keeps
103
+ // the per-unit meaning the API has always documented unless the caller says
104
+ // otherwise.
105
+ if (line.discountAmountFromStoredRow === true) return amount
106
+ return line.discountAmountBasis === 'line' ? amount : amount * quantity
107
+ }
108
+
80
109
  function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {
81
110
  const quantity = Math.max(toNumber(line.quantity, 0), 0)
82
111
  const taxRate = toNumber(line.taxRate, 0) / 100
@@ -85,14 +114,11 @@ function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResul
85
114
  (line.unitPriceGross !== null && line.unitPriceGross !== undefined
86
115
  ? toNumber(line.unitPriceGross) / (1 + taxRate)
87
116
  : 0)
88
- const discountPerUnit =
89
- line.discountAmount ??
90
- (line.discountPercent !== null && line.discountPercent !== undefined
91
- ? toNumber(line.discountPercent, 0) / 100 * toNumber(unitNet, 0)
92
- : 0)
93
-
94
117
  const netSubtotalBeforeDiscount = toNumber(unitNet, 0) * quantity
95
- const discountTotal = Math.min(Math.max(discountPerUnit * quantity, 0), netSubtotalBeforeDiscount)
118
+ const discountTotal = Math.min(
119
+ Math.max(resolveLineDiscountTotal(line, netSubtotalBeforeDiscount, quantity), 0),
120
+ netSubtotalBeforeDiscount,
121
+ )
96
122
  const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0)
97
123
  const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== undefined
98
124
  let taxAmount = explicitTaxAmount
@@ -0,0 +1,4 @@
1
+ export function cloneJson<T>(value: T): T {
2
+ if (value === null || value === undefined) return value
3
+ return JSON.parse(JSON.stringify(value)) as T
4
+ }
@@ -0,0 +1,98 @@
1
+ import type { SalesOrderLine, SalesQuoteLine } from '../data/entities'
2
+ import { cloneJson } from './json'
3
+ import type { SalesLineDiscountBasis, SalesLineSnapshot } from './types'
4
+
5
+ function toNumeric(value: unknown): number {
6
+ if (typeof value === 'number' && Number.isFinite(value)) return value
7
+ if (typeof value === 'string' && value.trim().length) {
8
+ const parsed = Number(value)
9
+ if (Number.isFinite(parsed)) return parsed
10
+ }
11
+ return 0
12
+ }
13
+
14
+ function mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesLineSnapshot {
15
+ return {
16
+ id: line.id,
17
+ lineNumber: line.lineNumber,
18
+ kind: line.kind,
19
+ productId: line.productId ?? null,
20
+ productVariantId: line.productVariantId ?? null,
21
+ name: line.name ?? null,
22
+ description: line.description ?? null,
23
+ comment: line.comment ?? null,
24
+ quantity: toNumeric(line.quantity),
25
+ quantityUnit: line.quantityUnit ?? null,
26
+ normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
27
+ normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
28
+ uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
29
+ currencyCode: line.currencyCode,
30
+ unitPriceNet: toNumeric(line.unitPriceNet),
31
+ unitPriceGross: toNumeric(line.unitPriceGross),
32
+ discountAmount: toNumeric(line.discountAmount),
33
+ // The persisted column holds the discount for the whole line, so the
34
+ // calculation engine must not multiply it out by quantity again. This flag
35
+ // is the only thing standing between a round trip and a compounding
36
+ // re-inflation, and it is why mappers must never set `discountAmountBasis`
37
+ // instead — that one means "a caller asserted this".
38
+ discountAmountFromStoredRow: true,
39
+ discountPercent: toNumeric(line.discountPercent),
40
+ taxRate: toNumeric(line.taxRate),
41
+ taxAmount: toNumeric(line.taxAmount),
42
+ totalNetAmount: toNumeric(line.totalNetAmount),
43
+ totalGrossAmount: toNumeric(line.totalGrossAmount),
44
+ configuration: line.configuration ? cloneJson(line.configuration) : null,
45
+ promotionCode: line.promotionCode ?? null,
46
+ metadata: line.metadata ? cloneJson(line.metadata) : null,
47
+ customFieldSetId: line.customFieldSetId ?? null,
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Rebuild a calculation snapshot from a persisted order line.
53
+ *
54
+ * Shared by `commands/documents.ts` and `commands/returns.ts` on purpose: the
55
+ * two files used to carry byte-identical copies, and that duplication is why
56
+ * the return flows kept the discount defect after the order flows were fixed.
57
+ */
58
+ export function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
59
+ return mapPersistedLine(line)
60
+ }
61
+
62
+ export function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {
63
+ return mapPersistedLine(line)
64
+ }
65
+
66
+ type UpsertDiscountFields = Pick<
67
+ SalesLineSnapshot,
68
+ 'discountAmount' | 'discountAmountBasis' | 'discountAmountFromStoredRow'
69
+ >
70
+
71
+ /**
72
+ * Decide the discount fields of an upsert payload one operand at a time.
73
+ *
74
+ * A line upsert can source its amount from two places with opposite meanings: a
75
+ * caller value, which is per unit unless the caller says otherwise, and the
76
+ * stored row, which is always a line total. Collapsing them into a single
77
+ * `caller ?? stored` expression means whichever origin the merged result is
78
+ * tagged with is wrong half the time — and the half that breaks is the
79
+ * upsert-existing path, which then re-inflates by a further factor of quantity
80
+ * while looking perfectly correct.
81
+ *
82
+ * Keeping `null` rather than `0` for "nothing supplied" is deliberate too: the
83
+ * column cannot represent that distinction, but the payload can, and the
84
+ * calculation engine needs it to tell a suppressing zero from an absent value.
85
+ */
86
+ export function resolveUpsertDiscountFields(
87
+ callerAmount: number | null | undefined,
88
+ callerBasis: SalesLineDiscountBasis | null | undefined,
89
+ existingSnapshot: Pick<SalesLineSnapshot, 'discountAmount'> | null | undefined,
90
+ ): UpsertDiscountFields {
91
+ if (callerAmount !== null && callerAmount !== undefined) {
92
+ return { discountAmount: callerAmount, discountAmountBasis: callerBasis ?? 'unit' }
93
+ }
94
+ return {
95
+ discountAmount: existingSnapshot?.discountAmount ?? null,
96
+ discountAmountFromStoredRow: existingSnapshot != null,
97
+ }
98
+ }
@@ -32,6 +32,13 @@ export type SalesLineUomSnapshot = {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * How a caller-supplied `discountAmount` should be read: as a rate per unit, or
37
+ * as the total for the whole line. Persisted rows always hold a line total —
38
+ * see `discountAmountFromStoredRow`, which is a separate signal on purpose.
39
+ */
40
+ export type SalesLineDiscountBasis = 'unit' | 'line'
41
+
35
42
  export type SalesLineSnapshot = {
36
43
  id?: string
37
44
  lineNumber?: number
@@ -50,6 +57,19 @@ export type SalesLineSnapshot = {
50
57
  unitPriceNet?: number | null
51
58
  unitPriceGross?: number | null
52
59
  discountAmount?: number | null
60
+ /**
61
+ * Caller-supplied ONLY. How to interpret a supplied `discountAmount`.
62
+ * Omitted means 'unit', which is the meaning the API has always documented.
63
+ * Entity-to-snapshot mappers MUST NOT set this: a populated value is what
64
+ * makes this a reliable *caller* signal.
65
+ */
66
+ discountAmountBasis?: SalesLineDiscountBasis | null
67
+ /**
68
+ * Set by entity-to-snapshot mappers ONLY. Marks `discountAmount` as
69
+ * reconstructed from a persisted row, so it is a line total and is NOT a
70
+ * caller assertion. Never persisted; never accepted from a request.
71
+ */
72
+ discountAmountFromStoredRow?: boolean
53
73
  discountPercent?: number | null
54
74
  taxRate?: number | null
55
75
  taxAmount?: number | null