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
|
@@ -12,7 +12,9 @@ import { $tableColumns } from 'vona-module-a-ormutils';
|
|
|
12
12
|
...$tableColumns('payPaymentSession', 'providerOrderId'),
|
|
13
13
|
...$tableColumns('payPaymentSession', 'providerCaptureId'),
|
|
14
14
|
...$tableColumns('payProviderOperation', 'paymentSessionId+kind'),
|
|
15
|
+
...$tableColumns('payProviderOperation', 'refundOperationId'),
|
|
15
16
|
...$tableColumns('payProviderOperation', 'state+nextAttemptAt'),
|
|
17
|
+
...$tableColumns('payProviderOperation', 'state+claimExpiresAt'),
|
|
16
18
|
...$tableColumns('payProviderOperation', 'idempotencyKey'),
|
|
17
19
|
...$tableColumns('payRefundOperation', 'paymentSessionId'),
|
|
18
20
|
...$tableColumns('payRefundOperation', 'state'),
|
|
@@ -21,6 +23,7 @@ import { $tableColumns } from 'vona-module-a-ormutils';
|
|
|
21
23
|
...$tableColumns('payWebhookInbox', 'paymentSessionId'),
|
|
22
24
|
...$tableColumns('payWebhookInbox', 'state'),
|
|
23
25
|
...$tableColumns('payPaymentAudit', 'paymentSessionId'),
|
|
26
|
+
...$tableColumns('payOutboxEvent', 'refundOperationId'),
|
|
24
27
|
...$tableColumns('payOutboxEvent', 'state+nextAttemptAt'),
|
|
25
28
|
...$tableColumns('payOutboxEvent', 'state+claimExpiresAt'),
|
|
26
29
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Meta } from 'vona-module-a-meta';
|
|
2
2
|
import { BeanRedlockBase } from 'vona-module-a-redlock';
|
|
3
3
|
|
|
4
|
-
export type TypeRedlockLockResource =
|
|
4
|
+
export type TypeRedlockLockResource =
|
|
5
|
+
| `pay.providerOperation.start.${string}`
|
|
6
|
+
| `pay.webhook.${string}`;
|
|
5
7
|
export type TypeRedlockLockIsolateResource = never;
|
|
6
8
|
|
|
7
9
|
@Meta()
|
|
@@ -33,6 +33,7 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
|
|
|
33
33
|
await this.bean.model.createTable(providerOperation.$table, table => {
|
|
34
34
|
table.basicFields();
|
|
35
35
|
table.tableIdentity(providerOperation.paymentSessionId);
|
|
36
|
+
table.tableIdentity(providerOperation.refundOperationId);
|
|
36
37
|
table.string(providerOperation.kind, 32);
|
|
37
38
|
table.string(providerOperation.state, 32);
|
|
38
39
|
table.string(providerOperation.idempotencyKey, 100);
|
|
@@ -41,6 +42,9 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
|
|
|
41
42
|
table.string(providerOperation.providerResourceId, 255);
|
|
42
43
|
table.integer(providerOperation.attemptCount);
|
|
43
44
|
table.dateTime(providerOperation.claimedAt);
|
|
45
|
+
table.string(providerOperation.claimToken, 100);
|
|
46
|
+
table.dateTime(providerOperation.claimExpiresAt);
|
|
47
|
+
table.dateTime(providerOperation.submittedAt);
|
|
44
48
|
table.dateTime(providerOperation.nextAttemptAt);
|
|
45
49
|
table.string(providerOperation.errorCode, 100);
|
|
46
50
|
table.string(providerOperation.errorSummary, 255);
|
|
@@ -71,9 +75,11 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
|
|
|
71
75
|
table.tableIdentity(webhookInbox.paymentSessionId);
|
|
72
76
|
table.tableIdentity(webhookInbox.refundOperationId);
|
|
73
77
|
table.string(webhookInbox.paymentState, 32);
|
|
78
|
+
table.string(webhookInbox.refundState, 32);
|
|
74
79
|
table.integer(webhookInbox.amountMinor);
|
|
75
80
|
table.string(webhookInbox.currency, 3);
|
|
76
81
|
table.string(webhookInbox.providerCaptureId, 255);
|
|
82
|
+
table.string(webhookInbox.providerRefundId, 255);
|
|
77
83
|
table.string(webhookInbox.payloadHash, 64);
|
|
78
84
|
table.string(webhookInbox.state, 32);
|
|
79
85
|
table.integer(webhookInbox.retryCount);
|
|
@@ -99,6 +105,7 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
|
|
|
99
105
|
table.basicFields();
|
|
100
106
|
table.string(outboxEvent.eventType, 100);
|
|
101
107
|
table.tableIdentity(outboxEvent.paymentSessionId);
|
|
108
|
+
table.tableIdentity(outboxEvent.refundOperationId);
|
|
102
109
|
table.json(outboxEvent.payload);
|
|
103
110
|
table.string(outboxEvent.state, 32);
|
|
104
111
|
table.integer(outboxEvent.attemptCount);
|
|
@@ -3,6 +3,8 @@ import type { IQueueExecute, IQueuePushOptions } from 'vona-module-a-queue';
|
|
|
3
3
|
|
|
4
4
|
import { BeanQueueBase, Queue } from 'vona-module-a-queue';
|
|
5
5
|
|
|
6
|
+
import type { IPaymentOutcomeEvent, IRefundOutcomeEvent } from '../types/payment.ts';
|
|
7
|
+
|
|
6
8
|
export interface IOutboxDispatchJobData {
|
|
7
9
|
outboxEventId: TableIdentity;
|
|
8
10
|
}
|
|
@@ -16,13 +18,16 @@ export class QueueOutboxDispatch
|
|
|
16
18
|
const event = await this.scope.service.outbox.claim(data.outboxEventId);
|
|
17
19
|
if (!event) return;
|
|
18
20
|
try {
|
|
19
|
-
if (event.eventType !== 'payment.outcome.v1') {
|
|
20
|
-
this.app.throw(409, `unsupported payment outbox event: ${event.eventType}`);
|
|
21
|
-
}
|
|
22
21
|
const session = await this.scope.model.paymentSession.getById(event.paymentSessionId);
|
|
23
22
|
if (!session) this.app.throw(404, 'payment session not found');
|
|
24
23
|
const payScene = this.bean.payScene.get(session.payScene as never);
|
|
25
|
-
|
|
24
|
+
if (event.eventType === 'payment.outcome.v1') {
|
|
25
|
+
await payScene.onPaymentOutcome(event.payload as IPaymentOutcomeEvent);
|
|
26
|
+
} else if (event.eventType === 'refund.outcome.v1') {
|
|
27
|
+
await payScene.onRefundOutcome(event.payload as IRefundOutcomeEvent);
|
|
28
|
+
} else {
|
|
29
|
+
this.app.throw(409, `unsupported payment outbox event: ${event.eventType}`);
|
|
30
|
+
}
|
|
26
31
|
await this.scope.service.outbox.markDispatched(event.id, event.claimToken!);
|
|
27
32
|
} catch (error) {
|
|
28
33
|
await this.scope.service.outbox.release(event.id, event.claimToken!, error);
|
package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/schedule.providerOperationDispatch.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IScheduleExecute } from 'vona-module-a-schedule';
|
|
2
|
+
|
|
3
|
+
import { BeanBase } from 'vona';
|
|
4
|
+
import { Schedule } from 'vona-module-a-schedule';
|
|
5
|
+
|
|
6
|
+
@Schedule({ repeat: { every: 60 * 1000 } })
|
|
7
|
+
export class ScheduleProviderOperationDispatch extends BeanBase implements IScheduleExecute {
|
|
8
|
+
async execute() {
|
|
9
|
+
await this.scope.service.providerOperation.queueDue();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -5,20 +5,27 @@ import { Api, v } from 'vona-module-a-openapiutils';
|
|
|
5
5
|
import { Entity, EntityBase } from 'vona-module-a-orm';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
IPaymentOutcomeEvent,
|
|
10
|
+
IRefundOutcomeEvent,
|
|
11
|
+
TypeOutboxEventState,
|
|
12
|
+
} from '../types/payment.ts';
|
|
9
13
|
|
|
10
14
|
export interface IEntityOptionsOutboxEvent extends IDecoratorEntityOptions {}
|
|
11
15
|
|
|
12
16
|
@Entity<IEntityOptionsOutboxEvent>('payOutboxEvent')
|
|
13
17
|
export class EntityOutboxEvent extends EntityBase {
|
|
14
|
-
@Api.field(z.
|
|
15
|
-
eventType: 'payment.outcome.v1';
|
|
18
|
+
@Api.field(z.enum(['payment.outcome.v1', 'refund.outcome.v1']))
|
|
19
|
+
eventType: 'payment.outcome.v1' | 'refund.outcome.v1';
|
|
16
20
|
|
|
17
21
|
@Api.field(v.tableIdentity())
|
|
18
22
|
paymentSessionId: TableIdentity;
|
|
19
23
|
|
|
24
|
+
@Api.field(v.optional(), v.tableIdentity())
|
|
25
|
+
refundOperationId?: TableIdentity;
|
|
26
|
+
|
|
20
27
|
@Api.field(v.required())
|
|
21
|
-
payload: IPaymentOutcomeEvent;
|
|
28
|
+
payload: IPaymentOutcomeEvent | IRefundOutcomeEvent;
|
|
22
29
|
|
|
23
30
|
@Api.field(z.enum(['pending', 'claimed', 'dispatched', 'failed']))
|
|
24
31
|
state: TypeOutboxEventState;
|
|
@@ -14,6 +14,9 @@ export class EntityProviderOperation extends EntityBase {
|
|
|
14
14
|
@Api.field(v.tableIdentity())
|
|
15
15
|
paymentSessionId: TableIdentity;
|
|
16
16
|
|
|
17
|
+
@Api.field(v.optional(), v.tableIdentity())
|
|
18
|
+
refundOperationId?: TableIdentity;
|
|
19
|
+
|
|
17
20
|
@Api.field(z.enum(['start', 'confirm', 'query', 'refund']))
|
|
18
21
|
kind: TypeProviderOperationKind;
|
|
19
22
|
|
|
@@ -40,6 +43,15 @@ export class EntityProviderOperation extends EntityBase {
|
|
|
40
43
|
@Api.field(v.optional())
|
|
41
44
|
claimedAt?: Date;
|
|
42
45
|
|
|
46
|
+
@Api.field(v.optional(), v.max(100))
|
|
47
|
+
claimToken?: string;
|
|
48
|
+
|
|
49
|
+
@Api.field(v.optional())
|
|
50
|
+
claimExpiresAt?: Date;
|
|
51
|
+
|
|
52
|
+
@Api.field(v.optional())
|
|
53
|
+
submittedAt?: Date;
|
|
54
|
+
|
|
43
55
|
@Api.field(v.optional())
|
|
44
56
|
nextAttemptAt?: Date;
|
|
45
57
|
|
|
@@ -33,6 +33,9 @@ export class EntityWebhookInbox extends EntityBase {
|
|
|
33
33
|
@Api.field(v.optional(), z.enum(['succeeded', 'failed', 'cancelled']))
|
|
34
34
|
paymentState?: 'succeeded' | 'failed' | 'cancelled';
|
|
35
35
|
|
|
36
|
+
@Api.field(v.optional(), z.enum(['pending', 'succeeded', 'failed', 'cancelled']))
|
|
37
|
+
refundState?: 'pending' | 'succeeded' | 'failed' | 'cancelled';
|
|
38
|
+
|
|
36
39
|
@Api.field(v.optional(), z.number().int().nonnegative())
|
|
37
40
|
amountMinor?: number;
|
|
38
41
|
|
|
@@ -42,6 +45,9 @@ export class EntityWebhookInbox extends EntityBase {
|
|
|
42
45
|
@Api.field(v.optional(), v.max(255))
|
|
43
46
|
providerCaptureId?: string;
|
|
44
47
|
|
|
48
|
+
@Api.field(v.optional(), v.max(255))
|
|
49
|
+
providerRefundId?: string;
|
|
50
|
+
|
|
45
51
|
@Api.field(v.required(), v.min(64), v.max(64))
|
|
46
52
|
payloadHash: string;
|
|
47
53
|
|
|
@@ -5,7 +5,7 @@ import { BeanBase } from 'vona';
|
|
|
5
5
|
import { Service } from 'vona-module-a-bean';
|
|
6
6
|
import { Core } from 'vona-module-a-core';
|
|
7
7
|
|
|
8
|
-
import type { IPaymentOutcomeEvent } from '../types/payment.ts';
|
|
8
|
+
import type { IPaymentOutcomeEvent, IRefundOutcomeEvent } from '../types/payment.ts';
|
|
9
9
|
|
|
10
10
|
const ClaimLeaseMilliseconds = 60_000;
|
|
11
11
|
const MaxAttempts = 10;
|
|
@@ -14,16 +14,21 @@ const MaxAttempts = 10;
|
|
|
14
14
|
export class ServiceOutbox extends BeanBase {
|
|
15
15
|
async enqueue(
|
|
16
16
|
paymentSessionId: TableIdentity,
|
|
17
|
-
eventType: 'payment.outcome.v1',
|
|
18
|
-
payload: IPaymentOutcomeEvent,
|
|
17
|
+
eventType: 'payment.outcome.v1' | 'refund.outcome.v1',
|
|
18
|
+
payload: IPaymentOutcomeEvent | IRefundOutcomeEvent,
|
|
19
19
|
) {
|
|
20
20
|
const event = await this.scope.model.outboxEvent.insert({
|
|
21
21
|
eventType,
|
|
22
22
|
paymentSessionId,
|
|
23
|
+
refundOperationId:
|
|
24
|
+
eventType === 'refund.outcome.v1'
|
|
25
|
+
? (payload as IRefundOutcomeEvent).refundOperationId
|
|
26
|
+
: undefined,
|
|
23
27
|
payload,
|
|
24
28
|
state: 'pending',
|
|
25
29
|
attemptCount: 0,
|
|
26
|
-
|
|
30
|
+
// Keep newly committed events due after database timestamp precision is applied.
|
|
31
|
+
nextAttemptAt: new Date(Date.now() - 1_000),
|
|
27
32
|
});
|
|
28
33
|
if (!this.app.meta.isTest) {
|
|
29
34
|
this.ctx.db.commit(() => {
|
|
@@ -102,7 +107,9 @@ export class ServiceOutbox extends BeanBase {
|
|
|
102
107
|
});
|
|
103
108
|
return { ...event, state: 'failed' as const, errorSummary };
|
|
104
109
|
}
|
|
105
|
-
const nextAttemptAt = new Date(
|
|
110
|
+
const nextAttemptAt = new Date(
|
|
111
|
+
Math.ceil((Date.now() + retryDelayMilliseconds(event.attemptCount)) / 1_000) * 1_000,
|
|
112
|
+
);
|
|
106
113
|
await this.scope.model.outboxEvent.updateById(event.id, {
|
|
107
114
|
state: 'pending',
|
|
108
115
|
claimedAt: undefined,
|
|
@@ -77,35 +77,7 @@ export class ServicePaymentSession extends BeanBase {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
async start(paymentSessionId: TableIdentity): Promise<EntityPaymentSession> {
|
|
80
|
-
|
|
81
|
-
const { provider, clientOptions } = this.bean.payProvider.resolveByName(
|
|
82
|
-
session.providerName,
|
|
83
|
-
session.clientName,
|
|
84
|
-
);
|
|
85
|
-
if (clientOptions.environment !== session.environment) {
|
|
86
|
-
this.app.throw(500, 'payment session provider environment is inconsistent');
|
|
87
|
-
}
|
|
88
|
-
const snapshot = await provider.startPayment(
|
|
89
|
-
{
|
|
90
|
-
paymentSessionId: session.id,
|
|
91
|
-
businessReference: session.businessReference,
|
|
92
|
-
idempotencyKey: `${session.correlationId}:start`,
|
|
93
|
-
amountMinor: session.amountMinor,
|
|
94
|
-
currency: session.currency,
|
|
95
|
-
providerOrderId: session.providerOrderId,
|
|
96
|
-
},
|
|
97
|
-
clientOptions,
|
|
98
|
-
);
|
|
99
|
-
if (isTerminalPaymentSnapshot(snapshot)) {
|
|
100
|
-
return await this.settleStartSnapshot(session.id, snapshot);
|
|
101
|
-
}
|
|
102
|
-
return await this.transition(session.id, snapshot.state, {
|
|
103
|
-
nextAction: snapshot.nextAction,
|
|
104
|
-
providerPaymentId: snapshot.providerPaymentId,
|
|
105
|
-
providerOrderId: snapshot.providerOrderId,
|
|
106
|
-
providerCaptureId: snapshot.providerCaptureId,
|
|
107
|
-
source: 'paymentSession.startProvider',
|
|
108
|
-
});
|
|
80
|
+
return await this.scope.service.providerOperation.start(paymentSessionId);
|
|
109
81
|
}
|
|
110
82
|
|
|
111
83
|
@Core.transaction()
|
|
@@ -268,11 +240,3 @@ export class ServicePaymentSession extends BeanBase {
|
|
|
268
240
|
return { ...session, ...options, state, finalizedAt };
|
|
269
241
|
}
|
|
270
242
|
}
|
|
271
|
-
|
|
272
|
-
function isTerminalPaymentSnapshot(
|
|
273
|
-
snapshot: IPayProviderPaymentSnapshot,
|
|
274
|
-
): snapshot is IPayProviderPaymentSnapshot & {
|
|
275
|
-
state: 'succeeded' | 'failed' | 'cancelled';
|
|
276
|
-
} {
|
|
277
|
-
return ['succeeded', 'failed', 'cancelled'].includes(snapshot.state);
|
|
278
|
-
}
|
|
@@ -1,5 +1,359 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
1
4
|
import { BeanBase } from 'vona';
|
|
2
5
|
import { Service } from 'vona-module-a-bean';
|
|
6
|
+
import { Core } from 'vona-module-a-core';
|
|
7
|
+
|
|
8
|
+
import type { EntityPaymentSession } from '../entity/paymentSession.tsx';
|
|
9
|
+
import type { EntityProviderOperation } from '../entity/providerOperation.tsx';
|
|
10
|
+
import type { IPayProviderPaymentSnapshot } from '../types/payment.ts';
|
|
11
|
+
|
|
12
|
+
const ClaimLeaseMilliseconds = 60_000;
|
|
13
|
+
const MaxAttempts = 10;
|
|
3
14
|
|
|
4
15
|
@Service()
|
|
5
|
-
export class ServiceProviderOperation extends BeanBase {
|
|
16
|
+
export class ServiceProviderOperation extends BeanBase {
|
|
17
|
+
async start(paymentSessionId: TableIdentity): Promise<EntityPaymentSession> {
|
|
18
|
+
return await this.scope.redlock.lock(
|
|
19
|
+
`pay.providerOperation.start.${paymentSessionId}`,
|
|
20
|
+
async () => {
|
|
21
|
+
const operation = await this.ensureStart(paymentSessionId);
|
|
22
|
+
await this.execute(operation.id);
|
|
23
|
+
const session = await this.scope.model.paymentSession.getById(paymentSessionId);
|
|
24
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
25
|
+
return session;
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Core.transaction()
|
|
31
|
+
async ensureStart(paymentSessionId: TableIdentity): Promise<EntityProviderOperation> {
|
|
32
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
|
|
33
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
34
|
+
|
|
35
|
+
const existing = await this.scope.model.providerOperation.getForUpdate({
|
|
36
|
+
paymentSessionId: session.id,
|
|
37
|
+
kind: 'start',
|
|
38
|
+
});
|
|
39
|
+
if (existing) return existing;
|
|
40
|
+
if (session.state !== 'created') this.app.throw(409, 'payment session is not ready to start');
|
|
41
|
+
if (session.expiresAt <= new Date()) this.app.throw(409, 'payment session is expired');
|
|
42
|
+
|
|
43
|
+
const now = new Date();
|
|
44
|
+
await this.scope.model.paymentSession.updateById(session.id, { state: 'starting' });
|
|
45
|
+
await this.scope.model.paymentAudit.insert({
|
|
46
|
+
paymentSessionId: session.id,
|
|
47
|
+
fromState: session.state,
|
|
48
|
+
toState: 'starting',
|
|
49
|
+
correlationId: session.correlationId,
|
|
50
|
+
source: 'providerOperation.start',
|
|
51
|
+
occurredAt: now,
|
|
52
|
+
});
|
|
53
|
+
return await this.scope.model.providerOperation.insert({
|
|
54
|
+
paymentSessionId: session.id,
|
|
55
|
+
kind: 'start',
|
|
56
|
+
state: 'created',
|
|
57
|
+
idempotencyKey: `${session.correlationId}:start`,
|
|
58
|
+
correlationId: session.correlationId,
|
|
59
|
+
attemptCount: 0,
|
|
60
|
+
nextAttemptAt: new Date(now.getTime() - 1_000),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async execute(providerOperationId: TableIdentity) {
|
|
65
|
+
const operation = await this.claim(providerOperationId);
|
|
66
|
+
if (!operation) return undefined;
|
|
67
|
+
try {
|
|
68
|
+
const session = await this.scope.model.paymentSession.getById(operation.paymentSessionId);
|
|
69
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
70
|
+
const { provider, clientOptions } = this.bean.payProvider.resolveByName(
|
|
71
|
+
session.providerName,
|
|
72
|
+
session.clientName,
|
|
73
|
+
);
|
|
74
|
+
if (clientOptions.environment !== session.environment) {
|
|
75
|
+
this.app.throw(500, 'payment session provider environment is inconsistent');
|
|
76
|
+
}
|
|
77
|
+
const isReconciliation =
|
|
78
|
+
operation.state === 'reconciliation_required' ||
|
|
79
|
+
operation.state === 'claimed' ||
|
|
80
|
+
operation.state === 'submitted';
|
|
81
|
+
await this.markSubmitted(operation.id, operation.claimToken!);
|
|
82
|
+
if (operation.kind === 'refund') {
|
|
83
|
+
const refundOperationId = operation.refundOperationId;
|
|
84
|
+
if (!refundOperationId) this.app.throw(500, 'refund provider operation is not linked');
|
|
85
|
+
const refund = await this.scope.model.refundOperation.getById(refundOperationId);
|
|
86
|
+
if (!refund) this.app.throw(404, 'refund operation not found');
|
|
87
|
+
if (!session.providerCaptureId)
|
|
88
|
+
this.app.throw(409, 'payment session has no provider capture');
|
|
89
|
+
const input = {
|
|
90
|
+
paymentSessionId: session.id,
|
|
91
|
+
refundOperationId: refund.id,
|
|
92
|
+
businessReference: refund.businessReference,
|
|
93
|
+
idempotencyKey: operation.idempotencyKey,
|
|
94
|
+
amountMinor: refund.amountMinor,
|
|
95
|
+
currency: refund.currency,
|
|
96
|
+
providerCaptureId: session.providerCaptureId,
|
|
97
|
+
};
|
|
98
|
+
const snapshot =
|
|
99
|
+
isReconciliation && provider.queryRefund
|
|
100
|
+
? await provider.queryRefund(input, clientOptions)
|
|
101
|
+
: await provider.createRefund(input, clientOptions);
|
|
102
|
+
await this.scope.service.refundOperation.settleProviderSnapshot(
|
|
103
|
+
operation.id,
|
|
104
|
+
operation.claimToken!,
|
|
105
|
+
snapshot,
|
|
106
|
+
);
|
|
107
|
+
return snapshot;
|
|
108
|
+
}
|
|
109
|
+
const input = {
|
|
110
|
+
paymentSessionId: session.id,
|
|
111
|
+
businessReference: session.businessReference,
|
|
112
|
+
idempotencyKey: operation.idempotencyKey,
|
|
113
|
+
amountMinor: session.amountMinor,
|
|
114
|
+
currency: session.currency,
|
|
115
|
+
providerOrderId: session.providerOrderId,
|
|
116
|
+
};
|
|
117
|
+
const snapshot =
|
|
118
|
+
operation.state === 'reconciliation_required'
|
|
119
|
+
? await provider.queryPayment(input, clientOptions)
|
|
120
|
+
: await provider.startPayment(input, clientOptions);
|
|
121
|
+
await this.settlePaymentSnapshot(operation.id, operation.claimToken!, snapshot);
|
|
122
|
+
return snapshot;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await this.releaseForReconciliation(operation.id, operation.claimToken!, error);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Core.transaction()
|
|
130
|
+
async claim(id: TableIdentity) {
|
|
131
|
+
const operation = await this.scope.model.providerOperation.getByIdForUpdate(id);
|
|
132
|
+
if (!operation || ['succeeded', 'failed'].includes(operation.state)) return undefined;
|
|
133
|
+
const now = new Date();
|
|
134
|
+
const eligible =
|
|
135
|
+
(operation.state === 'created' &&
|
|
136
|
+
(!operation.nextAttemptAt || operation.nextAttemptAt <= now)) ||
|
|
137
|
+
(operation.state === 'reconciliation_required' &&
|
|
138
|
+
(!operation.nextAttemptAt || operation.nextAttemptAt <= now)) ||
|
|
139
|
+
(['claimed', 'submitted'].includes(operation.state) &&
|
|
140
|
+
!!operation.claimExpiresAt &&
|
|
141
|
+
operation.claimExpiresAt <= now);
|
|
142
|
+
if (!eligible) return undefined;
|
|
143
|
+
if (operation.attemptCount >= MaxAttempts) {
|
|
144
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
145
|
+
state: 'failed',
|
|
146
|
+
finalizedAt: now,
|
|
147
|
+
errorSummary: 'provider operation attempts exhausted',
|
|
148
|
+
});
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const claimToken = randomUUID();
|
|
152
|
+
const claimExpiresAt = new Date(now.getTime() + ClaimLeaseMilliseconds);
|
|
153
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
154
|
+
state: 'claimed',
|
|
155
|
+
claimedAt: now,
|
|
156
|
+
claimToken,
|
|
157
|
+
claimExpiresAt,
|
|
158
|
+
attemptCount: operation.attemptCount + 1,
|
|
159
|
+
errorCode: undefined,
|
|
160
|
+
errorSummary: undefined,
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
...operation,
|
|
164
|
+
state: operation.state,
|
|
165
|
+
claimedAt: now,
|
|
166
|
+
claimToken,
|
|
167
|
+
claimExpiresAt,
|
|
168
|
+
attemptCount: operation.attemptCount + 1,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@Core.transaction()
|
|
173
|
+
async markSubmitted(id: TableIdentity, claimToken: string) {
|
|
174
|
+
const operation = await this.scope.model.providerOperation.getByIdForUpdate(id);
|
|
175
|
+
if (!operation || operation.state !== 'claimed' || operation.claimToken !== claimToken)
|
|
176
|
+
return undefined;
|
|
177
|
+
const submittedAt = new Date();
|
|
178
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
179
|
+
state: 'submitted',
|
|
180
|
+
submittedAt,
|
|
181
|
+
});
|
|
182
|
+
return { ...operation, state: 'submitted' as const, submittedAt };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Core.transaction()
|
|
186
|
+
async settlePaymentSnapshot(
|
|
187
|
+
id: TableIdentity,
|
|
188
|
+
claimToken: string,
|
|
189
|
+
snapshot: IPayProviderPaymentSnapshot,
|
|
190
|
+
) {
|
|
191
|
+
const operation = await this.scope.model.providerOperation.getByIdForUpdate(id);
|
|
192
|
+
if (
|
|
193
|
+
!operation ||
|
|
194
|
+
!['claimed', 'submitted'].includes(operation.state) ||
|
|
195
|
+
operation.claimToken !== claimToken
|
|
196
|
+
) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const session = await this.scope.model.paymentSession.getByIdForUpdate(
|
|
200
|
+
operation.paymentSessionId,
|
|
201
|
+
);
|
|
202
|
+
if (!session) this.app.throw(404, 'payment session not found');
|
|
203
|
+
if (['succeeded', 'failed', 'cancelled', 'expired'].includes(session.state)) {
|
|
204
|
+
await this._complete(operation, {
|
|
205
|
+
providerResourceId: snapshot.providerCaptureId ?? snapshot.providerOrderId,
|
|
206
|
+
});
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (isTerminalPaymentState(snapshot.state)) {
|
|
211
|
+
const finalizedAt = new Date();
|
|
212
|
+
await this.scope.model.paymentSession.updateById(session.id, {
|
|
213
|
+
state: snapshot.state,
|
|
214
|
+
nextAction: snapshot.nextAction,
|
|
215
|
+
providerPaymentId: snapshot.providerPaymentId,
|
|
216
|
+
providerOrderId: snapshot.providerOrderId,
|
|
217
|
+
providerCaptureId: snapshot.providerCaptureId,
|
|
218
|
+
finalizedAt,
|
|
219
|
+
});
|
|
220
|
+
await this.scope.model.paymentAudit.insert({
|
|
221
|
+
paymentSessionId: session.id,
|
|
222
|
+
providerOperationId: operation.id,
|
|
223
|
+
fromState: session.state,
|
|
224
|
+
toState: snapshot.state,
|
|
225
|
+
correlationId: session.correlationId,
|
|
226
|
+
source: 'providerOperation',
|
|
227
|
+
occurredAt: finalizedAt,
|
|
228
|
+
});
|
|
229
|
+
await this.scope.service.outbox.enqueue(session.id, 'payment.outcome.v1', {
|
|
230
|
+
eventId: `${operation.correlationId}:${operation.kind}`,
|
|
231
|
+
paymentSessionId: session.id,
|
|
232
|
+
businessReference: session.businessReference,
|
|
233
|
+
providerName: session.providerName,
|
|
234
|
+
state: snapshot.state,
|
|
235
|
+
providerCaptureId: snapshot.providerCaptureId,
|
|
236
|
+
amountMinor: session.amountMinor,
|
|
237
|
+
currency: session.currency,
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
await this.scope.model.paymentSession.updateById(session.id, {
|
|
241
|
+
state: snapshot.state,
|
|
242
|
+
nextAction: snapshot.nextAction,
|
|
243
|
+
providerPaymentId: snapshot.providerPaymentId,
|
|
244
|
+
providerOrderId: snapshot.providerOrderId,
|
|
245
|
+
providerCaptureId: snapshot.providerCaptureId,
|
|
246
|
+
});
|
|
247
|
+
await this.scope.model.paymentAudit.insert({
|
|
248
|
+
paymentSessionId: session.id,
|
|
249
|
+
providerOperationId: operation.id,
|
|
250
|
+
fromState: session.state,
|
|
251
|
+
toState: snapshot.state,
|
|
252
|
+
correlationId: session.correlationId,
|
|
253
|
+
source: 'providerOperation',
|
|
254
|
+
occurredAt: new Date(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
await this._complete(operation, {
|
|
258
|
+
providerResourceId: snapshot.providerCaptureId ?? snapshot.providerOrderId,
|
|
259
|
+
});
|
|
260
|
+
return { ...session, ...snapshot };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Core.transaction()
|
|
264
|
+
async releaseForReconciliation(id: TableIdentity, claimToken: string, error: unknown) {
|
|
265
|
+
const operation = await this.scope.model.providerOperation.getByIdForUpdate(id);
|
|
266
|
+
if (
|
|
267
|
+
!operation ||
|
|
268
|
+
!['claimed', 'submitted'].includes(operation.state) ||
|
|
269
|
+
operation.claimToken !== claimToken
|
|
270
|
+
) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const errorSummary = summarizeError(error);
|
|
274
|
+
const now = new Date();
|
|
275
|
+
if (operation.attemptCount >= MaxAttempts) {
|
|
276
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
277
|
+
state: 'failed',
|
|
278
|
+
claimToken: undefined,
|
|
279
|
+
claimExpiresAt: undefined,
|
|
280
|
+
finalizedAt: now,
|
|
281
|
+
errorSummary,
|
|
282
|
+
});
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
const nextAttemptAt = new Date(
|
|
286
|
+
Math.ceil((now.getTime() + retryDelayMilliseconds(operation.attemptCount)) / 1_000) * 1_000,
|
|
287
|
+
);
|
|
288
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
289
|
+
state: 'reconciliation_required',
|
|
290
|
+
claimToken: undefined,
|
|
291
|
+
claimExpiresAt: undefined,
|
|
292
|
+
nextAttemptAt,
|
|
293
|
+
errorSummary,
|
|
294
|
+
});
|
|
295
|
+
return { ...operation, state: 'reconciliation_required' as const, nextAttemptAt, errorSummary };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async queueDue(limit = 100) {
|
|
299
|
+
const now = new Date();
|
|
300
|
+
const due = await this.scope.model.providerOperation.select({
|
|
301
|
+
where: {
|
|
302
|
+
state: ['created', 'reconciliation_required'],
|
|
303
|
+
nextAttemptAt: { _lte_: now },
|
|
304
|
+
},
|
|
305
|
+
orders: [
|
|
306
|
+
['nextAttemptAt', 'asc'],
|
|
307
|
+
['id', 'asc'],
|
|
308
|
+
],
|
|
309
|
+
limit,
|
|
310
|
+
});
|
|
311
|
+
const expiredClaims = await this.scope.model.providerOperation.select({
|
|
312
|
+
where: {
|
|
313
|
+
state: ['claimed', 'submitted'],
|
|
314
|
+
claimExpiresAt: { _lte_: now },
|
|
315
|
+
},
|
|
316
|
+
orders: [
|
|
317
|
+
['claimExpiresAt', 'asc'],
|
|
318
|
+
['id', 'asc'],
|
|
319
|
+
],
|
|
320
|
+
limit: Math.max(0, limit - due.length),
|
|
321
|
+
});
|
|
322
|
+
const operations = [...due, ...expiredClaims];
|
|
323
|
+
for (const operation of operations) {
|
|
324
|
+
await this.execute(operation.id);
|
|
325
|
+
}
|
|
326
|
+
return operations.length;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async _complete(
|
|
330
|
+
operation: EntityProviderOperation,
|
|
331
|
+
options: { providerResourceId?: string },
|
|
332
|
+
) {
|
|
333
|
+
await this.scope.model.providerOperation.updateById(operation.id, {
|
|
334
|
+
state: 'succeeded',
|
|
335
|
+
providerResourceId: options.providerResourceId,
|
|
336
|
+
claimToken: undefined,
|
|
337
|
+
claimExpiresAt: undefined,
|
|
338
|
+
nextAttemptAt: undefined,
|
|
339
|
+
finalizedAt: new Date(),
|
|
340
|
+
errorCode: undefined,
|
|
341
|
+
errorSummary: undefined,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isTerminalPaymentState(
|
|
347
|
+
state: IPayProviderPaymentSnapshot['state'],
|
|
348
|
+
): state is 'succeeded' | 'failed' | 'cancelled' {
|
|
349
|
+
return ['succeeded', 'failed', 'cancelled'].includes(state);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function retryDelayMilliseconds(attemptCount: number) {
|
|
353
|
+
return Math.min(60_000, 1_000 * 2 ** Math.max(0, attemptCount - 1));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function summarizeError(error: unknown) {
|
|
357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
358
|
+
return message.slice(0, 255);
|
|
359
|
+
}
|