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.
Files changed (109) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/SKILL.md +1 -1
  3. package/.claude/skills/cabloy-backend-scaffold/evals/evals.json +6 -0
  4. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +13 -4
  5. package/.claude/skills/cabloy-workflow/SKILL.md +4 -2
  6. package/.claude/skills/cabloy-workflow/evals/evals.json +14 -2
  7. package/.claude/skills/cabloy-worktree-environment/SKILL.md +206 -0
  8. package/.claude/skills/cabloy-worktree-environment/evals/evals.json +77 -0
  9. package/.github/workflows/vona-test-pg.yml +3 -0
  10. package/.github/workflows/vona-test-sqlite3.yml +3 -0
  11. package/CHANGELOG.md +21 -0
  12. package/CLAUDE.md +1 -1
  13. package/cabloy-docs/.vitepress/config.mjs +2 -0
  14. package/cabloy-docs/ai/playbook-technical-blog-authoring.md +158 -0
  15. package/cabloy-docs/backend/backend-contract-emission-specimen.md +2 -2
  16. package/cabloy-docs/backend/dto-guide.md +10 -7
  17. package/cabloy-docs/backend/dto-infer-generation.md +127 -7
  18. package/cabloy-docs/backend/foundation.md +1 -1
  19. package/cabloy-docs/backend/introduction.md +1 -0
  20. package/cabloy-docs/backend/module-dependencies.md +104 -0
  21. package/cabloy-docs/backend/serialization-guide.md +6 -0
  22. package/cabloy-docs/fullstack/parallel-worktree-environment.md +72 -34
  23. package/cabloy-docs/fullstack/suites-and-modules.md +1 -0
  24. package/cabloy-docs/reference/package-map.md +1 -0
  25. package/e2e/specs/a-commerce/commerce.spec.ts +89 -0
  26. package/package.json +2 -1
  27. package/scripts/init.ts +7 -12
  28. package/scripts/initAppName.test.ts +72 -0
  29. package/scripts/initAppName.ts +50 -0
  30. package/vona/packages-vona/vona/package.json +1 -1
  31. package/vona/pnpm-lock.yaml +48 -146
  32. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineCreate.tsx +6 -27
  33. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineItem.tsx +6 -31
  34. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineUpdate.tsx +6 -2
  35. package/vona/src/suite/a-commerce/modules/commerce-member/src/lib/addressMine.ts +16 -0
  36. package/vona/src/suite/a-commerce/modules/commerce-member/src/service/address.ts +4 -17
  37. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +63 -19
  38. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  39. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +11 -2
  40. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +26 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +19 -2
  42. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +5 -2
  43. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +5 -2
  44. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +26 -35
  45. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +6 -1
  46. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +40 -4
  47. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponTemplateFormLayout.test.ts +85 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +1 -1
  49. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/order.ts +3 -5
  50. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSummary.tsx +7 -40
  51. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/shipmentView.tsx +6 -15
  52. package/vona/src/suite/a-commerce/modules/commerce-trade/src/lib/order.ts +9 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +121 -11
  54. package/vona/src/suite/a-commerce/modules/commerce-trade/test/order.test.ts +25 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +19 -2
  56. package/vona/src/suite/a-training/modules/training-record/src/dto/detailRecordSubjectResItem.tsx +6 -1
  57. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +3 -1
  58. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +4 -1
  59. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +45 -0
  60. package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordResItem.tsx +6 -1
  61. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSummary.tsx +5 -23
  62. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +37 -5
  63. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  64. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +29 -0
  65. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +3 -0
  66. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +3 -1
  67. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.version.ts +7 -0
  68. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/queue.outboxDispatch.ts +9 -4
  69. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/schedule.providerOperationDispatch.ts +11 -0
  70. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/outboxEvent.tsx +11 -4
  71. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/providerOperation.tsx +12 -0
  72. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/webhookInbox.tsx +6 -0
  73. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/outbox.ts +12 -5
  74. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +1 -37
  75. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +355 -1
  76. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +226 -0
  77. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/webhook.ts +148 -50
  78. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +2 -1
  79. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  80. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/.metadata/index.ts +39 -22
  81. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +42 -13
  82. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/controller/mockPayment.ts +11 -0
  83. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundComplete.tsx +13 -0
  84. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundReceipt.tsx +16 -0
  85. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/service/payMock.ts +56 -0
  86. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +2 -1
  87. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +117 -8
  88. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  89. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  90. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  91. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoGet.ts +5 -5
  92. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  93. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +54 -12
  94. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +625 -64
  95. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +55 -15
  96. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -1
  97. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeOrder.ts +15 -17
  98. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +12 -8
  99. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +111 -26
  100. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeOrder.ts +3 -3
  101. package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionRefund/controller.tsx +1 -8
  102. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +5 -9
  103. package/zova/src/suite/a-training/modules/training-student/src/.metadata/index.ts +1 -1
  104. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +301 -17
  105. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +5461 -1788
  106. package/vona/src/suite-vendor/a-pay/modules/a-pay/test/outbox.test.ts +0 -209
  107. package/vona/src/suite-vendor/a-pay/modules/a-pay/test/paymentSession.test.ts +0 -153
  108. package/vona/src/suite-vendor/a-pay/modules/a-pay/test/webhook.test.ts +0 -278
  109. 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 = `pay.webhook.${string}`;
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
- await payScene.onPaymentOutcome(event.payload);
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);
@@ -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 { IPaymentOutcomeEvent, TypeOutboxEventState } from '../types/payment.ts';
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.literal('payment.outcome.v1'))
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
- nextAttemptAt: new Date(),
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(Date.now() + retryDelayMilliseconds(event.attemptCount));
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
- const session = await this.beginStart(paymentSessionId);
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
+ }