@open-mercato/core 0.7.1-develop.7113.1.9d83dca10c → 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 (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/crmFixtures.js.map +1 -1
  3. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  5. package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
  6. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
  7. package/dist/modules/communication_channels/lib/test-seed.js +5 -0
  8. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  9. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
  10. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
  11. package/dist/modules/customers/backend/customers/deals/page.js +4 -3
  12. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  15. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
  16. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
  17. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  18. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  19. package/dist/modules/customers/cli.js +5 -5
  20. package/dist/modules/customers/cli.js.map +2 -2
  21. package/dist/modules/customers/commands/deals.js.map +2 -2
  22. package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
  23. package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
  24. package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
  25. package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
  26. package/dist/modules/customers/lib/closureStage.js.map +2 -2
  27. package/dist/modules/customers/lib/dealStatus.js +6 -4
  28. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  29. package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
  30. package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
  31. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
  32. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
  33. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
  34. package/dist/modules/eudr/notifications.js +5 -0
  35. package/dist/modules/eudr/notifications.js.map +2 -2
  36. package/dist/modules/query_index/lib/search-tokens.js +59 -28
  37. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  38. package/dist/modules/sales/commands/documents.js +146 -99
  39. package/dist/modules/sales/commands/documents.js.map +2 -2
  40. package/dist/modules/sales/commands/returns.js +1 -30
  41. package/dist/modules/sales/commands/returns.js.map +2 -2
  42. package/dist/modules/sales/commands/shared.js +1 -4
  43. package/dist/modules/sales/commands/shared.js.map +2 -2
  44. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
  45. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
  46. package/dist/modules/sales/data/validators.js +3 -0
  47. package/dist/modules/sales/data/validators.js.map +2 -2
  48. package/dist/modules/sales/lib/calculations.js +14 -2
  49. package/dist/modules/sales/lib/calculations.js.map +2 -2
  50. package/dist/modules/sales/lib/json.js +8 -0
  51. package/dist/modules/sales/lib/json.js.map +7 -0
  52. package/dist/modules/sales/lib/lineSnapshots.js +66 -0
  53. package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
  54. package/dist/modules/warranty_claims/notifications.js +5 -0
  55. package/dist/modules/warranty_claims/notifications.js.map +2 -2
  56. package/dist/modules/wms/notifications.js +2 -0
  57. package/dist/modules/wms/notifications.js.map +2 -2
  58. package/package.json +7 -7
  59. package/src/helpers/integration/crmFixtures.ts +1 -1
  60. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
  61. package/src/modules/communication_channels/lib/adapter.ts +11 -0
  62. package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
  63. package/src/modules/communication_channels/lib/test-seed.ts +19 -0
  64. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
  65. package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
  66. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
  67. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
  68. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
  69. package/src/modules/customers/cli.ts +5 -5
  70. package/src/modules/customers/commands/deals.ts +4 -3
  71. package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
  72. package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
  73. package/src/modules/customers/i18n/de.json +1 -1
  74. package/src/modules/customers/i18n/en.json +1 -1
  75. package/src/modules/customers/i18n/es.json +1 -1
  76. package/src/modules/customers/i18n/ko.json +1 -1
  77. package/src/modules/customers/i18n/pl.json +1 -1
  78. package/src/modules/customers/lib/closureStage.ts +2 -1
  79. package/src/modules/customers/lib/dealStatus.ts +23 -12
  80. package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
  81. package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
  82. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
  83. package/src/modules/eudr/notifications.ts +5 -0
  84. package/src/modules/query_index/lib/search-tokens.ts +100 -43
  85. package/src/modules/sales/commands/documents.ts +185 -105
  86. package/src/modules/sales/commands/returns.ts +1 -31
  87. package/src/modules/sales/commands/shared.ts +1 -4
  88. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
  89. package/src/modules/sales/data/validators.ts +3 -0
  90. package/src/modules/sales/lib/calculations.ts +33 -7
  91. package/src/modules/sales/lib/json.ts +4 -0
  92. package/src/modules/sales/lib/lineSnapshots.ts +98 -0
  93. package/src/modules/sales/lib/types.ts +20 -0
  94. package/src/modules/warranty_claims/notifications.ts +5 -0
  95. package/src/modules/wms/notifications.ts +2 -0
@@ -1,6 +1,7 @@
1
1
  // @ts-nocheck
2
2
 
3
3
  import { randomUUID } from "crypto";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { z } from "zod";
5
6
  import { registerCommand } from "@open-mercato/shared/lib/commands";
6
7
  import type { CommandHandler } from "@open-mercato/shared/lib/commands";
@@ -129,6 +130,11 @@ import {
129
130
  type SalesLineCalculationResult,
130
131
  type SalesDocumentCalculationResult,
131
132
  } from "../lib/types";
133
+ import {
134
+ mapOrderLineEntityToSnapshot,
135
+ mapQuoteLineEntityToSnapshot,
136
+ resolveUpsertDiscountFields,
137
+ } from "../lib/lineSnapshots";
132
138
  import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
133
139
  import { resolveDictionaryEntryValue, resolveCachedDictionaryEntryValue } from "../lib/dictionaries";
134
140
  import type { CacheStrategy } from "@open-mercato/cache";
@@ -2969,68 +2975,6 @@ function buildCalculationContext(params: {
2969
2975
  };
2970
2976
  }
2971
2977
 
2972
- function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
2973
- return {
2974
- id: line.id,
2975
- lineNumber: line.lineNumber,
2976
- kind: line.kind,
2977
- productId: line.productId ?? null,
2978
- productVariantId: line.productVariantId ?? null,
2979
- name: line.name ?? null,
2980
- description: line.description ?? null,
2981
- comment: line.comment ?? null,
2982
- quantity: toNumeric(line.quantity),
2983
- quantityUnit: line.quantityUnit ?? null,
2984
- normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
2985
- normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
2986
- uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
2987
- currencyCode: line.currencyCode,
2988
- unitPriceNet: toNumeric(line.unitPriceNet),
2989
- unitPriceGross: toNumeric(line.unitPriceGross),
2990
- discountAmount: toNumeric(line.discountAmount),
2991
- discountPercent: toNumeric(line.discountPercent),
2992
- taxRate: toNumeric(line.taxRate),
2993
- taxAmount: toNumeric(line.taxAmount),
2994
- totalNetAmount: toNumeric(line.totalNetAmount),
2995
- totalGrossAmount: toNumeric(line.totalGrossAmount),
2996
- configuration: line.configuration ? cloneJson(line.configuration) : null,
2997
- promotionCode: line.promotionCode ?? null,
2998
- metadata: line.metadata ? cloneJson(line.metadata) : null,
2999
- customFieldSetId: line.customFieldSetId ?? null,
3000
- };
3001
- }
3002
-
3003
- function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {
3004
- return {
3005
- id: line.id,
3006
- lineNumber: line.lineNumber,
3007
- kind: line.kind,
3008
- productId: line.productId ?? null,
3009
- productVariantId: line.productVariantId ?? null,
3010
- name: line.name ?? null,
3011
- description: line.description ?? null,
3012
- comment: line.comment ?? null,
3013
- quantity: toNumeric(line.quantity),
3014
- quantityUnit: line.quantityUnit ?? null,
3015
- normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
3016
- normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
3017
- uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
3018
- currencyCode: line.currencyCode,
3019
- unitPriceNet: toNumeric(line.unitPriceNet),
3020
- unitPriceGross: toNumeric(line.unitPriceGross),
3021
- discountAmount: toNumeric(line.discountAmount),
3022
- discountPercent: toNumeric(line.discountPercent),
3023
- taxRate: toNumeric(line.taxRate),
3024
- taxAmount: toNumeric(line.taxAmount),
3025
- totalNetAmount: toNumeric(line.totalNetAmount),
3026
- totalGrossAmount: toNumeric(line.totalGrossAmount),
3027
- configuration: line.configuration ? cloneJson(line.configuration) : null,
3028
- promotionCode: line.promotionCode ?? null,
3029
- metadata: line.metadata ? cloneJson(line.metadata) : null,
3030
- customFieldSetId: line.customFieldSetId ?? null,
3031
- };
3032
- }
3033
-
3034
2978
  function mapOrderAdjustmentToDraft(
3035
2979
  adjustment: SalesOrderAdjustment,
3036
2980
  ): SalesAdjustmentDraft {
@@ -3087,6 +3031,13 @@ async function emitTotalsCalculated(
3087
3031
  await eventBus.emitEvent("sales.document.totals.calculated", payload);
3088
3032
  }
3089
3033
 
3034
+ function isStoredRowSourcedLine(line: DocumentLineCreateInput): boolean {
3035
+ return (
3036
+ (line as Pick<SalesLineSnapshot, "discountAmountFromStoredRow">)
3037
+ .discountAmountFromStoredRow === true
3038
+ );
3039
+ }
3040
+
3090
3041
  function createLineSnapshotFromInput(
3091
3042
  line: DocumentLineCreateInput,
3092
3043
  lineNumber: number,
@@ -3118,6 +3069,16 @@ function createLineSnapshotFromInput(
3118
3069
  unitPriceNet: line.unitPriceNet ?? null,
3119
3070
  unitPriceGross: line.unitPriceGross ?? null,
3120
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" }),
3121
3082
  discountPercent: line.discountPercent ?? null,
3122
3083
  taxRate: line.taxRate ?? null,
3123
3084
  taxAmount: line.taxAmount ?? null,
@@ -7266,8 +7227,11 @@ const orderLineUpsertCommand: CommandHandler<
7266
7227
  order.currencyCode,
7267
7228
  unitPriceNet: unitPriceNet ?? 0,
7268
7229
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7269
- discountAmount:
7270
- parsed.discountAmount ?? existingSnapshot?.discountAmount ?? 0,
7230
+ ...resolveUpsertDiscountFields(
7231
+ parsed.discountAmount,
7232
+ parsed.discountAmountBasis,
7233
+ existingSnapshot,
7234
+ ),
7271
7235
  discountPercent:
7272
7236
  parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
7273
7237
  taxRate: taxRate ?? 0,
@@ -7760,8 +7724,11 @@ const quoteLineUpsertCommand: CommandHandler<
7760
7724
  quote.currencyCode,
7761
7725
  unitPriceNet: unitPriceNet ?? 0,
7762
7726
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7763
- discountAmount:
7764
- parsed.discountAmount ?? existingSnapshot?.discountAmount ?? 0,
7727
+ ...resolveUpsertDiscountFields(
7728
+ parsed.discountAmount,
7729
+ parsed.discountAmountBasis,
7730
+ existingSnapshot,
7731
+ ),
7765
7732
  discountPercent:
7766
7733
  parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
7767
7734
  taxRate: taxRate ?? 0,
@@ -9197,6 +9164,88 @@ const createInvoiceCommand: CommandHandler<
9197
9164
  },
9198
9165
  };
9199
9166
 
9167
+ const invoiceUpdateChangeKeys = [
9168
+ "invoiceNumber",
9169
+ "statusEntryId",
9170
+ "status",
9171
+ "issueDate",
9172
+ "dueDate",
9173
+ "currencyCode",
9174
+ "subtotalNetAmount",
9175
+ "subtotalGrossAmount",
9176
+ "discountTotalAmount",
9177
+ "taxTotalAmount",
9178
+ "grandTotalNetAmount",
9179
+ "grandTotalGrossAmount",
9180
+ "paidTotalAmount",
9181
+ "outstandingAmount",
9182
+ "metadata",
9183
+ ] as const;
9184
+
9185
+ function toAuditISOString(value: unknown): string | null {
9186
+ if (value instanceof Date) {
9187
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
9188
+ }
9189
+ if (typeof value === "string") {
9190
+ const parsed = new Date(value);
9191
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
9192
+ }
9193
+ return null;
9194
+ }
9195
+
9196
+ function auditValuesEqual(left: unknown, right: unknown): boolean {
9197
+ if (Object.is(left, right)) return true;
9198
+ if (left instanceof Date || right instanceof Date) {
9199
+ const leftIso = toAuditISOString(left);
9200
+ const rightIso = toAuditISOString(right);
9201
+ return leftIso !== null && rightIso !== null && leftIso === rightIso;
9202
+ }
9203
+ return isDeepStrictEqual(left, right);
9204
+ }
9205
+
9206
+ function buildAuditChanges(
9207
+ before: Record<string, unknown>,
9208
+ after: Record<string, unknown>,
9209
+ keys: readonly string[],
9210
+ ): Record<string, { from: unknown; to: unknown }> {
9211
+ const changedKeys = keys.filter((key) => !auditValuesEqual(before[key], after[key]));
9212
+ return buildChanges(before, after, changedKeys);
9213
+ }
9214
+
9215
+ function selectInvoiceUpdateChangeKeys(
9216
+ input: z.infer<typeof invoiceUpdateSchema>,
9217
+ ): Array<(typeof invoiceUpdateChangeKeys)[number]> {
9218
+ return invoiceUpdateChangeKeys.filter((key) =>
9219
+ key === "status"
9220
+ ? input.statusEntryId !== undefined
9221
+ : input[key as keyof z.infer<typeof invoiceUpdateSchema>] !== undefined,
9222
+ );
9223
+ }
9224
+
9225
+ function applyInvoiceHeaderUpdate(
9226
+ invoice: SalesInvoice,
9227
+ input: z.infer<typeof invoiceUpdateSchema>,
9228
+ resolvedStatus: string | null | undefined,
9229
+ ): void {
9230
+ if (input.invoiceNumber !== undefined) invoice.invoiceNumber = input.invoiceNumber;
9231
+ if (input.statusEntryId !== undefined) {
9232
+ invoice.statusEntryId = input.statusEntryId;
9233
+ invoice.status = resolvedStatus ?? null;
9234
+ }
9235
+ if (input.issueDate !== undefined) invoice.issueDate = input.issueDate;
9236
+ if (input.dueDate !== undefined) invoice.dueDate = input.dueDate;
9237
+ if (input.currencyCode !== undefined) invoice.currencyCode = input.currencyCode;
9238
+ if (input.subtotalNetAmount !== undefined) invoice.subtotalNetAmount = toNumericString(input.subtotalNetAmount);
9239
+ if (input.subtotalGrossAmount !== undefined) invoice.subtotalGrossAmount = toNumericString(input.subtotalGrossAmount);
9240
+ if (input.discountTotalAmount !== undefined) invoice.discountTotalAmount = toNumericString(input.discountTotalAmount);
9241
+ if (input.taxTotalAmount !== undefined) invoice.taxTotalAmount = toNumericString(input.taxTotalAmount);
9242
+ if (input.grandTotalNetAmount !== undefined) invoice.grandTotalNetAmount = toNumericString(input.grandTotalNetAmount);
9243
+ if (input.grandTotalGrossAmount !== undefined) invoice.grandTotalGrossAmount = toNumericString(input.grandTotalGrossAmount);
9244
+ if (input.paidTotalAmount !== undefined) invoice.paidTotalAmount = toNumericString(input.paidTotalAmount);
9245
+ if (input.outstandingAmount !== undefined) invoice.outstandingAmount = toNumericString(input.outstandingAmount);
9246
+ if (input.metadata !== undefined) invoice.metadata = input.metadata;
9247
+ }
9248
+
9200
9249
  const updateInvoiceCommand: CommandHandler<
9201
9250
  z.infer<typeof invoiceUpdateSchema>,
9202
9251
  { invoiceId: string }
@@ -9225,29 +9274,12 @@ const updateInvoiceCommand: CommandHandler<
9225
9274
  deletedAt: null,
9226
9275
  });
9227
9276
 
9228
- const changes = buildChanges(invoice, parsed, [
9229
- "invoiceNumber",
9230
- "statusEntryId",
9231
- "status",
9232
- "issueDate",
9233
- "dueDate",
9234
- "currencyCode",
9235
- "subtotalNetAmount",
9236
- "subtotalGrossAmount",
9237
- "discountTotalAmount",
9238
- "taxTotalAmount",
9239
- "grandTotalNetAmount",
9240
- "grandTotalGrossAmount",
9241
- "paidTotalAmount",
9242
- "outstandingAmount",
9243
- "metadata",
9244
- ]);
9245
-
9277
+ let resolvedStatus: string | null | undefined;
9246
9278
  if (parsed.statusEntryId !== undefined) {
9247
- invoice.status = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: invoice.tenantId });
9279
+ resolvedStatus = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: invoice.tenantId });
9248
9280
  }
9249
9281
 
9250
- Object.assign(invoice, changes);
9282
+ applyInvoiceHeaderUpdate(invoice, parsed, resolvedStatus);
9251
9283
  invoice.updatedAt = new Date();
9252
9284
  await em.flush();
9253
9285
 
@@ -9283,11 +9315,18 @@ const updateInvoiceCommand: CommandHandler<
9283
9315
  const em = (ctx.container.resolve("em") as EntityManager).fork();
9284
9316
  return loadInvoiceSnapshot(em, result.invoiceId);
9285
9317
  },
9286
- buildLog: async ({ result, snapshots }) => {
9318
+ buildLog: async ({ input, result, snapshots }) => {
9287
9319
  const before = snapshots.before as InvoiceGraphSnapshot | undefined;
9288
9320
  const after = snapshots.after as InvoiceGraphSnapshot | undefined;
9289
9321
  if (!after) return null;
9290
9322
  const { translate } = await resolveTranslations();
9323
+ const changes = before
9324
+ ? buildAuditChanges(
9325
+ before.invoice as unknown as Record<string, unknown>,
9326
+ after.invoice as unknown as Record<string, unknown>,
9327
+ selectInvoiceUpdateChangeKeys(input),
9328
+ )
9329
+ : {};
9291
9330
  return {
9292
9331
  actionLabel: translate("sales.audit.invoices.update", "Update invoice"),
9293
9332
  resourceKind: "sales.invoice",
@@ -9296,6 +9335,7 @@ const updateInvoiceCommand: CommandHandler<
9296
9335
  organizationId: after.invoice.organizationId,
9297
9336
  snapshotBefore: before,
9298
9337
  snapshotAfter: after,
9338
+ changes: Object.keys(changes).length ? changes : null,
9299
9339
  payload: {
9300
9340
  undo: { before, after } satisfies InvoiceUndoPayload,
9301
9341
  },
@@ -9704,6 +9744,52 @@ const createCreditMemoCommand: CommandHandler<
9704
9744
  },
9705
9745
  };
9706
9746
 
9747
+ const creditMemoUpdateChangeKeys = [
9748
+ "creditMemoNumber",
9749
+ "statusEntryId",
9750
+ "status",
9751
+ "reason",
9752
+ "issueDate",
9753
+ "currencyCode",
9754
+ "subtotalNetAmount",
9755
+ "subtotalGrossAmount",
9756
+ "taxTotalAmount",
9757
+ "grandTotalNetAmount",
9758
+ "grandTotalGrossAmount",
9759
+ "metadata",
9760
+ ] as const;
9761
+
9762
+ function selectCreditMemoUpdateChangeKeys(
9763
+ input: z.infer<typeof creditMemoUpdateSchema>,
9764
+ ): Array<(typeof creditMemoUpdateChangeKeys)[number]> {
9765
+ return creditMemoUpdateChangeKeys.filter((key) =>
9766
+ key === "status"
9767
+ ? input.statusEntryId !== undefined
9768
+ : input[key as keyof z.infer<typeof creditMemoUpdateSchema>] !== undefined,
9769
+ );
9770
+ }
9771
+
9772
+ function applyCreditMemoHeaderUpdate(
9773
+ creditMemo: SalesCreditMemo,
9774
+ input: z.infer<typeof creditMemoUpdateSchema>,
9775
+ resolvedStatus: string | null | undefined,
9776
+ ): void {
9777
+ if (input.creditMemoNumber !== undefined) creditMemo.creditMemoNumber = input.creditMemoNumber;
9778
+ if (input.statusEntryId !== undefined) {
9779
+ creditMemo.statusEntryId = input.statusEntryId;
9780
+ creditMemo.status = resolvedStatus ?? null;
9781
+ }
9782
+ if (input.reason !== undefined) creditMemo.reason = input.reason;
9783
+ if (input.issueDate !== undefined) creditMemo.issueDate = input.issueDate;
9784
+ if (input.currencyCode !== undefined) creditMemo.currencyCode = input.currencyCode;
9785
+ if (input.subtotalNetAmount !== undefined) creditMemo.subtotalNetAmount = toNumericString(input.subtotalNetAmount);
9786
+ if (input.subtotalGrossAmount !== undefined) creditMemo.subtotalGrossAmount = toNumericString(input.subtotalGrossAmount);
9787
+ if (input.taxTotalAmount !== undefined) creditMemo.taxTotalAmount = toNumericString(input.taxTotalAmount);
9788
+ if (input.grandTotalNetAmount !== undefined) creditMemo.grandTotalNetAmount = toNumericString(input.grandTotalNetAmount);
9789
+ if (input.grandTotalGrossAmount !== undefined) creditMemo.grandTotalGrossAmount = toNumericString(input.grandTotalGrossAmount);
9790
+ if (input.metadata !== undefined) creditMemo.metadata = input.metadata;
9791
+ }
9792
+
9707
9793
  const updateCreditMemoCommand: CommandHandler<
9708
9794
  z.infer<typeof creditMemoUpdateSchema>,
9709
9795
  { creditMemoId: string }
@@ -9732,26 +9818,12 @@ const updateCreditMemoCommand: CommandHandler<
9732
9818
  deletedAt: null,
9733
9819
  });
9734
9820
 
9735
- const changes = buildChanges(creditMemo, parsed, [
9736
- "creditMemoNumber",
9737
- "statusEntryId",
9738
- "status",
9739
- "reason",
9740
- "issueDate",
9741
- "currencyCode",
9742
- "subtotalNetAmount",
9743
- "subtotalGrossAmount",
9744
- "taxTotalAmount",
9745
- "grandTotalNetAmount",
9746
- "grandTotalGrossAmount",
9747
- "metadata",
9748
- ]);
9749
-
9821
+ let resolvedStatus: string | null | undefined;
9750
9822
  if (parsed.statusEntryId !== undefined) {
9751
- creditMemo.status = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: creditMemo.tenantId });
9823
+ resolvedStatus = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: creditMemo.tenantId });
9752
9824
  }
9753
9825
 
9754
- Object.assign(creditMemo, changes);
9826
+ applyCreditMemoHeaderUpdate(creditMemo, parsed, resolvedStatus);
9755
9827
  creditMemo.updatedAt = new Date();
9756
9828
  await em.flush();
9757
9829
 
@@ -9787,11 +9859,18 @@ const updateCreditMemoCommand: CommandHandler<
9787
9859
  const em = (ctx.container.resolve("em") as EntityManager).fork();
9788
9860
  return loadCreditMemoSnapshot(em, result.creditMemoId);
9789
9861
  },
9790
- buildLog: async ({ result, snapshots }) => {
9862
+ buildLog: async ({ input, result, snapshots }) => {
9791
9863
  const before = snapshots.before as CreditMemoGraphSnapshot | undefined;
9792
9864
  const after = snapshots.after as CreditMemoGraphSnapshot | undefined;
9793
9865
  if (!after) return null;
9794
9866
  const { translate } = await resolveTranslations();
9867
+ const changes = before
9868
+ ? buildAuditChanges(
9869
+ before.creditMemo as unknown as Record<string, unknown>,
9870
+ after.creditMemo as unknown as Record<string, unknown>,
9871
+ selectCreditMemoUpdateChangeKeys(input),
9872
+ )
9873
+ : {};
9795
9874
  return {
9796
9875
  actionLabel: translate("sales.audit.credit_memos.update", "Update credit memo"),
9797
9876
  resourceKind: "sales.credit_memo",
@@ -9800,6 +9879,7 @@ const updateCreditMemoCommand: CommandHandler<
9800
9879
  organizationId: after.creditMemo.organizationId,
9801
9880
  snapshotBefore: before,
9802
9881
  snapshotAfter: after,
9882
+ changes: Object.keys(changes).length ? changes : null,
9803
9883
  payload: {
9804
9884
  undo: { before, after } satisfies CreditMemoUndoPayload,
9805
9885
  },
@@ -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
+ }