@open-mercato/checkout 0.10.1-develop.7716.1.cefe7975df → 0.10.1-develop.7721.1.b5c4a00a68
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-CHKT-046-pay-link-usage-counters.spec.js +107 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/helpers/fixtures.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +6 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/shared.js +1 -4
- package/dist/modules/checkout/commands/shared.js.map +2 -2
- package/dist/modules/checkout/commands/transactions.js +38 -18
- package/dist/modules/checkout/commands/transactions.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-CHKT-046-pay-link-usage-counters.spec.ts +136 -0
- package/src/modules/checkout/__integration__/helpers/fixtures.ts +1 -0
- package/src/modules/checkout/commands/__tests__/create-transaction-reservation.test.ts +124 -0
- package/src/modules/checkout/commands/__tests__/link-undo-runtime-counters.test.ts +222 -0
- package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +124 -2
- package/src/modules/checkout/commands/links.ts +6 -0
- package/src/modules/checkout/commands/shared.ts +5 -3
- package/src/modules/checkout/commands/transactions.ts +42 -18
- package/src/modules/checkout/i18n/de.json +2 -0
- package/src/modules/checkout/i18n/en.json +2 -0
- package/src/modules/checkout/i18n/es.json +2 -0
- package/src/modules/checkout/i18n/ko.json +2 -0
- package/src/modules/checkout/i18n/pl.json +2 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { registerCommand } from "@open-mercato/shared/lib/commands";
|
|
2
2
|
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
3
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
3
4
|
import { CheckoutLink, CheckoutTransaction } from "../data/entities.js";
|
|
4
5
|
import { transactionCreateSchema, transactionUpdateStatusSchema } from "../data/validators.js";
|
|
5
6
|
import { emitCheckoutEvent } from "../events.js";
|
|
6
7
|
import {
|
|
7
|
-
applyTerminalTransactionState,
|
|
8
8
|
isTerminalCheckoutStatus,
|
|
9
9
|
mapGatewayStatusToCheckoutStatus,
|
|
10
10
|
parseCheckoutInput,
|
|
@@ -44,12 +44,21 @@ const createTransactionCommand = {
|
|
|
44
44
|
if (currentLink.status !== "active") {
|
|
45
45
|
throw new CrudHttpError(422, { error: "This payment link is not currently accepting payments" });
|
|
46
46
|
}
|
|
47
|
-
const
|
|
47
|
+
const transaction2 = tx.create(CheckoutTransaction, {
|
|
48
|
+
...parsed,
|
|
49
|
+
organizationId: scope.organizationId,
|
|
50
|
+
tenantId: scope.tenantId,
|
|
51
|
+
amount: toMoneyString(parsed.amount) ?? "0.00",
|
|
52
|
+
status: "processing"
|
|
53
|
+
});
|
|
54
|
+
tx.persist(transaction2);
|
|
55
|
+
await tx.flush();
|
|
56
|
+
const reserved = await tx.execute(
|
|
48
57
|
`
|
|
49
58
|
UPDATE checkout_links
|
|
50
59
|
SET active_reservation_count = active_reservation_count + 1,
|
|
51
60
|
is_locked = true,
|
|
52
|
-
updated_at =
|
|
61
|
+
updated_at = clock_timestamp()
|
|
53
62
|
WHERE id = ?
|
|
54
63
|
AND organization_id = ?
|
|
55
64
|
AND tenant_id = ?
|
|
@@ -59,27 +68,18 @@ const createTransactionCommand = {
|
|
|
59
68
|
max_completions IS NULL
|
|
60
69
|
OR completion_count + active_reservation_count < max_completions
|
|
61
70
|
)
|
|
62
|
-
RETURNING
|
|
71
|
+
RETURNING active_reservation_count
|
|
63
72
|
`,
|
|
64
73
|
[parsed.linkId, scope.organizationId, scope.tenantId]
|
|
65
74
|
);
|
|
66
|
-
if (!reserved[0]
|
|
75
|
+
if (!reserved[0]) {
|
|
67
76
|
throw new CrudHttpError(422, { error: "This payment link is no longer available" });
|
|
68
77
|
}
|
|
69
78
|
lockedLinkId = currentLink.id;
|
|
70
79
|
lockedLinkSlug = currentLink.slug;
|
|
71
80
|
lockedLinkTemplateId = currentLink.templateId ?? null;
|
|
72
81
|
lockedLinkGatewayProvider = currentLink.gatewayProviderKey ?? null;
|
|
73
|
-
shouldEmitLockedEvent =
|
|
74
|
-
const transaction2 = tx.create(CheckoutTransaction, {
|
|
75
|
-
...parsed,
|
|
76
|
-
organizationId: scope.organizationId,
|
|
77
|
-
tenantId: scope.tenantId,
|
|
78
|
-
amount: toMoneyString(parsed.amount) ?? "0.00",
|
|
79
|
-
status: "processing"
|
|
80
|
-
});
|
|
81
|
-
tx.persist(transaction2);
|
|
82
|
-
await tx.flush();
|
|
82
|
+
shouldEmitLockedEvent = Number(reserved[0].active_reservation_count) === 1;
|
|
83
83
|
return transaction2;
|
|
84
84
|
});
|
|
85
85
|
if (shouldEmitLockedEvent && lockedLinkId && lockedLinkSlug) {
|
|
@@ -180,9 +180,29 @@ const updateTransactionStatusCommand = {
|
|
|
180
180
|
}
|
|
181
181
|
await tx.refresh(transaction);
|
|
182
182
|
if (!previousTerminal && nextTerminal) {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
const released = await tx.execute(
|
|
184
|
+
`
|
|
185
|
+
UPDATE checkout_links
|
|
186
|
+
SET active_reservation_count = GREATEST(active_reservation_count - 1, 0),
|
|
187
|
+
completion_count = completion_count + ?,
|
|
188
|
+
is_locked = GREATEST(active_reservation_count - 1, 0) > 0,
|
|
189
|
+
updated_at = clock_timestamp()
|
|
190
|
+
WHERE id = ?
|
|
191
|
+
AND organization_id = ?
|
|
192
|
+
AND tenant_id = ?
|
|
193
|
+
AND deleted_at IS NULL
|
|
194
|
+
RETURNING completion_count, max_completions
|
|
195
|
+
`,
|
|
196
|
+
[nextStatus === "completed" ? 1 : 0, link.id, scope.organizationId, scope.tenantId]
|
|
197
|
+
);
|
|
198
|
+
if (!released[0]) {
|
|
199
|
+
const { translate } = await resolveTranslations();
|
|
200
|
+
throw new CrudHttpError(404, {
|
|
201
|
+
error: translate("checkout.errors.links.notFound", "Payment link not found")
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const maxCompletions = released[0].max_completions;
|
|
205
|
+
if (nextStatus === "completed" && maxCompletions != null && Number(released[0].completion_count) === Number(maxCompletions)) {
|
|
186
206
|
emitUsageLimitReached = true;
|
|
187
207
|
usageLimitReachedLinkId = link.id;
|
|
188
208
|
usageLimitReachedLinkSlug = link.slug;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/checkout/commands/transactions.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CheckoutLink, CheckoutTransaction } from '../data/entities'\nimport { transactionCreateSchema, transactionUpdateStatusSchema } from '../data/validators'\nimport { emitCheckoutEvent } from '../events'\nimport {\n applyTerminalTransactionState,\n isTerminalCheckoutStatus,\n mapGatewayStatusToCheckoutStatus,\n parseCheckoutInput,\n toMoneyString,\n} from '../lib/utils'\nimport { assertValidCheckoutStatusTransition } from '../lib/transaction-status-machine'\n\nfunction resolveTransactionScope(input: { tenantId?: string | null; organizationId?: string | null }) {\n if (!input.organizationId || !input.tenantId) {\n throw new CrudHttpError(400, { error: 'Transaction scope is required' })\n }\n return {\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n }\n}\n\ntype CheckoutTerminalEventPayload = {\n transactionId: string\n linkId: string\n templateId: string | null\n slug: string\n status: CheckoutTransaction['status']\n paymentStatus: string | null\n amount: number\n currency: string\n gatewayProvider: string | null\n gatewayTransactionId: string | null\n occurredAt: string\n tenantId: string\n organizationId: string\n}\n\nconst createTransactionCommand: CommandHandler<Record<string, unknown>, { id: string }> = {\n id: 'checkout.transaction.create',\n async execute(rawInput, ctx) {\n const { parsed } = parseCheckoutInput(rawInput, transactionCreateSchema.parse)\n const scope = resolveTransactionScope(parsed)\n const em = ctx.container.resolve('em') as EntityManager\n let lockedLinkId: string | null = null\n let lockedLinkSlug: string | null = null\n let lockedLinkTemplateId: string | null = null\n let lockedLinkGatewayProvider: string | null = null\n let shouldEmitLockedEvent = false\n const transaction = await em.transactional(async (tx) => {\n const currentLink = await tx.findOne(CheckoutLink, {\n id: parsed.linkId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n })\n if (!currentLink) {\n throw new CrudHttpError(404, { error: 'Payment link not found' })\n }\n if (currentLink.status !== 'active') {\n throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' })\n }\n const reserved = await tx.getConnection().execute<Array<{ id: string }>>(\n `\n UPDATE checkout_links\n SET active_reservation_count = active_reservation_count + 1,\n is_locked = true,\n updated_at = now()\n WHERE id = ?\n AND organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status = 'active'\n AND (\n max_completions IS NULL\n OR completion_count + active_reservation_count < max_completions\n )\n RETURNING id\n `,\n [parsed.linkId, scope.organizationId, scope.tenantId],\n )\n if (!reserved[0]?.id) {\n throw new CrudHttpError(422, { error: 'This payment link is no longer available' })\n }\n lockedLinkId = currentLink.id\n lockedLinkSlug = currentLink.slug\n lockedLinkTemplateId = currentLink.templateId ?? null\n lockedLinkGatewayProvider = currentLink.gatewayProviderKey ?? null\n shouldEmitLockedEvent = !currentLink.isLocked\n const transaction = tx.create(CheckoutTransaction, {\n ...parsed,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n amount: toMoneyString(parsed.amount) ?? '0.00',\n status: 'processing',\n })\n tx.persist(transaction)\n await tx.flush()\n return transaction\n })\n if (shouldEmitLockedEvent && lockedLinkId && lockedLinkSlug) {\n await emitCheckoutEvent('checkout.link.locked', {\n id: lockedLinkId,\n slug: lockedLinkSlug,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n }\n await emitCheckoutEvent('checkout.transaction.created', {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n status: transaction.status,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n await emitCheckoutEvent('checkout.transaction.customerDataCaptured', {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n templateId: lockedLinkTemplateId,\n slug: lockedLinkSlug,\n status: transaction.status,\n paymentStatus: transaction.paymentStatus ?? null,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n gatewayProvider: lockedLinkGatewayProvider,\n gatewayTransactionId: transaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n customerDataCaptured: true,\n }).catch(() => undefined)\n return { id: transaction.id }\n },\n}\n\nconst updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, { ok: true }> = {\n id: 'checkout.transaction.updateStatus',\n async execute(rawInput, ctx) {\n const { parsed } = parseCheckoutInput(rawInput, transactionUpdateStatusSchema.parse)\n const scope = resolveTransactionScope(parsed)\n const em = ctx.container.resolve('em') as EntityManager\n let terminalEventPayload: CheckoutTerminalEventPayload | null = null\n let emitUsageLimitReached = false\n let usageLimitReachedLinkId: string | null = null\n let usageLimitReachedLinkSlug: string | null = null\n await em.transactional(async (tx) => {\n const transaction = await tx.findOne(CheckoutTransaction, {\n id: parsed.id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!transaction) throw new CrudHttpError(404, { error: 'Transaction not found' })\n const link = await tx.findOne(CheckoutLink, {\n id: transaction.linkId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n })\n if (!link) throw new CrudHttpError(404, { error: 'Payment link not found' })\n\n const previousStatus = transaction.status\n const nextStatus = parsed.status\n const previousTerminal = isTerminalCheckoutStatus(previousStatus)\n const nextTerminal = isTerminalCheckoutStatus(nextStatus)\n\n // State-machine guard: reject any transition not permitted by the\n // VALID_CHECKOUT_TRANSITIONS map (e.g. completed \u2192 processing).\n // This runs inside the DB transaction so the check and the write share\n // the same serialisable snapshot.\n assertValidCheckoutStatusTransition(previousStatus, nextStatus)\n\n // Resolve the new field values before the write.\n const newPaymentStatus = parsed.paymentStatus ?? transaction.paymentStatus ?? null\n const newGatewayTransactionId = parsed.gatewayTransactionId ?? transaction.gatewayTransactionId ?? null\n\n // Atomic compare-and-swap: the WHERE clause pins the current status so\n // a concurrent writer that already advanced the status to a terminal\n // state will cause this update to match 0 rows \u2014 eliminating the TOCTOU\n // window between the findOne above and this write.\n const affected = await tx.nativeUpdate(\n CheckoutTransaction,\n {\n id: parsed.id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n status: previousStatus,\n },\n {\n status: nextStatus,\n paymentStatus: newPaymentStatus,\n gatewayTransactionId: newGatewayTransactionId,\n updatedAt: new Date(),\n },\n )\n\n if (affected === 0) {\n // A concurrent writer already advanced the status. Force a DB round-trip\n // (refresh: true bypasses the MikroORM identity map) so the reported\n // currentStatus reflects the winning writer's value, not the stale snapshot.\n const actualTx = await tx.findOne(\n CheckoutTransaction,\n { id: parsed.id, organizationId: scope.organizationId, tenantId: scope.tenantId },\n { fields: ['status'], refresh: true },\n )\n throw new CrudHttpError(409, {\n error: `[internal] Transaction status was already updated by a concurrent process (expected \"${previousStatus}\", actual \"${actualTx?.status ?? 'unknown'}\")`,\n code: 'concurrent_status_update',\n expectedStatus: previousStatus,\n currentStatus: actualTx?.status ?? 'unknown',\n requestedStatus: nextStatus,\n })\n }\n\n // Sync the entity state in the unit of work by refreshing it.\n // This loads the updated values (status, paymentStatus, gatewayTransactionId)\n // from the DB and marks the entity as clean, avoiding a redundant second write\n // during the subsequent tx.flush().\n await tx.refresh(transaction)\n\n // Only apply terminal link state and emit the terminal event when the\n // status actually changes \u2014 prevents double-notification on idempotent\n // redeliveries (e.g. authorized \u2192 captured, both mapping to 'completed').\n if (!previousTerminal && nextTerminal) {\n const { usageLimitReached } = applyTerminalTransactionState(link, nextStatus)\n await tx.flush()\n if (usageLimitReached) {\n emitUsageLimitReached = true\n usageLimitReachedLinkId = link.id\n usageLimitReachedLinkSlug = link.slug\n }\n }\n if (nextTerminal && previousStatus !== nextStatus) {\n terminalEventPayload = {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n templateId: link.templateId ?? null,\n slug: link.slug,\n status: transaction.status,\n paymentStatus: transaction.paymentStatus ?? null,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n gatewayProvider: link.gatewayProviderKey ?? null,\n gatewayTransactionId: transaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }\n }\n })\n if (terminalEventPayload !== null) {\n if (parsed.status === 'completed') {\n await emitCheckoutEvent('checkout.transaction.completed', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'failed') {\n await emitCheckoutEvent('checkout.transaction.failed', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'cancelled') {\n await emitCheckoutEvent('checkout.transaction.cancelled', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'expired') {\n await emitCheckoutEvent('checkout.transaction.expired', terminalEventPayload).catch(() => undefined)\n }\n }\n if (emitUsageLimitReached && usageLimitReachedLinkId && usageLimitReachedLinkSlug) {\n await emitCheckoutEvent('checkout.link.usageLimitReached', {\n id: usageLimitReachedLinkId,\n slug: usageLimitReachedLinkSlug,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n }\n return { ok: true }\n },\n}\n\nregisterCommand(createTransactionCommand)\nregisterCommand(updateTransactionStatusCommand)\n\nexport function mapGatewayStatusForCommand(status: string | null | undefined) {\n return mapGatewayStatusToCheckoutStatus(status)\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,uBAAuB;AAChC,SAAS,qBAAqB;AAC9B,SAAS,cAAc,2BAA2B;AAClD,SAAS,yBAAyB,qCAAqC;AACvE,SAAS,yBAAyB;AAClC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CheckoutLink, CheckoutTransaction } from '../data/entities'\nimport { transactionCreateSchema, transactionUpdateStatusSchema } from '../data/validators'\nimport { emitCheckoutEvent } from '../events'\nimport {\n isTerminalCheckoutStatus,\n mapGatewayStatusToCheckoutStatus,\n parseCheckoutInput,\n toMoneyString,\n} from '../lib/utils'\nimport { assertValidCheckoutStatusTransition } from '../lib/transaction-status-machine'\n\nfunction resolveTransactionScope(input: { tenantId?: string | null; organizationId?: string | null }) {\n if (!input.organizationId || !input.tenantId) {\n throw new CrudHttpError(400, { error: 'Transaction scope is required' })\n }\n return {\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n }\n}\n\ntype CheckoutTerminalEventPayload = {\n transactionId: string\n linkId: string\n templateId: string | null\n slug: string\n status: CheckoutTransaction['status']\n paymentStatus: string | null\n amount: number\n currency: string\n gatewayProvider: string | null\n gatewayTransactionId: string | null\n occurredAt: string\n tenantId: string\n organizationId: string\n}\n\nconst createTransactionCommand: CommandHandler<Record<string, unknown>, { id: string }> = {\n id: 'checkout.transaction.create',\n async execute(rawInput, ctx) {\n const { parsed } = parseCheckoutInput(rawInput, transactionCreateSchema.parse)\n const scope = resolveTransactionScope(parsed)\n const em = ctx.container.resolve('em') as EntityManager\n let lockedLinkId: string | null = null\n let lockedLinkSlug: string | null = null\n let lockedLinkTemplateId: string | null = null\n let lockedLinkGatewayProvider: string | null = null\n let shouldEmitLockedEvent = false\n const transaction = await em.transactional(async (tx) => {\n const currentLink = await tx.findOne(CheckoutLink, {\n id: parsed.linkId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n })\n if (!currentLink) {\n throw new CrudHttpError(404, { error: 'Payment link not found' })\n }\n if (currentLink.status !== 'active') {\n throw new CrudHttpError(422, { error: 'This payment link is not currently accepting payments' })\n }\n const transaction = tx.create(CheckoutTransaction, {\n ...parsed,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n amount: toMoneyString(parsed.amount) ?? '0.00',\n status: 'processing',\n })\n tx.persist(transaction)\n await tx.flush()\n const reserved = await tx.execute<Array<{ active_reservation_count: number }>>(\n `\n UPDATE checkout_links\n SET active_reservation_count = active_reservation_count + 1,\n is_locked = true,\n updated_at = clock_timestamp()\n WHERE id = ?\n AND organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status = 'active'\n AND (\n max_completions IS NULL\n OR completion_count + active_reservation_count < max_completions\n )\n RETURNING active_reservation_count\n `,\n [parsed.linkId, scope.organizationId, scope.tenantId],\n )\n if (!reserved[0]) {\n throw new CrudHttpError(422, { error: 'This payment link is no longer available' })\n }\n lockedLinkId = currentLink.id\n lockedLinkSlug = currentLink.slug\n lockedLinkTemplateId = currentLink.templateId ?? null\n lockedLinkGatewayProvider = currentLink.gatewayProviderKey ?? null\n shouldEmitLockedEvent = Number(reserved[0].active_reservation_count) === 1\n return transaction\n })\n if (shouldEmitLockedEvent && lockedLinkId && lockedLinkSlug) {\n await emitCheckoutEvent('checkout.link.locked', {\n id: lockedLinkId,\n slug: lockedLinkSlug,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n }\n await emitCheckoutEvent('checkout.transaction.created', {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n status: transaction.status,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n await emitCheckoutEvent('checkout.transaction.customerDataCaptured', {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n templateId: lockedLinkTemplateId,\n slug: lockedLinkSlug,\n status: transaction.status,\n paymentStatus: transaction.paymentStatus ?? null,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n gatewayProvider: lockedLinkGatewayProvider,\n gatewayTransactionId: transaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n customerDataCaptured: true,\n }).catch(() => undefined)\n return { id: transaction.id }\n },\n}\n\nconst updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, { ok: true }> = {\n id: 'checkout.transaction.updateStatus',\n async execute(rawInput, ctx) {\n const { parsed } = parseCheckoutInput(rawInput, transactionUpdateStatusSchema.parse)\n const scope = resolveTransactionScope(parsed)\n const em = ctx.container.resolve('em') as EntityManager\n let terminalEventPayload: CheckoutTerminalEventPayload | null = null\n let emitUsageLimitReached = false\n let usageLimitReachedLinkId: string | null = null\n let usageLimitReachedLinkSlug: string | null = null\n await em.transactional(async (tx) => {\n const transaction = await tx.findOne(CheckoutTransaction, {\n id: parsed.id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!transaction) throw new CrudHttpError(404, { error: 'Transaction not found' })\n const link = await tx.findOne(CheckoutLink, {\n id: transaction.linkId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n })\n if (!link) throw new CrudHttpError(404, { error: 'Payment link not found' })\n\n const previousStatus = transaction.status\n const nextStatus = parsed.status\n const previousTerminal = isTerminalCheckoutStatus(previousStatus)\n const nextTerminal = isTerminalCheckoutStatus(nextStatus)\n\n // State-machine guard: reject any transition not permitted by the\n // VALID_CHECKOUT_TRANSITIONS map (e.g. completed \u2192 processing).\n // This runs inside the DB transaction so the check and the write share\n // the same serialisable snapshot.\n assertValidCheckoutStatusTransition(previousStatus, nextStatus)\n\n // Resolve the new field values before the write.\n const newPaymentStatus = parsed.paymentStatus ?? transaction.paymentStatus ?? null\n const newGatewayTransactionId = parsed.gatewayTransactionId ?? transaction.gatewayTransactionId ?? null\n\n // Atomic compare-and-swap: the WHERE clause pins the current status so\n // a concurrent writer that already advanced the status to a terminal\n // state will cause this update to match 0 rows \u2014 eliminating the TOCTOU\n // window between the findOne above and this write.\n const affected = await tx.nativeUpdate(\n CheckoutTransaction,\n {\n id: parsed.id,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n status: previousStatus,\n },\n {\n status: nextStatus,\n paymentStatus: newPaymentStatus,\n gatewayTransactionId: newGatewayTransactionId,\n updatedAt: new Date(),\n },\n )\n\n if (affected === 0) {\n // A concurrent writer already advanced the status. Force a DB round-trip\n // (refresh: true bypasses the MikroORM identity map) so the reported\n // currentStatus reflects the winning writer's value, not the stale snapshot.\n const actualTx = await tx.findOne(\n CheckoutTransaction,\n { id: parsed.id, organizationId: scope.organizationId, tenantId: scope.tenantId },\n { fields: ['status'], refresh: true },\n )\n throw new CrudHttpError(409, {\n error: `[internal] Transaction status was already updated by a concurrent process (expected \"${previousStatus}\", actual \"${actualTx?.status ?? 'unknown'}\")`,\n code: 'concurrent_status_update',\n expectedStatus: previousStatus,\n currentStatus: actualTx?.status ?? 'unknown',\n requestedStatus: nextStatus,\n })\n }\n\n // Sync the entity state in the unit of work by refreshing it.\n // This loads the updated values (status, paymentStatus, gatewayTransactionId)\n // from the DB and marks the entity as clean, avoiding a redundant second write\n // during the subsequent tx.flush().\n await tx.refresh(transaction)\n\n // Only apply terminal link state and emit the terminal event when the\n // status actually changes \u2014 prevents double-notification on idempotent\n // redeliveries (e.g. authorized \u2192 captured, both mapping to 'completed').\n if (!previousTerminal && nextTerminal) {\n const released = await tx.execute<Array<{ completion_count: number; max_completions: number | null }>>(\n `\n UPDATE checkout_links\n SET active_reservation_count = GREATEST(active_reservation_count - 1, 0),\n completion_count = completion_count + ?,\n is_locked = GREATEST(active_reservation_count - 1, 0) > 0,\n updated_at = clock_timestamp()\n WHERE id = ?\n AND organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n RETURNING completion_count, max_completions\n `,\n [nextStatus === 'completed' ? 1 : 0, link.id, scope.organizationId, scope.tenantId],\n )\n if (!released[0]) {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(404, {\n error: translate('checkout.errors.links.notFound', 'Payment link not found'),\n })\n }\n const maxCompletions = released[0].max_completions\n if (\n nextStatus === 'completed'\n && maxCompletions != null\n && Number(released[0].completion_count) === Number(maxCompletions)\n ) {\n emitUsageLimitReached = true\n usageLimitReachedLinkId = link.id\n usageLimitReachedLinkSlug = link.slug\n }\n }\n if (nextTerminal && previousStatus !== nextStatus) {\n terminalEventPayload = {\n transactionId: transaction.id,\n linkId: transaction.linkId,\n templateId: link.templateId ?? null,\n slug: link.slug,\n status: transaction.status,\n paymentStatus: transaction.paymentStatus ?? null,\n amount: Number(transaction.amount),\n currency: transaction.currencyCode,\n gatewayProvider: link.gatewayProviderKey ?? null,\n gatewayTransactionId: transaction.gatewayTransactionId ?? null,\n occurredAt: new Date().toISOString(),\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }\n }\n })\n if (terminalEventPayload !== null) {\n if (parsed.status === 'completed') {\n await emitCheckoutEvent('checkout.transaction.completed', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'failed') {\n await emitCheckoutEvent('checkout.transaction.failed', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'cancelled') {\n await emitCheckoutEvent('checkout.transaction.cancelled', terminalEventPayload).catch(() => undefined)\n } else if (parsed.status === 'expired') {\n await emitCheckoutEvent('checkout.transaction.expired', terminalEventPayload).catch(() => undefined)\n }\n }\n if (emitUsageLimitReached && usageLimitReachedLinkId && usageLimitReachedLinkSlug) {\n await emitCheckoutEvent('checkout.link.usageLimitReached', {\n id: usageLimitReachedLinkId,\n slug: usageLimitReachedLinkSlug,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n }).catch(() => undefined)\n }\n return { ok: true }\n },\n}\n\nregisterCommand(createTransactionCommand)\nregisterCommand(updateTransactionStatusCommand)\n\nexport function mapGatewayStatusForCommand(status: string | null | undefined) {\n return mapGatewayStatusToCheckoutStatus(status)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["transaction"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/checkout",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7721.1.b5c4a00a68",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,18 +62,18 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
66
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.10.1-develop.7721.1.b5c4a00a68",
|
|
66
|
+
"@open-mercato/ui": "0.10.1-develop.7721.1.b5c4a00a68",
|
|
67
67
|
"bcryptjs": "^3.0.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
71
|
+
"@open-mercato/shared": "0.10.1-develop.7721.1.b5c4a00a68",
|
|
72
72
|
"react": "^19.0.0",
|
|
73
73
|
"react-dom": "^19.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
76
|
+
"@open-mercato/shared": "0.10.1-develop.7721.1.b5c4a00a68",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"@types/react": "^19.2.18",
|
|
79
79
|
"@types/react-dom": "^19.2.5",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { expectOperation, skipIfUndoTestsDisabled, undoOk } from '@open-mercato/core/helpers/integration/undoHarness'
|
|
4
|
+
import {
|
|
5
|
+
createFixedTemplateInput,
|
|
6
|
+
createLinkFixture,
|
|
7
|
+
deleteCheckoutEntityIfExists,
|
|
8
|
+
readLink,
|
|
9
|
+
submitPayLink,
|
|
10
|
+
updateLink,
|
|
11
|
+
} from './helpers/fixtures'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-CHKT-046: Pay-link usage counters stay equal to the link's transactions.
|
|
15
|
+
*
|
|
16
|
+
* The example mock gateway captures every session immediately, so each accepted
|
|
17
|
+
* submit reserves a usage slot and completes it inside one request. Parallel submits
|
|
18
|
+
* are therefore parallel terminal transitions on the same link row.
|
|
19
|
+
*
|
|
20
|
+
* Invariants: completionCount = completed payments, activeReservationCount = payments
|
|
21
|
+
* in flight (0 here), isLocked = activeReservationCount > 0, never more completions
|
|
22
|
+
* than maxCompletions — including after undoing an edit made before the payments.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const PAYMENT = { customerData: {}, acceptedLegalConsents: {}, amount: 49.99 }
|
|
26
|
+
|
|
27
|
+
async function payInParallel(request: APIRequestContext, slug: string, count: number) {
|
|
28
|
+
return Promise.all(Array.from({ length: count }, () => submitPayLink(request, slug, PAYMENT)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test.describe('TC-CHKT-046: Pay-link usage counters under concurrent payments and undo', () => {
|
|
32
|
+
test('parallel payments on one link are all counted and release their reservations', async ({ request }) => {
|
|
33
|
+
const token = await getAuthToken(request)
|
|
34
|
+
let linkId: string | null = null
|
|
35
|
+
try {
|
|
36
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'active', collectCustomerDetails: false }))
|
|
37
|
+
linkId = link.id
|
|
38
|
+
|
|
39
|
+
const responses = await payInParallel(request, link.slug, 4)
|
|
40
|
+
expect(responses.map((response) => response.status())).toEqual([201, 201, 201, 201])
|
|
41
|
+
|
|
42
|
+
const stored = await readLink(request, token, link.id)
|
|
43
|
+
expect(stored.completionCount).toBe(4)
|
|
44
|
+
expect(stored.activeReservationCount).toBe(0)
|
|
45
|
+
expect(stored.isLocked).toBe(false)
|
|
46
|
+
} finally {
|
|
47
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a duplicated Idempotency-Key submit does not leave a reservation behind', async ({ request }) => {
|
|
52
|
+
const token = await getAuthToken(request)
|
|
53
|
+
let linkId: string | null = null
|
|
54
|
+
try {
|
|
55
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({
|
|
56
|
+
status: 'active',
|
|
57
|
+
collectCustomerDetails: false,
|
|
58
|
+
maxCompletions: 2,
|
|
59
|
+
}))
|
|
60
|
+
linkId = link.id
|
|
61
|
+
const idempotencyKey = `tc-chkt-046-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
62
|
+
|
|
63
|
+
const responses = await Promise.all([
|
|
64
|
+
submitPayLink(request, link.slug, PAYMENT, { idempotencyKey }),
|
|
65
|
+
submitPayLink(request, link.slug, PAYMENT, { idempotencyKey }),
|
|
66
|
+
])
|
|
67
|
+
const bodies = await Promise.all(responses.map((response) => response.json() as Promise<{ transactionId?: string }>))
|
|
68
|
+
expect(bodies[0].transactionId).toBeTruthy()
|
|
69
|
+
expect(bodies[1].transactionId).toBe(bodies[0].transactionId)
|
|
70
|
+
|
|
71
|
+
const stored = await readLink(request, token, link.id)
|
|
72
|
+
expect(stored.completionCount).toBe(1)
|
|
73
|
+
expect(stored.activeReservationCount).toBe(0)
|
|
74
|
+
expect(stored.isLocked).toBe(false)
|
|
75
|
+
} finally {
|
|
76
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('a limited link sells exactly its limit under parallel payments', async ({ request }) => {
|
|
81
|
+
const token = await getAuthToken(request)
|
|
82
|
+
let linkId: string | null = null
|
|
83
|
+
try {
|
|
84
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({
|
|
85
|
+
status: 'active',
|
|
86
|
+
collectCustomerDetails: false,
|
|
87
|
+
maxCompletions: 2,
|
|
88
|
+
}))
|
|
89
|
+
linkId = link.id
|
|
90
|
+
|
|
91
|
+
const responses = await payInParallel(request, link.slug, 6)
|
|
92
|
+
const accepted = responses.filter((response) => response.status() === 201)
|
|
93
|
+
expect(accepted).toHaveLength(2)
|
|
94
|
+
|
|
95
|
+
const stored = await readLink(request, token, link.id)
|
|
96
|
+
expect(stored.completionCount).toBe(2)
|
|
97
|
+
expect(stored.activeReservationCount).toBe(0)
|
|
98
|
+
expect(stored.isLocked).toBe(false)
|
|
99
|
+
|
|
100
|
+
const afterLimit = await submitPayLink(request, link.slug, PAYMENT)
|
|
101
|
+
expect(afterLimit.status()).toBe(422)
|
|
102
|
+
} finally {
|
|
103
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('undoing an edit made before a sale does not reopen a sold-out link', async ({ request }) => {
|
|
108
|
+
skipIfUndoTestsDisabled()
|
|
109
|
+
const token = await getAuthToken(request)
|
|
110
|
+
let linkId: string | null = null
|
|
111
|
+
try {
|
|
112
|
+
const input = createFixedTemplateInput({ status: 'active', collectCustomerDetails: false, maxCompletions: 1 })
|
|
113
|
+
const link = await createLinkFixture(request, token, input)
|
|
114
|
+
linkId = link.id
|
|
115
|
+
|
|
116
|
+
const updateResponse = await updateLink(request, token, link.id, { ...input, slug: link.slug, title: `${input.title} (edited)` })
|
|
117
|
+
expect(updateResponse.status()).toBe(200)
|
|
118
|
+
const updateOperation = expectOperation(updateResponse, 'checkout.link.update')
|
|
119
|
+
|
|
120
|
+
const sale = await submitPayLink(request, link.slug, PAYMENT)
|
|
121
|
+
expect(sale.status()).toBe(201)
|
|
122
|
+
|
|
123
|
+
await undoOk(request, token, updateOperation.undoToken, 'undo pay-link edit after a sale')
|
|
124
|
+
|
|
125
|
+
const stored = await readLink(request, token, link.id)
|
|
126
|
+
expect(stored.title).toBe(input.title)
|
|
127
|
+
expect(stored.completionCount).toBe(1)
|
|
128
|
+
expect(stored.activeReservationCount).toBe(0)
|
|
129
|
+
|
|
130
|
+
const afterUndo = await submitPayLink(request, link.slug, PAYMENT)
|
|
131
|
+
expect(afterUndo.status()).toBe(422)
|
|
132
|
+
} finally {
|
|
133
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -71,6 +71,7 @@ export type CheckoutLinkRecord = {
|
|
|
71
71
|
templateId?: string | null
|
|
72
72
|
completionCount?: number
|
|
73
73
|
activeReservationCount?: number
|
|
74
|
+
isLocked?: boolean
|
|
74
75
|
fixedPriceAmount?: number | null
|
|
75
76
|
displayCustomFieldsOnPage?: boolean
|
|
76
77
|
publicCustomFields?: Array<{ key?: string; label?: string; value?: unknown }>
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
|
|
6
|
+
import '../transactions'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../events', () => ({
|
|
9
|
+
emitCheckoutEvent: jest.fn(async () => undefined),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const { emitCheckoutEvent } = jest.requireMock('../../events') as {
|
|
13
|
+
emitCheckoutEvent: jest.Mock
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
|
|
17
|
+
const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
|
|
18
|
+
const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
|
|
19
|
+
const TX_ID = '123e4567-e89b-12d3-a456-426614174020'
|
|
20
|
+
|
|
21
|
+
type ReservedRow = { id?: string; active_reservation_count: number }
|
|
22
|
+
|
|
23
|
+
function makeMockEm(options: { isLocked?: boolean; reserved?: ReservedRow[] } = {}) {
|
|
24
|
+
const reserved = options.reserved ?? [{ id: LINK_ID, active_reservation_count: 1 }]
|
|
25
|
+
const link = {
|
|
26
|
+
id: LINK_ID,
|
|
27
|
+
organizationId: ORG_ID,
|
|
28
|
+
tenantId: TENANT_ID,
|
|
29
|
+
slug: 'test-link',
|
|
30
|
+
templateId: null,
|
|
31
|
+
gatewayProviderKey: 'stripe',
|
|
32
|
+
status: 'active',
|
|
33
|
+
isLocked: options.isLocked ?? false,
|
|
34
|
+
deletedAt: null,
|
|
35
|
+
}
|
|
36
|
+
const poolExecute = jest.fn(async () => reserved)
|
|
37
|
+
const tx = {
|
|
38
|
+
findOne: jest.fn(async () => link),
|
|
39
|
+
create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({ id: TX_ID, ...data })),
|
|
40
|
+
persist: jest.fn(),
|
|
41
|
+
flush: jest.fn(async () => undefined),
|
|
42
|
+
execute: jest.fn(async () => reserved),
|
|
43
|
+
getConnection: jest.fn(() => ({ execute: poolExecute })),
|
|
44
|
+
}
|
|
45
|
+
const em = {
|
|
46
|
+
transactional: jest.fn(async (fn: (inner: typeof tx) => Promise<unknown>) => fn(tx)),
|
|
47
|
+
}
|
|
48
|
+
return { em, tx, poolExecute }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeContext(em: unknown): CommandRuntimeContext {
|
|
52
|
+
return {
|
|
53
|
+
container: {
|
|
54
|
+
resolve: (token: string) => (token === 'em' ? em : null),
|
|
55
|
+
} as unknown as CommandRuntimeContext['container'],
|
|
56
|
+
auth: null,
|
|
57
|
+
organizationScope: null,
|
|
58
|
+
selectedOrganizationId: ORG_ID,
|
|
59
|
+
organizationIds: [ORG_ID],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runCreate(em: unknown) {
|
|
64
|
+
const handler = commandRegistry.get('checkout.transaction.create')
|
|
65
|
+
if (!handler) throw new Error('checkout.transaction.create not registered')
|
|
66
|
+
return handler.execute({
|
|
67
|
+
linkId: LINK_ID,
|
|
68
|
+
amount: 49.99,
|
|
69
|
+
currencyCode: 'USD',
|
|
70
|
+
idempotencyKey: 'idempotency-key-0001',
|
|
71
|
+
customerData: {},
|
|
72
|
+
organizationId: ORG_ID,
|
|
73
|
+
tenantId: TENANT_ID,
|
|
74
|
+
}, makeContext(em))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function reservationCalls(mock: jest.Mock) {
|
|
78
|
+
return mock.mock.calls.filter(([sql]) => typeof sql === 'string' && sql.includes('UPDATE checkout_links'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('createTransactionCommand — usage slot reservation', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
jest.clearAllMocks()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reserves the slot through the transaction, after inserting the transaction row', async () => {
|
|
87
|
+
const { em, tx, poolExecute } = makeMockEm()
|
|
88
|
+
|
|
89
|
+
await runCreate(em)
|
|
90
|
+
|
|
91
|
+
expect(reservationCalls(poolExecute)).toHaveLength(0)
|
|
92
|
+
const calls = reservationCalls(tx.execute)
|
|
93
|
+
expect(calls).toHaveLength(1)
|
|
94
|
+
const [sql, params] = calls[0] as [string, unknown[]]
|
|
95
|
+
expect(sql).toMatch(/active_reservation_count = active_reservation_count \+ 1/)
|
|
96
|
+
expect(sql).toMatch(/completion_count \+ active_reservation_count < max_completions/)
|
|
97
|
+
expect(params).toEqual([LINK_ID, ORG_ID, TENANT_ID])
|
|
98
|
+
expect(tx.flush.mock.invocationCallOrder[0]).toBeLessThan(tx.execute.mock.invocationCallOrder[0])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('rejects with 422 and emits nothing when no slot is left', async () => {
|
|
102
|
+
const { em } = makeMockEm({ reserved: [] })
|
|
103
|
+
|
|
104
|
+
await expect(runCreate(em)).rejects.toMatchObject({ status: 422 })
|
|
105
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('emits checkout.link.locked when this reservation takes the link from zero to one', async () => {
|
|
109
|
+
const { em } = makeMockEm({ isLocked: false, reserved: [{ id: LINK_ID, active_reservation_count: 1 }] })
|
|
110
|
+
|
|
111
|
+
await runCreate(em)
|
|
112
|
+
|
|
113
|
+
expect(emitCheckoutEvent).toHaveBeenCalledWith('checkout.link.locked', expect.objectContaining({ id: LINK_ID }))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not emit checkout.link.locked when another reservation already holds the link', async () => {
|
|
117
|
+
const { em } = makeMockEm({ isLocked: false, reserved: [{ id: LINK_ID, active_reservation_count: 2 }] })
|
|
118
|
+
|
|
119
|
+
await runCreate(em)
|
|
120
|
+
|
|
121
|
+
expect(emitCheckoutEvent).not.toHaveBeenCalledWith('checkout.link.locked', expect.any(Object))
|
|
122
|
+
expect(emitCheckoutEvent).toHaveBeenCalledWith('checkout.transaction.created', expect.any(Object))
|
|
123
|
+
})
|
|
124
|
+
})
|