@open-mercato/checkout 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861

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.
@@ -0,0 +1,98 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
3
+ import { createFixedTemplateInput, createLinkFixture, readLink } from './helpers/fixtures'
4
+
5
+ /**
6
+ * TC-CHKT-039: OSS optimistic locking now guards the pay-link/template DELETE.
7
+ *
8
+ * QA round-6 (PR #2055): after a save conflict surfaced the unified conflict
9
+ * bar, clicking Delete still removed the stale record. The pay-link/template
10
+ * delete path was unguarded — the client sent no version header and the
11
+ * `checkout.link.delete` / `checkout.template.delete` commands never called
12
+ * `enforceCommandOptimisticLock` (only the *.update commands did).
13
+ *
14
+ * This spec proves the server contract the UI relies on:
15
+ * - GET detail exposes `updatedAt`.
16
+ * - DELETE without the header succeeds (strictly additive).
17
+ * - DELETE with a stale header returns 409 with the structured conflict body.
18
+ * - DELETE with a fresh header deletes the record.
19
+ *
20
+ * Requires `OM_OPTIMISTIC_LOCK=all` (CI default).
21
+ */
22
+ const OPTIMISTIC_LOCK_HEADER = 'x-om-ext-optimistic-lock-expected-updated-at'
23
+
24
+ function readUpdatedAt(record: Record<string, unknown>): string {
25
+ const raw = record.updatedAt ?? record.updated_at
26
+ expect(typeof raw, 'link detail should expose updatedAt as a string').toBe('string')
27
+ return new Date(Date.parse(raw as string)).toISOString()
28
+ }
29
+
30
+ test.describe('TC-CHKT-039: pay-link stale DELETE optimistic-lock guard', () => {
31
+ test('stale DELETE returns 409; fresh DELETE succeeds; header-less stays backward-compatible', async ({ request }) => {
32
+ const token = await getAuthToken(request)
33
+
34
+ // Two links: one to prove the stale→409→fresh path, one to prove the
35
+ // strictly-additive header-less delete still works.
36
+ const guarded = await createLinkFixture(request, token, createFixedTemplateInput())
37
+ const additive = await createLinkFixture(request, token, createFixedTemplateInput())
38
+ let guardedDeleted = false
39
+ let additiveDeleted = false
40
+
41
+ try {
42
+ const detail = await readLink(request, token, guarded.id)
43
+ const t0 = readUpdatedAt(detail as Record<string, unknown>)
44
+
45
+ // A save advances the version, making t0 stale.
46
+ const bump = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
47
+ method: 'PUT',
48
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
49
+ data: { subtitle: 'bumped for stale-delete test' },
50
+ })
51
+ expect(bump.status(), 'PUT bump should succeed').toBeLessThan(300)
52
+
53
+ // Stale DELETE → 409 with the structured conflict body; record survives.
54
+ const staleDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
55
+ method: 'DELETE',
56
+ headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t0 },
57
+ })
58
+ expect(staleDelete.status(), 'DELETE with a stale header should return 409').toBe(409)
59
+ expect((await staleDelete.json()) as Record<string, unknown>).toMatchObject({
60
+ code: 'optimistic_lock_conflict',
61
+ expectedUpdatedAt: t0,
62
+ })
63
+
64
+ const stillThere = await readLink(request, token, guarded.id)
65
+ const t1 = readUpdatedAt(stillThere as Record<string, unknown>)
66
+ expect(t1, 'stale delete must NOT have removed the record').not.toBe(t0)
67
+
68
+ // Fresh DELETE → succeeds.
69
+ const freshDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
70
+ method: 'DELETE',
71
+ headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t1 },
72
+ })
73
+ expect(freshDelete.status(), 'DELETE with a fresh header should succeed').toBeLessThan(300)
74
+ guardedDeleted = true
75
+
76
+ // Header-less DELETE still works (strictly additive).
77
+ const nohdrDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
78
+ method: 'DELETE',
79
+ headers: { Authorization: `Bearer ${token}` },
80
+ })
81
+ expect(nohdrDelete.status(), 'DELETE without a header should succeed').toBeLessThan(300)
82
+ additiveDeleted = true
83
+ } finally {
84
+ if (!guardedDeleted) {
85
+ await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
86
+ method: 'DELETE',
87
+ headers: { Authorization: `Bearer ${token}` },
88
+ }).catch(() => undefined)
89
+ }
90
+ if (!additiveDeleted) {
91
+ await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
92
+ method: 'DELETE',
93
+ headers: { Authorization: `Bearer ${token}` },
94
+ }).catch(() => undefined)
95
+ }
96
+ }
97
+ })
98
+ })
@@ -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
+ })
@@ -6,6 +6,7 @@ import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato
6
6
  import { setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
7
7
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
8
8
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
9
10
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
11
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
12
  import { CheckoutLink, CheckoutLinkTemplate, CheckoutTransaction } from '../data/entities'
@@ -234,6 +235,12 @@ const updateLinkCommand: CommandHandler<Record<string, unknown>, { ok: true; slu
234
235
  deletedAt: null,
235
236
  }, undefined, scope)
236
237
  if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
238
+ enforceCommandOptimisticLock({
239
+ resourceKind: 'checkout.link',
240
+ resourceId: link.id,
241
+ current: link.updatedAt ?? null,
242
+ request: ctx.request ?? null,
243
+ })
237
244
  if (link.isLocked) {
238
245
  throw new CrudHttpError(422, { error: 'This link has active transactions and cannot be edited' })
239
246
  }
@@ -380,6 +387,12 @@ const deleteLinkCommand: CommandHandler<Record<string, unknown>, { ok: true }> =
380
387
  deletedAt: null,
381
388
  }, undefined, scope)
382
389
  if (!link) throw new CrudHttpError(404, { error: 'Link not found' })
390
+ enforceCommandOptimisticLock({
391
+ resourceKind: 'checkout.link',
392
+ resourceId: link.id,
393
+ current: link.updatedAt ?? null,
394
+ request: ctx.request ?? null,
395
+ })
383
396
  const activeCount = await em.count(CheckoutTransaction, {
384
397
  linkId: link.id,
385
398
  organizationId: scope.organizationId,
@@ -5,6 +5,7 @@ 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
7
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
9
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
11
  import { CheckoutLink, CheckoutLinkTemplate } from '../data/entities'
@@ -226,6 +227,12 @@ const updateTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
226
227
  deletedAt: null,
227
228
  }, undefined, scope)
228
229
  if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
230
+ enforceCommandOptimisticLock({
231
+ resourceKind: 'checkout.template',
232
+ resourceId: template.id,
233
+ current: template.updatedAt ?? null,
234
+ request: ctx.request ?? null,
235
+ })
229
236
  const beforeCustom = await loadCustomFieldSnapshot(em, {
230
237
  entityId: CHECKOUT_ENTITY_IDS.template,
231
238
  recordId: template.id,
@@ -376,6 +383,12 @@ const deleteTemplateCommand: CommandHandler<Record<string, unknown>, { ok: true
376
383
  deletedAt: null,
377
384
  }, undefined, scope)
378
385
  if (!template) throw new CrudHttpError(404, { error: 'Template not found' })
386
+ enforceCommandOptimisticLock({
387
+ resourceKind: 'checkout.template',
388
+ resourceId: template.id,
389
+ current: template.updatedAt ?? null,
390
+ request: ctx.request ?? null,
391
+ })
379
392
  template.deletedAt = new Date()
380
393
  await em.flush()
381
394
  await emitCheckoutEvent('checkout.template.deleted', {
@@ -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'
@@ -1638,11 +1640,20 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
1638
1640
  customFields: collectCustomFieldValues(values),
1639
1641
  }
1640
1642
  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
- })
1643
+ let response: { id?: string; slug?: string; ok?: boolean }
1644
+ try {
1645
+ response = await withScopedApiRequestHeaders(
1646
+ recordId ? buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null) : {},
1647
+ () => readApiResultOrThrow<{ id?: string; slug?: string; ok?: boolean }>(endpoint, {
1648
+ method: recordId ? 'PUT' : 'POST',
1649
+ headers: { 'Content-Type': 'application/json' },
1650
+ body: JSON.stringify(payload),
1651
+ }),
1652
+ )
1653
+ } catch (error) {
1654
+ if (surfaceRecordConflict(error, t)) return
1655
+ throw error
1656
+ }
1646
1657
  const targetId = recordId ?? (typeof response?.id === 'string' ? response.id : null)
1647
1658
  const logoAttachmentId = readString(values.logoAttachmentId)
1648
1659
  if (
@@ -1687,7 +1698,15 @@ export function LinkTemplateForm({ mode, recordId }: Props) {
1687
1698
  : `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.saved'))}&type=success`
1688
1699
  }}
1689
1700
  onDelete={recordId ? async () => {
1690
- await apiCallOrThrow(`/api/checkout/${mode === 'link' ? 'links' : 'templates'}/${encodeURIComponent(recordId)}`, { method: 'DELETE' })
1701
+ try {
1702
+ await withScopedApiRequestHeaders(
1703
+ buildOptimisticLockHeader(readString(initialValues?.updatedAt) || null),
1704
+ () => apiCallOrThrow(`/api/checkout/${mode === 'link' ? 'links' : 'templates'}/${encodeURIComponent(recordId)}`, { method: 'DELETE' }),
1705
+ )
1706
+ } catch (error) {
1707
+ if (surfaceRecordConflict(error, t)) return
1708
+ throw error
1709
+ }
1691
1710
  window.location.href = mode === 'link'
1692
1711
  ? `/backend/checkout/pay-links?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`
1693
1712
  : `/backend/checkout/templates?flash=${encodeURIComponent(t('checkout.common.flash.deleted'))}&type=success`