cabloy 5.1.201 → 5.1.203
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/.cabloy-version +1 -1
- package/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +18 -17
- package/README.md +2 -2
- package/package.json +2 -3
- package/repo-agent-governance/managed-assets.json +33 -33
- package/repo-agent-governance/policies/repository.md +1 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +3 -2
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +11 -10
- package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +4 -2
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +16 -13
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +18 -11
- package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -2
- package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +1 -1
- package/repo-agent-governance/skills/cabloy-worktree-environment/SKILL.md +5 -5
- package/repo-agent-governance/tests/spec-audit.test.mjs +323 -0
- package/repo-agent-governance/tools/spec-audit/audit.mjs +129 -3
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +26 -7
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +82 -0
- package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +40 -1
- package/repo-docs/ai/ai-spec-driven-development.md +1 -0
- package/repo-docs/ai/playbook-spec-execution.md +3 -2
- package/repo-docs/ai/playbook-spec-generation.md +1 -1
- package/repo-docs/assets/img/cabloy-admin-ssr-cover-en.png +0 -0
- package/repo-docs/assets/img/cabloy-admin-tabs-cover-en.png +0 -0
- package/repo-docs/assets/img/cabloy-ai-crud-cover-en.png +0 -0
- package/repo-docs/demo.md +14 -0
- package/repo-docs/frontend/scripts.md +10 -18
- package/repo-docs/fullstack/parallel-worktree-environment.md +2 -2
- package/repo-docs/index.md +2 -2
- package/repo-docs/reference/repo-scripts.md +15 -31
- package/repo-e2e/config/playwright.config.ts +14 -19
- package/repo-e2e/scripts/e2e.test.ts +17 -0
- package/repo-e2e/scripts/e2e.ts +5 -1
- package/repo-e2e/scripts/runE2e.ts +3 -15
- package/repo-e2e/scripts/runE2eArgs.test.ts +21 -17
- package/repo-e2e/scripts/runE2eArgs.ts +16 -29
- package/repo-e2e/scripts/startE2eVona.ts +20 -9
- package/repo-e2e/specs/a-commerce.spec.ts +232 -31
- package/repo-e2e/specs/cabloy-basic.spec.ts +39 -12
- package/repo-e2e/specs/home-user-account.spec.ts +9 -2
- package/scripts/upgrade.test.ts +147 -0
- package/scripts/upgrade.ts +49 -27
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +51 -5
- package/vona/packages-cli/cli-set-api/src/lib/command/bin.dev.ts +6 -1
- package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +52 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +41 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/e2eFixture.ts +52 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/lib/e2eFixture.ts +224 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/e2eFixture.ts +694 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/e2eFixture.test.ts +434 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
import type { EntityOutboxEvent, IPaymentOutcomeEvent } from 'vona-module-a-pay';
|
|
3
|
+
|
|
4
|
+
import { BeanBase } from 'vona';
|
|
5
|
+
import { Service } from 'vona-module-a-bean';
|
|
6
|
+
import { Core } from 'vona-module-a-core';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createOwnedFixtureName,
|
|
10
|
+
fixtureAdvancedPaymentAuditMatches,
|
|
11
|
+
fixtureEventNeedsDispatch,
|
|
12
|
+
fixtureReservationMatchesLine,
|
|
13
|
+
isFixtureCustomer,
|
|
14
|
+
isOwnedFixtureName,
|
|
15
|
+
selectPaymentFixtureEvent,
|
|
16
|
+
selectRefundFixtureEvent,
|
|
17
|
+
} from '../lib/e2eFixture.ts';
|
|
18
|
+
import { compactProviderReference } from '../lib/providerReference.ts';
|
|
19
|
+
|
|
20
|
+
const mockProvider = 'pay-mock:mock';
|
|
21
|
+
|
|
22
|
+
@Service()
|
|
23
|
+
export class ServiceE2eFixture extends BeanBase {
|
|
24
|
+
private _ownerId() {
|
|
25
|
+
if (!this.app.meta.isTest && !this.app.meta.isDev) this.app.throw(404);
|
|
26
|
+
const user = this.bean.passport.currentUser;
|
|
27
|
+
if (!isFixtureCustomer(user)) this.app.throw(403);
|
|
28
|
+
return user.id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async _operatorId() {
|
|
32
|
+
if (!this.app.meta.isTest && !this.app.meta.isDev) this.app.throw(404);
|
|
33
|
+
const user = this.bean.passport.currentUser;
|
|
34
|
+
if (!user || user.anonymous || !(await this.bean.passport.isSystemAdmin())) this.app.throw(403);
|
|
35
|
+
return user.id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Published stock is created and removed by the same Admin identity; settlement
|
|
39
|
+
// and order deletion require the owning synthetic customer identity separately.
|
|
40
|
+
@Core.transaction()
|
|
41
|
+
async createCatalogue() {
|
|
42
|
+
const userId = await this._operatorId();
|
|
43
|
+
const name = createOwnedFixtureName(userId);
|
|
44
|
+
const category = await this.$scope.commerceCatalog.model.category.insert({
|
|
45
|
+
name,
|
|
46
|
+
published: true,
|
|
47
|
+
});
|
|
48
|
+
const product = await this.$scope.commerceCatalog.model.product.insert({
|
|
49
|
+
categoryId: category.id,
|
|
50
|
+
title: name,
|
|
51
|
+
published: true,
|
|
52
|
+
});
|
|
53
|
+
const sku = await this.$scope.commerceCatalog.model.sku.insert({
|
|
54
|
+
productId: product.id,
|
|
55
|
+
code: name,
|
|
56
|
+
priceCents: 4599,
|
|
57
|
+
attributes: [],
|
|
58
|
+
lifecycle: 'active',
|
|
59
|
+
});
|
|
60
|
+
const stock = await this.scope.service.stockBalance.adjustStock({
|
|
61
|
+
skuId: sku.id,
|
|
62
|
+
delta: 10,
|
|
63
|
+
reason: 'owned E2E catalogue fixture',
|
|
64
|
+
correlationId: name,
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
categoryId: category.id,
|
|
68
|
+
productId: product.id,
|
|
69
|
+
skuId: sku.id,
|
|
70
|
+
stockBalanceId: stock.id,
|
|
71
|
+
title: product.title,
|
|
72
|
+
code: sku.code,
|
|
73
|
+
initialStock: stock.onHand,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async _order(orderId: TableIdentity) {
|
|
78
|
+
const userId = this._ownerId();
|
|
79
|
+
const order = await this.scope.model.order.get({ id: orderId, userId });
|
|
80
|
+
if (!order) this.app.throw(404, 'owned E2E order not found');
|
|
81
|
+
// Require an order line from a synthetic, published catalogue fixture; a
|
|
82
|
+
// fixture account cannot use this endpoint to dispatch real order events.
|
|
83
|
+
const lines = await this.scope.model.orderLine.select({ where: { orderId: order.id } });
|
|
84
|
+
if (
|
|
85
|
+
lines.length !== 1 ||
|
|
86
|
+
lines[0].quantity !== 1 ||
|
|
87
|
+
order.couponSnapshot ||
|
|
88
|
+
order.currency !== 'USD' ||
|
|
89
|
+
order.payableTotalCents !== 4599 ||
|
|
90
|
+
lines[0].skuCodeSnapshot !== lines[0].titleSnapshot ||
|
|
91
|
+
lines[0].unitPriceCents !== 4599
|
|
92
|
+
) {
|
|
93
|
+
this.app.throw(409, 'E2E order must contain one owned fixture item');
|
|
94
|
+
}
|
|
95
|
+
const sku = await this.$scope.commerceCatalog.model.sku.getById(lines[0].skuId);
|
|
96
|
+
const product = sku && (await this.$scope.commerceCatalog.model.product.getById(sku.productId));
|
|
97
|
+
const category =
|
|
98
|
+
product && (await this.$scope.commerceCatalog.model.category.getById(product.categoryId));
|
|
99
|
+
if (
|
|
100
|
+
!sku ||
|
|
101
|
+
!product ||
|
|
102
|
+
!category ||
|
|
103
|
+
!/^e2e-fixture-\d+-[0-9a-f]{12}$/.test(category.name) ||
|
|
104
|
+
sku.code !== category.name ||
|
|
105
|
+
product.title !== category.name ||
|
|
106
|
+
lines[0].skuCodeSnapshot !== sku.code ||
|
|
107
|
+
lines[0].titleSnapshot !== product.title ||
|
|
108
|
+
String(lines[0].productId) !== String(product.id) ||
|
|
109
|
+
sku.priceCents !== 4599 ||
|
|
110
|
+
sku.lifecycle !== 'active' ||
|
|
111
|
+
!product.published ||
|
|
112
|
+
!category.published ||
|
|
113
|
+
!(await this.$scope.commerceMember.model.address.get({ id: order.addressId, userId }))
|
|
114
|
+
) {
|
|
115
|
+
this.app.throw(409, 'order does not use a synthetic E2E catalogue');
|
|
116
|
+
}
|
|
117
|
+
const allLines = await this.scope.model.orderLine.select({ where: { skuId: sku.id } });
|
|
118
|
+
if (allLines.length !== 1 || String(allLines[0].id) !== String(lines[0].id)) {
|
|
119
|
+
this.app.throw(409, 'E2E SKU is not exclusively owned by this order');
|
|
120
|
+
}
|
|
121
|
+
return order;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async _session(paymentSessionId: TableIdentity, userId: TableIdentity) {
|
|
125
|
+
const session = await this.$scope.pay.model.paymentSession.getById(paymentSessionId);
|
|
126
|
+
if (
|
|
127
|
+
!session ||
|
|
128
|
+
String(session.userId) !== String(userId) ||
|
|
129
|
+
session.providerName !== mockProvider ||
|
|
130
|
+
session.clientName !== 'default' ||
|
|
131
|
+
session.environment !== 'sandbox' ||
|
|
132
|
+
session.payScene !== 'commerce-payment:commerceOrder'
|
|
133
|
+
) {
|
|
134
|
+
this.app.throw(409, 'E2E payment session is not owned mock Commerce payment');
|
|
135
|
+
}
|
|
136
|
+
return session;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async dispatchPayment(orderId: TableIdentity, attemptId: TableIdentity) {
|
|
140
|
+
const order = await this._order(orderId);
|
|
141
|
+
const attempt = await this.$scope.commercePayment.model.paymentAttempt.getById(attemptId);
|
|
142
|
+
if (
|
|
143
|
+
!attempt?.paymentSessionId ||
|
|
144
|
+
String(attempt.orderId) !== String(order.id) ||
|
|
145
|
+
String(attempt.userId) !== String(order.userId)
|
|
146
|
+
) {
|
|
147
|
+
this.app.throw(404, 'owned E2E payment attempt not found');
|
|
148
|
+
}
|
|
149
|
+
const session = await this._session(attempt.paymentSessionId, order.userId);
|
|
150
|
+
if (
|
|
151
|
+
session.businessReference !== String(attempt.id) ||
|
|
152
|
+
session.amountMinor !== attempt.amountCents ||
|
|
153
|
+
session.currency !== attempt.currency ||
|
|
154
|
+
attempt.amountCents !== order.payableTotalCents ||
|
|
155
|
+
attempt.currency !== order.currency ||
|
|
156
|
+
session.providerName !== attempt.providerName ||
|
|
157
|
+
!['succeeded', 'failed', 'cancelled'].includes(session.state)
|
|
158
|
+
) {
|
|
159
|
+
this.app.throw(409, 'E2E payment facts are inconsistent');
|
|
160
|
+
}
|
|
161
|
+
const events = await this.$scope.pay.model.outboxEvent.select({
|
|
162
|
+
where: { paymentSessionId: session.id, eventType: 'payment.outcome.v1' },
|
|
163
|
+
});
|
|
164
|
+
const inboxes = await this.$scope.pay.model.webhookInbox.select({
|
|
165
|
+
where: { paymentSessionId: session.id },
|
|
166
|
+
});
|
|
167
|
+
const { event, payload } = this._matchPaymentEvent(events, inboxes, {
|
|
168
|
+
paymentSessionId: session.id,
|
|
169
|
+
businessReference: String(attempt.id),
|
|
170
|
+
amountMinor: attempt.amountCents,
|
|
171
|
+
currency: attempt.currency,
|
|
172
|
+
clientName: session.clientName,
|
|
173
|
+
environment: session.environment,
|
|
174
|
+
});
|
|
175
|
+
if (
|
|
176
|
+
payload.state !== session.state ||
|
|
177
|
+
(payload.providerCaptureId ?? undefined) !== (session.providerCaptureId ?? undefined)
|
|
178
|
+
) {
|
|
179
|
+
this.app.throw(409, 'payment webhook outcome conflicts with session');
|
|
180
|
+
}
|
|
181
|
+
const needsDispatch = this._needsDispatch(event);
|
|
182
|
+
if (needsDispatch) {
|
|
183
|
+
if (attempt.state !== 'created' || order.state !== 'awaiting_payment')
|
|
184
|
+
this.app.throw(409, 'payment event cannot be dispatched');
|
|
185
|
+
await this.$scope.pay.queue.outboxDispatch.execute({ outboxEventId: event.id });
|
|
186
|
+
}
|
|
187
|
+
const currentEvent = await this.$scope.pay.model.outboxEvent.getById(event.id);
|
|
188
|
+
const currentOrder = await this.scope.model.order.getById(order.id);
|
|
189
|
+
const currentAttempt = await this.$scope.commercePayment.model.paymentAttempt.getById(
|
|
190
|
+
attempt.id,
|
|
191
|
+
);
|
|
192
|
+
const finalOrderState = payload.state === 'succeeded' ? 'paid' : 'cancelled';
|
|
193
|
+
const line = await this.scope.model.orderLine.get({ orderId: order.id });
|
|
194
|
+
const reservation =
|
|
195
|
+
line && (await this.scope.model.stockReservation.get({ orderLineId: line.id }));
|
|
196
|
+
const balance = line && (await this.scope.model.stockBalance.get({ skuId: line.skuId }));
|
|
197
|
+
const consumed = payload.state === 'succeeded';
|
|
198
|
+
if (
|
|
199
|
+
currentEvent?.state !== 'dispatched' ||
|
|
200
|
+
currentAttempt?.state !== payload.state ||
|
|
201
|
+
(currentAttempt.providerCaptureId ?? undefined) !==
|
|
202
|
+
(payload.providerCaptureId ?? undefined) ||
|
|
203
|
+
!currentOrder ||
|
|
204
|
+
(currentOrder.state !== finalOrderState &&
|
|
205
|
+
(needsDispatch ||
|
|
206
|
+
payload.state !== 'succeeded' ||
|
|
207
|
+
(currentOrder.state !== 'shipped' && currentOrder.state !== 'refunded') ||
|
|
208
|
+
!(await this._advancedPaymentReplayMatches(
|
|
209
|
+
order,
|
|
210
|
+
attempt.id,
|
|
211
|
+
payload,
|
|
212
|
+
currentOrder.state,
|
|
213
|
+
)))) ||
|
|
214
|
+
!reservation ||
|
|
215
|
+
!line ||
|
|
216
|
+
!balance ||
|
|
217
|
+
!fixtureReservationMatchesLine(reservation, line, balance) ||
|
|
218
|
+
reservation.state !==
|
|
219
|
+
(currentOrder?.state === 'refunded' && !needsDispatch
|
|
220
|
+
? 'restored'
|
|
221
|
+
: consumed
|
|
222
|
+
? 'consumed'
|
|
223
|
+
: 'released') ||
|
|
224
|
+
balance.reserved !== 0 ||
|
|
225
|
+
balance.onHand !==
|
|
226
|
+
(currentOrder?.state === 'refunded' && !needsDispatch
|
|
227
|
+
? 10
|
|
228
|
+
: consumed
|
|
229
|
+
? 10 - reservation!.quantity
|
|
230
|
+
: 10) ||
|
|
231
|
+
balance.available !== balance.onHand
|
|
232
|
+
) {
|
|
233
|
+
this.app.throw(409, 'payment event did not durably settle the Commerce order');
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
orderId: order.id,
|
|
237
|
+
orderState: currentOrder.state,
|
|
238
|
+
attemptState: currentAttempt.state,
|
|
239
|
+
stock: balance.available,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async _advancedPaymentReplayMatches(
|
|
244
|
+
order: { id: TableIdentity; userId: TableIdentity; correlationId: string },
|
|
245
|
+
paymentAttemptId: TableIdentity,
|
|
246
|
+
payload: IPaymentOutcomeEvent,
|
|
247
|
+
state: 'shipped' | 'refunded',
|
|
248
|
+
) {
|
|
249
|
+
const payment = this.$scope.commercePayment;
|
|
250
|
+
const paymentAudits = await payment.model.paymentAudit.select({
|
|
251
|
+
where: { paymentAttemptId },
|
|
252
|
+
});
|
|
253
|
+
if (
|
|
254
|
+
paymentAudits.length !== 1 ||
|
|
255
|
+
String(paymentAudits[0].orderId) !== String(order.id) ||
|
|
256
|
+
String(paymentAudits[0].userId) !== String(order.userId) ||
|
|
257
|
+
paymentAudits[0].provider !== mockProvider ||
|
|
258
|
+
paymentAudits[0].providerEventId !== payload.eventId ||
|
|
259
|
+
paymentAudits[0].outcome !== 'succeeded' ||
|
|
260
|
+
paymentAudits[0].fromAttemptState !== 'created' ||
|
|
261
|
+
paymentAudits[0].toOrderState !== 'paid' ||
|
|
262
|
+
paymentAudits[0].reason !== 'provider payment succeeded' ||
|
|
263
|
+
paymentAudits[0].idempotencyKey !==
|
|
264
|
+
compactProviderReference('payment-event', payload.eventId, 100) ||
|
|
265
|
+
paymentAudits[0].correlationId !==
|
|
266
|
+
compactProviderReference(
|
|
267
|
+
'payment-correlation',
|
|
268
|
+
`${order.correlationId}:provider:${payload.eventId}`,
|
|
269
|
+
93,
|
|
270
|
+
) ||
|
|
271
|
+
!paymentAudits[0].processedAt
|
|
272
|
+
) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const orderAudits = await this.scope.model.orderAudit.select({
|
|
276
|
+
where: { orderId: order.id },
|
|
277
|
+
orders: [['id', 'asc']],
|
|
278
|
+
});
|
|
279
|
+
const shipment = await this.scope.model.shipment.get({ orderId: order.id });
|
|
280
|
+
if (state === 'shipped') {
|
|
281
|
+
return (
|
|
282
|
+
!!shipment &&
|
|
283
|
+
String(shipment.orderId) === String(order.id) &&
|
|
284
|
+
shipment.correlationId === `${order.correlationId}:shipment` &&
|
|
285
|
+
!!shipment.shippedAt &&
|
|
286
|
+
fixtureAdvancedPaymentAuditMatches(
|
|
287
|
+
orderAudits,
|
|
288
|
+
order.id,
|
|
289
|
+
state,
|
|
290
|
+
paymentAudits[0].correlationId,
|
|
291
|
+
shipment.correlationId,
|
|
292
|
+
)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (shipment) return false;
|
|
296
|
+
const requests = await payment.model.refundRequest.select({ where: { orderId: order.id } });
|
|
297
|
+
const attempts = await payment.model.refundAttempt.select({ where: { orderId: order.id } });
|
|
298
|
+
if (
|
|
299
|
+
requests.length !== 1 ||
|
|
300
|
+
attempts.length !== 1 ||
|
|
301
|
+
String(requests[0].userId) !== String(order.userId) ||
|
|
302
|
+
String(attempts[0].userId) !== String(order.userId) ||
|
|
303
|
+
String(attempts[0].refundRequestId) !== String(requests[0].id) ||
|
|
304
|
+
!attempts[0].refundOperationId ||
|
|
305
|
+
requests[0].state !== 'refunded' ||
|
|
306
|
+
attempts[0].state !== 'succeeded' ||
|
|
307
|
+
requests[0].amountCents !== 4599 ||
|
|
308
|
+
attempts[0].amountCents !== requests[0].amountCents ||
|
|
309
|
+
requests[0].currency !== 'USD' ||
|
|
310
|
+
attempts[0].currency !== requests[0].currency
|
|
311
|
+
) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
const operation = await this.$scope.pay.model.refundOperation.getById(
|
|
315
|
+
attempts[0].refundOperationId,
|
|
316
|
+
);
|
|
317
|
+
if (
|
|
318
|
+
!operation ||
|
|
319
|
+
String(operation.paymentSessionId) !== String(payload.paymentSessionId) ||
|
|
320
|
+
operation.businessReference !== String(attempts[0].id) ||
|
|
321
|
+
operation.state !== 'succeeded' ||
|
|
322
|
+
operation.providerRefundId !== attempts[0].providerRefundId ||
|
|
323
|
+
operation.amountMinor !== attempts[0].amountCents ||
|
|
324
|
+
operation.currency !== attempts[0].currency
|
|
325
|
+
) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const refundEvents = await this.$scope.pay.model.outboxEvent.select({
|
|
329
|
+
where: { paymentSessionId: payload.paymentSessionId, eventType: 'refund.outcome.v1' },
|
|
330
|
+
});
|
|
331
|
+
const inboxes = await this.$scope.pay.model.webhookInbox.select({
|
|
332
|
+
where: { paymentSessionId: payload.paymentSessionId, refundOperationId: operation.id },
|
|
333
|
+
});
|
|
334
|
+
let refundEvent: EntityOutboxEvent;
|
|
335
|
+
let refundPayload: ReturnType<typeof selectRefundFixtureEvent>['payload'];
|
|
336
|
+
try {
|
|
337
|
+
({ event: refundEvent, payload: refundPayload } = selectRefundFixtureEvent(
|
|
338
|
+
refundEvents,
|
|
339
|
+
inboxes,
|
|
340
|
+
{
|
|
341
|
+
paymentSessionId: payload.paymentSessionId,
|
|
342
|
+
refundOperationId: operation.id,
|
|
343
|
+
businessReference: String(attempts[0].id),
|
|
344
|
+
amountMinor: attempts[0].amountCents,
|
|
345
|
+
currency: attempts[0].currency,
|
|
346
|
+
clientName: 'default',
|
|
347
|
+
environment: 'sandbox',
|
|
348
|
+
},
|
|
349
|
+
));
|
|
350
|
+
if (fixtureEventNeedsDispatch(refundEvent)) return false;
|
|
351
|
+
} catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
const refundAudits = await payment.model.refundAudit.select({
|
|
355
|
+
where: { refundRequestId: requests[0].id },
|
|
356
|
+
});
|
|
357
|
+
const finalAudit = refundAudits.find(audit => audit.toRefundState === 'refunded');
|
|
358
|
+
if (
|
|
359
|
+
refundAudits.length !== 3 ||
|
|
360
|
+
!finalAudit ||
|
|
361
|
+
String(finalAudit.refundAttemptId) !== String(attempts[0].id) ||
|
|
362
|
+
String(finalAudit.orderId) !== String(order.id) ||
|
|
363
|
+
String(finalAudit.userId) !== String(order.userId) ||
|
|
364
|
+
finalAudit.attemptState !== 'succeeded' ||
|
|
365
|
+
!finalAudit.processedAt ||
|
|
366
|
+
refundPayload.state !== 'succeeded' ||
|
|
367
|
+
refundPayload.providerRefundId !== operation.providerRefundId
|
|
368
|
+
) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
const refundCorrelationId = compactProviderReference(
|
|
372
|
+
'refund-correlation',
|
|
373
|
+
`${order.correlationId}:provider-refund:${refundPayload.eventId}`,
|
|
374
|
+
100,
|
|
375
|
+
);
|
|
376
|
+
return (
|
|
377
|
+
finalAudit.correlationId === refundCorrelationId &&
|
|
378
|
+
fixtureAdvancedPaymentAuditMatches(
|
|
379
|
+
orderAudits,
|
|
380
|
+
order.id,
|
|
381
|
+
state,
|
|
382
|
+
paymentAudits[0].correlationId,
|
|
383
|
+
undefined,
|
|
384
|
+
refundCorrelationId,
|
|
385
|
+
)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async dispatchRefund(orderId: TableIdentity, attemptId: TableIdentity) {
|
|
390
|
+
const order = await this._order(orderId);
|
|
391
|
+
const attempt = await this.$scope.commercePayment.model.refundAttempt.getById(attemptId);
|
|
392
|
+
if (
|
|
393
|
+
!attempt?.refundOperationId ||
|
|
394
|
+
String(attempt.orderId) !== String(order.id) ||
|
|
395
|
+
String(attempt.userId) !== String(order.userId)
|
|
396
|
+
) {
|
|
397
|
+
this.app.throw(404, 'owned E2E refund attempt not found');
|
|
398
|
+
}
|
|
399
|
+
const request = await this.$scope.commercePayment.model.refundRequest.getById(
|
|
400
|
+
attempt.refundRequestId,
|
|
401
|
+
);
|
|
402
|
+
if (
|
|
403
|
+
!request ||
|
|
404
|
+
String(request.orderId) !== String(order.id) ||
|
|
405
|
+
String(request.userId) !== String(order.userId) ||
|
|
406
|
+
request.amountCents !== attempt.amountCents ||
|
|
407
|
+
request.currency !== attempt.currency
|
|
408
|
+
) {
|
|
409
|
+
this.app.throw(409, 'E2E refund request facts are inconsistent');
|
|
410
|
+
}
|
|
411
|
+
const refund = await this.$scope.pay.model.refundOperation.getById(attempt.refundOperationId);
|
|
412
|
+
if (!refund) this.app.throw(404, 'E2E refund operation not found');
|
|
413
|
+
const session = await this._session(refund.paymentSessionId, order.userId);
|
|
414
|
+
const paymentAttempt = await this.$scope.commercePayment.model.paymentAttempt.get({
|
|
415
|
+
orderId: order.id,
|
|
416
|
+
});
|
|
417
|
+
if (
|
|
418
|
+
!paymentAttempt ||
|
|
419
|
+
String(paymentAttempt.paymentSessionId) !== String(session.id) ||
|
|
420
|
+
paymentAttempt.state !== 'succeeded' ||
|
|
421
|
+
String(paymentAttempt.userId) !== String(order.userId) ||
|
|
422
|
+
session.businessReference !== String(paymentAttempt.id) ||
|
|
423
|
+
session.state !== 'succeeded' ||
|
|
424
|
+
session.providerCaptureId === undefined ||
|
|
425
|
+
session.amountMinor !== paymentAttempt.amountCents ||
|
|
426
|
+
session.currency !== paymentAttempt.currency ||
|
|
427
|
+
session.providerName !== paymentAttempt.providerName ||
|
|
428
|
+
refund.businessReference !== String(attempt.id) ||
|
|
429
|
+
refund.amountMinor !== attempt.amountCents ||
|
|
430
|
+
refund.currency !== attempt.currency ||
|
|
431
|
+
attempt.amountCents !== order.payableTotalCents ||
|
|
432
|
+
attempt.currency !== order.currency ||
|
|
433
|
+
!['succeeded', 'failed', 'cancelled'].includes(refund.state)
|
|
434
|
+
) {
|
|
435
|
+
this.app.throw(409, 'E2E refund operation facts are inconsistent');
|
|
436
|
+
}
|
|
437
|
+
const events = await this.$scope.pay.model.outboxEvent.select({
|
|
438
|
+
where: { paymentSessionId: session.id, eventType: 'refund.outcome.v1' },
|
|
439
|
+
});
|
|
440
|
+
const inboxes = await this.$scope.pay.model.webhookInbox.select({
|
|
441
|
+
where: { paymentSessionId: session.id, refundOperationId: refund.id },
|
|
442
|
+
});
|
|
443
|
+
const { event, payload } = this._matchRefundEvent(events, inboxes, {
|
|
444
|
+
paymentSessionId: session.id,
|
|
445
|
+
refundOperationId: refund.id,
|
|
446
|
+
businessReference: String(attempt.id),
|
|
447
|
+
amountMinor: attempt.amountCents,
|
|
448
|
+
currency: attempt.currency,
|
|
449
|
+
clientName: session.clientName,
|
|
450
|
+
environment: session.environment,
|
|
451
|
+
});
|
|
452
|
+
if (payload.state !== refund.state || payload.providerRefundId !== refund.providerRefundId)
|
|
453
|
+
this.app.throw(409, 'refund webhook outcome conflicts with operation');
|
|
454
|
+
if (this._needsDispatch(event)) {
|
|
455
|
+
if (
|
|
456
|
+
attempt.state !== 'created' ||
|
|
457
|
+
request.state !== 'approved' ||
|
|
458
|
+
order.state !== 'refund_approved'
|
|
459
|
+
) {
|
|
460
|
+
this.app.throw(409, 'refund event cannot be dispatched');
|
|
461
|
+
}
|
|
462
|
+
await this.$scope.pay.queue.outboxDispatch.execute({ outboxEventId: event.id });
|
|
463
|
+
}
|
|
464
|
+
const currentEvent = await this.$scope.pay.model.outboxEvent.getById(event.id);
|
|
465
|
+
const currentOrder = await this.scope.model.order.getById(order.id);
|
|
466
|
+
const currentAttempt = await this.$scope.commercePayment.model.refundAttempt.getById(
|
|
467
|
+
attempt.id,
|
|
468
|
+
);
|
|
469
|
+
const currentRequest = await this.$scope.commercePayment.model.refundRequest.getById(
|
|
470
|
+
request.id,
|
|
471
|
+
);
|
|
472
|
+
const succeeded = payload.state === 'succeeded';
|
|
473
|
+
const line = await this.scope.model.orderLine.get({ orderId: order.id });
|
|
474
|
+
const reservation =
|
|
475
|
+
line && (await this.scope.model.stockReservation.get({ orderLineId: line.id }));
|
|
476
|
+
const balance = line && (await this.scope.model.stockBalance.get({ skuId: line.skuId }));
|
|
477
|
+
if (
|
|
478
|
+
currentEvent?.state !== 'dispatched' ||
|
|
479
|
+
currentAttempt?.state !== (succeeded ? 'succeeded' : 'failed') ||
|
|
480
|
+
currentAttempt.providerRefundId !== payload.providerRefundId ||
|
|
481
|
+
currentRequest?.state !== (succeeded ? 'refunded' : 'failed') ||
|
|
482
|
+
currentOrder?.state !== (succeeded ? 'refunded' : 'paid') ||
|
|
483
|
+
!reservation ||
|
|
484
|
+
!line ||
|
|
485
|
+
!balance ||
|
|
486
|
+
!fixtureReservationMatchesLine(reservation, line, balance) ||
|
|
487
|
+
reservation.state !== (succeeded ? 'restored' : 'consumed') ||
|
|
488
|
+
balance.reserved !== 0 ||
|
|
489
|
+
balance.onHand !== (succeeded ? 10 : 10 - reservation!.quantity) ||
|
|
490
|
+
balance.available !== balance.onHand
|
|
491
|
+
) {
|
|
492
|
+
this.app.throw(409, 'refund event did not durably settle the Commerce order');
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
orderId: order.id,
|
|
496
|
+
orderState: currentOrder.state,
|
|
497
|
+
refundState: currentRequest.state,
|
|
498
|
+
stock: balance.available,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private _needsDispatch(event: EntityOutboxEvent) {
|
|
503
|
+
try {
|
|
504
|
+
return fixtureEventNeedsDispatch(event);
|
|
505
|
+
} catch {
|
|
506
|
+
this.app.throw(409, 'matching mock webhook outbox is not eligible for dispatch');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private _matchPaymentEvent(
|
|
511
|
+
events: EntityOutboxEvent[],
|
|
512
|
+
inboxes: Parameters<typeof selectPaymentFixtureEvent>[1],
|
|
513
|
+
expected: Parameters<typeof selectPaymentFixtureEvent>[2],
|
|
514
|
+
) {
|
|
515
|
+
try {
|
|
516
|
+
return selectPaymentFixtureEvent(events, inboxes, expected);
|
|
517
|
+
} catch {
|
|
518
|
+
this.app.throw(409, 'matching processed mock payment webhook outbox is required');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private _matchRefundEvent(
|
|
523
|
+
events: EntityOutboxEvent[],
|
|
524
|
+
inboxes: Parameters<typeof selectRefundFixtureEvent>[1],
|
|
525
|
+
expected: Parameters<typeof selectRefundFixtureEvent>[2],
|
|
526
|
+
) {
|
|
527
|
+
try {
|
|
528
|
+
return selectRefundFixtureEvent(events, inboxes, expected);
|
|
529
|
+
} catch {
|
|
530
|
+
this.app.throw(409, 'matching processed mock refund webhook outbox is required');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@Core.transaction()
|
|
535
|
+
async removeOrder(orderId: TableIdentity) {
|
|
536
|
+
const order = await this._order(orderId);
|
|
537
|
+
const pay = this.$scope.pay;
|
|
538
|
+
const payment = this.$scope.commercePayment;
|
|
539
|
+
const attempts = await payment.model.paymentAttempt.select({ where: { orderId: order.id } });
|
|
540
|
+
const refunds = await payment.model.refundRequest.select({ where: { orderId: order.id } });
|
|
541
|
+
const refundAttempts = await payment.model.refundAttempt.select({
|
|
542
|
+
where: { orderId: order.id },
|
|
543
|
+
});
|
|
544
|
+
if (
|
|
545
|
+
attempts.length > 1 ||
|
|
546
|
+
refunds.length > 1 ||
|
|
547
|
+
refundAttempts.length > 1 ||
|
|
548
|
+
(attempts.length === 0 &&
|
|
549
|
+
(refunds.length > 0 || refundAttempts.length > 0 || order.state !== 'awaiting_payment')) ||
|
|
550
|
+
attempts.some(
|
|
551
|
+
item => String(item.userId) !== String(order.userId) || !item.paymentSessionId,
|
|
552
|
+
) ||
|
|
553
|
+
refunds.some(item => String(item.userId) !== String(order.userId)) ||
|
|
554
|
+
refundAttempts.some(
|
|
555
|
+
item =>
|
|
556
|
+
!refunds.some(
|
|
557
|
+
refund =>
|
|
558
|
+
String(refund.id) === String(item.refundRequestId) &&
|
|
559
|
+
String(refund.userId) === String(order.userId),
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
) {
|
|
563
|
+
this.app.throw(409, 'E2E order payment/refund ownership conflict');
|
|
564
|
+
}
|
|
565
|
+
for (const attempt of attempts) {
|
|
566
|
+
const session = await this._session(attempt.paymentSessionId!, order.userId);
|
|
567
|
+
if (session.businessReference !== String(attempt.id))
|
|
568
|
+
this.app.throw(409, 'payment ownership conflict');
|
|
569
|
+
const operations = await pay.model.refundOperation.select({
|
|
570
|
+
where: { paymentSessionId: session.id },
|
|
571
|
+
});
|
|
572
|
+
for (const operation of operations) {
|
|
573
|
+
if (
|
|
574
|
+
!refundAttempts.some(
|
|
575
|
+
item =>
|
|
576
|
+
String(item.id) === operation.businessReference &&
|
|
577
|
+
String(item.refundOperationId) === String(operation.id),
|
|
578
|
+
)
|
|
579
|
+
) {
|
|
580
|
+
this.app.throw(409, 'refund operation ownership conflict');
|
|
581
|
+
}
|
|
582
|
+
await pay.model.outboxEvent.delete({ refundOperationId: operation.id });
|
|
583
|
+
await pay.model.webhookInbox.delete({ refundOperationId: operation.id });
|
|
584
|
+
const providers = await pay.model.providerOperation.select({
|
|
585
|
+
where: { refundOperationId: operation.id },
|
|
586
|
+
});
|
|
587
|
+
for (const provider of providers) {
|
|
588
|
+
await pay.model.providerOperationRecoveryAudit.delete({
|
|
589
|
+
providerOperationId: provider.id,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
await pay.model.providerOperation.delete({ refundOperationId: operation.id });
|
|
593
|
+
await pay.model.refundOperation.deleteById(operation.id);
|
|
594
|
+
}
|
|
595
|
+
await pay.model.outboxEvent.delete({ paymentSessionId: session.id });
|
|
596
|
+
await pay.model.paymentAudit.delete({ paymentSessionId: session.id });
|
|
597
|
+
await pay.model.webhookInbox.delete({ paymentSessionId: session.id });
|
|
598
|
+
const providers = await pay.model.providerOperation.select({
|
|
599
|
+
where: { paymentSessionId: session.id },
|
|
600
|
+
});
|
|
601
|
+
for (const provider of providers)
|
|
602
|
+
await pay.model.providerOperationRecoveryAudit.delete({ providerOperationId: provider.id });
|
|
603
|
+
await pay.model.providerOperation.delete({ paymentSessionId: session.id });
|
|
604
|
+
for (const refund of refunds)
|
|
605
|
+
await payment.model.refundAudit.delete({ refundRequestId: refund.id });
|
|
606
|
+
await payment.model.refundAttempt.delete({ orderId: order.id });
|
|
607
|
+
await payment.model.refundRequest.delete({ orderId: order.id });
|
|
608
|
+
await payment.model.paymentAudit.delete({ paymentAttemptId: attempt.id });
|
|
609
|
+
await payment.model.paymentAttempt.deleteById(attempt.id);
|
|
610
|
+
await pay.model.paymentSession.deleteById(session.id);
|
|
611
|
+
}
|
|
612
|
+
await this.scope.model.shipment.delete({ orderId: order.id });
|
|
613
|
+
await this.scope.model.orderAudit.delete({ orderId: order.id });
|
|
614
|
+
const lines = await this.scope.model.orderLine.select({ where: { orderId: order.id } });
|
|
615
|
+
for (const line of lines) {
|
|
616
|
+
const reservations = await this.scope.model.stockReservation.select({
|
|
617
|
+
where: { orderLineId: line.id },
|
|
618
|
+
});
|
|
619
|
+
const balance = await this.scope.model.stockBalance.get({ skuId: line.skuId });
|
|
620
|
+
if (
|
|
621
|
+
reservations.length !== 1 ||
|
|
622
|
+
!balance ||
|
|
623
|
+
!fixtureReservationMatchesLine(reservations[0], line, balance)
|
|
624
|
+
) {
|
|
625
|
+
this.app.throw(409, 'E2E order reservation ownership conflict');
|
|
626
|
+
}
|
|
627
|
+
if (reservations[0].state === 'reserved') {
|
|
628
|
+
await this.scope.service.stockBalance.release({
|
|
629
|
+
reservationId: reservations[0].id,
|
|
630
|
+
reason: 'owned E2E fixture cleanup',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
await this.scope.model.stockReservation.deleteById(reservations[0].id);
|
|
634
|
+
}
|
|
635
|
+
await this.scope.model.orderLine.delete({ orderId: order.id });
|
|
636
|
+
await this.scope.model.order.deleteById(order.id);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
@Core.transaction()
|
|
640
|
+
async removeCustomer() {
|
|
641
|
+
const userId = this._ownerId();
|
|
642
|
+
if ((await this.scope.model.order.select({ where: { userId }, limit: 1 })).length)
|
|
643
|
+
this.app.throw(409, 'E2E customer still owns orders');
|
|
644
|
+
const addresses = await this.$scope.commerceMember.model.address.select({ where: { userId } });
|
|
645
|
+
for (const address of addresses)
|
|
646
|
+
await this.$scope.commerceMember.model.address.deleteById(address.id);
|
|
647
|
+
const carts = await this.scope.model.cart.select({ where: { userId } });
|
|
648
|
+
for (const cart of carts) {
|
|
649
|
+
await this.scope.model.cartItem.delete({ cartId: cart.id });
|
|
650
|
+
await this.scope.model.cart.deleteById(cart.id);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
@Core.transaction()
|
|
655
|
+
async removeCatalogue(categoryId: TableIdentity) {
|
|
656
|
+
const userId = await this._operatorId();
|
|
657
|
+
const category = await this.$scope.commerceCatalog.model.category.getById(categoryId);
|
|
658
|
+
if (!category || !isOwnedFixtureName(category.name, userId))
|
|
659
|
+
this.app.throw(404, 'E2E category not found');
|
|
660
|
+
const products = await this.$scope.commerceCatalog.model.product.select({
|
|
661
|
+
where: { categoryId },
|
|
662
|
+
});
|
|
663
|
+
if (products.length !== 1 || products[0].title !== category.name) {
|
|
664
|
+
this.app.throw(409, 'E2E product ownership conflict');
|
|
665
|
+
}
|
|
666
|
+
const skus = await this.$scope.commerceCatalog.model.sku.select({
|
|
667
|
+
where: { productId: products[0].id },
|
|
668
|
+
});
|
|
669
|
+
if (skus.length !== 1 || skus[0].code !== category.name) {
|
|
670
|
+
this.app.throw(409, 'E2E SKU ownership conflict');
|
|
671
|
+
}
|
|
672
|
+
const sku = skus[0];
|
|
673
|
+
if ((await this.scope.model.orderLine.select({ where: { skuId: sku.id }, limit: 1 })).length)
|
|
674
|
+
this.app.throw(409, 'E2E catalogue still has order lines');
|
|
675
|
+
if ((await this.scope.model.cartItem.select({ where: { skuId: sku.id }, limit: 1 })).length) {
|
|
676
|
+
this.app.throw(409, 'E2E catalogue still has cart items');
|
|
677
|
+
}
|
|
678
|
+
const balance = await this.scope.model.stockBalance.get({ skuId: sku.id });
|
|
679
|
+
if (!balance) this.app.throw(409, 'E2E catalogue stock balance is missing');
|
|
680
|
+
if (balance.reserved !== 0 || balance.available !== balance.onHand) {
|
|
681
|
+
this.app.throw(409, 'E2E catalogue still has reserved stock');
|
|
682
|
+
}
|
|
683
|
+
const reservations = await this.scope.model.stockReservation.select({
|
|
684
|
+
where: { stockBalanceId: balance.id },
|
|
685
|
+
limit: 1,
|
|
686
|
+
});
|
|
687
|
+
if (reservations.length) this.app.throw(409, 'E2E catalogue still has stock reservations');
|
|
688
|
+
await this.scope.model.stockAudit.delete({ skuId: sku.id });
|
|
689
|
+
await this.scope.model.stockBalance.deleteById(balance.id);
|
|
690
|
+
await this.$scope.commerceCatalog.model.sku.deleteById(sku.id);
|
|
691
|
+
await this.$scope.commerceCatalog.model.product.deleteById(products[0].id);
|
|
692
|
+
await this.$scope.commerceCatalog.model.category.deleteById(category.id);
|
|
693
|
+
}
|
|
694
|
+
}
|