@open-mercato/checkout 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js +39 -0
  3. package/dist/modules/checkout/__integration__/TC-CHKT-042.spec.js.map +7 -0
  4. package/dist/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.js +62 -0
  5. package/dist/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.js.map +7 -0
  6. package/dist/modules/checkout/api/pay/[slug]/submit/route.js +43 -28
  7. package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
  8. package/dist/modules/checkout/api/transactions/route.js +23 -6
  9. package/dist/modules/checkout/api/transactions/route.js.map +2 -2
  10. package/dist/modules/checkout/backend/checkout/pay-links/page.js +2 -1
  11. package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
  12. package/dist/modules/checkout/backend/checkout/templates/page.js +2 -1
  13. package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
  14. package/dist/modules/checkout/backend/checkout/transactions/page.js +2 -1
  15. package/dist/modules/checkout/backend/checkout/transactions/page.js.map +2 -2
  16. package/dist/modules/checkout/commands/links.js +1 -1
  17. package/dist/modules/checkout/commands/links.js.map +2 -2
  18. package/dist/modules/checkout/commands/transactions.js +45 -13
  19. package/dist/modules/checkout/commands/transactions.js.map +2 -2
  20. package/dist/modules/checkout/components/CustomerFieldsEditor.js +2 -2
  21. package/dist/modules/checkout/components/CustomerFieldsEditor.js.map +2 -2
  22. package/dist/modules/checkout/components/GatewaySettingsFields.js +2 -2
  23. package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
  24. package/dist/modules/checkout/components/LinkTemplateForm.js +8 -8
  25. package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
  26. package/dist/modules/checkout/components/LogoUploadField.js +1 -1
  27. package/dist/modules/checkout/components/LogoUploadField.js.map +2 -2
  28. package/dist/modules/checkout/components/PayPage.js +39 -38
  29. package/dist/modules/checkout/components/PayPage.js.map +2 -2
  30. package/dist/modules/checkout/extension-points.js +68 -0
  31. package/dist/modules/checkout/extension-points.js.map +7 -0
  32. package/dist/modules/checkout/lib/transaction-status-machine.js +31 -0
  33. package/dist/modules/checkout/lib/transaction-status-machine.js.map +7 -0
  34. package/dist/modules/checkout/search.js +44 -6
  35. package/dist/modules/checkout/search.js.map +2 -2
  36. package/dist/modules/checkout/setup.js +28 -0
  37. package/dist/modules/checkout/setup.js.map +2 -2
  38. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js +32 -0
  39. package/dist/modules/checkout/subscribers/search-reindex-transaction-created.js.map +7 -0
  40. package/dist/modules/checkout/workers/transaction-expiry.worker.js +9 -2
  41. package/dist/modules/checkout/workers/transaction-expiry.worker.js.map +2 -2
  42. package/package.json +10 -9
  43. package/src/modules/checkout/__integration__/TC-CHKT-042.spec.ts +41 -0
  44. package/src/modules/checkout/__integration__/TC-CHKT-043-concurrent-status-update.spec.ts +112 -0
  45. package/src/modules/checkout/__tests__/search.test.ts +21 -0
  46. package/src/modules/checkout/__tests__/setup-schedules.test.ts +99 -0
  47. package/src/modules/checkout/api/pay/[slug]/submit/__tests__/route.test.ts +77 -0
  48. package/src/modules/checkout/api/pay/[slug]/submit/route.ts +58 -28
  49. package/src/modules/checkout/api/transactions/__tests__/route.test.ts +113 -0
  50. package/src/modules/checkout/api/transactions/route.ts +30 -6
  51. package/src/modules/checkout/backend/checkout/pay-links/page.tsx +2 -1
  52. package/src/modules/checkout/backend/checkout/templates/page.tsx +2 -1
  53. package/src/modules/checkout/backend/checkout/transactions/page.tsx +2 -1
  54. package/src/modules/checkout/commands/__tests__/update-transaction-status.test.ts +363 -0
  55. package/src/modules/checkout/commands/links.ts +5 -1
  56. package/src/modules/checkout/commands/transactions.ts +68 -13
  57. package/src/modules/checkout/components/CustomerFieldsEditor.tsx +2 -2
  58. package/src/modules/checkout/components/GatewaySettingsFields.tsx +2 -2
  59. package/src/modules/checkout/components/LinkTemplateForm.tsx +8 -8
  60. package/src/modules/checkout/components/LogoUploadField.tsx +1 -1
  61. package/src/modules/checkout/components/PayPage.tsx +38 -37
  62. package/src/modules/checkout/extension-points.ts +67 -0
  63. package/src/modules/checkout/i18n/de.json +1 -0
  64. package/src/modules/checkout/i18n/en.json +1 -0
  65. package/src/modules/checkout/i18n/es.json +1 -0
  66. package/src/modules/checkout/i18n/ko.json +463 -0
  67. package/src/modules/checkout/i18n/pl.json +1 -0
  68. package/src/modules/checkout/lib/__tests__/transaction-status-machine.test.ts +152 -0
  69. package/src/modules/checkout/lib/transaction-status-machine.ts +71 -0
  70. package/src/modules/checkout/search.ts +44 -6
  71. package/src/modules/checkout/setup.ts +51 -0
  72. package/src/modules/checkout/subscribers/__tests__/search-reindex-transaction-created.test.ts +29 -0
  73. package/src/modules/checkout/subscribers/search-reindex-transaction-created.ts +39 -0
  74. package/src/modules/checkout/workers/__tests__/transaction-expiry.worker.test.ts +124 -0
  75. package/src/modules/checkout/workers/transaction-expiry.worker.ts +11 -0
@@ -0,0 +1,71 @@
1
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
+ import type { CheckoutTransaction } from '../data/entities'
3
+
4
+ /**
5
+ * Allowed status transitions for a CheckoutTransaction.
6
+ *
7
+ * Terminal states (completed, failed, cancelled, expired) have an empty
8
+ * allowed-set, meaning NO transition out of them is permitted. This prevents
9
+ * the race condition where the submit route's late `updateStatus('processing')`
10
+ * call overwrites a `'completed'` status that the gateway webhook set first.
11
+ *
12
+ * Allowed path:
13
+ * pending ──► processing (transaction created, payment session initiated)
14
+ * pending ──► completed (captured webhook arrives before processing state)
15
+ * processing ──► completed (webhook: payment captured)
16
+ * processing ──► failed (webhook or session error)
17
+ * processing ──► cancelled (customer or merchant cancellation)
18
+ * processing ──► expired (expiry worker)
19
+ *
20
+ * Rejected (regression guard):
21
+ * completed ──► * REJECTED
22
+ * failed ──► * REJECTED
23
+ * cancelled ──► * REJECTED
24
+ * expired ──► * REJECTED
25
+ */
26
+ export const VALID_CHECKOUT_TRANSITIONS: Record<CheckoutTransaction['status'], CheckoutTransaction['status'][]> = {
27
+ pending: ['processing', 'completed', 'failed', 'cancelled', 'expired'],
28
+ processing: ['completed', 'failed', 'cancelled', 'expired'],
29
+ // Terminal states — no transitions out
30
+ completed: [],
31
+ failed: [],
32
+ cancelled: [],
33
+ expired: [],
34
+ }
35
+
36
+ /**
37
+ * Returns `true` when transitioning from `from` to `to` is allowed by the
38
+ * state machine. Same-state transitions are allowed as safe no-ops to support
39
+ * idempotency.
40
+ */
41
+ export function isValidCheckoutStatusTransition(
42
+ from: CheckoutTransaction['status'],
43
+ to: CheckoutTransaction['status'],
44
+ ): boolean {
45
+ if (from === to) return true
46
+ const allowed = VALID_CHECKOUT_TRANSITIONS[from]
47
+ if (!allowed) return false
48
+ return allowed.includes(to)
49
+ }
50
+
51
+ /**
52
+ * Throws a structured 409 Conflict when the requested status transition is
53
+ * not allowed by the state machine. The error body is compatible with the
54
+ * `conflict` error shape that the frontend handles.
55
+ *
56
+ * This must be called **inside** a database transaction so that the check and
57
+ * the subsequent write are atomic. The atomic `nativeUpdate` WHERE-clause
58
+ * provides the TOCTOU safety net; this function is the human-readable guard.
59
+ */
60
+ export function assertValidCheckoutStatusTransition(
61
+ from: CheckoutTransaction['status'],
62
+ to: CheckoutTransaction['status'],
63
+ ): void {
64
+ if (isValidCheckoutStatusTransition(from, to)) return
65
+ throw new CrudHttpError(409, {
66
+ error: `[internal] Cannot transition payment from "${from}" to "${to}"`,
67
+ code: 'invalid_status_transition',
68
+ currentStatus: from,
69
+ requestedStatus: to,
70
+ })
71
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SearchModuleConfig } from '@open-mercato/shared/modules/search'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
2
3
  import { CHECKOUT_ENTITY_IDS } from './lib/constants'
3
4
 
4
5
  function asSearchText(value: unknown): string {
@@ -34,15 +35,52 @@ export const searchConfig: SearchModuleConfig = {
34
35
  searchable: ['name', 'title'],
35
36
  excluded: ['passwordHash', 'gatewaySettings', 'customerFieldsSchema'],
36
37
  },
38
+ buildSource: async (ctx) => {
39
+ const { t } = await resolveTranslations()
40
+ const linkTemplate = t('checkout.search.subtitle.linkTemplate', 'Link Template')
41
+ return {
42
+ text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],
43
+ presenter: { title: asSearchText(ctx.record.name), subtitle: linkTemplate },
44
+ checksumSource: { record: ctx.record, customFields: ctx.customFields },
45
+ }
46
+ },
47
+ formatResult: async (ctx) => {
48
+ const { t } = await resolveTranslations()
49
+ return {
50
+ title: asSearchText(ctx.record.name),
51
+ subtitle: t('checkout.search.subtitle.linkTemplate', 'Link Template'),
52
+ icon: 'lucide:file-text',
53
+ }
54
+ },
55
+ },
56
+ {
57
+ entityId: CHECKOUT_ENTITY_IDS.transaction,
58
+ enabled: true,
59
+ priority: 6,
60
+ fieldPolicy: {
61
+ searchable: ['email', 'firstName', 'lastName'],
62
+ },
37
63
  buildSource: async (ctx) => ({
38
- text: [`${asSearchText(ctx.record.name)}: ${asSearchText(ctx.record.title)}`],
39
- presenter: { title: asSearchText(ctx.record.name), subtitle: 'Link Template' },
40
- checksumSource: { record: ctx.record, customFields: ctx.customFields },
64
+ text: [
65
+ asSearchText(ctx.record.email),
66
+ asSearchText(ctx.record.firstName),
67
+ asSearchText(ctx.record.lastName),
68
+ ],
69
+ fields: {
70
+ email: ctx.record.email,
71
+ first_name: ctx.record.firstName,
72
+ last_name: ctx.record.lastName,
73
+ },
74
+ presenter: {
75
+ title: asSearchText(ctx.record.email),
76
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
77
+ },
78
+ checksumSource: { record: ctx.record },
41
79
  }),
42
80
  formatResult: async (ctx) => ({
43
- title: asSearchText(ctx.record.name),
44
- subtitle: 'Link Template',
45
- icon: 'lucide:file-text',
81
+ title: asSearchText(ctx.record.email),
82
+ subtitle: `${asSearchText(ctx.record.firstName)} ${asSearchText(ctx.record.lastName)}`.trim(),
83
+ icon: 'lucide:receipt',
46
84
  }),
47
85
  },
48
86
  ],
@@ -1,14 +1,65 @@
1
1
  import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import { createHash } from 'node:crypto'
2
3
  import { ensureCheckoutFieldsetsAndDefinitions } from './seed/customFields'
3
4
  import { seedCheckoutExamples } from './seed/examples'
5
+ import { CHECKOUT_EXPIRY_QUEUE } from './workers/transaction-expiry.worker'
4
6
  export { DEFAULT_CHECKOUT_CUSTOMER_FIELDS } from './lib/defaults'
5
7
 
8
+ function stableUuidFromString(input: string): string {
9
+ const bytes = createHash('sha256').update(input).digest().subarray(0, 16)
10
+ bytes[6] = (bytes[6] & 0x0f) | 0x50
11
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
12
+ const hex = Buffer.from(bytes).toString('hex')
13
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
14
+ }
15
+
16
+ type SchedulerServiceLike = {
17
+ register: (registration: {
18
+ id: string
19
+ name: string
20
+ description?: string
21
+ scopeType: 'organization'
22
+ organizationId: string
23
+ tenantId: string
24
+ scheduleType: 'cron' | 'interval'
25
+ scheduleValue: string
26
+ timezone?: string
27
+ targetType: 'queue'
28
+ targetQueue: string
29
+ targetPayload?: Record<string, unknown>
30
+ sourceType: 'module'
31
+ sourceModule: string
32
+ isEnabled?: boolean
33
+ }) => Promise<void>
34
+ }
35
+
6
36
  export const setup: ModuleSetupConfig = {
7
37
  async seedDefaults(ctx) {
8
38
  await ensureCheckoutFieldsetsAndDefinitions(ctx.em, {
9
39
  tenantId: ctx.tenantId,
10
40
  organizationId: ctx.organizationId,
11
41
  })
42
+
43
+ const cradle = ctx.container as { hasRegistration?: (name: string) => boolean }
44
+ if (typeof cradle.hasRegistration !== 'function' || !cradle.hasRegistration('schedulerService')) return
45
+
46
+ const schedulerService = ctx.container.resolve('schedulerService') as SchedulerServiceLike
47
+ await schedulerService.register({
48
+ id: stableUuidFromString(`checkout:transaction-expiry:${ctx.tenantId}:${ctx.organizationId}`),
49
+ name: 'Checkout transaction expiry',
50
+ description: 'Marks stale checkout transactions as expired and runs usage limit updates.',
51
+ scopeType: 'organization',
52
+ organizationId: ctx.organizationId,
53
+ tenantId: ctx.tenantId,
54
+ scheduleType: 'interval',
55
+ scheduleValue: '10m',
56
+ timezone: 'UTC',
57
+ targetType: 'queue',
58
+ targetQueue: CHECKOUT_EXPIRY_QUEUE,
59
+ sourceType: 'module',
60
+ sourceModule: 'checkout',
61
+ isEnabled: true,
62
+ })
12
63
  },
13
64
 
14
65
  defaultRoleFeatures: {
@@ -0,0 +1,29 @@
1
+ import handle from '../search-reindex-transaction-created'
2
+
3
+ const emitEvent = jest.fn(async () => undefined)
4
+ const context = { resolve: jest.fn(() => ({ emitEvent })) }
5
+
6
+ describe('checkout transaction search reindex subscriber', () => {
7
+ beforeEach(() => jest.clearAllMocks())
8
+
9
+ it('bridges transaction creation to the query index', async () => {
10
+ await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1', organizationId: 'org-1' }, context)
11
+
12
+ expect(emitEvent).toHaveBeenCalledWith('query_index.upsert_one', {
13
+ entityType: 'checkout:checkout_transaction',
14
+ recordId: 'transaction-1',
15
+ organizationId: 'org-1',
16
+ tenantId: 'tenant-1',
17
+ crudAction: 'created',
18
+ coverageBaseDelta: 1,
19
+ }, { tenantId: 'tenant-1', organizationId: 'org-1' })
20
+ })
21
+
22
+ it('skips incomplete payloads and unavailable event buses', async () => {
23
+ await handle({ transactionId: 'transaction-1' }, context)
24
+ await handle({ transactionId: 'transaction-1', tenantId: 'tenant-1' }, {
25
+ resolve: () => { throw new Error('unavailable') },
26
+ })
27
+ expect(emitEvent).not.toHaveBeenCalled()
28
+ })
29
+ })
@@ -0,0 +1,39 @@
1
+ export const metadata = {
2
+ event: 'checkout.transaction.created',
3
+ persistent: false,
4
+ id: 'checkout:query-index-reindex-transaction-created',
5
+ }
6
+
7
+ type Payload = {
8
+ transactionId?: string
9
+ tenantId?: string | null
10
+ organizationId?: string | null
11
+ }
12
+
13
+ type EventBus = { emitEvent: (name: string, body: unknown, options?: unknown) => Promise<void> }
14
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
15
+
16
+ export default async function handle(payload: Payload, ctx: HandlerContext): Promise<void> {
17
+ const recordId = typeof payload.transactionId === 'string' ? payload.transactionId : null
18
+ const tenantId = typeof payload.tenantId === 'string' ? payload.tenantId : null
19
+ if (!recordId || !tenantId) return
20
+
21
+ let eventBus: EventBus | null = null
22
+ try {
23
+ eventBus = ctx.resolve<EventBus>('eventBus')
24
+ } catch {
25
+ return
26
+ }
27
+
28
+ await eventBus.emitEvent('query_index.upsert_one', {
29
+ entityType: 'checkout:checkout_transaction',
30
+ recordId,
31
+ organizationId: payload.organizationId ?? null,
32
+ tenantId,
33
+ crudAction: 'created',
34
+ coverageBaseDelta: 1,
35
+ }, {
36
+ tenantId,
37
+ organizationId: payload.organizationId ?? null,
38
+ }).catch(() => undefined)
39
+ }
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+ import type { JobContext, QueuedJob } from '@open-mercato/queue'
3
+ import { CheckoutTransaction } from '../../data/entities'
4
+ import type { CheckoutExpiryJob } from '../transaction-expiry.worker'
5
+
6
+ const findWithDecryption = jest.fn()
7
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
8
+ findWithDecryption: (...args: unknown[]) => findWithDecryption(...args),
9
+ }))
10
+
11
+ describe('checkout transaction-expiry worker', () => {
12
+ const mockCommandBus = {
13
+ execute: jest.fn().mockResolvedValue({ ok: true }),
14
+ }
15
+
16
+ type MockEntityManager = {
17
+ fork: () => MockEntityManager
18
+ }
19
+
20
+ const mockEm: MockEntityManager = {
21
+ fork: () => mockEm,
22
+ }
23
+
24
+ const resolve = <ResolvedValue = unknown>(name: string): ResolvedValue => {
25
+ if (name === 'em') return mockEm as ResolvedValue
26
+ if (name === 'commandBus') return mockCommandBus as ResolvedValue
27
+ throw new Error(`[internal] Missing dependency: ${name}`)
28
+ }
29
+
30
+ const mockContext: JobContext & { resolve: typeof resolve } = {
31
+ resolve,
32
+ jobId: 'job-1',
33
+ attemptNumber: 1,
34
+ queueName: 'checkout-transaction-expiry',
35
+ }
36
+
37
+ const baseJob = {
38
+ id: 'job-1',
39
+ createdAt: '2026-08-01T00:00:00.000Z',
40
+ }
41
+
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ })
45
+
46
+ it('throws an error if tenantId or organizationId is missing', async () => {
47
+ const { default: handle } = await import('../transaction-expiry.worker')
48
+
49
+ await expect(
50
+ handle(
51
+ {
52
+ ...baseJob,
53
+ payload: { batchSize: 10 },
54
+ } as unknown as QueuedJob<CheckoutExpiryJob>,
55
+ mockContext,
56
+ ),
57
+ ).rejects.toThrow('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
58
+
59
+ expect(findWithDecryption).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('queries only transactions matching the payload tenantId and organizationId and passes decryption scope', async () => {
63
+ const { default: handle } = await import('../transaction-expiry.worker')
64
+
65
+ const mockTransactions = [
66
+ {
67
+ id: 'txn-1',
68
+ organizationId: 'org-123',
69
+ tenantId: 'tenant-456',
70
+ },
71
+ {
72
+ id: 'txn-2',
73
+ organizationId: 'org-123',
74
+ tenantId: 'tenant-456',
75
+ },
76
+ ]
77
+
78
+ findWithDecryption.mockResolvedValueOnce(mockTransactions)
79
+
80
+ await handle(
81
+ {
82
+ ...baseJob,
83
+ payload: {
84
+ batchSize: 50,
85
+ tenantId: 'tenant-456',
86
+ organizationId: 'org-123',
87
+ },
88
+ } satisfies QueuedJob<CheckoutExpiryJob>,
89
+ mockContext,
90
+ )
91
+
92
+ expect(findWithDecryption).toHaveBeenCalledWith(
93
+ mockEm,
94
+ CheckoutTransaction,
95
+ expect.objectContaining({
96
+ status: 'processing',
97
+ tenantId: 'tenant-456',
98
+ organizationId: 'org-123',
99
+ }),
100
+ expect.objectContaining({
101
+ limit: 50,
102
+ }),
103
+ {
104
+ tenantId: 'tenant-456',
105
+ organizationId: 'org-123',
106
+ },
107
+ )
108
+
109
+ expect(mockCommandBus.execute).toHaveBeenCalledTimes(2)
110
+ expect(mockCommandBus.execute).toHaveBeenNthCalledWith(
111
+ 1,
112
+ 'checkout.transaction.updateStatus',
113
+ expect.objectContaining({
114
+ input: {
115
+ id: 'txn-1',
116
+ status: 'expired',
117
+ paymentStatus: 'expired',
118
+ organizationId: 'org-123',
119
+ tenantId: 'tenant-456',
120
+ },
121
+ }),
122
+ )
123
+ })
124
+ })
@@ -14,6 +14,8 @@ const EXPIRY_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2 hours
14
14
 
15
15
  export type CheckoutExpiryJob = {
16
16
  batchSize?: number
17
+ tenantId: string
18
+ organizationId: string
17
19
  }
18
20
 
19
21
  export const metadata: WorkerMeta = {
@@ -32,14 +34,23 @@ export default async function handle(job: QueuedJob<CheckoutExpiryJob>, ctx: Han
32
34
  const batchSize = job.payload?.batchSize ?? 100
33
35
  const cutoff = new Date(Date.now() - EXPIRY_TIMEOUT_MS)
34
36
 
37
+ if (!job.payload?.tenantId || !job.payload?.organizationId) {
38
+ throw new Error('[internal] tenantId and organizationId are required in CheckoutExpiryJob')
39
+ }
40
+
41
+ const { tenantId, organizationId } = job.payload
42
+
35
43
  const staleTransactions = await findWithDecryption(
36
44
  em,
37
45
  CheckoutTransaction,
38
46
  {
39
47
  status: 'processing',
40
48
  createdAt: { $lt: cutoff },
49
+ tenantId,
50
+ organizationId,
41
51
  },
42
52
  { limit: batchSize, orderBy: { createdAt: 'ASC' } },
53
+ { tenantId, organizationId },
43
54
  )
44
55
 
45
56
  for (const transaction of staleTransactions) {