cabloy 5.1.124 → 5.1.126
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/.claude/skills/cabloy-backend-scaffold/SKILL.md +1 -1
- package/.claude/skills/cabloy-backend-scaffold/evals/evals.json +6 -0
- package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +13 -4
- package/.claude/skills/cabloy-workflow/SKILL.md +4 -2
- package/.claude/skills/cabloy-workflow/evals/evals.json +14 -2
- package/.claude/skills/cabloy-worktree-environment/SKILL.md +206 -0
- package/.claude/skills/cabloy-worktree-environment/evals/evals.json +77 -0
- package/.github/workflows/vona-test-pg.yml +3 -0
- package/.github/workflows/vona-test-sqlite3.yml +3 -0
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -1
- package/cabloy-docs/.vitepress/config.mjs +2 -0
- package/cabloy-docs/ai/playbook-technical-blog-authoring.md +158 -0
- package/cabloy-docs/backend/backend-contract-emission-specimen.md +2 -2
- package/cabloy-docs/backend/dto-guide.md +10 -7
- package/cabloy-docs/backend/dto-infer-generation.md +127 -7
- package/cabloy-docs/backend/foundation.md +1 -1
- package/cabloy-docs/backend/introduction.md +1 -0
- package/cabloy-docs/backend/module-dependencies.md +104 -0
- package/cabloy-docs/backend/serialization-guide.md +6 -0
- package/cabloy-docs/fullstack/parallel-worktree-environment.md +72 -34
- package/cabloy-docs/fullstack/suites-and-modules.md +1 -0
- package/cabloy-docs/reference/package-map.md +1 -0
- package/e2e/specs/a-commerce/commerce.spec.ts +89 -0
- package/package.json +2 -1
- package/scripts/init.ts +7 -12
- package/scripts/initAppName.test.ts +72 -0
- package/scripts/initAppName.ts +50 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +48 -146
- package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineCreate.tsx +6 -27
- package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineItem.tsx +6 -31
- package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineUpdate.tsx +6 -2
- package/vona/src/suite/a-commerce/modules/commerce-member/src/lib/addressMine.ts +16 -0
- package/vona/src/suite/a-commerce/modules/commerce-member/src/service/address.ts +4 -17
- package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +63 -19
- package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +11 -2
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +26 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +19 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +5 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +5 -2
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +26 -35
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +6 -1
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +40 -4
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponTemplateFormLayout.test.ts +85 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/order.ts +3 -5
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSummary.tsx +7 -40
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/shipmentView.tsx +6 -15
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/lib/order.ts +9 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +121 -11
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/order.test.ts +25 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +19 -2
- package/vona/src/suite/a-training/modules/training-record/src/dto/detailRecordSubjectResItem.tsx +6 -1
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +3 -1
- package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +4 -1
- package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +45 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordResItem.tsx +6 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSummary.tsx +5 -23
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +37 -5
- package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +29 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +3 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +3 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.version.ts +7 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/queue.outboxDispatch.ts +9 -4
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/schedule.providerOperationDispatch.ts +11 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/outboxEvent.tsx +11 -4
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/providerOperation.tsx +12 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/webhookInbox.tsx +6 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/outbox.ts +12 -5
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +1 -37
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +355 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +226 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/webhook.ts +148 -50
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +2 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/.metadata/index.ts +39 -22
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +42 -13
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/controller/mockPayment.ts +11 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundComplete.tsx +13 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundReceipt.tsx +16 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/service/payMock.ts +56 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +2 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +117 -8
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/package.json +5 -5
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoGet.ts +5 -5
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +54 -12
- package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +625 -64
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +55 -15
- package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeOrder.ts +15 -17
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +12 -8
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +111 -26
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeOrder.ts +3 -3
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionRefund/controller.tsx +1 -8
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +5 -9
- package/zova/src/suite/a-training/modules/training-student/src/.metadata/index.ts +1 -1
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +301 -17
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +5461 -1788
- package/vona/src/suite-vendor/a-pay/modules/a-pay/test/outbox.test.ts +0 -209
- package/vona/src/suite-vendor/a-pay/modules/a-pay/test/paymentSession.test.ts +0 -153
- package/vona/src/suite-vendor/a-pay/modules/a-pay/test/webhook.test.ts +0 -278
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/test/payMock.test.ts +0 -141
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
4
|
-
import { app } from 'vona-mock';
|
|
5
|
-
|
|
6
|
-
import type { IPaymentOutcomeEvent } from '../src/types/payment.ts';
|
|
7
|
-
|
|
8
|
-
interface IFixture {
|
|
9
|
-
userId?: number;
|
|
10
|
-
paymentSessionId?: number;
|
|
11
|
-
outboxEventIds: number[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function createFixture(suffix: string): Promise<IFixture> {
|
|
15
|
-
const scope = app.scope('a-pay');
|
|
16
|
-
const user = await app.bean.user.register({ name: `pay-outbox-${suffix}` }, true);
|
|
17
|
-
const session = await scope.service.paymentSession.create({
|
|
18
|
-
userId: user.id,
|
|
19
|
-
payScene: 'commerce-payment:commerceOrder',
|
|
20
|
-
businessReference: `business-${suffix}`,
|
|
21
|
-
amountMinor: 1299,
|
|
22
|
-
currency: 'USD',
|
|
23
|
-
correlationId: `payment-${suffix}`,
|
|
24
|
-
});
|
|
25
|
-
return { userId: user.id as number, paymentSessionId: session.id as number, outboxEventIds: [] };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function createOutcomePayload(fixture: IFixture): IPaymentOutcomeEvent {
|
|
29
|
-
return {
|
|
30
|
-
eventId: randomUUID(),
|
|
31
|
-
paymentSessionId: fixture.paymentSessionId!,
|
|
32
|
-
businessReference: 'outbox-test',
|
|
33
|
-
providerName: 'test-provider',
|
|
34
|
-
state: 'succeeded',
|
|
35
|
-
amountMinor: 1299,
|
|
36
|
-
currency: 'USD',
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function insertOutbox(
|
|
41
|
-
fixture: IFixture,
|
|
42
|
-
overrides?: Partial<{
|
|
43
|
-
eventType: 'payment.outcome.v1';
|
|
44
|
-
state: 'pending' | 'claimed';
|
|
45
|
-
attemptCount: number;
|
|
46
|
-
nextAttemptAt: Date;
|
|
47
|
-
claimToken: string;
|
|
48
|
-
claimedAt: Date;
|
|
49
|
-
claimExpiresAt: Date;
|
|
50
|
-
}>,
|
|
51
|
-
) {
|
|
52
|
-
const event = await app.scope('a-pay').model.outboxEvent.insert({
|
|
53
|
-
eventType: overrides?.eventType ?? 'payment.outcome.v1',
|
|
54
|
-
paymentSessionId: fixture.paymentSessionId!,
|
|
55
|
-
payload: createOutcomePayload(fixture),
|
|
56
|
-
state: overrides?.state ?? 'pending',
|
|
57
|
-
attemptCount: overrides?.attemptCount ?? 0,
|
|
58
|
-
nextAttemptAt: overrides?.nextAttemptAt ?? new Date(Date.now() - 1_000),
|
|
59
|
-
claimToken: overrides?.claimToken,
|
|
60
|
-
claimedAt: overrides?.claimedAt,
|
|
61
|
-
claimExpiresAt: overrides?.claimExpiresAt,
|
|
62
|
-
});
|
|
63
|
-
fixture.outboxEventIds.push(event.id as number);
|
|
64
|
-
return event;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function cleanup(fixture: IFixture) {
|
|
68
|
-
const scope = app.scope('a-pay');
|
|
69
|
-
for (const id of fixture.outboxEventIds) await scope.model.outboxEvent.delete({ id });
|
|
70
|
-
if (fixture.paymentSessionId !== undefined) {
|
|
71
|
-
await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
72
|
-
await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
73
|
-
await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
|
|
74
|
-
}
|
|
75
|
-
if (fixture.userId !== undefined) {
|
|
76
|
-
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
77
|
-
await app.bean.user.removeById(fixture.userId);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
describe('outbox.test.ts', { concurrency: false }, () => {
|
|
82
|
-
it('claims, backs off, and exhausts durable delivery attempts', async () => {
|
|
83
|
-
await app.bean.executor.mockCtx(async () => {
|
|
84
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
85
|
-
try {
|
|
86
|
-
const event = await insertOutbox(fixture);
|
|
87
|
-
const scope = app.scope('a-pay');
|
|
88
|
-
const first = await scope.service.outbox.claim(event.id);
|
|
89
|
-
assert.equal(first?.attemptCount, 1);
|
|
90
|
-
assert.ok(first?.claimToken);
|
|
91
|
-
const releaseStart = Date.now();
|
|
92
|
-
await scope.service.outbox.release(
|
|
93
|
-
event.id,
|
|
94
|
-
first!.claimToken!,
|
|
95
|
-
new Error('first failure'),
|
|
96
|
-
);
|
|
97
|
-
let persisted = await scope.model.outboxEvent.getById(event.id);
|
|
98
|
-
assert.equal(persisted?.state, 'pending');
|
|
99
|
-
assert.equal(persisted?.errorSummary, 'first failure');
|
|
100
|
-
assert.ok((persisted?.nextAttemptAt?.getTime() ?? 0) >= releaseStart + 900);
|
|
101
|
-
assert.equal(await scope.service.outbox.claim(event.id), undefined);
|
|
102
|
-
await scope.model.outboxEvent.updateById(event.id, {
|
|
103
|
-
nextAttemptAt: new Date(Date.now() - 1),
|
|
104
|
-
});
|
|
105
|
-
const second = await scope.service.outbox.claim(event.id);
|
|
106
|
-
assert.equal(second?.attemptCount, 2);
|
|
107
|
-
const secondReleaseStart = Date.now();
|
|
108
|
-
await scope.service.outbox.release(
|
|
109
|
-
event.id,
|
|
110
|
-
second!.claimToken!,
|
|
111
|
-
new Error('second failure'),
|
|
112
|
-
);
|
|
113
|
-
persisted = await scope.model.outboxEvent.getById(event.id);
|
|
114
|
-
assert.ok((persisted?.nextAttemptAt?.getTime() ?? 0) >= secondReleaseStart + 1_900);
|
|
115
|
-
|
|
116
|
-
const exhausted = await insertOutbox(fixture, { attemptCount: 10 });
|
|
117
|
-
assert.equal(await scope.service.outbox.claim(exhausted.id), undefined);
|
|
118
|
-
assert.equal((await scope.model.outboxEvent.getById(exhausted.id))?.state, 'failed');
|
|
119
|
-
} finally {
|
|
120
|
-
await cleanup(fixture);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('retries a dispatch when the persisted payment scene is unavailable', async () => {
|
|
126
|
-
await app.bean.executor.mockCtx(async () => {
|
|
127
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
128
|
-
try {
|
|
129
|
-
const scope = app.scope('a-pay');
|
|
130
|
-
await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
|
|
131
|
-
payScene: 'test-payment',
|
|
132
|
-
});
|
|
133
|
-
const event = await insertOutbox(fixture);
|
|
134
|
-
await scope.queue.outboxDispatch.pushAsync({ outboxEventId: event.id });
|
|
135
|
-
const persisted = await scope.model.outboxEvent.getById(event.id);
|
|
136
|
-
assert.equal(persisted?.state, 'pending');
|
|
137
|
-
assert.equal(persisted?.attemptCount, 1);
|
|
138
|
-
assert.match(persisted?.errorSummary ?? '', /payment scene not found/);
|
|
139
|
-
} finally {
|
|
140
|
-
await cleanup(fixture);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('reclaims expired leases and fences stale claim tokens', async () => {
|
|
146
|
-
await app.bean.executor.mockCtx(async () => {
|
|
147
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
148
|
-
try {
|
|
149
|
-
const event = await insertOutbox(fixture);
|
|
150
|
-
const scope = app.scope('a-pay');
|
|
151
|
-
const first = await scope.service.outbox.claim(event.id);
|
|
152
|
-
await scope.model.outboxEvent.updateById(event.id, {
|
|
153
|
-
claimExpiresAt: new Date(Date.now() - 1),
|
|
154
|
-
});
|
|
155
|
-
const second = await scope.service.outbox.claim(event.id);
|
|
156
|
-
assert.ok(second?.claimToken);
|
|
157
|
-
assert.notEqual(second?.claimToken, first?.claimToken);
|
|
158
|
-
assert.equal(second?.attemptCount, 2);
|
|
159
|
-
assert.equal(
|
|
160
|
-
await scope.service.outbox.markDispatched(event.id, first!.claimToken!),
|
|
161
|
-
undefined,
|
|
162
|
-
);
|
|
163
|
-
assert.equal(
|
|
164
|
-
await scope.service.outbox.release(event.id, first!.claimToken!, new Error('stale')),
|
|
165
|
-
undefined,
|
|
166
|
-
);
|
|
167
|
-
assert.equal(
|
|
168
|
-
(await scope.model.outboxEvent.getById(event.id))?.claimToken,
|
|
169
|
-
second?.claimToken,
|
|
170
|
-
);
|
|
171
|
-
await scope.service.outbox.markDispatched(event.id, second!.claimToken!);
|
|
172
|
-
const persisted = await scope.model.outboxEvent.getById(event.id);
|
|
173
|
-
assert.equal(persisted?.state, 'dispatched');
|
|
174
|
-
assert.equal(persisted?.claimToken, undefined);
|
|
175
|
-
} finally {
|
|
176
|
-
await cleanup(fixture);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('rolls back enqueued durable rows with the enclosing transaction', async () => {
|
|
182
|
-
await app.bean.executor.mockCtx(async () => {
|
|
183
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
184
|
-
try {
|
|
185
|
-
const scope = app.scope('a-pay');
|
|
186
|
-
const eventId = await assert.rejects(
|
|
187
|
-
app.ctx.db.transaction.begin(async () => {
|
|
188
|
-
const event = await scope.service.outbox.enqueue(
|
|
189
|
-
fixture.paymentSessionId!,
|
|
190
|
-
'payment.outcome.v1',
|
|
191
|
-
createOutcomePayload(fixture),
|
|
192
|
-
);
|
|
193
|
-
fixture.outboxEventIds.push(event.id as number);
|
|
194
|
-
throw new Error(String(event.id));
|
|
195
|
-
}),
|
|
196
|
-
/\d+/,
|
|
197
|
-
);
|
|
198
|
-
assert.equal(eventId, undefined);
|
|
199
|
-
const events = await scope.model.outboxEvent.select({
|
|
200
|
-
where: { paymentSessionId: fixture.paymentSessionId },
|
|
201
|
-
});
|
|
202
|
-
assert.deepEqual(events, []);
|
|
203
|
-
fixture.outboxEventIds = [];
|
|
204
|
-
} finally {
|
|
205
|
-
await cleanup(fixture);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
});
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
4
|
-
import { app } from 'vona-mock';
|
|
5
|
-
|
|
6
|
-
interface IFixture {
|
|
7
|
-
userId?: number;
|
|
8
|
-
paymentSessionId?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function createFixture(expiresAt: Date): Promise<IFixture> {
|
|
12
|
-
const scope = app.scope('a-pay');
|
|
13
|
-
const suffix = randomUUID().slice(0, 12);
|
|
14
|
-
const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
|
|
15
|
-
const session = await scope.service.paymentSession.create({
|
|
16
|
-
userId: user.id,
|
|
17
|
-
payScene: 'commerce-payment:commerceOrder',
|
|
18
|
-
businessReference: `business-${suffix}`,
|
|
19
|
-
amountMinor: 1299,
|
|
20
|
-
currency: 'USD',
|
|
21
|
-
correlationId: `payment-${suffix}`,
|
|
22
|
-
});
|
|
23
|
-
await scope.model.paymentSession.updateById(session.id, { expiresAt });
|
|
24
|
-
return { userId: user.id as number, paymentSessionId: session.id as number };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function cleanup(fixture: IFixture) {
|
|
28
|
-
const scope = app.scope('a-pay');
|
|
29
|
-
if (fixture.paymentSessionId !== undefined) {
|
|
30
|
-
await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
31
|
-
await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
|
|
32
|
-
}
|
|
33
|
-
if (fixture.userId !== undefined) {
|
|
34
|
-
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
35
|
-
await app.bean.user.removeById(fixture.userId);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe('paymentSession.test.ts', { concurrency: false }, () => {
|
|
40
|
-
it('derives provider facts and expiry from the payment scene', async () => {
|
|
41
|
-
await app.bean.executor.mockCtx(async () => {
|
|
42
|
-
const scope = app.scope('a-pay');
|
|
43
|
-
const suffix = randomUUID().slice(0, 12);
|
|
44
|
-
const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
|
|
45
|
-
const fixture: IFixture = { userId: user.id as number };
|
|
46
|
-
try {
|
|
47
|
-
const createdAt = Date.now();
|
|
48
|
-
const session = await scope.service.paymentSession.create({
|
|
49
|
-
userId: user.id,
|
|
50
|
-
payScene: 'commerce-payment:commerceOrder',
|
|
51
|
-
businessReference: `business-${suffix}`,
|
|
52
|
-
amountMinor: 1299,
|
|
53
|
-
currency: 'USD',
|
|
54
|
-
correlationId: `payment-${suffix}`,
|
|
55
|
-
});
|
|
56
|
-
fixture.paymentSessionId = session.id as number;
|
|
57
|
-
assert.equal(session.providerName, 'pay-mock:mock');
|
|
58
|
-
assert.equal(session.clientName, 'default');
|
|
59
|
-
assert.equal(session.environment, 'sandbox');
|
|
60
|
-
assert.ok(session.expiresAt.getTime() >= createdAt + 30 * 60 * 1000);
|
|
61
|
-
assert.ok(session.expiresAt.getTime() <= Date.now() + 30 * 60 * 1000);
|
|
62
|
-
} finally {
|
|
63
|
-
await cleanup(fixture);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('rejects a currency outside the payment scene policy', async () => {
|
|
69
|
-
await app.bean.executor.mockCtx(async () => {
|
|
70
|
-
const scope = app.scope('a-pay');
|
|
71
|
-
const suffix = randomUUID().slice(0, 12);
|
|
72
|
-
const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
|
|
73
|
-
const fixture: IFixture = { userId: user.id as number };
|
|
74
|
-
try {
|
|
75
|
-
await assert.rejects(
|
|
76
|
-
scope.service.paymentSession.create({
|
|
77
|
-
userId: user.id,
|
|
78
|
-
payScene: 'commerce-payment:commerceOrder',
|
|
79
|
-
businessReference: `business-${suffix}`,
|
|
80
|
-
amountMinor: 1299,
|
|
81
|
-
currency: 'EUR',
|
|
82
|
-
correlationId: `payment-${suffix}`,
|
|
83
|
-
}),
|
|
84
|
-
{ status: 422 },
|
|
85
|
-
);
|
|
86
|
-
assert.deepEqual(
|
|
87
|
-
await scope.model.paymentSession.select({
|
|
88
|
-
where: { correlationId: `payment-${suffix}` },
|
|
89
|
-
}),
|
|
90
|
-
[],
|
|
91
|
-
);
|
|
92
|
-
} finally {
|
|
93
|
-
await cleanup(fixture);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('starts only an unexpired created payment session', async () => {
|
|
99
|
-
await app.bean.executor.mockCtx(async () => {
|
|
100
|
-
const fixture = await createFixture(new Date(Date.now() + 60_000));
|
|
101
|
-
try {
|
|
102
|
-
const scope = app.scope('a-pay');
|
|
103
|
-
const session = await scope.service.paymentSession.start(fixture.paymentSessionId!);
|
|
104
|
-
assert.equal(session.state, 'requires_action');
|
|
105
|
-
await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
|
|
106
|
-
status: 409,
|
|
107
|
-
});
|
|
108
|
-
} finally {
|
|
109
|
-
await cleanup(fixture);
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('does not start an expired payment session', async () => {
|
|
115
|
-
await app.bean.executor.mockCtx(async () => {
|
|
116
|
-
const fixture = await createFixture(new Date(Date.now() - 1));
|
|
117
|
-
try {
|
|
118
|
-
const scope = app.scope('a-pay');
|
|
119
|
-
await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
|
|
120
|
-
status: 409,
|
|
121
|
-
});
|
|
122
|
-
assert.equal(
|
|
123
|
-
(await scope.model.paymentSession.getById(fixture.paymentSessionId!))?.state,
|
|
124
|
-
'created',
|
|
125
|
-
);
|
|
126
|
-
} finally {
|
|
127
|
-
await cleanup(fixture);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('allows only one competing start to call the provider', async () => {
|
|
133
|
-
const fixture = await app.bean.executor.mockCtx(async () => {
|
|
134
|
-
return await createFixture(new Date(Date.now() + 60_000));
|
|
135
|
-
});
|
|
136
|
-
try {
|
|
137
|
-
const results = await Promise.allSettled([
|
|
138
|
-
app.bean.executor.mockCtx(async () => {
|
|
139
|
-
return await app.scope('a-pay').service.paymentSession.start(fixture.paymentSessionId!);
|
|
140
|
-
}),
|
|
141
|
-
app.bean.executor.mockCtx(async () => {
|
|
142
|
-
return await app.scope('a-pay').service.paymentSession.start(fixture.paymentSessionId!);
|
|
143
|
-
}),
|
|
144
|
-
]);
|
|
145
|
-
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
|
146
|
-
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
|
147
|
-
} finally {
|
|
148
|
-
await app.bean.executor.mockCtx(async () => {
|
|
149
|
-
await cleanup(fixture);
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
});
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert';
|
|
2
|
-
import { createHmac, randomUUID } from 'node:crypto';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
4
|
-
import { app } from 'vona-mock';
|
|
5
|
-
|
|
6
|
-
interface IFixture {
|
|
7
|
-
userId?: number;
|
|
8
|
-
paymentSessionId?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function createFixture(suffix: string): Promise<IFixture> {
|
|
12
|
-
const scope = app.scope('a-pay');
|
|
13
|
-
const user = await app.bean.user.register({ name: `pay-webhook-${suffix}` }, true);
|
|
14
|
-
const session = await scope.service.paymentSession.create({
|
|
15
|
-
userId: user.id,
|
|
16
|
-
payScene: 'commerce-payment:commerceOrder',
|
|
17
|
-
businessReference: `business-${suffix}`,
|
|
18
|
-
amountMinor: 1299,
|
|
19
|
-
currency: 'USD',
|
|
20
|
-
correlationId: `payment-${suffix}`,
|
|
21
|
-
});
|
|
22
|
-
return { userId: user.id as number, paymentSessionId: session.id as number };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function cleanup(fixture: IFixture) {
|
|
26
|
-
const scope = app.scope('a-pay');
|
|
27
|
-
if (fixture.paymentSessionId !== undefined) {
|
|
28
|
-
await scope.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
29
|
-
await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
30
|
-
await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
|
|
31
|
-
await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
|
|
32
|
-
}
|
|
33
|
-
if (fixture.userId !== undefined) {
|
|
34
|
-
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
35
|
-
await app.bean.user.removeById(fixture.userId);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function createRawBody(
|
|
40
|
-
fixture: IFixture,
|
|
41
|
-
suffix: string,
|
|
42
|
-
state: 'succeeded' | 'failed' | 'cancelled' = 'succeeded',
|
|
43
|
-
options?: { eventId?: string; providerPaymentId?: string; providerCaptureId?: string },
|
|
44
|
-
) {
|
|
45
|
-
return JSON.stringify({
|
|
46
|
-
state,
|
|
47
|
-
amountMinor: 1299,
|
|
48
|
-
eventType: `payment.${state}`,
|
|
49
|
-
paymentSessionId: String(fixture.paymentSessionId),
|
|
50
|
-
eventId: options?.eventId ?? `mock-${suffix}`,
|
|
51
|
-
providerPaymentId: options?.providerPaymentId ?? `payment-${suffix}`,
|
|
52
|
-
providerCaptureId: options?.providerCaptureId ?? `capture-${suffix}`,
|
|
53
|
-
currency: 'USD',
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function createCommand(rawBody: string) {
|
|
58
|
-
const body = JSON.parse(rawBody);
|
|
59
|
-
return {
|
|
60
|
-
providerName: 'pay-mock:mock',
|
|
61
|
-
clientName: 'default',
|
|
62
|
-
environment: 'sandbox' as const,
|
|
63
|
-
rawBody,
|
|
64
|
-
verified: {
|
|
65
|
-
eventId: body.eventId,
|
|
66
|
-
eventType: body.eventType,
|
|
67
|
-
paymentSessionId: body.paymentSessionId,
|
|
68
|
-
payment: {
|
|
69
|
-
state: body.state,
|
|
70
|
-
providerPaymentId: body.providerPaymentId,
|
|
71
|
-
providerCaptureId: body.providerCaptureId,
|
|
72
|
-
},
|
|
73
|
-
summary: {
|
|
74
|
-
amountMinor: body.amountMinor,
|
|
75
|
-
currency: body.currency,
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sign(rawBody: string, clientName: 'default' | 'secondary' = 'default') {
|
|
82
|
-
const options = app.bean.payProvider.getOptions('pay-mock:mock', clientName);
|
|
83
|
-
assert.equal(typeof options.secretWebhook, 'string');
|
|
84
|
-
return createHmac('sha256', options.secretWebhook).update(rawBody).digest('hex');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function webhookUrl(providerName = 'pay-mock:mock', clientName = 'default') {
|
|
88
|
-
const url = app.util.getAbsoluteUrlByApiPath(`/pay/webhook/${providerName}/${clientName}`);
|
|
89
|
-
return url.startsWith('http') ? url : `http://127.0.0.1:${app.config.server.listen.port}${url}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function countFacts(paymentSessionId: number) {
|
|
93
|
-
const scope = app.scope('a-pay');
|
|
94
|
-
const [inboxes, audits, outbox] = await Promise.all([
|
|
95
|
-
scope.model.webhookInbox.select({ where: { paymentSessionId } }),
|
|
96
|
-
scope.model.paymentAudit.select({ where: { paymentSessionId } }),
|
|
97
|
-
scope.model.outboxEvent.select({ where: { paymentSessionId } }),
|
|
98
|
-
]);
|
|
99
|
-
return { inboxes, audits, outbox };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
|
|
103
|
-
it('accepts a raw-body signed HTTP webhook and rejects an invalid signature without mutation', async () => {
|
|
104
|
-
const fixture: IFixture = {};
|
|
105
|
-
try {
|
|
106
|
-
await app.bean.executor.mockCtx(async () => {
|
|
107
|
-
Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
|
|
108
|
-
});
|
|
109
|
-
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
110
|
-
const url = webhookUrl();
|
|
111
|
-
const invalid = await fetch(url, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: {
|
|
114
|
-
'Content-Type': 'application/json',
|
|
115
|
-
'x-pay-mock-signature': 'invalid',
|
|
116
|
-
},
|
|
117
|
-
body: rawBody,
|
|
118
|
-
});
|
|
119
|
-
assert.equal(invalid.status, 401, await invalid.text());
|
|
120
|
-
await app.bean.executor.mockCtx(async () => {
|
|
121
|
-
assert.deepEqual(await countFacts(fixture.paymentSessionId!), {
|
|
122
|
-
inboxes: [],
|
|
123
|
-
audits: [],
|
|
124
|
-
outbox: [],
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
const valid = await fetch(url, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: {
|
|
130
|
-
'Content-Type': 'application/json',
|
|
131
|
-
'x-pay-mock-signature': sign(rawBody),
|
|
132
|
-
},
|
|
133
|
-
body: rawBody,
|
|
134
|
-
});
|
|
135
|
-
assert.equal(valid.status, 200);
|
|
136
|
-
await app.bean.executor.mockCtx(async () => {
|
|
137
|
-
const session = await app
|
|
138
|
-
.scope('a-pay')
|
|
139
|
-
.model.paymentSession.getById(fixture.paymentSessionId!);
|
|
140
|
-
const facts = await countFacts(fixture.paymentSessionId!);
|
|
141
|
-
assert.equal(session?.state, 'succeeded');
|
|
142
|
-
assert.deepEqual(
|
|
143
|
-
[facts.inboxes.length, facts.audits.length, facts.outbox.length],
|
|
144
|
-
[1, 1, 1],
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
} finally {
|
|
148
|
-
await app.bean.executor.mockCtx(async () => await cleanup(fixture));
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('uses the route client webhook secret and preserves session client binding', async () => {
|
|
153
|
-
const fixture: IFixture = {};
|
|
154
|
-
try {
|
|
155
|
-
await app.bean.executor.mockCtx(async () => {
|
|
156
|
-
Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
|
|
157
|
-
});
|
|
158
|
-
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
159
|
-
const url = webhookUrl('pay-mock:mock', 'secondary');
|
|
160
|
-
const wrongClient = await fetch(url, {
|
|
161
|
-
method: 'POST',
|
|
162
|
-
headers: {
|
|
163
|
-
'Content-Type': 'application/json',
|
|
164
|
-
'x-pay-mock-signature': sign(rawBody, 'default'),
|
|
165
|
-
},
|
|
166
|
-
body: rawBody,
|
|
167
|
-
});
|
|
168
|
-
assert.equal(wrongClient.status, 401, await wrongClient.text());
|
|
169
|
-
const selectedClient = await fetch(url, {
|
|
170
|
-
method: 'POST',
|
|
171
|
-
headers: {
|
|
172
|
-
'Content-Type': 'application/json',
|
|
173
|
-
'x-pay-mock-signature': sign(rawBody, 'secondary'),
|
|
174
|
-
},
|
|
175
|
-
body: rawBody,
|
|
176
|
-
});
|
|
177
|
-
assert.equal(selectedClient.status, 409, await selectedClient.text());
|
|
178
|
-
await app.bean.executor.mockCtx(async () => {
|
|
179
|
-
assert.deepEqual(await countFacts(fixture.paymentSessionId!), {
|
|
180
|
-
inboxes: [],
|
|
181
|
-
audits: [],
|
|
182
|
-
outbox: [],
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
} finally {
|
|
186
|
-
await app.bean.executor.mockCtx(async () => await cleanup(fixture));
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('persists one verified payment fact and one logical outbox event across replay', async () => {
|
|
191
|
-
await app.bean.executor.mockCtx(async () => {
|
|
192
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
193
|
-
try {
|
|
194
|
-
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
195
|
-
const first = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
|
|
196
|
-
const replay = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
|
|
197
|
-
assert.equal(replay.id, first.id);
|
|
198
|
-
assert.equal(replay.state, 'processed');
|
|
199
|
-
const session = await app
|
|
200
|
-
.scope('a-pay')
|
|
201
|
-
.model.paymentSession.getById(fixture.paymentSessionId!);
|
|
202
|
-
const facts = await countFacts(fixture.paymentSessionId!);
|
|
203
|
-
assert.equal(session?.state, 'succeeded');
|
|
204
|
-
assert.deepEqual(
|
|
205
|
-
[facts.inboxes.length, facts.audits.length, facts.outbox.length],
|
|
206
|
-
[1, 1, 1],
|
|
207
|
-
);
|
|
208
|
-
assert.equal(facts.outbox[0]?.eventType, 'payment.outcome.v1');
|
|
209
|
-
} finally {
|
|
210
|
-
await cleanup(fixture);
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('converges concurrent duplicate and out-of-order terminal webhooks on the first terminal state', async () => {
|
|
216
|
-
const fixture: IFixture = {};
|
|
217
|
-
try {
|
|
218
|
-
await app.bean.executor.mockCtx(async () => {
|
|
219
|
-
Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
|
|
220
|
-
});
|
|
221
|
-
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
222
|
-
const receive = async () => {
|
|
223
|
-
return await app.bean.executor.mockCtx(async () => {
|
|
224
|
-
return await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
|
|
225
|
-
});
|
|
226
|
-
};
|
|
227
|
-
const results = await Promise.allSettled([receive(), receive()]);
|
|
228
|
-
assert.equal(
|
|
229
|
-
results.filter(result => result.status === 'fulfilled').length,
|
|
230
|
-
2,
|
|
231
|
-
JSON.stringify(results),
|
|
232
|
-
);
|
|
233
|
-
await app.bean.executor.mockCtx(async () => {
|
|
234
|
-
const failedRawBody = createRawBody(fixture, randomUUID().slice(0, 12), 'failed');
|
|
235
|
-
const ignored = await app
|
|
236
|
-
.scope('a-pay')
|
|
237
|
-
.service.webhook.receive(createCommand(failedRawBody));
|
|
238
|
-
const session = await app
|
|
239
|
-
.scope('a-pay')
|
|
240
|
-
.model.paymentSession.getById(fixture.paymentSessionId!);
|
|
241
|
-
const facts = await countFacts(fixture.paymentSessionId!);
|
|
242
|
-
assert.equal(session?.state, 'succeeded');
|
|
243
|
-
assert.equal(ignored.state, 'processed');
|
|
244
|
-
assert.match(ignored.errorSummary ?? '', /ignored after succeeded/);
|
|
245
|
-
assert.deepEqual(
|
|
246
|
-
[facts.inboxes.length, facts.audits.length, facts.outbox.length],
|
|
247
|
-
[2, 1, 1],
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
} finally {
|
|
251
|
-
await app.bean.executor.mockCtx(async () => await cleanup(fixture));
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('rejects a same-state webhook with conflicting provider facts', async () => {
|
|
256
|
-
await app.bean.executor.mockCtx(async () => {
|
|
257
|
-
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
258
|
-
try {
|
|
259
|
-
const first = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
260
|
-
await app.scope('a-pay').service.webhook.receive(createCommand(first));
|
|
261
|
-
const conflict = createRawBody(fixture, randomUUID().slice(0, 12), 'succeeded', {
|
|
262
|
-
providerCaptureId: `other-capture-${randomUUID().slice(0, 12)}`,
|
|
263
|
-
});
|
|
264
|
-
await assert.rejects(
|
|
265
|
-
app.scope('a-pay').service.webhook.receive(createCommand(conflict)),
|
|
266
|
-
(error: any) => error.code === 409,
|
|
267
|
-
);
|
|
268
|
-
const facts = await countFacts(fixture.paymentSessionId!);
|
|
269
|
-
assert.deepEqual(
|
|
270
|
-
[facts.inboxes.length, facts.audits.length, facts.outbox.length],
|
|
271
|
-
[1, 1, 1],
|
|
272
|
-
);
|
|
273
|
-
} finally {
|
|
274
|
-
await cleanup(fixture);
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
});
|