@open-mercato/core 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7536.1.f9a5776a07

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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +14 -6
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +6 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
  7. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
  8. package/dist/modules/catalog/ai-tools/types.js.map +2 -2
  9. package/dist/modules/currencies/backend/currencies/[id]/page.js +5 -2
  10. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  11. package/dist/modules/currencies/commands/currencies.js +399 -137
  12. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  13. package/dist/modules/currencies/lib/seeds.js +2 -0
  14. package/dist/modules/currencies/lib/seeds.js.map +2 -2
  15. package/dist/modules/customer_accounts/api/login.js +14 -7
  16. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  17. package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
  18. package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
  19. package/dist/modules/customers/commands/companies.js +10 -3
  20. package/dist/modules/customers/commands/companies.js.map +2 -2
  21. package/dist/modules/customers/commands/interactionRestoreDetails.js +75 -0
  22. package/dist/modules/customers/commands/interactionRestoreDetails.js.map +7 -0
  23. package/dist/modules/customers/commands/people.js +10 -3
  24. package/dist/modules/customers/commands/people.js.map +2 -2
  25. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
  26. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
  27. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
  28. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
  29. package/dist/modules/forms/api/form-submissions/route.js +16 -1
  30. package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
  31. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
  32. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
  33. package/dist/modules/forms/api/runtime-helpers.js +16 -0
  34. package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
  35. package/dist/modules/forms/data/validators.js.map +2 -2
  36. package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
  37. package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
  38. package/dist/modules/forms/lib/runtime-principal.js +27 -10
  39. package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
  40. package/dist/modules/forms/services/distribution-service.js +35 -13
  41. package/dist/modules/forms/services/distribution-service.js.map +2 -2
  42. package/dist/modules/forms/services/submission-service.js +4 -0
  43. package/dist/modules/forms/services/submission-service.js.map +2 -2
  44. package/dist/modules/staff/commands/leave-requests.js +54 -24
  45. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  46. package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
  47. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  48. package/dist/modules/warranty_claims/commands/claims.js +1 -1
  49. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  50. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
  51. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
  52. package/dist/modules/wms/commands/inventory-actions.js +19 -3
  53. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  54. package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
  55. package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
  56. package/dist/modules/workflows/lib/parallel-handler.js +20 -14
  57. package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
  58. package/dist/modules/workflows/lib/workflow-executor.js +21 -23
  59. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/auth/api/logout.ts +15 -6
  62. package/src/modules/auth/api/session/refresh.ts +6 -1
  63. package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
  64. package/src/modules/catalog/ai-tools/types.ts +1 -0
  65. package/src/modules/currencies/backend/currencies/[id]/page.tsx +5 -1
  66. package/src/modules/currencies/commands/currencies.ts +475 -168
  67. package/src/modules/currencies/i18n/de.json +4 -0
  68. package/src/modules/currencies/i18n/en.json +4 -0
  69. package/src/modules/currencies/i18n/es.json +4 -0
  70. package/src/modules/currencies/i18n/ko.json +4 -0
  71. package/src/modules/currencies/i18n/pl.json +4 -0
  72. package/src/modules/currencies/lib/seeds.ts +2 -0
  73. package/src/modules/customer_accounts/api/login.ts +15 -7
  74. package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
  75. package/src/modules/customers/commands/companies.ts +16 -4
  76. package/src/modules/customers/commands/interactionRestoreDetails.ts +110 -0
  77. package/src/modules/customers/commands/people.ts +16 -4
  78. package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
  79. package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
  80. package/src/modules/forms/api/form-submissions/route.ts +21 -2
  81. package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
  82. package/src/modules/forms/api/runtime-helpers.ts +16 -0
  83. package/src/modules/forms/data/validators.ts +2 -1
  84. package/src/modules/forms/i18n/de.json +1 -0
  85. package/src/modules/forms/i18n/en.json +1 -0
  86. package/src/modules/forms/i18n/es.json +1 -0
  87. package/src/modules/forms/i18n/ko.json +1 -0
  88. package/src/modules/forms/i18n/pl.json +1 -0
  89. package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
  90. package/src/modules/forms/lib/runtime-principal.ts +35 -11
  91. package/src/modules/forms/services/distribution-service.ts +35 -13
  92. package/src/modules/forms/services/submission-service.ts +12 -0
  93. package/src/modules/staff/commands/leave-requests.ts +54 -23
  94. package/src/modules/staff/i18n/de.json +2 -0
  95. package/src/modules/staff/i18n/en.json +2 -0
  96. package/src/modules/staff/i18n/es.json +2 -0
  97. package/src/modules/staff/i18n/ko.json +2 -0
  98. package/src/modules/staff/i18n/pl.json +2 -0
  99. package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
  100. package/src/modules/warranty_claims/commands/claims.ts +1 -1
  101. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
  102. package/src/modules/wms/commands/inventory-actions.ts +30 -10
  103. package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
  104. package/src/modules/workflows/lib/parallel-handler.ts +24 -15
  105. package/src/modules/workflows/lib/workflow-executor.ts +24 -27
@@ -1,7 +1,7 @@
1
1
  import { registerCommand } from "@open-mercato/shared/lib/commands";
2
2
  import { buildChanges, requireId, emitCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
3
3
  import { extractUndoPayload } from "@open-mercato/shared/lib/commands/undo";
4
- import { makeCreateRedo } from "@open-mercato/shared/lib/commands/redo";
4
+ import { resolveRedoSnapshot, restoreCreatedRow, reviveSnapshotSeed } from "@open-mercato/shared/lib/commands/redo";
5
5
  import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
6
6
  import { CrudHttpError, conflict, isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
7
7
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
@@ -45,55 +45,159 @@ async function loadCurrencySnapshot(em, id, ctx) {
45
45
  updatedAt: record.updatedAt.toISOString()
46
46
  };
47
47
  }
48
- async function enforceBaseCurrency(em, currencyId, organizationId, tenantId) {
49
- await em.nativeUpdate(
48
+ const NO_BASE_TRANSITION = { promoted: false, demotedBaseCurrencyIds: [] };
49
+ const BASE_CURRENCY_LOCK_SQL = "select pg_advisory_xact_lock(hashtextextended(?, 0))";
50
+ const UPDATABLE_CURRENCY_FIELDS = [
51
+ "code",
52
+ "name",
53
+ "symbol",
54
+ "decimalPlaces",
55
+ "thousandsSeparator",
56
+ "decimalSeparator",
57
+ "isBase",
58
+ "isActive"
59
+ ];
60
+ function baseCurrencyLockKey(scope) {
61
+ return `currencies:base:${scope.tenantId}:${scope.organizationId}`;
62
+ }
63
+ async function lockBaseCurrencyScope(em, scope) {
64
+ if (!em.isInTransaction()) {
65
+ throw new Error("[internal] Currency base lock requires an active transaction");
66
+ }
67
+ await em.execute(BASE_CURRENCY_LOCK_SQL, [baseCurrencyLockKey(scope)]);
68
+ }
69
+ async function findLiveBaseCurrencyIds(em, scope, excludeId) {
70
+ const rows = await em.find(
50
71
  Currency,
51
72
  {
52
- organizationId,
53
- tenantId,
54
- id: { $ne: currencyId },
73
+ tenantId: scope.tenantId,
74
+ organizationId: scope.organizationId,
55
75
  isBase: true,
56
- deletedAt: null
76
+ deletedAt: null,
77
+ ...excludeId ? { id: { $ne: excludeId } } : {}
78
+ },
79
+ { orderBy: { createdAt: "asc", id: "asc" } }
80
+ );
81
+ return rows.map((row) => row.id);
82
+ }
83
+ async function setBaseFlag(em, scope, ids, isBase) {
84
+ if (ids.length === 0) return;
85
+ await em.nativeUpdate(
86
+ Currency,
87
+ {
88
+ tenantId: scope.tenantId,
89
+ organizationId: scope.organizationId,
90
+ id: { $in: ids }
57
91
  },
58
- { isBase: false, updatedAt: /* @__PURE__ */ new Date() }
92
+ { isBase, updatedAt: /* @__PURE__ */ new Date() }
59
93
  );
60
94
  }
95
+ async function findFirstLivePredecessorId(em, scope, candidateIds) {
96
+ if (candidateIds.length === 0) return null;
97
+ const rows = await em.find(Currency, {
98
+ tenantId: scope.tenantId,
99
+ organizationId: scope.organizationId,
100
+ id: { $in: candidateIds },
101
+ deletedAt: null
102
+ });
103
+ const liveIds = new Set(rows.map((row) => row.id));
104
+ return candidateIds.find((id) => liveIds.has(id)) ?? null;
105
+ }
106
+ async function translatedHttpError(status, key, fallback) {
107
+ const { translate } = await resolveTranslations();
108
+ return new CrudHttpError(status, { error: translate(key, fallback) });
109
+ }
110
+ async function resolvePredecessorOrRefuse(em, scope, candidateIds) {
111
+ const predecessorId = await findFirstLivePredecessorId(em, scope, candidateIds);
112
+ if (!predecessorId) {
113
+ throw await translatedHttpError(
114
+ 409,
115
+ "currencies.errors.undoWouldClearBase",
116
+ "This change can't be undone because it would leave the organization without a base currency."
117
+ );
118
+ }
119
+ return predecessorId;
120
+ }
121
+ function countActiveRatesForCode(em, scope, code) {
122
+ return em.count(ExchangeRate, {
123
+ $or: [
124
+ { fromCurrencyCode: code },
125
+ { toCurrencyCode: code }
126
+ ],
127
+ organizationId: scope.organizationId,
128
+ tenantId: scope.tenantId,
129
+ deletedAt: null,
130
+ isActive: true
131
+ });
132
+ }
133
+ function readScopedSnapshotRecord(em, snapshot) {
134
+ return em.findOne(Currency, {
135
+ id: snapshot.id,
136
+ tenantId: snapshot.tenantId,
137
+ organizationId: snapshot.organizationId
138
+ });
139
+ }
140
+ function scopeOf(record) {
141
+ return { tenantId: record.tenantId, organizationId: record.organizationId };
142
+ }
143
+ function omitIsBase(input) {
144
+ const rest = { ...input };
145
+ delete rest.isBase;
146
+ return rest;
147
+ }
148
+ function isPromotionByLegacyLog(payload) {
149
+ return payload?.before?.isBase === false && payload?.after?.isBase === true;
150
+ }
151
+ function isDemotionByLegacyLog(payload) {
152
+ return !payload?.base && payload?.before?.isBase === true && payload?.after?.isBase === false;
153
+ }
61
154
  const createCurrencyCommand = {
62
155
  id: "currencies.currencies.create",
63
156
  async execute(input, ctx) {
64
157
  const parsed = currencyCreateSchema.parse(input);
65
158
  ensureCurrencyCommandScope(ctx, parsed);
66
159
  const em = ctx.container.resolve("em").fork();
67
- const existing = await em.findOne(Currency, {
68
- code: parsed.code,
69
- organizationId: parsed.organizationId,
70
- tenantId: parsed.tenantId,
71
- deletedAt: null
72
- });
73
- if (existing) {
74
- throw conflict("Currency code already exists for this organization.");
75
- }
76
- const now = /* @__PURE__ */ new Date();
77
- const record = em.create(Currency, {
78
- organizationId: parsed.organizationId,
79
- tenantId: parsed.tenantId,
80
- code: parsed.code,
81
- name: parsed.name,
82
- symbol: parsed.symbol ?? null,
83
- decimalPlaces: parsed.decimalPlaces ?? 2,
84
- thousandsSeparator: parsed.thousandsSeparator ?? null,
85
- decimalSeparator: parsed.decimalSeparator ?? null,
86
- isBase: parsed.isBase ?? false,
87
- isActive: parsed.isActive !== false,
88
- createdAt: now,
89
- updatedAt: now
90
- });
91
- em.persist(record);
160
+ const scope = scopeOf(parsed);
161
+ let demotedBaseCurrencyIds = [];
162
+ let record;
92
163
  try {
93
164
  await withAtomicFlush(
94
165
  em,
95
166
  [
96
- () => record.isBase ? enforceBaseCurrency(em, record.id, record.organizationId, record.tenantId) : void 0
167
+ async () => {
168
+ await lockBaseCurrencyScope(em, scope);
169
+ const existing = await em.findOne(Currency, {
170
+ code: parsed.code,
171
+ organizationId: parsed.organizationId,
172
+ tenantId: parsed.tenantId,
173
+ deletedAt: null
174
+ });
175
+ if (existing) {
176
+ throw conflict("Currency code already exists for this organization.");
177
+ }
178
+ if (parsed.isBase) {
179
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope);
180
+ await setBaseFlag(em, scope, demotedBaseCurrencyIds, false);
181
+ }
182
+ },
183
+ () => {
184
+ const now = /* @__PURE__ */ new Date();
185
+ record = em.create(Currency, {
186
+ organizationId: parsed.organizationId,
187
+ tenantId: parsed.tenantId,
188
+ code: parsed.code,
189
+ name: parsed.name,
190
+ symbol: parsed.symbol ?? null,
191
+ decimalPlaces: parsed.decimalPlaces ?? 2,
192
+ thousandsSeparator: parsed.thousandsSeparator ?? null,
193
+ decimalSeparator: parsed.decimalSeparator ?? null,
194
+ isBase: parsed.isBase ?? false,
195
+ isActive: parsed.isActive !== false,
196
+ createdAt: now,
197
+ updatedAt: now
198
+ });
199
+ em.persist(record);
200
+ }
97
201
  ],
98
202
  { transaction: true }
99
203
  );
@@ -115,13 +219,13 @@ const createCurrencyCommand = {
115
219
  },
116
220
  events: currencyCrudEvents
117
221
  });
118
- return { currencyId: record.id };
222
+ return record.isBase ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } } : { currencyId: record.id };
119
223
  },
120
224
  captureAfter: async (_input, result, ctx) => {
121
225
  const em = ctx.container.resolve("em").fork();
122
226
  return loadCurrencySnapshot(em, result.currencyId, ctx);
123
227
  },
124
- buildLog: async ({ snapshots }) => {
228
+ buildLog: async ({ snapshots, result }) => {
125
229
  const after = snapshots.after;
126
230
  if (!after) return null;
127
231
  const { translate } = await resolveTranslations();
@@ -132,31 +236,104 @@ const createCurrencyCommand = {
132
236
  tenantId: after.tenantId,
133
237
  organizationId: after.organizationId,
134
238
  snapshotAfter: after,
135
- payload: { undo: { after } }
239
+ payload: { undo: { after, base: result?.base ?? NO_BASE_TRANSITION } }
136
240
  };
137
241
  },
138
242
  undo: async ({ logEntry, ctx }) => {
139
243
  const payload = extractUndoPayload(logEntry);
140
244
  const after = payload?.after ?? null;
141
245
  if (!after) return;
246
+ const scope = scopeOf(after);
247
+ const promoted = payload?.base ? payload.base.promoted : after.isBase;
248
+ const predecessorIds = payload?.base?.demotedBaseCurrencyIds ?? [];
142
249
  const em = ctx.container.resolve("em").fork();
143
- const record = await em.findOne(Currency, { id: after.id });
144
- if (!record) return;
145
- record.deletedAt = /* @__PURE__ */ new Date();
146
- record.isActive = false;
147
- await em.flush();
250
+ let record = null;
251
+ let restoredBaseId = null;
252
+ await withAtomicFlush(
253
+ em,
254
+ [
255
+ async () => {
256
+ await lockBaseCurrencyScope(em, scope);
257
+ const current = await readScopedSnapshotRecord(em, after);
258
+ if (!current || current.deletedAt) return;
259
+ const activeRatesCount = await countActiveRatesForCode(em, scope, current.code);
260
+ if (activeRatesCount > 0) {
261
+ throw await translatedHttpError(
262
+ 409,
263
+ "currencies.errors.undoHasRates",
264
+ "This change can't be undone because exchange rates now use this currency. Delete or deactivate those exchange rates first."
265
+ );
266
+ }
267
+ if (current.isBase) {
268
+ restoredBaseId = await resolvePredecessorOrRefuse(em, scope, promoted ? predecessorIds : []);
269
+ }
270
+ record = current;
271
+ },
272
+ () => {
273
+ if (!record) return;
274
+ record.deletedAt = /* @__PURE__ */ new Date();
275
+ record.isActive = false;
276
+ record.isBase = false;
277
+ },
278
+ () => restoredBaseId ? setBaseFlag(em, scope, [restoredBaseId], true) : void 0
279
+ ],
280
+ { transaction: true }
281
+ );
148
282
  },
149
- redo: makeCreateRedo({
150
- entityClass: Currency,
151
- buildResult: (entity) => ({ currencyId: entity.id }),
152
- events: currencyCrudEvents,
153
- afterRestore: async ({ em, entity }) => {
154
- if (entity.isBase) {
155
- await enforceBaseCurrency(em, entity.id, entity.organizationId, entity.tenantId);
156
- await em.flush();
283
+ redo: async ({ ctx, logEntry }) => {
284
+ const snapshot = resolveRedoSnapshot(logEntry);
285
+ if (!snapshot || !snapshot.id) {
286
+ throw new CrudHttpError(400, { error: "[internal] redo snapshot unavailable for create command" });
287
+ }
288
+ const scope = scopeOf(snapshot);
289
+ const isBase = snapshot.isBase === true;
290
+ const em = ctx.container.resolve("em").fork();
291
+ let demotedBaseCurrencyIds = [];
292
+ let record;
293
+ try {
294
+ await withAtomicFlush(
295
+ em,
296
+ [
297
+ async () => {
298
+ await lockBaseCurrencyScope(em, scope);
299
+ if (isBase) {
300
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope, snapshot.id);
301
+ await setBaseFlag(em, scope, demotedBaseCurrencyIds, false);
302
+ }
303
+ },
304
+ async () => {
305
+ record = await restoreCreatedRow(
306
+ em,
307
+ Currency,
308
+ snapshot.id,
309
+ () => reviveSnapshotSeed(snapshot),
310
+ (lookupEm) => readScopedSnapshotRecord(lookupEm, snapshot)
311
+ );
312
+ record.isBase = isBase;
313
+ }
314
+ ],
315
+ { transaction: true }
316
+ );
317
+ } catch (err) {
318
+ if (isUniqueViolation(err)) {
319
+ throw conflict("[internal] Cannot redo: a record with the same unique key already exists.");
157
320
  }
321
+ throw err;
158
322
  }
159
- })
323
+ const de = ctx.container.resolve("dataEngine");
324
+ await emitCrudSideEffects({
325
+ dataEngine: de,
326
+ action: "created",
327
+ entity: record,
328
+ identifiers: {
329
+ id: record.id,
330
+ organizationId: record.organizationId,
331
+ tenantId: record.tenantId
332
+ },
333
+ events: currencyCrudEvents
334
+ });
335
+ return isBase ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } } : { currencyId: record.id };
336
+ }
160
337
  };
161
338
  const updateCurrencyCommand = {
162
339
  id: "currencies.currencies.update",
@@ -169,57 +346,81 @@ const updateCurrencyCommand = {
169
346
  async execute(input, ctx) {
170
347
  const parsed = currencyUpdateSchema.parse(input);
171
348
  requireId(parsed.id, "Currency ID is required");
172
- const em = ctx.container.resolve("em").fork();
173
- const record = await em.findOne(
349
+ const scopeRecord = await ctx.container.resolve("em").fork().findOne(
174
350
  Currency,
175
351
  buildCurrencyCommandWhere(ctx, { id: parsed.id })
176
352
  );
177
- if (!record) {
353
+ if (!scopeRecord) {
178
354
  throw new CrudHttpError(404, { error: "Currency not found" });
179
355
  }
180
- ensureCurrencyCommandScope(ctx, record);
181
- if (parsed.code && parsed.code !== record.code) {
182
- const existing = await em.findOne(Currency, {
183
- code: parsed.code,
184
- organizationId: record.organizationId,
185
- tenantId: record.tenantId,
186
- id: { $ne: record.id },
187
- deletedAt: null
188
- });
189
- if (existing) {
190
- throw conflict("Currency code already exists for this organization.");
191
- }
192
- }
193
- const allChanges = buildChanges(record, parsed, [
194
- "code",
195
- "name",
196
- "symbol",
197
- "decimalPlaces",
198
- "thousandsSeparator",
199
- "decimalSeparator",
200
- "isBase",
201
- "isActive"
202
- ]);
203
- const changes = Object.fromEntries(
204
- Object.entries(allChanges).filter(([, c]) => c.to !== void 0)
205
- );
206
- if (Object.keys(changes).length === 0) {
207
- return { currencyId: record.id };
208
- }
356
+ ensureCurrencyCommandScope(ctx, scopeRecord);
357
+ const scope = scopeOf(scopeRecord);
358
+ const em = ctx.container.resolve("em").fork();
359
+ let record;
360
+ let changes = {};
361
+ let promoted = false;
362
+ let demotedBaseCurrencyIds = [];
209
363
  await withAtomicFlush(
210
364
  em,
211
365
  [
212
- () => {
213
- for (const [key, change] of Object.entries(changes)) {
214
- ;
215
- record[key] = change.to;
366
+ async () => {
367
+ await lockBaseCurrencyScope(em, scope);
368
+ const current = await em.findOne(
369
+ Currency,
370
+ buildCurrencyCommandWhere(ctx, { id: parsed.id })
371
+ );
372
+ if (!current) {
373
+ throw new CrudHttpError(404, { error: "Currency not found" });
216
374
  }
217
- record.updatedAt = /* @__PURE__ */ new Date();
375
+ ensureCurrencyCommandScope(ctx, current);
376
+ record = current;
377
+ if (parsed.code && parsed.code !== record.code) {
378
+ const existing = await em.findOne(Currency, {
379
+ code: parsed.code,
380
+ organizationId: record.organizationId,
381
+ tenantId: record.tenantId,
382
+ id: { $ne: record.id },
383
+ deletedAt: null
384
+ });
385
+ if (existing) {
386
+ throw conflict("Currency code already exists for this organization.");
387
+ }
388
+ }
389
+ if (parsed.isBase === false && record.isBase) {
390
+ throw await translatedHttpError(
391
+ 400,
392
+ "currencies.errors.baseCurrencyUnset",
393
+ "Choose another currency as the base instead of clearing it."
394
+ );
395
+ }
396
+ const allChanges = buildChanges(
397
+ record,
398
+ parsed,
399
+ UPDATABLE_CURRENCY_FIELDS
400
+ );
401
+ changes = Object.fromEntries(
402
+ Object.entries(allChanges).filter(([, change]) => change.to !== void 0)
403
+ );
404
+ promoted = changes.isBase?.to === true;
405
+ if (promoted) {
406
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope, record.id);
407
+ }
408
+ },
409
+ () => {
410
+ if (Object.keys(changes).length === 0) return;
411
+ const assignments = Object.fromEntries(
412
+ Object.entries(changes).map(([key, change]) => [key, change.to])
413
+ );
414
+ Object.assign(record, assignments, { updatedAt: /* @__PURE__ */ new Date() });
218
415
  },
219
- () => parsed.isBase === true && record.isBase ? enforceBaseCurrency(em, record.id, record.organizationId, record.tenantId) : void 0
416
+ () => setBaseFlag(em, scope, demotedBaseCurrencyIds, false)
220
417
  ],
221
418
  { transaction: true }
222
419
  );
420
+ const result = promoted ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } } : { currencyId: record.id };
421
+ if (Object.keys(changes).length === 0) {
422
+ return result;
423
+ }
223
424
  const de = ctx.container.resolve("dataEngine");
224
425
  await emitCrudSideEffects({
225
426
  dataEngine: de,
@@ -232,17 +433,18 @@ const updateCurrencyCommand = {
232
433
  },
233
434
  events: currencyCrudEvents
234
435
  });
235
- return { currencyId: record.id };
436
+ return result;
236
437
  },
237
438
  captureAfter: async (_input, result, ctx) => {
238
439
  const em = ctx.container.resolve("em").fork();
239
440
  return loadCurrencySnapshot(em, result.currencyId, ctx);
240
441
  },
241
- buildLog: async ({ snapshots, result }) => {
442
+ buildLog: async ({ input, snapshots, result }) => {
242
443
  const before = snapshots.before;
243
444
  const after = snapshots.after;
244
445
  if (!after) return null;
245
446
  const { translate } = await resolveTranslations();
447
+ const base = result?.base ?? NO_BASE_TRANSITION;
246
448
  return {
247
449
  actionLabel: translate("currencies.audit.update", "Update currency"),
248
450
  resourceKind: "currencies.currency",
@@ -251,28 +453,56 @@ const updateCurrencyCommand = {
251
453
  organizationId: after.organizationId,
252
454
  snapshotBefore: before ?? void 0,
253
455
  snapshotAfter: after,
254
- payload: { undo: { before, after } }
456
+ payload: { undo: { before, after, base } },
457
+ redoInput: base.promoted ? input : omitIsBase(input)
255
458
  };
256
459
  },
257
460
  undo: async ({ logEntry, ctx }) => {
258
461
  const payload = extractUndoPayload(logEntry);
259
462
  const before = payload?.before ?? null;
260
463
  if (!before) return;
464
+ const scope = scopeOf(before);
465
+ const promoted = payload?.base ? payload.base.promoted : isPromotionByLegacyLog(payload);
466
+ const predecessorIds = payload?.base?.demotedBaseCurrencyIds ?? [];
261
467
  const em = ctx.container.resolve("em").fork();
262
- const record = await em.findOne(Currency, { id: before.id });
263
- if (!record) return;
264
- Object.assign(record, {
265
- code: before.code,
266
- name: before.name,
267
- symbol: before.symbol,
268
- decimalPlaces: before.decimalPlaces,
269
- thousandsSeparator: before.thousandsSeparator,
270
- decimalSeparator: before.decimalSeparator,
271
- isBase: before.isBase,
272
- isActive: before.isActive,
273
- updatedAt: /* @__PURE__ */ new Date()
274
- });
275
- await em.flush();
468
+ const demotedByLegacyLog = isDemotionByLegacyLog(payload);
469
+ let record = null;
470
+ let restoredBaseId = null;
471
+ let repromote = false;
472
+ await withAtomicFlush(
473
+ em,
474
+ [
475
+ async () => {
476
+ await lockBaseCurrencyScope(em, scope);
477
+ const current = await readScopedSnapshotRecord(em, before);
478
+ if (!current) return;
479
+ if (promoted && current.isBase && !current.deletedAt) {
480
+ restoredBaseId = await resolvePredecessorOrRefuse(em, scope, predecessorIds);
481
+ }
482
+ if (demotedByLegacyLog && !current.isBase && !current.deletedAt) {
483
+ repromote = (await findLiveBaseCurrencyIds(em, scope)).length === 0;
484
+ }
485
+ record = current;
486
+ },
487
+ () => {
488
+ if (!record) return;
489
+ Object.assign(record, {
490
+ code: before.code,
491
+ name: before.name,
492
+ symbol: before.symbol,
493
+ decimalPlaces: before.decimalPlaces,
494
+ thousandsSeparator: before.thousandsSeparator,
495
+ decimalSeparator: before.decimalSeparator,
496
+ isActive: before.isActive,
497
+ updatedAt: /* @__PURE__ */ new Date()
498
+ });
499
+ if (restoredBaseId) record.isBase = false;
500
+ if (repromote) record.isBase = true;
501
+ },
502
+ () => restoredBaseId ? setBaseFlag(em, scope, [restoredBaseId], true) : void 0
503
+ ],
504
+ { transaction: true }
505
+ );
276
506
  }
277
507
  };
278
508
  const deleteCurrencyCommand = {
@@ -286,36 +516,48 @@ const deleteCurrencyCommand = {
286
516
  async execute(input, ctx) {
287
517
  const parsed = currencyDeleteSchema.parse(input);
288
518
  requireId(parsed.id, "Currency ID is required");
289
- const em = ctx.container.resolve("em").fork();
290
- const record = await em.findOne(
519
+ const scopeRecord = await ctx.container.resolve("em").fork().findOne(
291
520
  Currency,
292
521
  buildCurrencyCommandWhere(ctx, { id: parsed.id })
293
522
  );
294
- if (!record) {
523
+ if (!scopeRecord) {
295
524
  throw new CrudHttpError(404, { error: "Currency not found" });
296
525
  }
297
- ensureCurrencyCommandScope(ctx, record);
298
- if (record.isBase) {
299
- throw new CrudHttpError(400, { error: "Cannot delete the base currency" });
300
- }
301
- const activeRatesCount = await em.count(ExchangeRate, {
302
- $or: [
303
- { fromCurrencyCode: record.code },
304
- { toCurrencyCode: record.code }
526
+ ensureCurrencyCommandScope(ctx, scopeRecord);
527
+ const scope = scopeOf(scopeRecord);
528
+ const em = ctx.container.resolve("em").fork();
529
+ let record;
530
+ await withAtomicFlush(
531
+ em,
532
+ [
533
+ async () => {
534
+ await lockBaseCurrencyScope(em, scope);
535
+ const current = await em.findOne(
536
+ Currency,
537
+ buildCurrencyCommandWhere(ctx, { id: parsed.id })
538
+ );
539
+ if (!current) {
540
+ throw new CrudHttpError(404, { error: "Currency not found" });
541
+ }
542
+ ensureCurrencyCommandScope(ctx, current);
543
+ if (current.isBase) {
544
+ throw new CrudHttpError(400, { error: "Cannot delete the base currency" });
545
+ }
546
+ const activeRatesCount = await countActiveRatesForCode(em, scope, current.code);
547
+ if (activeRatesCount > 0) {
548
+ throw new CrudHttpError(400, {
549
+ error: `Cannot delete currency ${current.code} because it has ${activeRatesCount} active exchange rate(s). Please delete or deactivate the exchange rates first.`
550
+ });
551
+ }
552
+ record = current;
553
+ },
554
+ () => {
555
+ record.deletedAt = /* @__PURE__ */ new Date();
556
+ record.isActive = false;
557
+ }
305
558
  ],
306
- organizationId: record.organizationId,
307
- tenantId: record.tenantId,
308
- deletedAt: null,
309
- isActive: true
310
- });
311
- if (activeRatesCount > 0) {
312
- throw new CrudHttpError(400, {
313
- error: `Cannot delete currency ${record.code} because it has ${activeRatesCount} active exchange rate(s). Please delete or deactivate the exchange rates first.`
314
- });
315
- }
316
- record.deletedAt = /* @__PURE__ */ new Date();
317
- record.isActive = false;
318
- await em.flush();
559
+ { transaction: true }
560
+ );
319
561
  const de = ctx.container.resolve("dataEngine");
320
562
  await emitCrudSideEffects({
321
563
  dataEngine: de,
@@ -330,7 +572,7 @@ const deleteCurrencyCommand = {
330
572
  });
331
573
  return { currencyId: record.id };
332
574
  },
333
- buildLog: async ({ snapshots, result }) => {
575
+ buildLog: async ({ snapshots }) => {
334
576
  const before = snapshots.before;
335
577
  if (!before) return null;
336
578
  const { translate } = await resolveTranslations();
@@ -348,13 +590,33 @@ const deleteCurrencyCommand = {
348
590
  const payload = extractUndoPayload(logEntry);
349
591
  const before = payload?.before ?? null;
350
592
  if (!before) return;
593
+ const scope = scopeOf(before);
351
594
  const em = ctx.container.resolve("em").fork();
352
- const record = await em.findOne(Currency, { id: before.id });
353
- if (!record) return;
354
- record.deletedAt = null;
355
- record.isActive = before.isActive;
356
- record.updatedAt = /* @__PURE__ */ new Date();
357
- await em.flush();
595
+ let record = null;
596
+ let reviveAsNonBase = false;
597
+ await withAtomicFlush(
598
+ em,
599
+ [
600
+ async () => {
601
+ await lockBaseCurrencyScope(em, scope);
602
+ const current = await readScopedSnapshotRecord(em, before);
603
+ if (!current) return;
604
+ if (current.isBase) {
605
+ const otherBaseIds = await findLiveBaseCurrencyIds(em, scope, current.id);
606
+ reviveAsNonBase = otherBaseIds.length > 0;
607
+ }
608
+ record = current;
609
+ },
610
+ () => {
611
+ if (!record) return;
612
+ record.deletedAt = null;
613
+ record.isActive = before.isActive;
614
+ if (reviveAsNonBase) record.isBase = false;
615
+ record.updatedAt = /* @__PURE__ */ new Date();
616
+ }
617
+ ],
618
+ { transaction: true }
619
+ );
358
620
  }
359
621
  };
360
622
  registerCommand(createCurrencyCommand);