@open-mercato/checkout 0.6.4-develop.4371.1.8f3030407e → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js +125 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
- package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +67 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/templates.js +69 -2
- package/dist/modules/checkout/commands/templates.js.map +2 -2
- package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
- package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
- package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
- package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
- package/dist/modules/checkout/data/validators.js +18 -2
- package/dist/modules/checkout/data/validators.js.map +2 -2
- package/dist/modules/checkout/lib/utils.js +26 -5
- package/dist/modules/checkout/lib/utils.js.map +2 -2
- package/jest.config.cjs +2 -0
- package/package.json +7 -8
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
- package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
- package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
- package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
- package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
- package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
- package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
- package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
- package/src/modules/checkout/commands/links.ts +67 -0
- package/src/modules/checkout/commands/templates.ts +74 -2
- package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
- package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
- package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
- package/src/modules/checkout/data/validators.ts +18 -2
- package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
- 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)
|
|
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)
|
|
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
|
-
|
|
63
|
+
patchSetting(fieldKey, next.length ? next : undefined)
|
|
43
64
|
},
|
|
44
|
-
[
|
|
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
|
-
<
|
|
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
|
-
|
|
114
|
+
onValueChange={(next) => patchSetting(field.key, next)}
|
|
95
115
|
>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
<
|
|
114
|
-
type="checkbox"
|
|
137
|
+
<Checkbox
|
|
115
138
|
checked={checked}
|
|
116
|
-
|
|
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
|
-
<
|
|
126
|
-
type="checkbox"
|
|
148
|
+
<Checkbox
|
|
127
149
|
checked={currentValue === true}
|
|
128
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
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`
|