@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
@@ -84,7 +84,9 @@ import {
84
84
  orderCreateSchema,
85
85
  ORDER_PAYMENT_LEDGER_WARNING_CODE,
86
86
  resolveSuppliedOrderPaymentLedgerFields,
87
+ amountsModeSchema,
87
88
  orderLineCreateSchema,
89
+ orderHeaderTotalsSchema,
88
90
  orderAdjustmentCreateSchema,
89
91
  invoiceCreateSchema,
90
92
  invoiceUpdateSchema,
@@ -130,9 +132,11 @@ import type {
130
132
  ShippingMethodContext,
131
133
  } from "../lib/providers";
132
134
  import {
135
+ type SalesAmountsMode,
133
136
  type SalesLineSnapshot,
134
137
  type SalesLineUomSnapshot,
135
138
  type SalesAdjustmentDraft,
139
+ type SalesDocumentAmounts,
136
140
  type SalesLineCalculationResult,
137
141
  type SalesDocumentCalculationResult,
138
142
  } from "../lib/types";
@@ -143,6 +147,18 @@ import {
143
147
  resolveUpsertTotalsOrigin,
144
148
  resolveUpsertCalculatedAmounts,
145
149
  } from "../lib/lineSnapshots";
150
+ import {
151
+ assertAmountsModeUnsupportedOnQuote,
152
+ assertExternalHeaderComplete,
153
+ assertExternalLineComplete,
154
+ assertUniformAmountsMode,
155
+ buildExternalHeaderTotals,
156
+ hasAnySuppliedHeaderTotal,
157
+ readPersistedHeaderTotals,
158
+ refuseOnExternalOrder,
159
+ requireOrderTotalsForExternalWrite,
160
+ } from "../lib/externalAmounts";
161
+ import { isExternalAmountsMode } from "../lib/calculations";
146
162
  import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
147
163
  import { resolveDictionaryEntryValue, resolveCachedDictionaryEntryValue } from "../lib/dictionaries";
148
164
  import { assertOrderCanBeCancelled, isCancelledOrderStatus } from "../lib/orderCancellationGuard";
@@ -390,6 +406,7 @@ type OrderGraphSnapshot = {
390
406
  outstandingAmount: string;
391
407
  totalsSnapshot: Record<string, unknown> | null;
392
408
  lineItemCount: number;
409
+ totalsMode: SalesAmountsMode;
393
410
  };
394
411
  lines: OrderLineSnapshot[];
395
412
  adjustments: OrderAdjustmentSnapshot[];
@@ -430,6 +447,7 @@ type OrderLineSnapshot = {
430
447
  taxAmount: string;
431
448
  totalNetAmount: string;
432
449
  totalGrossAmount: string;
450
+ amountsMode: SalesAmountsMode;
433
451
  configuration: Record<string, unknown> | null;
434
452
  promotionCode: string | null;
435
453
  promotionSnapshot: Record<string, unknown> | null;
@@ -604,7 +622,7 @@ const addressSnapshotSchema = z
604
622
  // is 0 — and would turn a clear into a written zero.
605
623
  const exchangeRateSchema = z.coerce.number().min(0);
606
624
 
607
- export const documentUpdateSchema = z
625
+ const documentUpdateFieldsSchema = z
608
626
  .object({
609
627
  id: z.string().uuid(),
610
628
  customerEntityId: z.string().uuid().nullable().optional(),
@@ -652,43 +670,63 @@ export const documentUpdateSchema = z
652
670
  tags: z.array(z.string().uuid()).optional(),
653
671
  customFields: z.record(z.string(), z.unknown()).optional(),
654
672
  customFieldSetId: z.string().uuid().nullable().optional(),
655
- })
656
- .refine(
657
- (input) =>
658
- typeof input.currencyCode === "string" ||
659
- input.placedAt !== undefined ||
660
- input.expectedDeliveryAt !== undefined ||
661
- input.channelId !== undefined ||
662
- input.statusEntryId !== undefined ||
663
- input.exchangeRate !== undefined ||
664
- input.paymentStatusEntryId !== undefined ||
665
- input.fulfillmentStatusEntryId !== undefined ||
666
- input.shippingAddressId !== undefined ||
667
- input.billingAddressId !== undefined ||
668
- input.customerEntityId !== undefined ||
669
- input.customerContactId !== undefined ||
670
- input.customerSnapshot !== undefined ||
671
- input.metadata !== undefined ||
672
- input.customerReference !== undefined ||
673
- input.externalReference !== undefined ||
674
- input.comment !== undefined ||
675
- input.comments !== undefined ||
676
- input.internalNotes !== undefined ||
677
- input.orderNumber !== undefined ||
678
- input.quoteNumber !== undefined ||
679
- input.shippingAddressSnapshot !== undefined ||
680
- input.billingAddressSnapshot !== undefined ||
681
- input.shippingMethodId !== undefined ||
682
- input.shippingMethodCode !== undefined ||
683
- input.shippingMethodSnapshot !== undefined ||
684
- input.paymentMethodId !== undefined ||
685
- input.paymentMethodCode !== undefined ||
686
- input.paymentMethodSnapshot !== undefined ||
687
- input.tags !== undefined ||
688
- input.customFields !== undefined ||
689
- input.customFieldSetId !== undefined,
690
- { message: "update_payload_empty" },
673
+ totalsMode: amountsModeSchema.optional(),
674
+ ...orderHeaderTotalsSchema.shape,
675
+ });
676
+
677
+ type DocumentUpdateFields = z.infer<typeof documentUpdateFieldsSchema>;
678
+
679
+ function carriesDocumentEdit(input: DocumentUpdateFields): boolean {
680
+ return (
681
+ typeof input.currencyCode === "string" ||
682
+ input.placedAt !== undefined ||
683
+ input.expectedDeliveryAt !== undefined ||
684
+ input.channelId !== undefined ||
685
+ input.statusEntryId !== undefined ||
686
+ input.exchangeRate !== undefined ||
687
+ input.paymentStatusEntryId !== undefined ||
688
+ input.fulfillmentStatusEntryId !== undefined ||
689
+ input.shippingAddressId !== undefined ||
690
+ input.billingAddressId !== undefined ||
691
+ input.customerEntityId !== undefined ||
692
+ input.customerContactId !== undefined ||
693
+ input.customerSnapshot !== undefined ||
694
+ input.metadata !== undefined ||
695
+ input.customerReference !== undefined ||
696
+ input.externalReference !== undefined ||
697
+ input.comment !== undefined ||
698
+ input.comments !== undefined ||
699
+ input.internalNotes !== undefined ||
700
+ input.orderNumber !== undefined ||
701
+ input.quoteNumber !== undefined ||
702
+ input.shippingAddressSnapshot !== undefined ||
703
+ input.billingAddressSnapshot !== undefined ||
704
+ input.shippingMethodId !== undefined ||
705
+ input.shippingMethodCode !== undefined ||
706
+ input.shippingMethodSnapshot !== undefined ||
707
+ input.paymentMethodId !== undefined ||
708
+ input.paymentMethodCode !== undefined ||
709
+ input.paymentMethodSnapshot !== undefined ||
710
+ input.tags !== undefined ||
711
+ input.customFields !== undefined ||
712
+ input.customFieldSetId !== undefined
691
713
  );
714
+ }
715
+
716
+ // The route cannot see an order's persisted mode, so it lets header totals count
717
+ // as an edit; each command re-applies `documentEditSchema` once it can. An empty
718
+ // update must not reach a quote: execution reverts a `sent` quote to draft.
719
+ export const documentUpdateSchema = documentUpdateFieldsSchema.refine(
720
+ (input) =>
721
+ carriesDocumentEdit(input) ||
722
+ input.totalsMode !== undefined ||
723
+ hasAnySuppliedHeaderTotal(input),
724
+ { message: "update_payload_empty" },
725
+ );
726
+
727
+ const documentEditSchema = documentUpdateFieldsSchema.refine(carriesDocumentEdit, {
728
+ message: "update_payload_empty",
729
+ });
692
730
 
693
731
  export type DocumentUpdateInput = z.infer<typeof documentUpdateSchema>;
694
732
 
@@ -2049,6 +2087,7 @@ async function loadOrderSnapshot(
2049
2087
  ? cloneJson(order.totalsSnapshot)
2050
2088
  : null,
2051
2089
  lineItemCount: order.lineItemCount,
2090
+ totalsMode: order.totalsMode ?? "computed",
2052
2091
  },
2053
2092
  lines: lines.map((line) => ({
2054
2093
  id: line.id,
@@ -2082,6 +2121,7 @@ async function loadOrderSnapshot(
2082
2121
  taxAmount: line.taxAmount,
2083
2122
  totalNetAmount: line.totalNetAmount,
2084
2123
  totalGrossAmount: line.totalGrossAmount,
2124
+ amountsMode: line.amountsMode ?? "computed",
2085
2125
  configuration: line.configuration ? cloneJson(line.configuration) : null,
2086
2126
  promotionCode: line.promotionCode ?? null,
2087
2127
  promotionSnapshot: line.promotionSnapshot
@@ -3124,6 +3164,7 @@ function createLineSnapshotFromInput(
3124
3164
  "customFields" in line && line.customFields
3125
3165
  ? cloneJson((line as any).customFields)
3126
3166
  : null,
3167
+ amountsMode: (line as { amountsMode?: SalesAmountsMode | null }).amountsMode ?? null,
3127
3168
  };
3128
3169
  }
3129
3170
 
@@ -3163,7 +3204,12 @@ function convertLineCalculationToEntityInput(
3163
3204
  index: number,
3164
3205
  ) {
3165
3206
  const line = lineResult.line;
3166
- return reconcileLinePersistedTotals({
3207
+ // The gross-derived net repair heals core-owned rows; on an external line a
3208
+ // zero net is the caller's figure, not a defect to correct.
3209
+ const persistTotals = isExternalAmountsMode(line.amountsMode)
3210
+ ? <T,>(payload: T): T => payload
3211
+ : reconcileLinePersistedTotals;
3212
+ return persistTotals({
3167
3213
  lineNumber: line.lineNumber ?? index + 1,
3168
3214
  kind: line.kind ?? "product",
3169
3215
  statusEntryId: sourceLine.statusEntryId ?? null,
@@ -3201,6 +3247,8 @@ function convertLineCalculationToEntityInput(
3201
3247
  taxAmount: toNumericString(lineResult.taxAmount) ?? "0",
3202
3248
  totalNetAmount: toNumericString(lineResult.netAmount) ?? "0",
3203
3249
  totalGrossAmount: toNumericString(lineResult.grossAmount) ?? "0",
3250
+ // Conditional: this converter also serves quote lines, which have no column.
3251
+ ...(line.amountsMode ? { amountsMode: line.amountsMode } : {}),
3204
3252
  configuration: line.configuration ? cloneJson(line.configuration) : null,
3205
3253
  promotionCode: line.promotionCode ?? null,
3206
3254
  promotionSnapshot: sourceLine.promotionSnapshot
@@ -3695,6 +3743,25 @@ function applyOrderTotals(
3695
3743
  order.lineItemCount = lineCount;
3696
3744
  }
3697
3745
 
3746
+ function applyOrderLineAmountsFromCalculation(params: {
3747
+ em: EntityManager;
3748
+ lines: SalesOrderLine[];
3749
+ calculation: SalesDocumentCalculationResult;
3750
+ amountsMode: SalesAmountsMode;
3751
+ }): void {
3752
+ const { em, lines, calculation, amountsMode } = params;
3753
+ lines.forEach((line, index) => {
3754
+ const result = calculation.lines[index];
3755
+ if (!result) return;
3756
+ line.amountsMode = amountsMode;
3757
+ line.discountAmount = toNumericString(result.discountAmount) ?? "0";
3758
+ line.taxAmount = toNumericString(result.taxAmount) ?? "0";
3759
+ line.totalNetAmount = toNumericString(result.netAmount) ?? "0";
3760
+ line.totalGrossAmount = toNumericString(result.grossAmount) ?? "0";
3761
+ em.persist(line);
3762
+ });
3763
+ }
3764
+
3698
3765
  function normalizePaymentTotal(value: unknown): number {
3699
3766
  if (typeof value === "number" && Number.isFinite(value))
3700
3767
  return Math.max(value, 0);
@@ -4069,6 +4136,9 @@ function applyOrderSnapshot(
4069
4136
  ? cloneJson(snapshot.totalsSnapshot)
4070
4137
  : null;
4071
4138
  order.lineItemCount = snapshot.lineItemCount;
4139
+ // Restored with the amounts, never separately: the lines come back with their
4140
+ // own mode, so omitting this leaves a mixed document the next write recomputes.
4141
+ order.totalsMode = snapshot.totalsMode ?? "computed";
4072
4142
  }
4073
4143
 
4074
4144
  async function assertQuoteGraphUndoCurrent(
@@ -4744,6 +4814,7 @@ async function restoreOrderGraph(
4744
4814
  ? cloneJson(snapshot.order.totalsSnapshot)
4745
4815
  : null,
4746
4816
  lineItemCount: snapshot.order.lineItemCount,
4817
+ totalsMode: snapshot.order.totalsMode ?? "computed",
4747
4818
  createdAt: new Date(),
4748
4819
  updatedAt: new Date(),
4749
4820
  });
@@ -4854,10 +4925,14 @@ async function restoreOrderGraph(
4854
4925
  discountPercent: line.discountPercent,
4855
4926
  taxRate: line.taxRate,
4856
4927
  taxAmount: line.taxAmount,
4857
- totalNetAmount: toNumericString(
4858
- deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
4859
- ),
4928
+ // Not repaired from gross: an external line's net is the caller's figure.
4929
+ totalNetAmount: isExternalAmountsMode(line.amountsMode)
4930
+ ? line.totalNetAmount
4931
+ : toNumericString(
4932
+ deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
4933
+ ),
4860
4934
  totalGrossAmount: line.totalGrossAmount,
4935
+ amountsMode: line.amountsMode ?? "computed",
4861
4936
  configuration: line.configuration ? cloneJson(line.configuration) : null,
4862
4937
  promotionCode: line.promotionCode ?? null,
4863
4938
  promotionSnapshot: line.promotionSnapshot
@@ -5157,6 +5232,7 @@ const createQuoteCommand: CommandHandler<
5157
5232
  updatedAt: new Date(),
5158
5233
  });
5159
5234
 
5235
+ await assertAmountsModeUnsupportedOnQuote(parsed.lines ?? []);
5160
5236
  const lineInputs = (parsed.lines ?? []).map((line, index) =>
5161
5237
  quoteLineCreateSchema.parse({
5162
5238
  ...line,
@@ -5539,6 +5615,8 @@ const updateQuoteCommand: CommandHandler<
5539
5615
  },
5540
5616
  async execute(rawInput, ctx) {
5541
5617
  const parsed = documentUpdateSchema.parse(rawInput ?? {});
5618
+ await assertAmountsModeUnsupportedOnQuote([parsed]);
5619
+ documentEditSchema.parse(rawInput ?? {});
5542
5620
  const em = (ctx.container.resolve("em") as EntityManager).fork();
5543
5621
  const quote = await findOneWithDecryption(em, SalesQuote, {
5544
5622
  id: parsed.id,
@@ -5836,6 +5914,17 @@ const updateOrderCommand: CommandHandler<
5836
5914
  if (!order)
5837
5915
  throw notFound("Sales order not found");
5838
5916
  ensureOrderScope(ctx, order.organizationId, order.tenantId);
5917
+ const currentTotalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
5918
+ const nextTotalsMode: SalesAmountsMode = parsed.totalsMode ?? currentTotalsMode;
5919
+ const totalsModeChanged = nextTotalsMode !== currentTotalsMode;
5920
+ const externalHeaderSupplied =
5921
+ isExternalAmountsMode(nextTotalsMode) && hasAnySuppliedHeaderTotal(parsed);
5922
+ if (!totalsModeChanged && !externalHeaderSupplied) {
5923
+ documentEditSchema.parse(rawInput ?? {});
5924
+ }
5925
+ if (isExternalAmountsMode(nextTotalsMode) && (totalsModeChanged || externalHeaderSupplied)) {
5926
+ await requireOrderTotalsForExternalWrite(parsed);
5927
+ }
5839
5928
  await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
5840
5929
  const previousStatus = normalizeStatusValue(order.status);
5841
5930
  if (parsed.statusEntryId && !isCancelledOrderStatus(previousStatus)) {
@@ -5849,6 +5938,13 @@ const updateOrderCommand: CommandHandler<
5849
5938
  }
5850
5939
  }
5851
5940
  let statusChangeNote: SalesNote | null = null;
5941
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(
5942
+ nextTotalsMode,
5943
+ )
5944
+ ? externalHeaderSupplied
5945
+ ? buildExternalHeaderTotals(parsed)
5946
+ : readPersistedHeaderTotals(order)
5947
+ : null;
5852
5948
  const shouldRecalculateTotals =
5853
5949
  parsed.shippingMethodId !== undefined ||
5854
5950
  parsed.shippingMethodSnapshot !== undefined ||
@@ -5856,7 +5952,9 @@ const updateOrderCommand: CommandHandler<
5856
5952
  parsed.paymentMethodId !== undefined ||
5857
5953
  parsed.paymentMethodSnapshot !== undefined ||
5858
5954
  parsed.paymentMethodCode !== undefined ||
5859
- parsed.currencyCode !== undefined;
5955
+ parsed.currencyCode !== undefined ||
5956
+ totalsModeChanged ||
5957
+ externalHeaderSupplied;
5860
5958
  // Apply the scalar update, totals recalc, and status-change note atomically.
5861
5959
  // applyDocumentUpdate/replace*/setRecordCustomFields flush mid-build, so
5862
5960
  // without a transaction a later failure would leave a half-updated order
@@ -5893,10 +5991,17 @@ const updateOrderCommand: CommandHandler<
5893
5991
  ),
5894
5992
  ]);
5895
5993
  const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
5994
+ // Leaving external: the stored tax and gross are the source's figures,
5995
+ // and the computed path honours both verbatim while it re-derives net,
5996
+ // so they must be re-derived too or net + tax ≠ gross (#7925).
5997
+ const leavingExternal =
5998
+ totalsModeChanged && !isExternalAmountsMode(nextTotalsMode);
5896
5999
  const calcLines = lineSnapshots.map((line, index) =>
5897
6000
  createLineSnapshotFromInput(
5898
6001
  {
5899
6002
  ...line,
6003
+ ...(leavingExternal ? { taxAmount: null, totalGrossAmount: null } : {}),
6004
+ amountsMode: nextTotalsMode,
5900
6005
  organizationId: order.organizationId,
5901
6006
  tenantId: order.tenantId,
5902
6007
  orderId: order.id,
@@ -5931,6 +6036,8 @@ const updateOrderCommand: CommandHandler<
5931
6036
  adjustments: adjustmentDrafts,
5932
6037
  context: calculationContext,
5933
6038
  existingTotals: resolveExistingPaymentTotals(order),
6039
+ totalsMode: nextTotalsMode,
6040
+ suppliedTotals,
5934
6041
  });
5935
6042
  const adjustmentInputs = adjustmentDrafts.map((adj, index) => ({
5936
6043
  organizationId: order.organizationId,
@@ -5955,6 +6062,15 @@ const updateOrderCommand: CommandHandler<
5955
6062
  calculation,
5956
6063
  adjustmentInputs,
5957
6064
  );
6065
+ if (totalsModeChanged) {
6066
+ applyOrderLineAmountsFromCalculation({
6067
+ em,
6068
+ lines: existingLines,
6069
+ calculation,
6070
+ amountsMode: nextTotalsMode,
6071
+ });
6072
+ order.totalsMode = nextTotalsMode;
6073
+ }
5958
6074
  applyOrderTotals(order, calculation.totals, calculation.lines.length);
5959
6075
  let eventBus: EventBus | null = null;
5960
6076
  try {
@@ -6306,7 +6422,23 @@ const createOrderCommand: CommandHandler<
6306
6422
  )
6307
6423
  : null;
6308
6424
 
6309
- const lineSnapshots: SalesLineSnapshot[] = normalizedLineInputs.map(
6425
+ const totalsMode: SalesAmountsMode = parsed.totalsMode ?? "computed";
6426
+ await assertUniformAmountsMode(totalsMode, normalizedLineInputs);
6427
+ const modedLineInputs = normalizedLineInputs.map((line) => ({
6428
+ ...line,
6429
+ amountsMode: totalsMode,
6430
+ }));
6431
+ if (isExternalAmountsMode(totalsMode)) {
6432
+ await assertExternalHeaderComplete(parsed);
6433
+ for (const [index, line] of modedLineInputs.entries()) {
6434
+ await assertExternalLineComplete(line, line.lineNumber ?? index + 1);
6435
+ }
6436
+ }
6437
+ order.totalsMode = totalsMode;
6438
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
6439
+ ? buildExternalHeaderTotals(parsed)
6440
+ : null;
6441
+ const lineSnapshots: SalesLineSnapshot[] = modedLineInputs.map(
6310
6442
  (line, index) =>
6311
6443
  createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
6312
6444
  );
@@ -6333,6 +6465,8 @@ const createOrderCommand: CommandHandler<
6333
6465
  adjustments: adjustmentDrafts,
6334
6466
  context: calculationContext,
6335
6467
  existingTotals: resolveExistingPaymentTotals(order),
6468
+ totalsMode,
6469
+ suppliedTotals,
6336
6470
  });
6337
6471
 
6338
6472
  let eventBus: EventBus | null = null;
@@ -6349,7 +6483,7 @@ const createOrderCommand: CommandHandler<
6349
6483
  [
6350
6484
  async () => {
6351
6485
  em.persist(order);
6352
- await replaceOrderLines(em, order, calculation, normalizedLineInputs);
6486
+ await replaceOrderLines(em, order, calculation, modedLineInputs);
6353
6487
  await replaceOrderAdjustments(
6354
6488
  em,
6355
6489
  order,
@@ -7188,13 +7322,17 @@ const convertQuoteToOrderCommand: CommandHandler<
7188
7322
  },
7189
7323
  };
7190
7324
 
7325
+ // Nested rather than spread flat: a line payload already has a `totalNetAmount`
7326
+ // that means the line's, not the document's.
7191
7327
  const orderLineUpsertSchema = orderLineCreateSchema.extend({
7192
7328
  id: z.string().uuid().optional(),
7329
+ orderTotals: orderHeaderTotalsSchema.optional(),
7193
7330
  });
7194
7331
 
7195
7332
  const orderLineDeleteSchema = z.object({
7196
7333
  id: z.string().uuid(),
7197
7334
  orderId: z.string().uuid(),
7335
+ orderTotals: orderHeaderTotalsSchema.optional(),
7198
7336
  });
7199
7337
 
7200
7338
  const quoteLineUpsertSchema = quoteLineCreateSchema.extend({
@@ -7511,6 +7649,14 @@ const orderLineUpsertCommand: CommandHandler<
7511
7649
  const existingSnapshot = parsed.id
7512
7650
  ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
7513
7651
  : null;
7652
+ const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
7653
+ await assertUniformAmountsMode(totalsMode, [parsed]);
7654
+ if (isExternalAmountsMode(totalsMode)) {
7655
+ await requireOrderTotalsForExternalWrite(parsed.orderTotals);
7656
+ }
7657
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
7658
+ ? buildExternalHeaderTotals(parsed.orderTotals)
7659
+ : null;
7514
7660
  await assertOrderAcceptsNewLine(order, existingSnapshot);
7515
7661
  await assertShippedOrderLineEditable(em, order, existingSnapshot, parsed);
7516
7662
  const priceMode =
@@ -7524,6 +7670,7 @@ const orderLineUpsertCommand: CommandHandler<
7524
7670
  let unitPriceGross =
7525
7671
  parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
7526
7672
  let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
7673
+ const unitPriceSupplied = unitPriceNet !== null || unitPriceGross !== null;
7527
7674
  if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
7528
7675
  let taxService: TaxCalculationService | null = null;
7529
7676
  try {
@@ -7659,11 +7806,25 @@ const orderLineUpsertCommand: CommandHandler<
7659
7806
  parsed.customFields && typeof parsed.customFields === "object"
7660
7807
  ? cloneJson(parsed.customFields)
7661
7808
  : ((existingSnapshot as any)?.customFields ?? null),
7809
+ amountsMode: totalsMode,
7662
7810
  };
7663
7811
  Object.assign(
7664
7812
  updatedSnapshot,
7665
7813
  resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
7666
7814
  );
7815
+ // After the line above, never before: tax and gross only exist once it runs,
7816
+ // and it nulls them when pricing changed, which an external line must restate.
7817
+ // The unit price is checked as resolved, not as defaulted to zero above,
7818
+ // and a price the tax service derived from no source price counts as absent.
7819
+ if (isExternalAmountsMode(totalsMode)) {
7820
+ await assertExternalLineComplete(
7821
+ {
7822
+ ...(updatedSnapshot as Record<string, unknown>),
7823
+ unitPriceNet: unitPriceSupplied ? unitPriceNet : null,
7824
+ },
7825
+ updatedSnapshot.lineNumber ?? 1,
7826
+ );
7827
+ }
7667
7828
  (updatedSnapshot as any).statusEntryId = statusEntryId;
7668
7829
  (updatedSnapshot as any).catalogSnapshot =
7669
7830
  parsed.catalogSnapshot ??
@@ -7716,6 +7877,8 @@ const orderLineUpsertCommand: CommandHandler<
7716
7877
  adjustments: adjustmentDrafts,
7717
7878
  context: calculationContext,
7718
7879
  existingTotals: resolveExistingPaymentTotals(order),
7880
+ totalsMode,
7881
+ suppliedTotals,
7719
7882
  });
7720
7883
  let eventBus: EventBus | null = null;
7721
7884
  try {
@@ -7858,6 +8021,13 @@ const orderLineDeleteCommand: CommandHandler<
7858
8021
  ),
7859
8022
  });
7860
8023
  }
8024
+ const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
8025
+ if (isExternalAmountsMode(totalsMode)) {
8026
+ await requireOrderTotalsForExternalWrite(parsed.orderTotals);
8027
+ }
8028
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
8029
+ ? buildExternalHeaderTotals(parsed.orderTotals)
8030
+ : null;
7861
8031
  const sourceInputs = filtered.map((line, index) => ({
7862
8032
  ...mapOrderLineEntityToSnapshot(line),
7863
8033
  statusEntryId: line.statusEntryId ?? null,
@@ -7895,6 +8065,8 @@ const orderLineDeleteCommand: CommandHandler<
7895
8065
  adjustments: adjustmentDrafts,
7896
8066
  context: calculationContext,
7897
8067
  existingTotals: resolveExistingPaymentTotals(order),
8068
+ totalsMode,
8069
+ suppliedTotals,
7898
8070
  });
7899
8071
  let eventBus: EventBus | null = null;
7900
8072
  try {
@@ -7986,6 +8158,7 @@ const quoteLineUpsertCommand: CommandHandler<
7986
8158
  async execute(input, ctx) {
7987
8159
  const rawBody = (input?.body as Record<string, unknown> | undefined) ?? {};
7988
8160
  const parsed = quoteLineUpsertSchema.parse(rawBody);
8161
+ await assertAmountsModeUnsupportedOnQuote([parsed]);
7989
8162
  const em = (ctx.container.resolve("em") as EntityManager).fork();
7990
8163
  const quote = await findOneWithDecryption(em, SalesQuote, {
7991
8164
  id: parsed.quoteId,
@@ -8460,6 +8633,7 @@ const orderAdjustmentUpsertCommand: CommandHandler<
8460
8633
  throw notFound("Sales order not found");
8461
8634
  ensureOrderScope(ctx, order.organizationId, order.tenantId);
8462
8635
  await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
8636
+ if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8463
8637
  if (parsed.scope === "line") {
8464
8638
  throw new CrudHttpError(400, {
8465
8639
  error: "Line-scoped adjustments are not supported yet.",
@@ -8752,6 +8926,7 @@ const orderAdjustmentDeleteCommand: CommandHandler<
8752
8926
  ensureOrderScope(ctx, order.organizationId, order.tenantId);
8753
8927
  await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
8754
8928
 
8929
+ if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8755
8930
  const [existingLines, adjustments] = await Promise.all([
8756
8931
  em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8757
8932
  em.find(
@@ -13,6 +13,8 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
13
13
  import { SalesDocumentNumberGenerator } from '../services/salesDocumentNumberGenerator'
14
14
  import type { SalesCalculationService } from '../services/salesCalculationService'
15
15
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
16
+ import { isExternalAmountsMode } from '../lib/calculations'
17
+ import { readPersistedHeaderTotals } from '../lib/externalAmounts'
16
18
  import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
17
19
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
18
20
  import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
@@ -135,6 +137,24 @@ function applyOrderTotals(order: SalesOrder, totals: SalesDocumentCalculationRes
135
137
  order.lineItemCount = lineCount
136
138
  }
137
139
 
140
+ // Deliberate, not an omission: an external order's header belongs to the source
141
+ // system, which issues its own credit document. The return itself is still recorded.
142
+ function applyOrderTotalsUnlessExternal(
143
+ order: SalesOrder,
144
+ calculation: SalesDocumentCalculationResult,
145
+ ): void {
146
+ if (isExternalAmountsMode(order.totalsMode ?? 'computed')) return
147
+ applyOrderTotals(order, calculation.totals, calculation.lines.length)
148
+ }
149
+
150
+ function resolveOrderCalculationMode(order: SalesOrder) {
151
+ const totalsMode = order.totalsMode ?? 'computed'
152
+ return {
153
+ totalsMode,
154
+ suppliedTotals: isExternalAmountsMode(totalsMode) ? readPersistedHeaderTotals(order) : null,
155
+ }
156
+ }
157
+
138
158
  function mapOrderAdjustmentToDraft(adjustment: SalesOrderAdjustment): SalesAdjustmentDraft {
139
159
  return {
140
160
  id: adjustment.id,
@@ -206,6 +226,7 @@ export async function recalculateOrderTotalsForDisplay(
206
226
  adjustments: adjustmentDrafts,
207
227
  context: buildCalculationContext(order),
208
228
  existingTotals: resolveExistingPaymentTotals(order),
229
+ ...resolveOrderCalculationMode(order),
209
230
  })
210
231
  return calculation.totals
211
232
  }
@@ -391,8 +412,9 @@ async function reverseReturnEffects(
391
412
  adjustments: adjustmentDrafts,
392
413
  context: buildCalculationContext(order),
393
414
  existingTotals: resolveExistingPaymentTotals(order),
415
+ ...resolveOrderCalculationMode(order),
394
416
  })
395
- applyOrderTotals(order, calculation.totals, calculation.lines.length)
417
+ applyOrderTotalsUnlessExternal(order, calculation)
396
418
  order.updatedAt = new Date()
397
419
  em.persist(order)
398
420
  },
@@ -577,8 +599,9 @@ async function restoreReturnEffects(
577
599
  adjustments: adjustmentDrafts,
578
600
  context: buildCalculationContext(order),
579
601
  existingTotals: resolveExistingPaymentTotals(order),
602
+ ...resolveOrderCalculationMode(order),
580
603
  })
581
- applyOrderTotals(order, calculation.totals, calculation.lines.length)
604
+ applyOrderTotalsUnlessExternal(order, calculation)
582
605
  order.updatedAt = new Date()
583
606
  em.persist(order)
584
607
  },
@@ -763,8 +786,9 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
763
786
  adjustments: adjustmentDrafts,
764
787
  context: buildCalculationContext(order),
765
788
  existingTotals: resolveExistingPaymentTotals(order),
789
+ ...resolveOrderCalculationMode(order),
766
790
  })
767
- applyOrderTotals(order, calculation.totals, calculation.lines.length)
791
+ applyOrderTotalsUnlessExternal(order, calculation)
768
792
  order.updatedAt = new Date()
769
793
  tx.persist(order)
770
794