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
@@ -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 paymentSessionId = command.verified.paymentSessionId;
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
- const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
52
- if (!session) this.app.throw(404, 'payment session not found');
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
- session.providerName !== command.providerName ||
55
- session.clientName !== command.clientName ||
56
- session.environment !== command.environment ||
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(409, 'payment webhook conflicts with the payment session');
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: 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
- const event: IPaymentOutcomeEvent = {
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
- await this.scope.service.outbox.enqueue(session.id, 'payment.outcome.v1', { ...event });
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(inbox.id, {
216
+ await this.scope.model.webhookInbox.updateById(inboxId, {
116
217
  state: 'processed',
117
218
  processedAt,
118
- errorSummary: transition.ignoredReason,
219
+ errorSummary,
119
220
  });
120
- return {
121
- ...inbox,
122
- state: 'processed' as const,
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 {}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-mock",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -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 */