@open-mercato/checkout 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js +125 -0
  3. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
  4. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
  5. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
  6. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
  7. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
  8. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
  9. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
  10. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
  11. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
  12. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
  13. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
  14. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
  15. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
  16. package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
  17. package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
  18. package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
  19. package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
  20. package/dist/modules/checkout/commands/links.js +67 -0
  21. package/dist/modules/checkout/commands/links.js.map +2 -2
  22. package/dist/modules/checkout/commands/templates.js +69 -2
  23. package/dist/modules/checkout/commands/templates.js.map +2 -2
  24. package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
  25. package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
  26. package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
  27. package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
  28. package/dist/modules/checkout/data/validators.js +18 -2
  29. package/dist/modules/checkout/data/validators.js.map +2 -2
  30. package/dist/modules/checkout/lib/utils.js +26 -5
  31. package/dist/modules/checkout/lib/utils.js.map +2 -2
  32. package/jest.config.cjs +2 -0
  33. package/package.json +7 -8
  34. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
  35. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
  36. package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
  37. package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
  38. package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
  39. package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
  40. package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
  41. package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
  42. package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
  43. package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
  44. package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
  45. package/src/modules/checkout/commands/links.ts +67 -0
  46. package/src/modules/checkout/commands/templates.ts +74 -2
  47. package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
  48. package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
  49. package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
  50. package/src/modules/checkout/data/validators.ts +18 -2
  51. package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
  52. package/src/modules/checkout/lib/utils.ts +41 -5
@@ -0,0 +1,261 @@
1
+ /** @jest-environment node */
2
+
3
+ import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
4
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
5
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
6
+
7
+ const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
8
+ const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
9
+ const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
10
+ const TEMPLATE_ID = '123e4567-e89b-12d3-a456-426614174020'
11
+ const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
12
+ const STALE_VERSION = '2026-06-01T09:00:00.000Z'
13
+
14
+ const FLUSH_SENTINEL = 'FLUSH_REACHED_AFTER_LOCK'
15
+
16
+ const mockFindOneWithDecryption = jest.fn()
17
+
18
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
19
+ findOneWithDecryption: jest.fn((...args: unknown[]) => mockFindOneWithDecryption(...args)),
20
+ }))
21
+
22
+ jest.mock('../../lib/gatewayProviderAvailability', () => ({
23
+ ensureGatewayProviderConfigured: jest.fn(async () => undefined),
24
+ getGatewayProviderConfigurationMessageKey: jest.fn(() => null),
25
+ }))
26
+
27
+ jest.mock('@open-mercato/shared/lib/commands/helpers', () => ({
28
+ setCustomFieldsIfAny: jest.fn(async () => undefined),
29
+ }))
30
+
31
+ jest.mock('@open-mercato/shared/lib/commands/customFieldSnapshots', () => ({
32
+ loadCustomFieldSnapshot: jest.fn(async () => ({})),
33
+ buildCustomFieldResetMap: jest.fn(() => ({})),
34
+ }))
35
+
36
+ jest.mock('@open-mercato/shared/lib/crud/custom-fields', () => ({
37
+ loadCustomFieldValues: jest.fn(async () => ({})),
38
+ }))
39
+
40
+ jest.mock('../../events', () => ({
41
+ emitCheckoutEvent: jest.fn(async () => undefined),
42
+ }))
43
+
44
+ // Importing the command modules registers the handlers via registerCommand.
45
+ import '../links'
46
+ import '../templates'
47
+
48
+ // flush runs immediately AFTER the lock guard in both update commands. Throwing
49
+ // a recognizable sentinel here lets the match / no-header cases prove the lock
50
+ // guard PASSED, without mocking the full post-flush path (custom fields, events).
51
+ const mockEm = {
52
+ flush: jest.fn(async () => {
53
+ throw new Error(FLUSH_SENTINEL)
54
+ }),
55
+ // delete commands count active transactions before flushing; 0 lets the
56
+ // happy path reach the flush sentinel so we can prove the lock guard passed.
57
+ count: jest.fn(async () => 0),
58
+ }
59
+
60
+ const mockDataEngine = {}
61
+
62
+ function makeContext(headerVersion: string | null): CommandRuntimeContext {
63
+ const headers = new Headers()
64
+ if (headerVersion) headers.set(OPTIMISTIC_LOCK_HEADER_NAME, headerVersion)
65
+ const request = new Request('http://localhost/api/checkout/links/x', { method: 'PUT', headers })
66
+ return {
67
+ container: {
68
+ resolve: (token: string) => {
69
+ if (token === 'em') return mockEm
70
+ if (token === 'dataEngine') return mockDataEngine
71
+ if (token === 'paymentGatewayDescriptorService') return {}
72
+ return null
73
+ },
74
+ } as unknown as CommandRuntimeContext['container'],
75
+ auth: { orgId: ORG_ID, tenantId: TENANT_ID } as CommandRuntimeContext['auth'],
76
+ organizationScope: null,
77
+ selectedOrganizationId: ORG_ID,
78
+ organizationIds: [ORG_ID],
79
+ request,
80
+ }
81
+ }
82
+
83
+ function linkRecord() {
84
+ return {
85
+ id: LINK_ID,
86
+ organizationId: ORG_ID,
87
+ tenantId: TENANT_ID,
88
+ name: 'Pay link',
89
+ title: null,
90
+ slug: 'pay-link',
91
+ templateId: null,
92
+ status: 'draft',
93
+ isLocked: false,
94
+ pricingMode: 'fixed',
95
+ gatewayProviderKey: null,
96
+ gatewaySettings: {},
97
+ fixedPriceAmount: null,
98
+ fixedPriceOriginalAmount: null,
99
+ customAmountMin: null,
100
+ customAmountMax: null,
101
+ passwordHash: null,
102
+ updatedAt: new Date(CURRENT_VERSION),
103
+ }
104
+ }
105
+
106
+ function templateRecord() {
107
+ return {
108
+ id: TEMPLATE_ID,
109
+ organizationId: ORG_ID,
110
+ tenantId: TENANT_ID,
111
+ name: 'Template',
112
+ title: null,
113
+ status: 'draft',
114
+ pricingMode: 'fixed',
115
+ gatewayProviderKey: null,
116
+ gatewaySettings: {},
117
+ fixedPriceAmount: null,
118
+ fixedPriceOriginalAmount: null,
119
+ customAmountMin: null,
120
+ customAmountMax: null,
121
+ passwordHash: null,
122
+ updatedAt: new Date(CURRENT_VERSION),
123
+ }
124
+ }
125
+
126
+ async function runUpdate(commandId: string, input: Record<string, unknown>, headerVersion: string | null) {
127
+ const handler = commandRegistry.get(commandId)
128
+ if (!handler) throw new Error(`Command ${commandId} not registered`)
129
+ return handler.execute(input, makeContext(headerVersion))
130
+ }
131
+
132
+ describe('checkout command optimistic locking', () => {
133
+ beforeEach(() => {
134
+ jest.clearAllMocks()
135
+ delete process.env.OM_OPTIMISTIC_LOCK
136
+ })
137
+
138
+ describe('checkout.link.update', () => {
139
+ beforeEach(() => {
140
+ mockFindOneWithDecryption.mockResolvedValue(linkRecord())
141
+ })
142
+
143
+ it('rejects a stale expected version with a structured 409 conflict', async () => {
144
+ expect.assertions(4)
145
+ try {
146
+ await runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, STALE_VERSION)
147
+ } catch (error) {
148
+ const httpError = error as { status?: number; body?: Record<string, unknown> }
149
+ expect(httpError.status).toBe(409)
150
+ expect(httpError.body?.code).toBe('optimistic_lock_conflict')
151
+ expect(httpError.body?.currentUpdatedAt).toBe(CURRENT_VERSION)
152
+ expect(mockEm.flush).not.toHaveBeenCalled()
153
+ }
154
+ })
155
+
156
+ it('passes the lock guard when the expected version matches', async () => {
157
+ await expect(
158
+ runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, CURRENT_VERSION),
159
+ ).rejects.toThrow(FLUSH_SENTINEL)
160
+ })
161
+
162
+ it('passes the lock guard when no expected-version header is sent (strictly additive)', async () => {
163
+ await expect(
164
+ runUpdate('checkout.link.update', { id: LINK_ID, status: 'draft' }, null),
165
+ ).rejects.toThrow(FLUSH_SENTINEL)
166
+ })
167
+ })
168
+
169
+ describe('checkout.template.update', () => {
170
+ beforeEach(() => {
171
+ mockFindOneWithDecryption.mockResolvedValue(templateRecord())
172
+ })
173
+
174
+ it('rejects a stale expected version with a structured 409 conflict', async () => {
175
+ expect.assertions(3)
176
+ try {
177
+ await runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, STALE_VERSION)
178
+ } catch (error) {
179
+ const httpError = error as { status?: number; body?: Record<string, unknown> }
180
+ expect(httpError.status).toBe(409)
181
+ expect(httpError.body?.code).toBe('optimistic_lock_conflict')
182
+ expect(mockEm.flush).not.toHaveBeenCalled()
183
+ }
184
+ })
185
+
186
+ it('passes the lock guard when the expected version matches', async () => {
187
+ await expect(
188
+ runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, CURRENT_VERSION),
189
+ ).rejects.toThrow(FLUSH_SENTINEL)
190
+ })
191
+
192
+ it('passes the lock guard when no expected-version header is sent', async () => {
193
+ await expect(
194
+ runUpdate('checkout.template.update', { id: TEMPLATE_ID, status: 'draft' }, null),
195
+ ).rejects.toThrow(FLUSH_SENTINEL)
196
+ })
197
+ })
198
+
199
+ // QA round-6 (#2055): a stale DELETE after a conflict still deleted the
200
+ // record because the delete commands did not enforce the lock. These guard
201
+ // that the header now produces a 409 on a stale delete.
202
+ describe('checkout.link.delete', () => {
203
+ beforeEach(() => {
204
+ mockFindOneWithDecryption.mockResolvedValue(linkRecord())
205
+ })
206
+
207
+ it('rejects a stale expected version with a structured 409 conflict', async () => {
208
+ expect.assertions(3)
209
+ try {
210
+ await runUpdate('checkout.link.delete', { id: LINK_ID }, STALE_VERSION)
211
+ } catch (error) {
212
+ const httpError = error as { status?: number; body?: Record<string, unknown> }
213
+ expect(httpError.status).toBe(409)
214
+ expect(httpError.body?.code).toBe('optimistic_lock_conflict')
215
+ expect(mockEm.flush).not.toHaveBeenCalled()
216
+ }
217
+ })
218
+
219
+ it('passes the lock guard when the expected version matches', async () => {
220
+ await expect(
221
+ runUpdate('checkout.link.delete', { id: LINK_ID }, CURRENT_VERSION),
222
+ ).rejects.toThrow(FLUSH_SENTINEL)
223
+ })
224
+
225
+ it('passes the lock guard when no expected-version header is sent (strictly additive)', async () => {
226
+ await expect(
227
+ runUpdate('checkout.link.delete', { id: LINK_ID }, null),
228
+ ).rejects.toThrow(FLUSH_SENTINEL)
229
+ })
230
+ })
231
+
232
+ describe('checkout.template.delete', () => {
233
+ beforeEach(() => {
234
+ mockFindOneWithDecryption.mockResolvedValue(templateRecord())
235
+ })
236
+
237
+ it('rejects a stale expected version with a structured 409 conflict', async () => {
238
+ expect.assertions(3)
239
+ try {
240
+ await runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, STALE_VERSION)
241
+ } catch (error) {
242
+ const httpError = error as { status?: number; body?: Record<string, unknown> }
243
+ expect(httpError.status).toBe(409)
244
+ expect(httpError.body?.code).toBe('optimistic_lock_conflict')
245
+ expect(mockEm.flush).not.toHaveBeenCalled()
246
+ }
247
+ })
248
+
249
+ it('passes the lock guard when the expected version matches', async () => {
250
+ await expect(
251
+ runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, CURRENT_VERSION),
252
+ ).rejects.toThrow(FLUSH_SENTINEL)
253
+ })
254
+
255
+ it('passes the lock guard when no expected-version header is sent', async () => {
256
+ await expect(
257
+ runUpdate('checkout.template.delete', { id: TEMPLATE_ID }, null),
258
+ ).rejects.toThrow(FLUSH_SENTINEL)
259
+ })
260
+ })
261
+ })
@@ -0,0 +1,21 @@
1
+ import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
2
+ import '../index'
3
+
4
+ describe('checkout undoable create command redo coverage', () => {
5
+ it('requires every logged undoable *.create command to define redo', () => {
6
+ const missingRedo = commandRegistry
7
+ .list()
8
+ .sort()
9
+ .filter((id) => id.startsWith('checkout.') && id.endsWith('.create'))
10
+ .filter((id) => {
11
+ const handler = commandRegistry.get(id)
12
+ return Boolean(
13
+ handler?.isUndoable &&
14
+ typeof handler.buildLog === 'function' &&
15
+ typeof handler.redo !== 'function',
16
+ )
17
+ })
18
+
19
+ expect(missingRedo).toEqual([])
20
+ })
21
+ })
@@ -4,8 +4,10 @@ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
4
4
  import { registerCommand } from '@open-mercato/shared/lib/commands'
5
5
  import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
6
6
  import { setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
7
+ import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
7
8
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
8
9
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
12
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
13
  import { CheckoutLink, CheckoutLinkTemplate, CheckoutTransaction } from '../data/entities'
@@ -199,6 +201,59 @@ const createLinkCommand: CommandHandler<Record<string, unknown>, { id: string; s
199
201
  link.deletedAt = new Date()
200
202
  await em.flush()
201
203
  },
204
+ redo: async ({ logEntry, ctx }) => {
205
+ const after = resolveRedoSnapshot<CheckoutLinkSnapshot>(logEntry)
206
+ if (!after) throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for checkout link create' })
207
+ const em = ctx.container.resolve('em') as EntityManager
208
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
209
+ let link = await findOneWithDecryption(
210
+ em,
211
+ CheckoutLink,
212
+ { id: after.id },
213
+ {},
214
+ { tenantId: after.tenantId, organizationId: after.organizationId },
215
+ )
216
+ if (link) {
217
+ restoreLinkFromSnapshot(link, after)
218
+ link.deletedAt = null
219
+ link.slug = await resolveRestoredLinkSlug(em, after)
220
+ } else {
221
+ link = em.create(CheckoutLink, {
222
+ ...createLinkFromSnapshot(after),
223
+ slug: await resolveRestoredLinkSlug(em, after),
224
+ })
225
+ em.persist(link)
226
+ }
227
+ await em.flush()
228
+ const reset = buildCustomFieldResetMap(after.custom, undefined)
229
+ if (Object.keys(reset).length) {
230
+ await setCustomFieldsIfAny({
231
+ dataEngine,
232
+ entityId: CHECKOUT_ENTITY_IDS.link,
233
+ recordId: after.id,
234
+ tenantId: after.tenantId,
235
+ organizationId: after.organizationId,
236
+ values: reset,
237
+ notify: false,
238
+ })
239
+ }
240
+ await emitCheckoutEvent('checkout.link.created', {
241
+ id: link.id,
242
+ slug: link.slug,
243
+ status: link.status,
244
+ tenantId: after.tenantId,
245
+ organizationId: after.organizationId,
246
+ }).catch(() => undefined)
247
+ if (link.status === 'active') {
248
+ await emitCheckoutEvent('checkout.link.published', {
249
+ id: link.id,
250
+ slug: link.slug,
251
+ tenantId: after.tenantId,
252
+ organizationId: after.organizationId,
253
+ }).catch(() => undefined)
254
+ }
255
+ return { id: link.id, slug: link.slug }
256
+ },
202
257
  }
203
258
 
204
259
  const updateLinkCommand: CommandHandler<Record<string, unknown>, { ok: true; slug: string }> = {
@@ -234,6 +289,12 @@ const updateLinkCommand: CommandHandler<Record<string, unknown>, { ok: true; slu
234
289
  deletedAt: null,
235
290
  }, undefined, scope)
236
291
  if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
292
+ enforceCommandOptimisticLock({
293
+ resourceKind: 'checkout.link',
294
+ resourceId: link.id,
295
+ current: link.updatedAt ?? null,
296
+ request: ctx.request ?? null,
297
+ })
237
298
  if (link.isLocked) {
238
299
  throw new CrudHttpError(422, { error: 'This link has active transactions and cannot be edited' })
239
300
  }
@@ -380,6 +441,12 @@ const deleteLinkCommand: CommandHandler<Record<string, unknown>, { ok: true }> =
380
441
  deletedAt: null,
381
442
  }, undefined, scope)
382
443
  if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
444
+ enforceCommandOptimisticLock({
445
+ resourceKind: 'checkout.link',
446
+ resourceId: link.id,
447
+ current: link.updatedAt ?? null,
448
+ request: ctx.request ?? null,
449
+ })
383
450
  const activeCount = await em.count(CheckoutTransaction, {
384
451
  linkId: link.id,
385
452
  organizationId: scope.organizationId,
@@ -4,7 +4,9 @@ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
4
4
  import { registerCommand } from '@open-mercato/shared/lib/commands'
5
5
  import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
6
6
  import { setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
7
+ import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
7
8
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { enforceCommandOptimisticLock, enforceRecordGoneIsConflict } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
11
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
12
  import { CheckoutLink, CheckoutLinkTemplate } from '../data/entities'
@@ -188,6 +190,45 @@ const createTemplateCommand: CommandHandler<Record<string, unknown>, { id: strin
188
190
  template.deletedAt = new Date()
189
191
  await em.flush()
190
192
  },
193
+ redo: async ({ logEntry, ctx }) => {
194
+ const after = resolveRedoSnapshot<CheckoutTemplateSnapshot>(logEntry)
195
+ if (!after) throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for checkout template create' })
196
+ const em = ctx.container.resolve('em') as EntityManager
197
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
198
+ let template = await findOneWithDecryption(
199
+ em,
200
+ CheckoutLinkTemplate,
201
+ { id: after.id },
202
+ {},
203
+ { tenantId: after.tenantId, organizationId: after.organizationId },
204
+ )
205
+ if (template) {
206
+ restoreTemplateFromSnapshot(template, after)
207
+ template.deletedAt = null
208
+ } else {
209
+ template = em.create(CheckoutLinkTemplate, createTemplateFromSnapshot(after))
210
+ em.persist(template)
211
+ }
212
+ await em.flush()
213
+ const reset = buildCustomFieldResetMap(after.custom, undefined)
214
+ if (Object.keys(reset).length) {
215
+ await setCustomFieldsIfAny({
216
+ dataEngine,
217
+ entityId: CHECKOUT_ENTITY_IDS.template,
218
+ recordId: after.id,
219
+ tenantId: after.tenantId,
220
+ organizationId: after.organizationId,
221
+ values: reset,
222
+ notify: false,
223
+ })
224
+ }
225
+ await emitCheckoutEvent('checkout.template.created', {
226
+ id: template.id,
227
+ tenantId: after.tenantId,
228
+ organizationId: after.organizationId,
229
+ }).catch(() => undefined)
230
+ return { id: template.id }
231
+ },
191
232
  }
192
233
 
193
234
  const updateTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true }> = {
@@ -225,7 +266,23 @@ const updateTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
225
266
  tenantId: scope.tenantId,
226
267
  deletedAt: null,
227
268
  }, undefined, scope)
228
- if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
269
+ if (!template) {
270
+ // The template was deleted in another tab. When the client opted into
271
+ // optimistic locking, surface the unified conflict bar instead of a bare
272
+ // 404 (#2529); otherwise the plain 404 still fires for API consumers.
273
+ enforceRecordGoneIsConflict({
274
+ resourceKind: 'checkout.template',
275
+ resourceId: parsed.id,
276
+ request: ctx.request ?? null,
277
+ })
278
+ throw new CrudHttpError(404, { error: 'Template not found' })
279
+ }
280
+ enforceCommandOptimisticLock({
281
+ resourceKind: 'checkout.template',
282
+ resourceId: template.id,
283
+ current: template.updatedAt ?? null,
284
+ request: ctx.request ?? null,
285
+ })
229
286
  const beforeCustom = await loadCustomFieldSnapshot(em, {
230
287
  entityId: CHECKOUT_ENTITY_IDS.template,
231
288
  recordId: template.id,
@@ -375,7 +432,22 @@ const deleteTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
375
432
  tenantId: scope.tenantId,
376
433
  deletedAt: null,
377
434
  }, undefined, scope)
378
- if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
435
+ if (!template) {
436
+ // Already deleted elsewhere — convert to a 409 conflict when the client
437
+ // sent the optimistic-lock header so the stale edit surfaces cleanly (#2529).
438
+ enforceRecordGoneIsConflict({
439
+ resourceKind: 'checkout.template',
440
+ resourceId: templateId,
441
+ request: ctx.request ?? null,
442
+ })
443
+ throw new CrudHttpError(404, { error: 'Template not found' })
444
+ }
445
+ enforceCommandOptimisticLock({
446
+ resourceKind: 'checkout.template',
447
+ resourceId: template.id,
448
+ current: template.updatedAt ?? null,
449
+ request: ctx.request ?? null,
450
+ })
379
451
  template.deletedAt = new Date()
380
452
  await em.flush()
381
453
  await emitCheckoutEvent('checkout.template.deleted', {
@@ -6,6 +6,14 @@ import { Input } from '@open-mercato/ui/primitives/input'
6
6
  import { Label } from '@open-mercato/ui/primitives/label'
7
7
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
8
8
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
9
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@open-mercato/ui/primitives/select'
9
17
 
10
18
  type Descriptor = {
11
19
  providerKey: string
@@ -31,6 +39,19 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
31
39
  const t = useT()
32
40
  const [descriptor, setDescriptor] = React.useState<Descriptor | null>(null)
33
41
 
42
+ const patchSetting = React.useCallback(
43
+ (fieldKey: string, nextValue: unknown) => {
44
+ const next = { ...(value ?? {}) }
45
+ if (nextValue === undefined || nextValue === null || nextValue === '') {
46
+ delete next[fieldKey]
47
+ } else {
48
+ next[fieldKey] = nextValue
49
+ }
50
+ onChange(next)
51
+ },
52
+ [onChange, value],
53
+ )
54
+
34
55
  const toggleMultiselectValue = React.useCallback(
35
56
  (fieldKey: string, optionValue: string) => {
36
57
  const current = Array.isArray(value?.[fieldKey])
@@ -39,9 +60,9 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
39
60
  const next = current.includes(optionValue)
40
61
  ? current.filter((entry) => entry !== optionValue)
41
62
  : [...current, optionValue]
42
- onChange({ ...(value ?? {}), [fieldKey]: next })
63
+ patchSetting(fieldKey, next.length ? next : undefined)
43
64
  },
44
- [onChange, value],
65
+ [patchSetting, value],
45
66
  )
46
67
 
47
68
  React.useEffect(() => {
@@ -88,16 +109,19 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
88
109
  <div key={field.key} className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3">
89
110
  <Label>{field.label}</Label>
90
111
  {field.type === 'select' ? (
91
- <select
92
- className="w-full rounded-md border bg-background px-3 py-2 text-sm"
112
+ <Select
93
113
  value={typeof currentValue === 'string' ? currentValue : ''}
94
- onChange={(event) => onChange({ ...(value ?? {}), [field.key]: event.target.value })}
114
+ onValueChange={(next) => patchSetting(field.key, next)}
95
115
  >
96
- <option value="">{t('checkout.gatewaySettings.selectPlaceholder')}</option>
97
- {(field.options ?? []).map((option) => (
98
- <option key={option.value} value={option.value}>{option.label}</option>
99
- ))}
100
- </select>
116
+ <SelectTrigger>
117
+ <SelectValue placeholder={t('checkout.gatewaySettings.selectPlaceholder')} />
118
+ </SelectTrigger>
119
+ <SelectContent>
120
+ {(field.options ?? []).map((option) => (
121
+ <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
122
+ ))}
123
+ </SelectContent>
124
+ </Select>
101
125
  ) : field.type === 'multiselect' ? (
102
126
  <div className="grid gap-2 sm:grid-cols-2">
103
127
  {(field.options ?? []).map((option) => {
@@ -110,10 +134,9 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
110
134
  key={option.value}
111
135
  className="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm"
112
136
  >
113
- <input
114
- type="checkbox"
137
+ <Checkbox
115
138
  checked={checked}
116
- onChange={() => toggleMultiselectValue(field.key, option.value)}
139
+ onCheckedChange={() => toggleMultiselectValue(field.key, option.value)}
117
140
  />
118
141
  <span>{option.label}</span>
119
142
  </label>
@@ -122,23 +145,22 @@ export function GatewaySettingsFields({ providerKey, value, onChange }: Props) {
122
145
  </div>
123
146
  ) : field.type === 'boolean' ? (
124
147
  <label className="flex items-center gap-2 text-sm">
125
- <input
126
- type="checkbox"
148
+ <Checkbox
127
149
  checked={currentValue === true}
128
- onChange={(event) => onChange({ ...(value ?? {}), [field.key]: event.target.checked })}
150
+ onCheckedChange={(next) => patchSetting(field.key, next === true)}
129
151
  />
130
152
  {t('checkout.common.enabled')}
131
153
  </label>
132
154
  ) : field.type === 'textarea' ? (
133
155
  <Textarea
134
156
  value={typeof currentValue === 'string' ? currentValue : ''}
135
- onChange={(event) => onChange({ ...(value ?? {}), [field.key]: event.target.value })}
157
+ onChange={(event) => patchSetting(field.key, event.target.value)}
136
158
  />
137
159
  ) : (
138
160
  <Input
139
161
  type={field.type === 'number' ? 'number' : field.type === 'secret' ? 'password' : 'text'}
140
162
  value={typeof currentValue === 'string' || typeof currentValue === 'number' ? `${currentValue}` : ''}
141
- onChange={(event) => onChange({ ...(value ?? {}), [field.key]: event.target.value })}
163
+ onChange={(event) => patchSetting(field.key, event.target.value)}
142
164
  />
143
165
  )}
144
166
  {field.description ? <p className="text-xs text-muted-foreground">{field.description}</p> : null}
@@ -24,7 +24,9 @@ import { CrudForm, type CrudField, type CrudFormGroup, type CrudFormGroupCompone
24
24
  import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs'
25
25
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
26
26
  import { SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
27
- import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
27
+ import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
28
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
29
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
28
30
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
29
31
  import { Button } from '@open-mercato/ui/primitives/button'
30
32
  import { ColorPicker } from '@open-mercato/ui/primitives/color-picker'
@@ -260,6 +262,7 @@ function normalizeFormValues(value: FormValues | null | undefined, t?: Translate
260
262
  primaryColor: readString(source.primaryColor).trim() || defaults.primaryColor,
261
263
  secondaryColor: readString(source.secondaryColor).trim() || defaults.secondaryColor,
262
264
  backgroundColor: readString(source.backgroundColor).trim() || defaults.backgroundColor,
265
+ gatewayProviderKey: readString(source.gatewayProviderKey).trim(),
263
266
  gatewaySettings: isRecord(source.gatewaySettings) ? source.gatewaySettings : {},
264
267
  customFieldsetCode: readString(source.customFieldsetCode).trim() || null,
265
268
  collectCustomerDetails: readBoolean(source.collectCustomerDetails, true),
@@ -1638,11 +1641,20 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
1638
1641
  customFields: collectCustomFieldValues(values),
1639
1642
  }
1640
1643
  const endpoint = `/api/checkout/${mode === 'link' ? 'links' : 'templates'}${recordId ? `/${encodeURIComponent(recordId)}` : ''}`
1641
- const response = await readApiResultOrThrow<{ id?: string; slug?: string; ok?: boolean }>(endpoint, {
1642
- method: recordId ? 'PUT' : 'POST',
1643
- headers: { 'Content-Type': 'application/json' },
1644
- body: JSON.stringify(payload),
1645
- })
1644
+ let response: { id?: string; slug?: string; ok?: boolean }
1645
+ try {
1646
+ response = await withScopedApiRequestHeaders(
1647
+ recordId ? buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null) : {},
1648
+ () => readApiResultOrThrow<{ id?: string; slug?: string; ok?: boolean }>(endpoint, {
1649
+ method: recordId ? 'PUT' : 'POST',
1650
+ headers: { 'Content-Type': 'application/json' },
1651
+ body: JSON.stringify(payload),
1652
+ }),
1653
+ )
1654
+ } catch (error) {
1655
+ if (surfaceRecordConflict(error, t)) return
1656
+ throw error
1657
+ }
1646
1658
  const targetId = recordId ?? (typeof response?.id === 'string' ? response.id : null)
1647
1659
  const logoAttachmentId = readString(values.logoAttachmentId)
1648
1660
  if (
@@ -1687,7 +1699,15 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
1687
1699
  : `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.saved'))}&type=success`
1688
1700
  }}
1689
1701
  onDelete={recordId ? async () => {
1690
- await apiCallOrThrow(`/api/checkout/${mode === 'link' ? 'links' : 'templates'}/${encodeURIComponent(recordId)}`, { method: 'DELETE' })
1702
+ try {
1703
+ await withScopedApiRequestHeaders(
1704
+ buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null),
1705
+ () => apiCallOrThrow(`/api/checkout/${mode === 'link' ? 'links' : 'templates'}/${encodeURIComponent(recordId)}`, { method: 'DELETE' }),
1706
+ )
1707
+ } catch (error) {
1708
+ if (surfaceRecordConflict(error, t)) return
1709
+ throw error
1710
+ }
1691
1711
  window.location.href = mode === 'link'
1692
1712
  ? `/backend/checkout/pay-links?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`
1693
1713
  : `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`