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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
|
|
3
|
+
import { BeanBase } from 'vona';
|
|
4
|
+
import { Service } from 'vona-module-a-bean';
|
|
5
|
+
import { Core } from 'vona-module-a-core';
|
|
6
|
+
|
|
7
|
+
import type { EntityRefundOperation } from '../entity/refundOperation.tsx';
|
|
8
|
+
import type { IPayProviderRefundSnapshot } from '../types/payment.ts';
|
|
9
|
+
|
|
10
|
+
export interface IRefundOperationCreateCommand {
|
|
11
|
+
paymentSessionId: TableIdentity;
|
|
12
|
+
businessReference: string;
|
|
13
|
+
amountMinor: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
idempotencyKey: string;
|
|
16
|
+
correlationId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Service()
|
|
20
|
+
export class ServiceRefundOperation extends BeanBase {
|
|
21
|
+
@Core.transaction()
|
|
22
|
+
async create(command: IRefundOperationCreateCommand): Promise<EntityRefundOperation> {
|
|
23
|
+
if (!Number.isSafeInteger(command.amountMinor) || command.amountMinor <= 0) {
|
|
24
|
+
this.app.throw(422, 'refund amount is invalid');
|
|
25
|
+
}
|
|
26
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(
|
|
27
|
+
command.paymentSessionId,
|
|
28
|
+
);
|
|
29
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
30
|
+
if (session.state !== 'succeeded' || !session.providerCaptureId) {
|
|
31
|
+
this.app.throw(409, 'payment session is not refundable');
|
|
32
|
+
}
|
|
33
|
+
if (session.currency !== command.currency) this.app.throw(409, 'refund currency conflicts');
|
|
34
|
+
const scene = this.bean.payScene.getOptions(session.payScene as never);
|
|
35
|
+
if (!scene.refund?.enabled) this.app.throw(409, 'payment scene refunds are disabled');
|
|
36
|
+
if (!scene.refund.allowPartial && command.amountMinor !== session.amountMinor) {
|
|
37
|
+
this.app.throw(422, 'payment scene does not allow partial refunds');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const existing = await this.scope.model.refundOperation.getForUpdate({
|
|
41
|
+
paymentSessionId: session.id,
|
|
42
|
+
idempotencyKey: command.idempotencyKey,
|
|
43
|
+
});
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (
|
|
46
|
+
existing.amountMinor !== command.amountMinor ||
|
|
47
|
+
existing.currency !== command.currency ||
|
|
48
|
+
existing.businessReference !== command.businessReference
|
|
49
|
+
) {
|
|
50
|
+
this.app.throw(409, 'refund idempotency key conflicts with an existing operation');
|
|
51
|
+
}
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
const operations = await this.scope.model.refundOperation.select({
|
|
55
|
+
where: { paymentSessionId: session.id },
|
|
56
|
+
});
|
|
57
|
+
const committedAmount = operations
|
|
58
|
+
.filter(item => ['created', 'submitting', 'pending', 'succeeded'].includes(item.state))
|
|
59
|
+
.reduce((total, item) => total + item.amountMinor, 0);
|
|
60
|
+
if (committedAmount + command.amountMinor > session.amountMinor) {
|
|
61
|
+
this.app.throw(422, 'refund amount exceeds the remaining captured amount');
|
|
62
|
+
}
|
|
63
|
+
const refund = await this.scope.model.refundOperation.insert({
|
|
64
|
+
paymentSessionId: session.id,
|
|
65
|
+
businessReference: command.businessReference,
|
|
66
|
+
amountMinor: command.amountMinor,
|
|
67
|
+
currency: command.currency,
|
|
68
|
+
state: 'created',
|
|
69
|
+
idempotencyKey: command.idempotencyKey,
|
|
70
|
+
});
|
|
71
|
+
await this.scope.model.providerOperation.insert({
|
|
72
|
+
paymentSessionId: session.id,
|
|
73
|
+
refundOperationId: refund.id,
|
|
74
|
+
kind: 'refund',
|
|
75
|
+
state: 'created',
|
|
76
|
+
idempotencyKey: `${command.idempotencyKey}:provider`,
|
|
77
|
+
correlationId: command.correlationId,
|
|
78
|
+
attemptCount: 0,
|
|
79
|
+
nextAttemptAt: new Date(Date.now() - 1_000),
|
|
80
|
+
});
|
|
81
|
+
return refund;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async submit(refundOperationId: TableIdentity) {
|
|
85
|
+
const operation = await this.scope.model.providerOperation.get({
|
|
86
|
+
refundOperationId,
|
|
87
|
+
kind: 'refund',
|
|
88
|
+
});
|
|
89
|
+
if (!operation) this.app.throw(404, 'refund provider operation not found');
|
|
90
|
+
await this.scope.service.providerOperation.execute(operation.id);
|
|
91
|
+
return await this.scope.model.refundOperation.getById(refundOperationId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Core.transaction()
|
|
95
|
+
async settleProviderSnapshot(
|
|
96
|
+
providerOperationId: TableIdentity,
|
|
97
|
+
claimToken: string,
|
|
98
|
+
snapshot: IPayProviderRefundSnapshot,
|
|
99
|
+
) {
|
|
100
|
+
const providerOperation =
|
|
101
|
+
await this.scope.model.providerOperation.getByIdForUpdate(providerOperationId);
|
|
102
|
+
if (
|
|
103
|
+
!providerOperation ||
|
|
104
|
+
providerOperation.kind !== 'refund' ||
|
|
105
|
+
!['claimed', 'submitted'].includes(providerOperation.state) ||
|
|
106
|
+
providerOperation.claimToken !== claimToken ||
|
|
107
|
+
!providerOperation.refundOperationId
|
|
108
|
+
) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const refund = await this.scope.model.refundOperation.getByIdForUpdate(
|
|
112
|
+
providerOperation.refundOperationId,
|
|
113
|
+
);
|
|
114
|
+
if (!refund) this.app.throw(404, 'refund operation not found');
|
|
115
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(refund.paymentSessionId);
|
|
116
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
117
|
+
if (
|
|
118
|
+
refund.providerRefundId &&
|
|
119
|
+
snapshot.providerRefundId &&
|
|
120
|
+
refund.providerRefundId !== snapshot.providerRefundId
|
|
121
|
+
) {
|
|
122
|
+
this.app.throw(409, 'provider refund identifier conflicts with the refund operation');
|
|
123
|
+
}
|
|
124
|
+
if (['succeeded', 'failed', 'cancelled'].includes(refund.state)) {
|
|
125
|
+
await this._completeProviderOperation(providerOperation, snapshot.providerRefundId);
|
|
126
|
+
return refund;
|
|
127
|
+
}
|
|
128
|
+
const finalizedAt = isTerminalRefundState(snapshot.state) ? new Date() : undefined;
|
|
129
|
+
await this.scope.model.refundOperation.updateById(refund.id, {
|
|
130
|
+
state: snapshot.state,
|
|
131
|
+
providerRefundId: snapshot.providerRefundId,
|
|
132
|
+
finalizedAt,
|
|
133
|
+
});
|
|
134
|
+
if (finalizedAt) {
|
|
135
|
+
await this.scope.service.outbox.enqueue(session.id, 'refund.outcome.v1', {
|
|
136
|
+
eventId: `${providerOperation.correlationId}:refund`,
|
|
137
|
+
paymentSessionId: session.id,
|
|
138
|
+
refundOperationId: refund.id,
|
|
139
|
+
businessReference: refund.businessReference,
|
|
140
|
+
providerName: session.providerName,
|
|
141
|
+
state: snapshot.state as 'succeeded' | 'failed' | 'cancelled',
|
|
142
|
+
providerRefundId: snapshot.providerRefundId,
|
|
143
|
+
amountMinor: refund.amountMinor,
|
|
144
|
+
currency: refund.currency,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
await this._completeProviderOperation(providerOperation, snapshot.providerRefundId);
|
|
148
|
+
return { ...refund, ...snapshot, finalizedAt };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@Core.transaction()
|
|
152
|
+
async transitionFromWebhook(
|
|
153
|
+
refundOperationId: TableIdentity,
|
|
154
|
+
options: {
|
|
155
|
+
eventId: string;
|
|
156
|
+
state: IPayProviderRefundSnapshot['state'];
|
|
157
|
+
providerRefundId?: string;
|
|
158
|
+
},
|
|
159
|
+
) {
|
|
160
|
+
const refund = await this.scope.model.refundOperation.getByIdForUpdate(refundOperationId);
|
|
161
|
+
if (!refund) this.app.throw(404, 'refund operation not found');
|
|
162
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(refund.paymentSessionId);
|
|
163
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
164
|
+
if (
|
|
165
|
+
refund.providerRefundId &&
|
|
166
|
+
options.providerRefundId &&
|
|
167
|
+
refund.providerRefundId !== options.providerRefundId
|
|
168
|
+
) {
|
|
169
|
+
this.app.throw(409, 'provider refund identifier conflicts with the refund operation');
|
|
170
|
+
}
|
|
171
|
+
if (refund.state === options.state) return { refund, changed: false };
|
|
172
|
+
if (['succeeded', 'failed', 'cancelled'].includes(refund.state)) {
|
|
173
|
+
return { refund, changed: false, ignoredReason: 'refund operation is already finalized' };
|
|
174
|
+
}
|
|
175
|
+
const finalizedAt = isTerminalRefundState(options.state) ? new Date() : undefined;
|
|
176
|
+
await this.scope.model.refundOperation.updateById(refund.id, {
|
|
177
|
+
state: options.state as 'succeeded' | 'failed' | 'cancelled',
|
|
178
|
+
providerRefundId: options.providerRefundId,
|
|
179
|
+
finalizedAt,
|
|
180
|
+
});
|
|
181
|
+
if (finalizedAt) {
|
|
182
|
+
await this.scope.service.outbox.enqueue(session.id, 'refund.outcome.v1', {
|
|
183
|
+
eventId: options.eventId,
|
|
184
|
+
paymentSessionId: session.id,
|
|
185
|
+
refundOperationId: refund.id,
|
|
186
|
+
businessReference: refund.businessReference,
|
|
187
|
+
providerName: session.providerName,
|
|
188
|
+
state: options.state as 'succeeded' | 'failed' | 'cancelled',
|
|
189
|
+
providerRefundId: options.providerRefundId,
|
|
190
|
+
amountMinor: refund.amountMinor,
|
|
191
|
+
currency: refund.currency,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
refund: {
|
|
196
|
+
...refund,
|
|
197
|
+
state: options.state,
|
|
198
|
+
providerRefundId: options.providerRefundId,
|
|
199
|
+
finalizedAt,
|
|
200
|
+
},
|
|
201
|
+
changed: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async _completeProviderOperation(
|
|
206
|
+
providerOperation: { id: TableIdentity },
|
|
207
|
+
providerResourceId?: string,
|
|
208
|
+
) {
|
|
209
|
+
await this.scope.model.providerOperation.updateById(providerOperation.id, {
|
|
210
|
+
state: 'succeeded',
|
|
211
|
+
providerResourceId,
|
|
212
|
+
claimToken: undefined,
|
|
213
|
+
claimExpiresAt: undefined,
|
|
214
|
+
nextAttemptAt: undefined,
|
|
215
|
+
finalizedAt: new Date(),
|
|
216
|
+
errorCode: undefined,
|
|
217
|
+
errorSummary: undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isTerminalRefundState(
|
|
223
|
+
state: IPayProviderRefundSnapshot['state'],
|
|
224
|
+
): state is 'succeeded' | 'failed' | 'cancelled' {
|
|
225
|
+
return ['succeeded', 'failed', 'cancelled'].includes(state);
|
|
226
|
+
}
|
|
@@ -5,8 +5,8 @@ import { Core } from 'vona-module-a-core';
|
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
7
|
IPayProviderVerifiedWebhook,
|
|
8
|
-
IPaymentOutcomeEvent,
|
|
9
8
|
TypePaymentSessionState,
|
|
9
|
+
TypeRefundOperationState,
|
|
10
10
|
} from '../types/payment.ts';
|
|
11
11
|
|
|
12
12
|
export interface IWebhookReceiveCommand {
|
|
@@ -29,53 +29,49 @@ export class ServiceWebhook extends BeanBase {
|
|
|
29
29
|
@Core.transaction()
|
|
30
30
|
async receiveLocked(command: IWebhookReceiveCommand) {
|
|
31
31
|
const payment = command.verified.payment;
|
|
32
|
-
const
|
|
32
|
+
const refund = command.verified.refund;
|
|
33
|
+
if (!!payment === !!refund)
|
|
34
|
+
this.app.throw(400, 'webhook must contain exactly one payment or refund');
|
|
33
35
|
const amountMinor = command.verified.summary?.amountMinor;
|
|
34
36
|
const currency = command.verified.summary?.currency;
|
|
35
|
-
const paymentState = payment?.state as
|
|
36
|
-
| Extract<TypePaymentSessionState, 'succeeded' | 'failed' | 'cancelled'>
|
|
37
|
-
| undefined;
|
|
38
37
|
if (
|
|
39
|
-
!payment ||
|
|
40
|
-
!paymentSessionId ||
|
|
41
|
-
!['succeeded', 'failed', 'cancelled'].includes(paymentState ?? '') ||
|
|
42
38
|
typeof amountMinor !== 'number' ||
|
|
43
39
|
!Number.isInteger(amountMinor) ||
|
|
44
40
|
amountMinor < 0 ||
|
|
45
41
|
typeof currency !== 'string' ||
|
|
46
42
|
currency.length !== 3
|
|
47
43
|
) {
|
|
48
|
-
this.app.throw(400, 'payment webhook is incomplete');
|
|
44
|
+
this.app.throw(400, 'payment webhook amount is incomplete');
|
|
49
45
|
}
|
|
46
|
+
if (payment) {
|
|
47
|
+
return await this._receivePayment(command, payment, amountMinor, currency);
|
|
48
|
+
}
|
|
49
|
+
return await this._receiveRefund(command, refund!, amountMinor, currency);
|
|
50
|
+
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
private async _receivePayment(
|
|
53
|
+
command: IWebhookReceiveCommand,
|
|
54
|
+
payment: NonNullable<IPayProviderVerifiedWebhook['payment']>,
|
|
55
|
+
amountMinor: number,
|
|
56
|
+
currency: string,
|
|
57
|
+
) {
|
|
58
|
+
const paymentSessionId = command.verified.paymentSessionId;
|
|
59
|
+
const paymentState = payment.state as Extract<
|
|
60
|
+
TypePaymentSessionState,
|
|
61
|
+
'succeeded' | 'failed' | 'cancelled'
|
|
62
|
+
>;
|
|
53
63
|
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
session.amountMinor !== amountMinor ||
|
|
58
|
-
session.currency !== currency
|
|
64
|
+
!paymentSessionId ||
|
|
65
|
+
!['succeeded', 'failed', 'cancelled'].includes(paymentState) ||
|
|
66
|
+
command.verified.refundOperationId
|
|
59
67
|
) {
|
|
60
|
-
this.app.throw(
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const payloadHash = createHash('sha256')
|
|
64
|
-
.update(command.rawBody ?? JSON.stringify(command.verified))
|
|
65
|
-
.digest('hex');
|
|
66
|
-
const existing = await this.scope.model.webhookInbox.getForUpdate({
|
|
67
|
-
providerName: command.providerName,
|
|
68
|
-
clientName: command.clientName,
|
|
69
|
-
environment: command.environment,
|
|
70
|
-
providerEventId: command.verified.eventId,
|
|
71
|
-
});
|
|
72
|
-
if (existing) {
|
|
73
|
-
if (existing.payloadHash !== payloadHash) {
|
|
74
|
-
this.app.throw(409, 'payment webhook event conflicts with a prior payload');
|
|
75
|
-
}
|
|
76
|
-
return existing;
|
|
68
|
+
this.app.throw(400, 'payment webhook is incomplete');
|
|
77
69
|
}
|
|
78
|
-
|
|
70
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
|
|
71
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
72
|
+
this._assertSessionMatches(command, session, currency);
|
|
73
|
+
const existing = await this._getExistingInbox(command);
|
|
74
|
+
if (existing) return existing;
|
|
79
75
|
const inbox = await this.scope.model.webhookInbox.insert({
|
|
80
76
|
providerName: command.providerName,
|
|
81
77
|
clientName: command.clientName,
|
|
@@ -83,45 +79,147 @@ export class ServiceWebhook extends BeanBase {
|
|
|
83
79
|
providerEventId: command.verified.eventId,
|
|
84
80
|
eventType: command.verified.eventType,
|
|
85
81
|
paymentSessionId: session.id,
|
|
86
|
-
paymentState
|
|
82
|
+
paymentState,
|
|
87
83
|
amountMinor,
|
|
88
84
|
currency,
|
|
89
85
|
providerCaptureId: payment.providerCaptureId,
|
|
90
|
-
payloadHash,
|
|
86
|
+
payloadHash: this._payloadHash(command),
|
|
91
87
|
state: 'received',
|
|
92
88
|
retryCount: 0,
|
|
93
89
|
});
|
|
94
|
-
|
|
95
90
|
const transition = await this.scope.service.paymentSession.transitionFromWebhook(session.id, {
|
|
96
|
-
state: paymentState
|
|
91
|
+
state: paymentState,
|
|
97
92
|
providerPaymentId: payment.providerPaymentId,
|
|
98
93
|
providerCaptureId: payment.providerCaptureId,
|
|
99
94
|
webhookInboxId: inbox.id,
|
|
100
95
|
});
|
|
101
96
|
if (transition.changed) {
|
|
102
|
-
|
|
97
|
+
await this.scope.service.outbox.enqueue(session.id, 'payment.outcome.v1', {
|
|
103
98
|
eventId: command.verified.eventId,
|
|
104
99
|
paymentSessionId: session.id,
|
|
105
100
|
businessReference: session.businessReference,
|
|
106
101
|
providerName: session.providerName,
|
|
107
|
-
state: paymentState
|
|
102
|
+
state: paymentState,
|
|
108
103
|
providerCaptureId: payment.providerCaptureId,
|
|
109
104
|
amountMinor,
|
|
110
105
|
currency,
|
|
111
|
-
};
|
|
112
|
-
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return await this._markProcessed(inbox.id, transition.ignoredReason);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async _receiveRefund(
|
|
112
|
+
command: IWebhookReceiveCommand,
|
|
113
|
+
refund: NonNullable<IPayProviderVerifiedWebhook['refund']>,
|
|
114
|
+
amountMinor: number,
|
|
115
|
+
currency: string,
|
|
116
|
+
) {
|
|
117
|
+
const refundOperationId = command.verified.refundOperationId;
|
|
118
|
+
if (!refundOperationId || command.verified.paymentSessionId) {
|
|
119
|
+
this.app.throw(400, 'refund webhook is incomplete');
|
|
120
|
+
}
|
|
121
|
+
const refundOperation =
|
|
122
|
+
await this.scope.model.refundOperation.getByIdForUpdate(refundOperationId);
|
|
123
|
+
if (!refundOperation) this.app.throw(404, 'refund operation not found');
|
|
124
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(
|
|
125
|
+
refundOperation.paymentSessionId,
|
|
126
|
+
);
|
|
127
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
128
|
+
this._assertSessionMatches(command, session, currency, false);
|
|
129
|
+
if (refundOperation.amountMinor !== amountMinor || refundOperation.currency !== currency) {
|
|
130
|
+
this.app.throw(409, 'refund webhook conflicts with the refund operation');
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
refundOperation.providerRefundId &&
|
|
134
|
+
refund.providerRefundId &&
|
|
135
|
+
refundOperation.providerRefundId !== refund.providerRefundId
|
|
136
|
+
) {
|
|
137
|
+
this.app.throw(409, 'refund webhook provider identifier conflicts with the refund operation');
|
|
138
|
+
}
|
|
139
|
+
const existing = await this._getExistingInbox(command);
|
|
140
|
+
if (existing) return existing;
|
|
141
|
+
const inbox = await this.scope.model.webhookInbox.insert({
|
|
142
|
+
providerName: command.providerName,
|
|
143
|
+
clientName: command.clientName,
|
|
144
|
+
environment: command.environment,
|
|
145
|
+
providerEventId: command.verified.eventId,
|
|
146
|
+
eventType: command.verified.eventType,
|
|
147
|
+
paymentSessionId: session.id,
|
|
148
|
+
refundOperationId: refundOperation.id,
|
|
149
|
+
refundState: refund.state,
|
|
150
|
+
amountMinor,
|
|
151
|
+
currency,
|
|
152
|
+
providerCaptureId: session.providerCaptureId,
|
|
153
|
+
providerRefundId: refund.providerRefundId,
|
|
154
|
+
payloadHash: this._payloadHash(command),
|
|
155
|
+
state: 'received',
|
|
156
|
+
retryCount: 0,
|
|
157
|
+
});
|
|
158
|
+
const transition = await this.scope.service.refundOperation.transitionFromWebhook(
|
|
159
|
+
refundOperation.id,
|
|
160
|
+
{
|
|
161
|
+
eventId: command.verified.eventId,
|
|
162
|
+
state: refund.state as Extract<
|
|
163
|
+
TypeRefundOperationState,
|
|
164
|
+
'pending' | 'succeeded' | 'failed' | 'cancelled'
|
|
165
|
+
>,
|
|
166
|
+
providerRefundId: refund.providerRefundId,
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
return await this._markProcessed(inbox.id, transition.ignoredReason);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private _assertSessionMatches(
|
|
173
|
+
command: IWebhookReceiveCommand,
|
|
174
|
+
session: {
|
|
175
|
+
providerName: string;
|
|
176
|
+
clientName: string;
|
|
177
|
+
environment: string;
|
|
178
|
+
amountMinor: number;
|
|
179
|
+
currency: string;
|
|
180
|
+
},
|
|
181
|
+
currency: string,
|
|
182
|
+
assertAmount = true,
|
|
183
|
+
) {
|
|
184
|
+
if (
|
|
185
|
+
session.providerName !== command.providerName ||
|
|
186
|
+
session.clientName !== command.clientName ||
|
|
187
|
+
session.environment !== command.environment ||
|
|
188
|
+
(assertAmount && session.amountMinor !== command.verified.summary?.amountMinor) ||
|
|
189
|
+
session.currency !== currency
|
|
190
|
+
) {
|
|
191
|
+
this.app.throw(409, 'payment webhook conflicts with the payment session');
|
|
113
192
|
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async _getExistingInbox(command: IWebhookReceiveCommand) {
|
|
196
|
+
const existing = await this.scope.model.webhookInbox.getForUpdate({
|
|
197
|
+
providerName: command.providerName,
|
|
198
|
+
clientName: command.clientName,
|
|
199
|
+
environment: command.environment,
|
|
200
|
+
providerEventId: command.verified.eventId,
|
|
201
|
+
});
|
|
202
|
+
if (existing && existing.payloadHash !== this._payloadHash(command)) {
|
|
203
|
+
this.app.throw(409, 'payment webhook event conflicts with a prior payload');
|
|
204
|
+
}
|
|
205
|
+
return existing;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private _payloadHash(command: IWebhookReceiveCommand) {
|
|
209
|
+
return createHash('sha256')
|
|
210
|
+
.update(command.rawBody ?? JSON.stringify(command.verified))
|
|
211
|
+
.digest('hex');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async _markProcessed(inboxId: string | number, errorSummary?: string) {
|
|
114
215
|
const processedAt = new Date();
|
|
115
|
-
await this.scope.model.webhookInbox.updateById(
|
|
216
|
+
await this.scope.model.webhookInbox.updateById(inboxId, {
|
|
116
217
|
state: 'processed',
|
|
117
218
|
processedAt,
|
|
118
|
-
errorSummary
|
|
219
|
+
errorSummary,
|
|
119
220
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
processedAt,
|
|
124
|
-
errorSummary: transition.ignoredReason,
|
|
125
|
-
};
|
|
221
|
+
const inbox = await this.scope.model.webhookInbox.getById(inboxId);
|
|
222
|
+
if (!inbox) this.app.throw(500, 'persisted webhook inbox not found');
|
|
223
|
+
return inbox;
|
|
126
224
|
}
|
|
127
225
|
}
|
|
@@ -2,11 +2,12 @@ import type { TableIdentity } from 'table-identity';
|
|
|
2
2
|
import type { OmitNever, VonaContext } from 'vona';
|
|
3
3
|
import type { ServiceOnion } from 'vona-module-a-onion';
|
|
4
4
|
|
|
5
|
-
import type { IPaymentOutcomeEvent } from './payment.ts';
|
|
5
|
+
import type { IPaymentOutcomeEvent, IRefundOutcomeEvent } from './payment.ts';
|
|
6
6
|
import type { IPayProviderRecord } from './payProvider.ts';
|
|
7
7
|
|
|
8
8
|
export interface IPaySceneExecute {
|
|
9
9
|
onPaymentOutcome(event: IPaymentOutcomeEvent): Promise<void>;
|
|
10
|
+
onRefundOutcome(event: IRefundOutcomeEvent): Promise<void>;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface IPaySceneRecord {}
|
|
@@ -6,15 +6,15 @@ export * from '../service/payMock.ts';
|
|
|
6
6
|
|
|
7
7
|
import 'vona-module-a-bean';
|
|
8
8
|
declare module 'vona-module-a-bean' {
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
export interface IServiceRecord {
|
|
11
11
|
'pay-mock:payMock': never;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
}
|
|
16
16
|
declare module 'vona-module-pay-mock' {
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
export interface ServicePayMock {
|
|
19
19
|
/** @internal */
|
|
20
20
|
get scope(): ScopeModulePayMock;
|
|
@@ -23,8 +23,8 @@ declare module 'vona-module-pay-mock' {
|
|
|
23
23
|
export interface ServicePayMock {
|
|
24
24
|
get $beanFullName(): 'pay-mock.service.payMock';
|
|
25
25
|
get $onionName(): 'pay-mock:payMock';
|
|
26
|
-
|
|
27
|
-
}
|
|
26
|
+
|
|
27
|
+
}
|
|
28
28
|
}
|
|
29
29
|
/** service: end */
|
|
30
30
|
/** service: begin */
|
|
@@ -45,27 +45,35 @@ declare module 'vona' {
|
|
|
45
45
|
/** dto: begin */
|
|
46
46
|
export * from '../dto/mockPaymentComplete.tsx';
|
|
47
47
|
export * from '../dto/mockPaymentReceipt.tsx';
|
|
48
|
+
export * from '../dto/mockRefundComplete.tsx';
|
|
49
|
+
export * from '../dto/mockRefundReceipt.tsx';
|
|
48
50
|
import type { IDtoOptionsMockPaymentComplete } from '../dto/mockPaymentComplete.tsx';
|
|
49
51
|
import type { IDtoOptionsMockPaymentReceipt } from '../dto/mockPaymentReceipt.tsx';
|
|
52
|
+
import type { IDtoOptionsMockRefundComplete } from '../dto/mockRefundComplete.tsx';
|
|
53
|
+
import type { IDtoOptionsMockRefundReceipt } from '../dto/mockRefundReceipt.tsx';
|
|
50
54
|
import 'vona-module-a-web';
|
|
51
55
|
declare module 'vona-module-a-web' {
|
|
52
|
-
|
|
56
|
+
|
|
53
57
|
export interface IDtoRecord {
|
|
54
58
|
'pay-mock:mockPaymentComplete': IDtoOptionsMockPaymentComplete;
|
|
55
59
|
'pay-mock:mockPaymentReceipt': IDtoOptionsMockPaymentReceipt;
|
|
60
|
+
'pay-mock:mockRefundComplete': IDtoOptionsMockRefundComplete;
|
|
61
|
+
'pay-mock:mockRefundReceipt': IDtoOptionsMockRefundReceipt;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
|
|
59
65
|
}
|
|
60
66
|
declare module 'vona-module-pay-mock' {
|
|
61
|
-
|
|
67
|
+
|
|
62
68
|
}
|
|
63
69
|
/** dto: end */
|
|
64
70
|
/** dto: begin */
|
|
65
71
|
import type { DtoMockPaymentComplete } from '../dto/mockPaymentComplete.tsx';
|
|
66
72
|
import type { DtoMockPaymentReceipt } from '../dto/mockPaymentReceipt.tsx';
|
|
73
|
+
import type { DtoMockRefundComplete } from '../dto/mockRefundComplete.tsx';
|
|
74
|
+
import type { DtoMockRefundReceipt } from '../dto/mockRefundReceipt.tsx';
|
|
67
75
|
declare module 'vona-module-pay-mock' {
|
|
68
|
-
|
|
76
|
+
|
|
69
77
|
export interface IDtoOptionsMockPaymentComplete {
|
|
70
78
|
fields?: TypeEntityOptionsFields<DtoMockPaymentComplete, IDtoOptionsMockPaymentComplete[TypeSymbolKeyFieldsMore]>;
|
|
71
79
|
}
|
|
@@ -73,6 +81,14 @@ declare module 'vona-module-pay-mock' {
|
|
|
73
81
|
export interface IDtoOptionsMockPaymentReceipt {
|
|
74
82
|
fields?: TypeEntityOptionsFields<DtoMockPaymentReceipt, IDtoOptionsMockPaymentReceipt[TypeSymbolKeyFieldsMore]>;
|
|
75
83
|
}
|
|
84
|
+
|
|
85
|
+
export interface IDtoOptionsMockRefundComplete {
|
|
86
|
+
fields?: TypeEntityOptionsFields<DtoMockRefundComplete, IDtoOptionsMockRefundComplete[TypeSymbolKeyFieldsMore]>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface IDtoOptionsMockRefundReceipt {
|
|
90
|
+
fields?: TypeEntityOptionsFields<DtoMockRefundReceipt, IDtoOptionsMockRefundReceipt[TypeSymbolKeyFieldsMore]>;
|
|
91
|
+
}
|
|
76
92
|
}
|
|
77
93
|
/** dto: end */
|
|
78
94
|
/** controller: begin */
|
|
@@ -80,15 +96,15 @@ export * from '../controller/mockPayment.ts';
|
|
|
80
96
|
import type { IControllerOptionsMockPayment } from '../controller/mockPayment.ts';
|
|
81
97
|
import 'vona-module-a-web';
|
|
82
98
|
declare module 'vona-module-a-web' {
|
|
83
|
-
|
|
99
|
+
|
|
84
100
|
export interface IControllerRecord {
|
|
85
101
|
'pay-mock:mockPayment': IControllerOptionsMockPayment;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
|
|
89
105
|
}
|
|
90
106
|
declare module 'vona-module-pay-mock' {
|
|
91
|
-
|
|
107
|
+
|
|
92
108
|
export interface ControllerMockPayment {
|
|
93
109
|
/** @internal */
|
|
94
110
|
get scope(): ScopeModulePayMock;
|
|
@@ -98,14 +114,14 @@ declare module 'vona-module-pay-mock' {
|
|
|
98
114
|
get $beanFullName(): 'pay-mock.controller.mockPayment';
|
|
99
115
|
get $onionName(): 'pay-mock:mockPayment';
|
|
100
116
|
get $onionOptions(): IControllerOptionsMockPayment;
|
|
101
|
-
}
|
|
117
|
+
}
|
|
102
118
|
}
|
|
103
119
|
/** controller: end */
|
|
104
120
|
/** controller: begin */
|
|
105
121
|
// @ts-ignore ignore
|
|
106
122
|
import type { ControllerMockPayment } from '../controller/mockPayment.ts';
|
|
107
123
|
declare module 'vona-module-pay-mock' {
|
|
108
|
-
|
|
124
|
+
|
|
109
125
|
export interface IControllerOptionsMockPayment {
|
|
110
126
|
actions?: TypeControllerOptionsActions<ControllerMockPayment>;
|
|
111
127
|
}
|
|
@@ -113,6 +129,7 @@ declare module 'vona-module-pay-mock' {
|
|
|
113
129
|
declare module 'vona-module-a-web' {
|
|
114
130
|
export interface IApiPathPostRecord{
|
|
115
131
|
'/pay/mock/payment-session/:id/complete': undefined;
|
|
132
|
+
'/pay/mock/payment-session/refund-operation/:id/complete': undefined;
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
}
|
|
@@ -123,15 +140,15 @@ export * from '../bean/payProvider.mock.ts';
|
|
|
123
140
|
import type { IPayProviderOptionsMock } from '../bean/payProvider.mock.ts';
|
|
124
141
|
import 'vona-module-a-pay';
|
|
125
142
|
declare module 'vona-module-a-pay' {
|
|
126
|
-
|
|
143
|
+
|
|
127
144
|
export interface IPayProviderRecord {
|
|
128
145
|
'pay-mock:mock': IPayProviderOptionsMock;
|
|
129
146
|
}
|
|
130
147
|
|
|
131
|
-
|
|
148
|
+
|
|
132
149
|
}
|
|
133
150
|
declare module 'vona-module-pay-mock' {
|
|
134
|
-
|
|
151
|
+
|
|
135
152
|
export interface PayProviderMock {
|
|
136
153
|
/** @internal */
|
|
137
154
|
get scope(): ScopeModulePayMock;
|
|
@@ -141,7 +158,7 @@ declare module 'vona-module-pay-mock' {
|
|
|
141
158
|
get $beanFullName(): 'pay-mock.payProvider.mock';
|
|
142
159
|
get $onionName(): 'pay-mock:mock';
|
|
143
160
|
get $onionOptions(): IPayProviderOptionsMock;
|
|
144
|
-
}
|
|
161
|
+
}
|
|
145
162
|
}
|
|
146
163
|
/** payProvider: end */
|
|
147
164
|
/** payProvider: begin */
|
|
@@ -172,11 +189,11 @@ declare module 'vona' {
|
|
|
172
189
|
export interface IBeanScopeContainer {
|
|
173
190
|
payMock: ScopeModulePayMock;
|
|
174
191
|
}
|
|
192
|
+
|
|
193
|
+
|
|
175
194
|
|
|
195
|
+
|
|
176
196
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
|
|
181
198
|
}
|
|
182
199
|
/** scope: end */
|