@open-mercato/core 0.9.1-develop.7532.1.8eac7e198f → 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 (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +8 -5
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/currencies/backend/currencies/[id]/page.js +5 -2
  5. package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
  6. package/dist/modules/currencies/commands/currencies.js +399 -137
  7. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  8. package/dist/modules/currencies/lib/seeds.js +2 -0
  9. package/dist/modules/currencies/lib/seeds.js.map +2 -2
  10. package/dist/modules/customers/commands/companies.js +10 -3
  11. package/dist/modules/customers/commands/companies.js.map +2 -2
  12. package/dist/modules/customers/commands/interactionRestoreDetails.js +75 -0
  13. package/dist/modules/customers/commands/interactionRestoreDetails.js.map +7 -0
  14. package/dist/modules/customers/commands/people.js +10 -3
  15. package/dist/modules/customers/commands/people.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/auth/api/logout.ts +9 -5
  18. package/src/modules/currencies/backend/currencies/[id]/page.tsx +5 -1
  19. package/src/modules/currencies/commands/currencies.ts +475 -168
  20. package/src/modules/currencies/i18n/de.json +4 -0
  21. package/src/modules/currencies/i18n/en.json +4 -0
  22. package/src/modules/currencies/i18n/es.json +4 -0
  23. package/src/modules/currencies/i18n/ko.json +4 -0
  24. package/src/modules/currencies/i18n/pl.json +4 -0
  25. package/src/modules/currencies/lib/seeds.ts +2 -0
  26. package/src/modules/customers/commands/companies.ts +16 -4
  27. package/src/modules/customers/commands/interactionRestoreDetails.ts +110 -0
  28. package/src/modules/customers/commands/people.ts +16 -4
@@ -2,7 +2,7 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
2
2
  import type { CommandHandler } from '@open-mercato/shared/lib/commands'
3
3
  import { buildChanges, requireId, emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
4
4
  import { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
- import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
5
+ import { resolveRedoSnapshot, restoreCreatedRow, reviveSnapshotSeed } from '@open-mercato/shared/lib/commands/redo'
6
6
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
7
7
  import type { EntityManager } from '@mikro-orm/postgresql'
8
8
  import { CrudHttpError, conflict, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
@@ -47,8 +47,6 @@ type CurrencySnapshot = {
47
47
  updatedAt: string
48
48
  }
49
49
 
50
- type CurrencyUndoPayload = UndoPayload<CurrencySnapshot>
51
-
52
50
  async function loadCurrencySnapshot(
53
51
  em: EntityManager,
54
52
  id: string,
@@ -77,71 +75,218 @@ async function loadCurrencySnapshot(
77
75
  }
78
76
  }
79
77
 
80
- async function enforceBaseCurrency(
81
- em: EntityManager,
82
- currencyId: string,
83
- organizationId: string,
78
+ type CurrencyScope = {
84
79
  tenantId: string
85
- ): Promise<void> {
86
- await em.nativeUpdate(
80
+ organizationId: string
81
+ }
82
+
83
+ type BaseCurrencyTransition = {
84
+ promoted: boolean
85
+ demotedBaseCurrencyIds: string[]
86
+ }
87
+
88
+ type CurrencyCommandResult = {
89
+ currencyId: string
90
+ base?: BaseCurrencyTransition
91
+ }
92
+
93
+ type CurrencyUndoPayload = UndoPayload<CurrencySnapshot> & {
94
+ base?: BaseCurrencyTransition
95
+ }
96
+
97
+ const NO_BASE_TRANSITION: BaseCurrencyTransition = { promoted: false, demotedBaseCurrencyIds: [] }
98
+
99
+ const BASE_CURRENCY_LOCK_SQL = 'select pg_advisory_xact_lock(hashtextextended(?, 0))'
100
+
101
+ const UPDATABLE_CURRENCY_FIELDS = [
102
+ 'code',
103
+ 'name',
104
+ 'symbol',
105
+ 'decimalPlaces',
106
+ 'thousandsSeparator',
107
+ 'decimalSeparator',
108
+ 'isBase',
109
+ 'isActive',
110
+ ] as const
111
+
112
+ function baseCurrencyLockKey(scope: CurrencyScope): string {
113
+ return `currencies:base:${scope.tenantId}:${scope.organizationId}`
114
+ }
115
+
116
+ /**
117
+ * Serialize every currency write inside one tenant and organization so the
118
+ * "exactly one live base currency" invariant is decided on fresh rows. The
119
+ * transaction owns the lock, so PostgreSQL releases it on commit/rollback.
120
+ */
121
+ async function lockBaseCurrencyScope(em: EntityManager, scope: CurrencyScope): Promise<void> {
122
+ if (!em.isInTransaction()) {
123
+ throw new Error('[internal] Currency base lock requires an active transaction')
124
+ }
125
+ await em.execute(BASE_CURRENCY_LOCK_SQL, [baseCurrencyLockKey(scope)])
126
+ }
127
+
128
+ async function findLiveBaseCurrencyIds(
129
+ em: EntityManager,
130
+ scope: CurrencyScope,
131
+ excludeId?: string,
132
+ ): Promise<string[]> {
133
+ const rows = await em.find(
87
134
  Currency,
88
135
  {
89
- organizationId,
90
- tenantId,
91
- id: { $ne: currencyId },
136
+ tenantId: scope.tenantId,
137
+ organizationId: scope.organizationId,
92
138
  isBase: true,
93
139
  deletedAt: null,
140
+ ...(excludeId ? { id: { $ne: excludeId } } : {}),
94
141
  },
95
- { isBase: false, updatedAt: new Date() }
142
+ { orderBy: { createdAt: 'asc', id: 'asc' } },
96
143
  )
144
+ return rows.map((row) => row.id)
145
+ }
146
+
147
+ async function setBaseFlag(
148
+ em: EntityManager,
149
+ scope: CurrencyScope,
150
+ ids: string[],
151
+ isBase: boolean,
152
+ ): Promise<void> {
153
+ if (ids.length === 0) return
154
+ await em.nativeUpdate(
155
+ Currency,
156
+ {
157
+ tenantId: scope.tenantId,
158
+ organizationId: scope.organizationId,
159
+ id: { $in: ids },
160
+ },
161
+ { isBase, updatedAt: new Date() },
162
+ )
163
+ }
164
+
165
+ async function findFirstLivePredecessorId(
166
+ em: EntityManager,
167
+ scope: CurrencyScope,
168
+ candidateIds: string[],
169
+ ): Promise<string | null> {
170
+ if (candidateIds.length === 0) return null
171
+ const rows = await em.find(Currency, {
172
+ tenantId: scope.tenantId,
173
+ organizationId: scope.organizationId,
174
+ id: { $in: candidateIds },
175
+ deletedAt: null,
176
+ })
177
+ const liveIds = new Set(rows.map((row) => row.id))
178
+ return candidateIds.find((id) => liveIds.has(id)) ?? null
179
+ }
180
+
181
+ async function translatedHttpError(status: number, key: string, fallback: string): Promise<CrudHttpError> {
182
+ const { translate } = await resolveTranslations()
183
+ return new CrudHttpError(status, { error: translate(key, fallback) })
184
+ }
185
+
186
+ async function resolvePredecessorOrRefuse(
187
+ em: EntityManager,
188
+ scope: CurrencyScope,
189
+ candidateIds: string[],
190
+ ): Promise<string> {
191
+ const predecessorId = await findFirstLivePredecessorId(em, scope, candidateIds)
192
+ if (!predecessorId) {
193
+ throw await translatedHttpError(
194
+ 409,
195
+ 'currencies.errors.undoWouldClearBase',
196
+ "This change can't be undone because it would leave the organization without a base currency.",
197
+ )
198
+ }
199
+ return predecessorId
200
+ }
201
+
202
+ function countActiveRatesForCode(em: EntityManager, scope: CurrencyScope, code: string): Promise<number> {
203
+ return em.count(ExchangeRate, {
204
+ $or: [
205
+ { fromCurrencyCode: code },
206
+ { toCurrencyCode: code },
207
+ ],
208
+ organizationId: scope.organizationId,
209
+ tenantId: scope.tenantId,
210
+ deletedAt: null,
211
+ isActive: true,
212
+ })
213
+ }
214
+
215
+ function readScopedSnapshotRecord(em: EntityManager, snapshot: CurrencySnapshot): Promise<Currency | null> {
216
+ return em.findOne(Currency, {
217
+ id: snapshot.id,
218
+ tenantId: snapshot.tenantId,
219
+ organizationId: snapshot.organizationId,
220
+ })
97
221
  }
98
222
 
99
- const createCurrencyCommand: CommandHandler<CurrencyCreateInput, { currencyId: string }> = {
223
+ function scopeOf(record: CurrencyScope): CurrencyScope {
224
+ return { tenantId: record.tenantId, organizationId: record.organizationId }
225
+ }
226
+
227
+ function omitIsBase(input: CurrencyUpdateInput): Partial<CurrencyUpdateInput> {
228
+ const rest: Partial<CurrencyUpdateInput> = { ...input }
229
+ delete rest.isBase
230
+ return rest
231
+ }
232
+
233
+ function isPromotionByLegacyLog(payload: CurrencyUndoPayload | null): boolean {
234
+ return payload?.before?.isBase === false && payload?.after?.isBase === true
235
+ }
236
+
237
+ function isDemotionByLegacyLog(payload: CurrencyUndoPayload | null): boolean {
238
+ return !payload?.base && payload?.before?.isBase === true && payload?.after?.isBase === false
239
+ }
240
+
241
+ const createCurrencyCommand: CommandHandler<CurrencyCreateInput, CurrencyCommandResult> = {
100
242
  id: 'currencies.currencies.create',
101
243
  async execute(input, ctx) {
102
244
  const parsed = currencyCreateSchema.parse(input)
103
245
  ensureCurrencyCommandScope(ctx, parsed)
104
246
 
105
247
  const em = (ctx.container.resolve('em') as EntityManager).fork()
248
+ const scope = scopeOf(parsed)
249
+ let demotedBaseCurrencyIds: string[] = []
250
+ let record!: Currency
106
251
 
107
- // Check for duplicate code
108
- const existing = await em.findOne(Currency, {
109
- code: parsed.code,
110
- organizationId: parsed.organizationId,
111
- tenantId: parsed.tenantId,
112
- deletedAt: null,
113
- })
114
- if (existing) {
115
- throw conflict('Currency code already exists for this organization.')
116
- }
117
-
118
- const now = new Date()
119
- const record = em.create(Currency, {
120
- organizationId: parsed.organizationId,
121
- tenantId: parsed.tenantId,
122
- code: parsed.code,
123
- name: parsed.name,
124
- symbol: parsed.symbol ?? null,
125
- decimalPlaces: parsed.decimalPlaces ?? 2,
126
- thousandsSeparator: parsed.thousandsSeparator ?? null,
127
- decimalSeparator: parsed.decimalSeparator ?? null,
128
- isBase: parsed.isBase ?? false,
129
- isActive: parsed.isActive !== false,
130
- createdAt: now,
131
- updatedAt: now,
132
- })
133
- em.persist(record)
134
-
135
- // Demote any existing base currency and insert the new record in one
136
- // transaction; a partial commit would leave zero or two base currencies.
137
252
  try {
138
253
  await withAtomicFlush(
139
254
  em,
140
255
  [
141
- () =>
142
- record.isBase
143
- ? enforceBaseCurrency(em, record.id, record.organizationId, record.tenantId)
144
- : undefined,
256
+ async () => {
257
+ await lockBaseCurrencyScope(em, scope)
258
+ const existing = await em.findOne(Currency, {
259
+ code: parsed.code,
260
+ organizationId: parsed.organizationId,
261
+ tenantId: parsed.tenantId,
262
+ deletedAt: null,
263
+ })
264
+ if (existing) {
265
+ throw conflict('Currency code already exists for this organization.')
266
+ }
267
+ if (parsed.isBase) {
268
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope)
269
+ await setBaseFlag(em, scope, demotedBaseCurrencyIds, false)
270
+ }
271
+ },
272
+ () => {
273
+ const now = new Date()
274
+ record = em.create(Currency, {
275
+ organizationId: parsed.organizationId,
276
+ tenantId: parsed.tenantId,
277
+ code: parsed.code,
278
+ name: parsed.name,
279
+ symbol: parsed.symbol ?? null,
280
+ decimalPlaces: parsed.decimalPlaces ?? 2,
281
+ thousandsSeparator: parsed.thousandsSeparator ?? null,
282
+ decimalSeparator: parsed.decimalSeparator ?? null,
283
+ isBase: parsed.isBase ?? false,
284
+ isActive: parsed.isActive !== false,
285
+ createdAt: now,
286
+ updatedAt: now,
287
+ })
288
+ em.persist(record)
289
+ },
145
290
  ],
146
291
  { transaction: true },
147
292
  )
@@ -165,13 +310,15 @@ const createCurrencyCommand: CommandHandler<CurrencyCreateInput, { currencyId: s
165
310
  events: currencyCrudEvents,
166
311
  })
167
312
 
168
- return { currencyId: record.id }
313
+ return record.isBase
314
+ ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } }
315
+ : { currencyId: record.id }
169
316
  },
170
317
  captureAfter: async (_input, result, ctx) => {
171
318
  const em = (ctx.container.resolve('em') as EntityManager).fork()
172
319
  return loadCurrencySnapshot(em, result.currencyId, ctx)
173
320
  },
174
- buildLog: async ({ snapshots }) => {
321
+ buildLog: async ({ snapshots, result }) => {
175
322
  const after = snapshots.after as CurrencySnapshot | undefined
176
323
  if (!after) return null
177
324
  const { translate } = await resolveTranslations()
@@ -182,34 +329,113 @@ const createCurrencyCommand: CommandHandler<CurrencyCreateInput, { currencyId: s
182
329
  tenantId: after.tenantId,
183
330
  organizationId: after.organizationId,
184
331
  snapshotAfter: after,
185
- payload: { undo: { after } },
332
+ payload: { undo: { after, base: result?.base ?? NO_BASE_TRANSITION } },
186
333
  }
187
334
  },
188
335
  undo: async ({ logEntry, ctx }) => {
189
336
  const payload = extractUndoPayload<CurrencyUndoPayload>(logEntry)
190
337
  const after = payload?.after ?? null
191
338
  if (!after) return
339
+ const scope = scopeOf(after)
340
+ const promoted = payload?.base ? payload.base.promoted : after.isBase
341
+ const predecessorIds = payload?.base?.demotedBaseCurrencyIds ?? []
192
342
  const em = (ctx.container.resolve('em') as EntityManager).fork()
193
- const record = await em.findOne(Currency, { id: after.id })
194
- if (!record) return
195
- record.deletedAt = new Date()
196
- record.isActive = false
197
- await em.flush()
343
+ let record: Currency | null = null
344
+ let restoredBaseId: string | null = null
345
+
346
+ await withAtomicFlush(
347
+ em,
348
+ [
349
+ async () => {
350
+ await lockBaseCurrencyScope(em, scope)
351
+ const current = await readScopedSnapshotRecord(em, after)
352
+ if (!current || current.deletedAt) return
353
+ const activeRatesCount = await countActiveRatesForCode(em, scope, current.code)
354
+ if (activeRatesCount > 0) {
355
+ throw await translatedHttpError(
356
+ 409,
357
+ 'currencies.errors.undoHasRates',
358
+ "This change can't be undone because exchange rates now use this currency. Delete or deactivate those exchange rates first.",
359
+ )
360
+ }
361
+ if (current.isBase) {
362
+ restoredBaseId = await resolvePredecessorOrRefuse(em, scope, promoted ? predecessorIds : [])
363
+ }
364
+ record = current
365
+ },
366
+ () => {
367
+ if (!record) return
368
+ record.deletedAt = new Date()
369
+ record.isActive = false
370
+ record.isBase = false
371
+ },
372
+ () => (restoredBaseId ? setBaseFlag(em, scope, [restoredBaseId], true) : undefined),
373
+ ],
374
+ { transaction: true },
375
+ )
198
376
  },
199
- redo: makeCreateRedo<Currency, CurrencySnapshot, CurrencyCreateInput, { currencyId: string }>({
200
- entityClass: Currency,
201
- buildResult: (entity) => ({ currencyId: entity.id }),
202
- events: currencyCrudEvents,
203
- afterRestore: async ({ em, entity }) => {
204
- if (entity.isBase) {
205
- await enforceBaseCurrency(em, entity.id, entity.organizationId, entity.tenantId)
206
- await em.flush()
377
+ redo: async ({ ctx, logEntry }) => {
378
+ const snapshot = resolveRedoSnapshot<CurrencySnapshot>(logEntry)
379
+ if (!snapshot || !snapshot.id) {
380
+ throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for create command' })
381
+ }
382
+ const scope = scopeOf(snapshot)
383
+ const isBase = snapshot.isBase === true
384
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
385
+ let demotedBaseCurrencyIds: string[] = []
386
+ let record!: Currency
387
+
388
+ try {
389
+ await withAtomicFlush(
390
+ em,
391
+ [
392
+ async () => {
393
+ await lockBaseCurrencyScope(em, scope)
394
+ if (isBase) {
395
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope, snapshot.id)
396
+ await setBaseFlag(em, scope, demotedBaseCurrencyIds, false)
397
+ }
398
+ },
399
+ async () => {
400
+ record = await restoreCreatedRow(
401
+ em,
402
+ Currency,
403
+ snapshot.id,
404
+ () => reviveSnapshotSeed(snapshot as unknown as Record<string, unknown>),
405
+ (lookupEm) => readScopedSnapshotRecord(lookupEm, snapshot),
406
+ )
407
+ record.isBase = isBase
408
+ },
409
+ ],
410
+ { transaction: true },
411
+ )
412
+ } catch (err) {
413
+ if (isUniqueViolation(err)) {
414
+ throw conflict('[internal] Cannot redo: a record with the same unique key already exists.')
207
415
  }
208
- },
209
- }),
416
+ throw err
417
+ }
418
+
419
+ const de = ctx.container.resolve('dataEngine') as DataEngine
420
+ await emitCrudSideEffects({
421
+ dataEngine: de,
422
+ action: 'created',
423
+ entity: record,
424
+ identifiers: {
425
+ id: record.id,
426
+ organizationId: record.organizationId,
427
+ tenantId: record.tenantId,
428
+ },
429
+ events: currencyCrudEvents,
430
+ })
431
+
432
+ return isBase
433
+ ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } }
434
+ : { currencyId: record.id }
435
+ },
210
436
  }
211
437
 
212
- const updateCurrencyCommand: CommandHandler<CurrencyUpdateInput, { currencyId: string }> = {
438
+ const updateCurrencyCommand: CommandHandler<CurrencyUpdateInput, CurrencyCommandResult> = {
213
439
  id: 'currencies.currencies.update',
214
440
  async prepare(input, ctx) {
215
441
  requireId(input.id, 'Currency ID is required')
@@ -221,71 +447,91 @@ const updateCurrencyCommand: CommandHandler<CurrencyUpdateInput, { currencyId: s
221
447
  const parsed = currencyUpdateSchema.parse(input)
222
448
  requireId(parsed.id, 'Currency ID is required')
223
449
 
224
- const em = (ctx.container.resolve('em') as EntityManager).fork()
225
- const record = await em.findOne(
450
+ const scopeRecord = await (ctx.container.resolve('em') as EntityManager).fork().findOne(
226
451
  Currency,
227
452
  buildCurrencyCommandWhere<Currency>(ctx, { id: parsed.id }),
228
453
  )
229
- if (!record) {
454
+ if (!scopeRecord) {
230
455
  throw new CrudHttpError(404, { error: 'Currency not found' })
231
456
  }
232
- ensureCurrencyCommandScope(ctx, record)
233
-
234
- // Check code uniqueness if changing code
235
- if (parsed.code && parsed.code !== record.code) {
236
- const existing = await em.findOne(Currency, {
237
- code: parsed.code,
238
- organizationId: record.organizationId,
239
- tenantId: record.tenantId,
240
- id: { $ne: record.id },
241
- deletedAt: null,
242
- })
243
- if (existing) {
244
- throw conflict('Currency code already exists for this organization.')
245
- }
246
- }
247
-
248
- const allChanges = buildChanges(record as unknown as Record<string, unknown>, parsed, [
249
- 'code',
250
- 'name',
251
- 'symbol',
252
- 'decimalPlaces',
253
- 'thousandsSeparator',
254
- 'decimalSeparator',
255
- 'isBase',
256
- 'isActive',
257
- ])
258
- const changes = Object.fromEntries(
259
- Object.entries(allChanges).filter(([, c]) => c.to !== undefined),
260
- ) as Record<string, { from: unknown; to: unknown }>
457
+ ensureCurrencyCommandScope(ctx, scopeRecord)
458
+ const scope = scopeOf(scopeRecord)
261
459
 
262
- if (Object.keys(changes).length === 0) {
263
- return { currencyId: record.id }
264
- }
460
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
461
+ let record!: Currency
462
+ let changes: Record<string, { from: unknown; to: unknown }> = {}
463
+ let promoted = false
464
+ let demotedBaseCurrencyIds: string[] = []
265
465
 
266
- // Demote any existing base currency and persist the scalar changes in one
267
- // transaction; a partial commit would leave zero or two base currencies.
268
- // The scalar mutations live in the first phase so the per-phase flush issues
269
- // the pending changeset on the managed `record` before `enforceBaseCurrency`
270
- // runs its interleaved `nativeUpdate` (which would otherwise drop the pending
271
- // UPDATE under v7).
272
466
  await withAtomicFlush(
273
467
  em,
274
468
  [
275
- () => {
276
- for (const [key, change] of Object.entries(changes)) {
277
- ;(record as any)[key] = change.to
469
+ async () => {
470
+ await lockBaseCurrencyScope(em, scope)
471
+ const current = await em.findOne(
472
+ Currency,
473
+ buildCurrencyCommandWhere<Currency>(ctx, { id: parsed.id }),
474
+ )
475
+ if (!current) {
476
+ throw new CrudHttpError(404, { error: 'Currency not found' })
278
477
  }
279
- record.updatedAt = new Date()
478
+ ensureCurrencyCommandScope(ctx, current)
479
+ record = current
480
+
481
+ if (parsed.code && parsed.code !== record.code) {
482
+ const existing = await em.findOne(Currency, {
483
+ code: parsed.code,
484
+ organizationId: record.organizationId,
485
+ tenantId: record.tenantId,
486
+ id: { $ne: record.id },
487
+ deletedAt: null,
488
+ })
489
+ if (existing) {
490
+ throw conflict('Currency code already exists for this organization.')
491
+ }
492
+ }
493
+
494
+ if (parsed.isBase === false && record.isBase) {
495
+ throw await translatedHttpError(
496
+ 400,
497
+ 'currencies.errors.baseCurrencyUnset',
498
+ 'Choose another currency as the base instead of clearing it.',
499
+ )
500
+ }
501
+
502
+ const allChanges = buildChanges(
503
+ record as unknown as Record<string, unknown>,
504
+ parsed,
505
+ UPDATABLE_CURRENCY_FIELDS,
506
+ )
507
+ changes = Object.fromEntries(
508
+ Object.entries(allChanges).filter(([, change]) => change.to !== undefined),
509
+ ) as Record<string, { from: unknown; to: unknown }>
510
+
511
+ promoted = changes.isBase?.to === true
512
+ if (promoted) {
513
+ demotedBaseCurrencyIds = await findLiveBaseCurrencyIds(em, scope, record.id)
514
+ }
515
+ },
516
+ () => {
517
+ if (Object.keys(changes).length === 0) return
518
+ const assignments = Object.fromEntries(
519
+ Object.entries(changes).map(([key, change]) => [key, change.to]),
520
+ )
521
+ Object.assign(record, assignments, { updatedAt: new Date() })
280
522
  },
281
- () =>
282
- parsed.isBase === true && record.isBase
283
- ? enforceBaseCurrency(em, record.id, record.organizationId, record.tenantId)
284
- : undefined,
523
+ () => setBaseFlag(em, scope, demotedBaseCurrencyIds, false),
285
524
  ],
286
525
  { transaction: true },
287
526
  )
288
527
 
528
+ const result: CurrencyCommandResult = promoted
529
+ ? { currencyId: record.id, base: { promoted: true, demotedBaseCurrencyIds } }
530
+ : { currencyId: record.id }
531
+ if (Object.keys(changes).length === 0) {
532
+ return result
533
+ }
534
+
289
535
  const de = ctx.container.resolve('dataEngine') as DataEngine
290
536
  await emitCrudSideEffects({
291
537
  dataEngine: de,
@@ -299,17 +545,18 @@ const updateCurrencyCommand: CommandHandler<CurrencyUpdateInput, { currencyId: s
299
545
  events: currencyCrudEvents,
300
546
  })
301
547
 
302
- return { currencyId: record.id }
548
+ return result
303
549
  },
304
550
  captureAfter: async (_input, result, ctx) => {
305
551
  const em = (ctx.container.resolve('em') as EntityManager).fork()
306
552
  return loadCurrencySnapshot(em, result.currencyId, ctx)
307
553
  },
308
- buildLog: async ({ snapshots, result }) => {
554
+ buildLog: async ({ input, snapshots, result }) => {
309
555
  const before = snapshots.before as CurrencySnapshot | undefined
310
556
  const after = snapshots.after as CurrencySnapshot | undefined
311
557
  if (!after) return null
312
558
  const { translate } = await resolveTranslations()
559
+ const base = result?.base ?? NO_BASE_TRANSITION
313
560
  return {
314
561
  actionLabel: translate('currencies.audit.update', 'Update currency'),
315
562
  resourceKind: 'currencies.currency',
@@ -318,28 +565,57 @@ const updateCurrencyCommand: CommandHandler<CurrencyUpdateInput, { currencyId: s
318
565
  organizationId: after.organizationId,
319
566
  snapshotBefore: before ?? undefined,
320
567
  snapshotAfter: after,
321
- payload: { undo: { before, after } },
568
+ payload: { undo: { before, after, base } },
569
+ redoInput: base.promoted ? input : omitIsBase(input),
322
570
  }
323
571
  },
324
572
  undo: async ({ logEntry, ctx }) => {
325
573
  const payload = extractUndoPayload<CurrencyUndoPayload>(logEntry)
326
574
  const before = payload?.before ?? null
327
575
  if (!before) return
576
+ const scope = scopeOf(before)
577
+ const promoted = payload?.base ? payload.base.promoted : isPromotionByLegacyLog(payload)
578
+ const predecessorIds = payload?.base?.demotedBaseCurrencyIds ?? []
328
579
  const em = (ctx.container.resolve('em') as EntityManager).fork()
329
- const record = await em.findOne(Currency, { id: before.id })
330
- if (!record) return
331
- Object.assign(record, {
332
- code: before.code,
333
- name: before.name,
334
- symbol: before.symbol,
335
- decimalPlaces: before.decimalPlaces,
336
- thousandsSeparator: before.thousandsSeparator,
337
- decimalSeparator: before.decimalSeparator,
338
- isBase: before.isBase,
339
- isActive: before.isActive,
340
- updatedAt: new Date(),
341
- })
342
- await em.flush()
580
+ const demotedByLegacyLog = isDemotionByLegacyLog(payload)
581
+ let record: Currency | null = null
582
+ let restoredBaseId: string | null = null
583
+ let repromote = false
584
+
585
+ await withAtomicFlush(
586
+ em,
587
+ [
588
+ async () => {
589
+ await lockBaseCurrencyScope(em, scope)
590
+ const current = await readScopedSnapshotRecord(em, before)
591
+ if (!current) return
592
+ if (promoted && current.isBase && !current.deletedAt) {
593
+ restoredBaseId = await resolvePredecessorOrRefuse(em, scope, predecessorIds)
594
+ }
595
+ if (demotedByLegacyLog && !current.isBase && !current.deletedAt) {
596
+ repromote = (await findLiveBaseCurrencyIds(em, scope)).length === 0
597
+ }
598
+ record = current
599
+ },
600
+ () => {
601
+ if (!record) return
602
+ Object.assign(record, {
603
+ code: before.code,
604
+ name: before.name,
605
+ symbol: before.symbol,
606
+ decimalPlaces: before.decimalPlaces,
607
+ thousandsSeparator: before.thousandsSeparator,
608
+ decimalSeparator: before.decimalSeparator,
609
+ isActive: before.isActive,
610
+ updatedAt: new Date(),
611
+ })
612
+ if (restoredBaseId) record.isBase = false
613
+ if (repromote) record.isBase = true
614
+ },
615
+ () => (restoredBaseId ? setBaseFlag(em, scope, [restoredBaseId], true) : undefined),
616
+ ],
617
+ { transaction: true },
618
+ )
343
619
  },
344
620
  }
345
621
 
@@ -355,42 +631,52 @@ const deleteCurrencyCommand: CommandHandler<CurrencyDeleteInput, { currencyId: s
355
631
  const parsed = currencyDeleteSchema.parse(input)
356
632
  requireId(parsed.id, 'Currency ID is required')
357
633
 
358
- const em = (ctx.container.resolve('em') as EntityManager).fork()
359
- const record = await em.findOne(
634
+ const scopeRecord = await (ctx.container.resolve('em') as EntityManager).fork().findOne(
360
635
  Currency,
361
636
  buildCurrencyCommandWhere<Currency>(ctx, { id: parsed.id }),
362
637
  )
363
- if (!record) {
638
+ if (!scopeRecord) {
364
639
  throw new CrudHttpError(404, { error: 'Currency not found' })
365
640
  }
366
- ensureCurrencyCommandScope(ctx, record)
641
+ ensureCurrencyCommandScope(ctx, scopeRecord)
642
+ const scope = scopeOf(scopeRecord)
367
643
 
368
- // Prevent deleting base currency
369
- if (record.isBase) {
370
- throw new CrudHttpError(400, { error: 'Cannot delete the base currency' })
371
- }
644
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
645
+ let record!: Currency
372
646
 
373
- // Prevent deleting currency with active exchange rates
374
- const activeRatesCount = await em.count(ExchangeRate, {
375
- $or: [
376
- { fromCurrencyCode: record.code },
377
- { toCurrencyCode: record.code },
378
- ],
379
- organizationId: record.organizationId,
380
- tenantId: record.tenantId,
381
- deletedAt: null,
382
- isActive: true,
383
- })
384
-
385
- if (activeRatesCount > 0) {
386
- throw new CrudHttpError(400, {
387
- error: `Cannot delete currency ${record.code} because it has ${activeRatesCount} active exchange rate(s). Please delete or deactivate the exchange rates first.`
388
- })
389
- }
647
+ await withAtomicFlush(
648
+ em,
649
+ [
650
+ async () => {
651
+ await lockBaseCurrencyScope(em, scope)
652
+ const current = await em.findOne(
653
+ Currency,
654
+ buildCurrencyCommandWhere<Currency>(ctx, { id: parsed.id }),
655
+ )
656
+ if (!current) {
657
+ throw new CrudHttpError(404, { error: 'Currency not found' })
658
+ }
659
+ ensureCurrencyCommandScope(ctx, current)
660
+
661
+ if (current.isBase) {
662
+ throw new CrudHttpError(400, { error: 'Cannot delete the base currency' })
663
+ }
390
664
 
391
- record.deletedAt = new Date()
392
- record.isActive = false
393
- await em.flush()
665
+ const activeRatesCount = await countActiveRatesForCode(em, scope, current.code)
666
+ if (activeRatesCount > 0) {
667
+ throw new CrudHttpError(400, {
668
+ error: `Cannot delete currency ${current.code} because it has ${activeRatesCount} active exchange rate(s). Please delete or deactivate the exchange rates first.`,
669
+ })
670
+ }
671
+ record = current
672
+ },
673
+ () => {
674
+ record.deletedAt = new Date()
675
+ record.isActive = false
676
+ },
677
+ ],
678
+ { transaction: true },
679
+ )
394
680
 
395
681
  const de = ctx.container.resolve('dataEngine') as DataEngine
396
682
  await emitCrudSideEffects({
@@ -407,7 +693,7 @@ const deleteCurrencyCommand: CommandHandler<CurrencyDeleteInput, { currencyId: s
407
693
 
408
694
  return { currencyId: record.id }
409
695
  },
410
- buildLog: async ({ snapshots, result }) => {
696
+ buildLog: async ({ snapshots }) => {
411
697
  const before = snapshots.before as CurrencySnapshot | undefined
412
698
  if (!before) return null
413
699
  const { translate } = await resolveTranslations()
@@ -425,13 +711,34 @@ const deleteCurrencyCommand: CommandHandler<CurrencyDeleteInput, { currencyId: s
425
711
  const payload = extractUndoPayload<CurrencyUndoPayload>(logEntry)
426
712
  const before = payload?.before ?? null
427
713
  if (!before) return
714
+ const scope = scopeOf(before)
428
715
  const em = (ctx.container.resolve('em') as EntityManager).fork()
429
- const record = await em.findOne(Currency, { id: before.id })
430
- if (!record) return
431
- record.deletedAt = null
432
- record.isActive = before.isActive
433
- record.updatedAt = new Date()
434
- await em.flush()
716
+ let record: Currency | null = null
717
+ let reviveAsNonBase = false
718
+
719
+ await withAtomicFlush(
720
+ em,
721
+ [
722
+ async () => {
723
+ await lockBaseCurrencyScope(em, scope)
724
+ const current = await readScopedSnapshotRecord(em, before)
725
+ if (!current) return
726
+ if (current.isBase) {
727
+ const otherBaseIds = await findLiveBaseCurrencyIds(em, scope, current.id)
728
+ reviveAsNonBase = otherBaseIds.length > 0
729
+ }
730
+ record = current
731
+ },
732
+ () => {
733
+ if (!record) return
734
+ record.deletedAt = null
735
+ record.isActive = before.isActive
736
+ if (reviveAsNonBase) record.isBase = false
737
+ record.updatedAt = new Date()
738
+ },
739
+ ],
740
+ { transaction: true },
741
+ )
435
742
  },
436
743
  }
437
744