cabloy 5.1.125 → 5.1.127

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 (127) 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 +36 -0
  12. package/CLAUDE.md +1 -1
  13. package/cabloy-docs/.vitepress/config.mjs +1 -0
  14. package/cabloy-docs/backend/backend-contract-emission-specimen.md +2 -2
  15. package/cabloy-docs/backend/dto-guide.md +10 -7
  16. package/cabloy-docs/backend/dto-infer-generation.md +127 -7
  17. package/cabloy-docs/backend/error-guide.md +17 -0
  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 +224 -4
  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-cli/cli/package.json +1 -1
  31. package/vona/packages-cli/cli-set-api/package.json +1 -1
  32. package/vona/packages-vona/vona/package.json +1 -1
  33. package/vona/packages-vona/vona-core/package.json +1 -1
  34. package/vona/packages-vona/vona-core/src/lib/bean/resource/error/errorClass.ts +21 -4
  35. package/vona/packages-vona/vona-core/src/lib/bean/resource/error/errorObject.ts +1 -0
  36. package/vona/packages-vona/vona-core/src/lib/bean/type.ts +3 -1
  37. package/vona/packages-vona/vona-core/src/types/interface/module.ts +6 -1
  38. package/vona/packages-vona/vona-mock/package.json +1 -1
  39. package/vona/pnpm-lock.yaml +27 -147
  40. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +36 -1
  41. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +1 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +1 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/categorySelectResItem.tsx +3 -1
  44. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/categoryView.tsx +3 -1
  45. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/category.tsx +21 -2
  46. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/category.ts +11 -1
  47. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/category.ts +7 -2
  48. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/categoryPresentation.test.ts +218 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineCreate.tsx +6 -27
  50. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineItem.tsx +6 -31
  51. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressMineUpdate.tsx +6 -2
  52. package/vona/src/suite/a-commerce/modules/commerce-member/src/lib/addressMine.ts +16 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-member/src/service/address.ts +4 -17
  54. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +63 -19
  55. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  56. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +11 -2
  57. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +26 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +19 -2
  59. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +5 -2
  60. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +5 -2
  61. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +26 -35
  62. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +6 -1
  63. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +40 -4
  64. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponTemplateFormLayout.test.ts +85 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +1 -1
  66. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/order.ts +3 -5
  67. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSummary.tsx +7 -40
  68. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/refundResult.tsx +3 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/shipmentView.tsx +6 -15
  70. package/vona/src/suite/a-commerce/modules/commerce-trade/src/lib/order.ts +9 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +123 -12
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/test/order.test.ts +25 -0
  73. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +19 -2
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/test/refundLifecycle.test.ts +111 -3
  75. package/vona/src/suite/a-training/modules/training-record/src/dto/detailRecordSubjectResItem.tsx +6 -1
  76. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +3 -1
  77. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +4 -1
  78. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +45 -0
  79. package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordResItem.tsx +6 -1
  80. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSummary.tsx +5 -23
  81. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +37 -5
  82. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  83. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +29 -0
  84. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +3 -0
  85. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +3 -1
  86. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.version.ts +7 -0
  87. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/queue.outboxDispatch.ts +9 -4
  88. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/schedule.providerOperationDispatch.ts +11 -0
  89. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/outboxEvent.tsx +11 -4
  90. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/providerOperation.tsx +12 -0
  91. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/entity/webhookInbox.tsx +6 -0
  92. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/outbox.ts +12 -5
  93. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +1 -37
  94. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +355 -1
  95. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +226 -0
  96. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/webhook.ts +148 -50
  97. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +2 -1
  98. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  99. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/.metadata/index.ts +39 -22
  100. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +42 -13
  101. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/controller/mockPayment.ts +11 -0
  102. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundComplete.tsx +13 -0
  103. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/dto/mockRefundReceipt.tsx +16 -0
  104. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/service/payMock.ts +56 -0
  105. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +2 -1
  106. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +117 -8
  107. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  108. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  109. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  110. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoGet.ts +5 -5
  112. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  113. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +58 -12
  114. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +673 -66
  115. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +55 -15
  116. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -1
  117. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeOrder.ts +15 -17
  118. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +12 -8
  119. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +116 -30
  120. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeOrder.ts +3 -3
  121. package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionRefund/controller.tsx +1 -8
  122. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +5 -9
  123. package/zova/src/suite/a-training/modules/training-student/src/.metadata/index.ts +1 -1
  124. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +301 -17
  125. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +5461 -1788
  126. package/zova/src/suite/cabloy-basic/modules/basic-select/src/bean/tableCell.select.tsx +12 -6
  127. package/zova/src/suite/cabloy-basic/modules/basic-select/src/component/formFieldSelect/controller.tsx +6 -3
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { createHmac, randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
5
 
6
6
  interface IFixture {
7
7
  categoryId?: number;
@@ -166,6 +166,23 @@ async function createCheckoutFixture(suffix: string, withCoupon = true): Promise
166
166
  }
167
167
 
168
168
  describe('paymentOutcome.test.ts', { concurrency: false, sequential: true }, () => {
169
+ const releaseTestLocks: Array<() => void> = [];
170
+
171
+ before(async () => {
172
+ try {
173
+ for (const scene of ['a-commerce', 'a-pay']) {
174
+ releaseTestLocks.push(await acquireTestLock(scene));
175
+ }
176
+ } catch (error) {
177
+ for (const release of releaseTestLocks.reverse()) release();
178
+ throw error;
179
+ }
180
+ });
181
+
182
+ after(() => {
183
+ for (const release of releaseTestLocks.reverse()) release();
184
+ });
185
+
169
186
  it('delivers a signed webhook through the durable outbox to Commerce exactly once', async () => {
170
187
  await app.bean.executor.mockCtx(async () => {
171
188
  const fixture: IFixture = {};
@@ -16,6 +16,7 @@ interface IFixture {
16
16
  customerName?: string;
17
17
  orderId?: number;
18
18
  paymentAttemptId?: number;
19
+ paymentSessionId?: number;
19
20
  }
20
21
 
21
22
  async function cleanup(fixture: IFixture) {
@@ -24,6 +25,21 @@ async function cleanup(fixture: IFixture) {
24
25
  const promotion = app.scope('commerce-promotion');
25
26
  const catalog = app.scope('commerce-catalog');
26
27
  const member = app.scope('commerce-member');
28
+ const pay = app.scope('a-pay');
29
+ if (fixture.paymentSessionId !== undefined) {
30
+ const refundOperations = await pay.model.refundOperation.select({
31
+ where: { paymentSessionId: fixture.paymentSessionId },
32
+ });
33
+ for (const refundOperation of refundOperations) {
34
+ await pay.model.providerOperation.delete({ refundOperationId: refundOperation.id });
35
+ await pay.model.outboxEvent.delete({ refundOperationId: refundOperation.id });
36
+ await pay.model.webhookInbox.delete({ refundOperationId: refundOperation.id });
37
+ await pay.model.refundOperation.delete({ id: refundOperation.id });
38
+ }
39
+ await pay.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
40
+ await pay.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
41
+ await pay.model.providerOperation.delete({ paymentSessionId: fixture.paymentSessionId });
42
+ }
27
43
  if (fixture.orderId !== undefined) {
28
44
  const requests = await payment.model.refundRequest.select({
29
45
  where: { orderId: fixture.orderId },
@@ -39,6 +55,8 @@ async function cleanup(fixture: IFixture) {
39
55
  await payment.model.paymentAudit.delete({ paymentAttemptId: fixture.paymentAttemptId });
40
56
  await payment.model.paymentAttempt.delete({ id: fixture.paymentAttemptId });
41
57
  }
58
+ if (fixture.paymentSessionId !== undefined)
59
+ await pay.model.paymentSession.delete({ id: fixture.paymentSessionId });
42
60
  if (fixture.orderId !== undefined)
43
61
  await trade.model.orderAudit.delete({ orderId: fixture.orderId });
44
62
  if (fixture.skuId !== undefined) await trade.model.stockAudit.delete({ skuId: fixture.skuId });
@@ -152,10 +170,17 @@ async function createPaidOrder(suffix: string): Promise<IFixture> {
152
170
  });
153
171
  fixture.orderId = checkout.orderId as number;
154
172
  fixture.paymentAttemptId = checkout.paymentAttemptId as number;
155
- await app.scope('commerce-trade').service.order.applyPaymentOutcome(fixture.paymentAttemptId, {
156
- outcome: 'succeeded',
157
- idempotencyKey: `refund-payment-${suffix}`,
173
+ fixture.paymentSessionId = checkout.paymentSessionId as number;
174
+ const pay = app.scope('a-pay');
175
+ await pay.service.paymentSession.start(fixture.paymentSessionId);
176
+ await app
177
+ .scope('pay-mock')
178
+ .service.payMock.completePaymentSession(fixture.paymentSessionId, 'succeeded');
179
+ const outbox = await pay.model.outboxEvent.get({
180
+ paymentSessionId: fixture.paymentSessionId,
181
+ eventType: 'payment.outcome.v1',
158
182
  });
183
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox!.id });
159
184
  return fixture;
160
185
  }
161
186
 
@@ -310,6 +335,89 @@ describe('refundLifecycle.test.ts', { concurrency: false, sequential: true }, ()
310
335
  });
311
336
  });
312
337
 
338
+ it('settles an executed mock-provider refund through the durable outbox', async () => {
339
+ await app.bean.executor.mockCtx(async () => {
340
+ const fixture: IFixture = {};
341
+ try {
342
+ Object.assign(fixture, await createPaidOrder(randomUUID().slice(0, 12)));
343
+ const order = app.scope('commerce-trade').service.order;
344
+ await order.requestRefund(fixture.orderId!, {
345
+ reason: 'provider-backed refund',
346
+ idempotencyKey: 'provider-backed-request-1',
347
+ });
348
+ await app.bean.passport.signout();
349
+ await app.bean.passport.signinMock();
350
+ await order.approveRefund(fixture.orderId!, {
351
+ reason: 'provider-backed approval',
352
+ idempotencyKey: 'provider-backed-approve-1',
353
+ });
354
+ const executed = await order.executeRefund(fixture.orderId!);
355
+ assert.ok(executed.refundAttemptId);
356
+ assert.ok(executed.refundOperationId);
357
+ assert.deepEqual(
358
+ [executed.orderState, executed.refundState, executed.refundAttemptState],
359
+ ['refund_approved', 'approved', 'created'],
360
+ );
361
+ const pay = app.scope('a-pay');
362
+ const refundOperation = await pay.model.refundOperation.getById(executed.refundOperationId);
363
+ const refundAttempt = await app
364
+ .scope('commerce-payment')
365
+ .model.refundAttempt.getById(executed.refundAttemptId);
366
+ assert.equal(String(refundAttempt?.refundOperationId), String(executed.refundOperationId));
367
+ assert.equal(refundOperation?.state, 'pending');
368
+ const receipt = await app
369
+ .scope('pay-mock')
370
+ .service.payMock.completeRefundOperation(executed.refundOperationId, 'succeeded');
371
+ assert.deepEqual(receipt, {
372
+ refundOperationId: executed.refundOperationId,
373
+ accepted: true,
374
+ });
375
+ const outbox = await pay.model.outboxEvent.get({
376
+ refundOperationId: executed.refundOperationId,
377
+ eventType: 'refund.outcome.v1',
378
+ });
379
+ assert.ok(outbox);
380
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
381
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
382
+ const [
383
+ dispatched,
384
+ settledOrder,
385
+ settledRequest,
386
+ settledAttempt,
387
+ line,
388
+ balance,
389
+ grant,
390
+ audits,
391
+ ] = await Promise.all([
392
+ pay.model.outboxEvent.getById(outbox.id),
393
+ app.scope('commerce-trade').model.order.getById(fixture.orderId!),
394
+ app.scope('commerce-payment').model.refundRequest.getById(executed.refundRequestId),
395
+ app.scope('commerce-payment').model.refundAttempt.getById(executed.refundAttemptId),
396
+ app.scope('commerce-trade').model.orderLine.get({ orderId: fixture.orderId }),
397
+ app.scope('commerce-trade').model.stockBalance.getById(fixture.balanceId!),
398
+ app.scope('commerce-promotion').model.couponGrant.getById(fixture.grantId!),
399
+ app
400
+ .scope('commerce-payment')
401
+ .model.refundAudit.select({ where: { orderId: fixture.orderId } }),
402
+ ]);
403
+ const reservation = await app
404
+ .scope('commerce-trade')
405
+ .model.stockReservation.get({ orderLineId: line?.id });
406
+ assert.deepEqual([dispatched?.state, dispatched?.attemptCount], ['dispatched', 1]);
407
+ assert.deepEqual(
408
+ [settledOrder?.state, settledRequest?.state, settledAttempt?.state, reservation?.state],
409
+ ['refunded', 'refunded', 'succeeded', 'restored'],
410
+ );
411
+ assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [1, 0, 1]);
412
+ assert.equal(grant?.state, 'redeemed');
413
+ assert.equal(audits.filter(audit => audit.toRefundState === 'refunded').length, 1);
414
+ } finally {
415
+ await app.bean.passport.signout();
416
+ await cleanup(fixture);
417
+ }
418
+ });
419
+ });
420
+
313
421
  it('preserves rejected and failed cycles while permitting a fresh request', async () => {
314
422
  await app.bean.executor.mockCtx(async () => {
315
423
  const fixture: IFixture = {};
@@ -38,7 +38,12 @@ export interface IDtoOptionsDetailRecordSubjectResItem extends IDecoratorDtoOpti
38
38
  export class DtoDetailRecordSubjectResItem extends $Dto.get(() => ModelSubject, {
39
39
  dtoClass: DtoDetailRecordSubjectBase,
40
40
  }) {
41
- @Api.field(v.title('#'), ZovaRender.order(1, 'core'), ZovaRender.cell('basic-details:lineNumber'))
41
+ @Api.field(
42
+ v.title('#'),
43
+ ZovaRender.order(1, 'core'),
44
+ ZovaRender.cell('basic-details:lineNumber'),
45
+ v.optional(),
46
+ )
42
47
  _lineNumber?: number;
43
48
 
44
49
  @Api.field(
@@ -49,7 +49,9 @@ export interface IDtoOptionsRecordSelectResItem extends IDecoratorDtoOptions {}
49
49
  }),
50
50
  ],
51
51
  })
52
- export class DtoRecordSelectResItem extends $Dto.get(() => ModelRecord) {
52
+ export class DtoRecordSelectResItem extends $Dto.get(() => ModelRecord, {
53
+ include: { student: true },
54
+ }) {
53
55
  @Api.field(
54
56
  ZovaRender.visible(false),
55
57
  v.optional(),
@@ -37,7 +37,10 @@ export interface IDtoOptionsRecordView extends IDecoratorDtoOptions {}
37
37
  },
38
38
  })
39
39
  export class DtoRecordView extends $Dto.get(() => ModelRecord, {
40
- include: { trainingRecordSubjects: { dtoClass: DtoDetailRecordSubjectView } },
40
+ include: {
41
+ student: true,
42
+ trainingRecordSubjects: { dtoClass: DtoDetailRecordSubjectView },
43
+ },
41
44
  }) {
42
45
  @Api.field(
43
46
  ZovaRender.visible(false),
@@ -8,10 +8,51 @@ import path from 'node:path';
8
8
  import { describe, it } from 'node:test';
9
9
  import { app } from 'vona-mock';
10
10
 
11
+ import { DtoDetailRecordSubjectResItem } from '../src/dto/detailRecordSubjectResItem.tsx';
12
+ import { DtoRecordSelectResItem } from '../src/dto/recordSelectResItem.tsx';
13
+ import { DtoRecordView } from '../src/dto/recordView.tsx';
14
+
11
15
  const dossierTextAttendance = Buffer.from('attendance dossier file');
12
16
  const dossierTextAssessment = Buffer.from('assessment dossier file');
13
17
 
14
18
  describe('record.test.ts', () => {
19
+ it('action:record:emittedDtoSchemas', async () => {
20
+ await app.bean.executor.mockCtx(async () => {
21
+ const subjectApiJson = await app.bean.openapi.generateJsonOfClass(
22
+ DtoDetailRecordSubjectResItem,
23
+ );
24
+ const subjectComponent = Object.values(subjectApiJson.components!.schemas as any).find(
25
+ item => (item as any).properties?._lineNumber,
26
+ ) as any;
27
+ assert.ok(subjectComponent?.properties?._lineNumber);
28
+ assert.equal(subjectComponent.required?.includes('_lineNumber'), false);
29
+
30
+ for (const [DtoClass, hasTrainingRecordSubjects] of [
31
+ [DtoRecordSelectResItem, false],
32
+ [DtoRecordView, true],
33
+ ] as const) {
34
+ const apiJson = await app.bean.openapi.generateJsonOfClass(DtoClass);
35
+ const component = Object.values(apiJson.components!.schemas as any).find(item => {
36
+ const properties = (item as any).properties;
37
+ return (
38
+ properties?.student &&
39
+ Boolean(properties?.trainingRecordSubjects) === hasTrainingRecordSubjects
40
+ );
41
+ }) as any;
42
+ assert.ok(component);
43
+ assert.ok(component.properties.student);
44
+ assert.equal(
45
+ Boolean(component.properties.trainingRecordSubjects),
46
+ hasTrainingRecordSubjects,
47
+ );
48
+ assert.deepEqual(Object.keys(component.properties.student.properties).sort(), [
49
+ 'id',
50
+ 'name',
51
+ ]);
52
+ }
53
+ });
54
+ });
55
+
15
56
  it('action:record', async () => {
16
57
  await app.bean.executor.mockCtx(async () => {
17
58
  const studentData: DtoStudentCreate = {
@@ -80,6 +121,8 @@ describe('record.test.ts', () => {
80
121
  assert.equal(!!recordItem, true);
81
122
  assert.equal(recordItem!.name, recordData.name);
82
123
  assert.equal(String(recordItem!.studentId), String(studentId));
124
+ assert.equal(String(recordItem!.student?.id), String(studentId));
125
+ assert.equal(recordItem!.student?.name, studentData.name);
83
126
  assert.equal(recordItem!.dossierFiles?.length, 1);
84
127
  assert.equal(recordItem!.dossierFiles?.[0]?.filename, 'attendance.txt');
85
128
 
@@ -90,6 +133,8 @@ describe('record.test.ts', () => {
90
133
  const recordDossierFile = record.dossierFiles?.[0];
91
134
  assert.equal(record.name, recordData.name);
92
135
  assert.equal(String(record.studentId), String(studentId));
136
+ assert.equal(String(record.student?.id), String(studentId));
137
+ assert.equal(record.student?.name, studentData.name);
93
138
  assert.equal(record.subjectCount, recordData.subjectCount);
94
139
  assert.equal(record.totalScore, recordData.totalScore);
95
140
  assert.equal(Number(record.averageScore), recordData.averageScore);
@@ -39,7 +39,12 @@ export class DtoDetailRecordResItem extends $Dto.get(() => ModelRecord, {
39
39
  dtoClass: DtoDetailRecordBase,
40
40
  include: { trainingRecordSubjects: false },
41
41
  }) {
42
- @Api.field(v.title('#'), ZovaRender.order(1, 'core'), ZovaRender.cell('basic-details:lineNumber'))
42
+ @Api.field(
43
+ v.title('#'),
44
+ ZovaRender.order(1, 'core'),
45
+ ZovaRender.cell('basic-details:lineNumber'),
46
+ v.optional(),
47
+ )
43
48
  _lineNumber?: number;
44
49
 
45
50
  @Api.field(
@@ -1,39 +1,21 @@
1
- import type { TableIdentity } from 'table-identity';
2
1
  import type { IDecoratorDtoOptions } from 'vona-module-a-web';
3
2
 
4
3
  import { Api, v } from 'vona-module-a-openapiutils';
4
+ import { $Dto } from 'vona-module-a-orm';
5
5
  import { Dto } from 'vona-module-a-web';
6
6
 
7
7
  import { $locale } from '../.metadata/locales.ts';
8
+ import { ModelStudent } from '../model/student.ts';
8
9
 
9
10
  export interface IDtoOptionsStudentSummary extends IDecoratorDtoOptions {}
10
11
 
11
12
  @Dto<IDtoOptionsStudentSummary>()
12
- export class DtoStudentSummary {
13
- @Api.field(v.tableIdentity())
14
- id: TableIdentity;
15
-
16
- @Api.field(v.title($locale('StudentName')))
17
- name: string;
18
-
19
- @Api.field(
20
- v.title($locale('Mobile')),
21
- v.serializerReplace({
22
- patternFrom: /^(\d{3})\d{4}(\d+)$/,
23
- patternTo: '$1****$2',
24
- }),
25
- )
26
- mobile: string;
27
-
28
- @Api.field(v.title($locale('Level')))
29
- level: number;
30
-
13
+ export class DtoStudentSummary extends $Dto.get(() => ModelStudent, {
14
+ columns: ['id', 'name', 'mobile', 'level', 'description'],
15
+ }) {
31
16
  @Api.field(v.title($locale('LevelTitle')))
32
17
  levelTitle: string;
33
18
 
34
- @Api.field(v.title($locale('Description')), v.optional())
35
- description?: string;
36
-
37
19
  @Api.field(v.title($locale('DescriptionLength')))
38
20
  descriptionLength: number;
39
21
 
@@ -1,8 +1,4 @@
1
- import type {
2
- DtoStudentSelectRes,
3
- DtoStudentSummary,
4
- EntityStudent,
5
- } from 'vona-module-training-student';
1
+ import type { DtoStudentSelectRes, EntityStudent } from 'vona-module-training-student';
6
2
 
7
3
  import assert from 'node:assert';
8
4
  import { describe, it } from 'node:test';
@@ -14,6 +10,9 @@ import {
14
10
  DtoStudentView,
15
11
  } from 'vona-module-training-student';
16
12
 
13
+ import { DtoDetailRecordResItem } from '../src/dto/detailRecordResItem.tsx';
14
+ import { DtoStudentSummary } from '../src/dto/studentSummary.tsx';
15
+
17
16
  describe('student.test.ts', () => {
18
17
  it('action:student:formLayoutMetadata', async () => {
19
18
  await app.bean.executor.mockCtx(async () => {
@@ -77,6 +76,39 @@ describe('student.test.ts', () => {
77
76
  });
78
77
  });
79
78
 
79
+ it('action:student:emittedDtoSchemas', async () => {
80
+ await app.bean.executor.mockCtx(async () => {
81
+ const summaryApiJson = await app.bean.openapi.generateJsonOfClass(DtoStudentSummary);
82
+ const summaryComponent = Object.values(summaryApiJson.components!.schemas as any).find(
83
+ item => {
84
+ return (item as any).properties?.summaryText;
85
+ },
86
+ ) as any;
87
+ assert.ok(summaryComponent);
88
+ assert.deepEqual(
89
+ Object.keys(summaryComponent.properties).sort(),
90
+ [
91
+ 'id',
92
+ 'name',
93
+ 'mobile',
94
+ 'level',
95
+ 'description',
96
+ 'levelTitle',
97
+ 'descriptionLength',
98
+ 'summaryText',
99
+ ].sort(),
100
+ );
101
+
102
+ const detailRecordApiJson =
103
+ await app.bean.openapi.generateJsonOfClass(DtoDetailRecordResItem);
104
+ const detailRecordComponent = Object.values(
105
+ detailRecordApiJson.components!.schemas as any,
106
+ ).find(item => (item as any).properties?._lineNumber) as any;
107
+ assert.ok(detailRecordComponent?.properties?._lineNumber);
108
+ assert.equal(detailRecordComponent.required?.includes('_lineNumber'), false);
109
+ });
110
+ });
111
+
80
112
  it('action:student', async () => {
81
113
  await app.bean.executor.mockCtx(async () => {
82
114
  // data
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-pay",
3
- "version": "5.0.2",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -501,6 +501,7 @@ declare module 'vona' {
501
501
  export * from '../service/outbox.ts';
502
502
  export * from '../service/paymentSession.ts';
503
503
  export * from '../service/providerOperation.ts';
504
+ export * from '../service/refundOperation.ts';
504
505
  export * from '../service/webhook.ts';
505
506
 
506
507
  import 'vona-module-a-bean';
@@ -510,6 +511,7 @@ declare module 'vona-module-a-bean' {
510
511
  'a-pay:outbox': never;
511
512
  'a-pay:paymentSession': never;
512
513
  'a-pay:providerOperation': never;
514
+ 'a-pay:refundOperation': never;
513
515
  'a-pay:webhook': never;
514
516
  }
515
517
 
@@ -550,6 +552,17 @@ declare module 'vona-module-a-pay' {
550
552
 
551
553
  }
552
554
 
555
+ export interface ServiceRefundOperation {
556
+ /** @internal */
557
+ get scope(): ScopeModuleAPay;
558
+ }
559
+
560
+ export interface ServiceRefundOperation {
561
+ get $beanFullName(): 'a-pay.service.refundOperation';
562
+ get $onionName(): 'a-pay:refundOperation';
563
+
564
+ }
565
+
553
566
  export interface ServiceWebhook {
554
567
  /** @internal */
555
568
  get scope(): ScopeModuleAPay;
@@ -566,11 +579,13 @@ declare module 'vona-module-a-pay' {
566
579
  import type { ServiceOutbox } from '../service/outbox.ts';
567
580
  import type { ServicePaymentSession } from '../service/paymentSession.ts';
568
581
  import type { ServiceProviderOperation } from '../service/providerOperation.ts';
582
+ import type { ServiceRefundOperation } from '../service/refundOperation.ts';
569
583
  import type { ServiceWebhook } from '../service/webhook.ts';
570
584
  export interface IModuleService {
571
585
  'outbox': ServiceOutbox;
572
586
  'paymentSession': ServicePaymentSession;
573
587
  'providerOperation': ServiceProviderOperation;
588
+ 'refundOperation': ServiceRefundOperation;
574
589
  'webhook': ServiceWebhook;
575
590
  }
576
591
  /** service: end */
@@ -582,6 +597,7 @@ declare module 'vona' {
582
597
  'a-pay.service.outbox': ServiceOutbox;
583
598
  'a-pay.service.paymentSession': ServicePaymentSession;
584
599
  'a-pay.service.providerOperation': ServiceProviderOperation;
600
+ 'a-pay.service.refundOperation': ServiceRefundOperation;
585
601
  'a-pay.service.webhook': ServiceWebhook;
586
602
  }
587
603
  }
@@ -675,12 +691,14 @@ export interface IModuleQueue {
675
691
  /** queue: end */
676
692
  /** schedule: begin */
677
693
  export * from '../bean/schedule.outboxDispatch.ts';
694
+ export * from '../bean/schedule.providerOperationDispatch.ts';
678
695
 
679
696
  import { type IDecoratorScheduleOptions } from 'vona-module-a-schedule';
680
697
  declare module 'vona-module-a-schedule' {
681
698
 
682
699
  export interface IScheduleRecord {
683
700
  'a-pay:outboxDispatch': IDecoratorScheduleOptions;
701
+ 'a-pay:providerOperationDispatch': IDecoratorScheduleOptions;
684
702
  }
685
703
 
686
704
 
@@ -696,6 +714,17 @@ declare module 'vona-module-a-pay' {
696
714
  get $beanFullName(): 'a-pay.schedule.outboxDispatch';
697
715
  get $onionName(): 'a-pay:outboxDispatch';
698
716
  get $onionOptions(): IDecoratorScheduleOptions;
717
+ }
718
+
719
+ export interface ScheduleProviderOperationDispatch {
720
+ /** @internal */
721
+ get scope(): ScopeModuleAPay;
722
+ }
723
+
724
+ export interface ScheduleProviderOperationDispatch {
725
+ get $beanFullName(): 'a-pay.schedule.providerOperationDispatch';
726
+ get $onionName(): 'a-pay:providerOperationDispatch';
727
+ get $onionOptions(): IDecoratorScheduleOptions;
699
728
  }
700
729
  }
701
730
  /** schedule: end */
@@ -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;