cabloy 5.1.117 → 5.1.119

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 (119) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/vona-test-pg.yml +2 -2
  3. package/.github/workflows/vona-test-sqlite3.yml +2 -2
  4. package/CHANGELOG.md +19 -0
  5. package/cabloy-docs/backend/metrics-guide.md +75 -0
  6. package/e2e/specs/a-commerce/commerce.spec.ts +111 -0
  7. package/observability/README.md +18 -0
  8. package/observability/collector/otel-collector.yaml +34 -0
  9. package/observability/docker-compose.metrics.yml +25 -0
  10. package/observability/grafana/provisioning/datasources/prometheus.yaml +8 -0
  11. package/observability/prometheus/prometheus.yaml +7 -0
  12. package/package.json +1 -1
  13. package/scripts/upgrade.ts +1 -0
  14. package/vona/env/.env +6 -0
  15. package/vona/packages-cli/cli/package.json +1 -1
  16. package/vona/packages-cli/cli-set-api/package.json +1 -1
  17. package/vona/packages-vona/vona/package.json +1 -1
  18. package/vona/packages-vona/vona-core/package.json +1 -1
  19. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +1 -1
  20. package/vona/packages-vona/vona-mock/package.json +1 -1
  21. package/vona/packages-vona/vona-mock/src/index.ts +2 -0
  22. package/vona/packages-vona/vona-mock/src/lib/testLock.ts +21 -0
  23. package/vona/pnpm-lock.yaml +167 -64
  24. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +13 -3
  25. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/product.test.ts +124 -70
  26. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +221 -0
  27. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +12 -0
  28. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +91 -0
  29. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/refundAttempt.tsx +37 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/refundAudit.tsx +47 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/refundRequest.tsx +43 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/refundAttempt.ts +10 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/refundAudit.ts +10 -0
  34. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/refundRequest.ts +10 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +36 -0
  36. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/order.ts +43 -0
  37. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderDetail.tsx +23 -2
  38. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSelectResItem.tsx +3 -0
  39. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSummary.tsx +23 -2
  40. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/refundOutcomeCreate.tsx +16 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/refundRequestCreate.tsx +15 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/refundResult.tsx +35 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/refundReview.tsx +15 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +55 -13
  45. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +459 -10
  46. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +2 -4
  47. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +2 -4
  48. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutTransaction.test.ts +2 -4
  49. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +2 -4
  50. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +2 -4
  51. package/vona/src/suite/a-commerce/modules/commerce-trade/test/refundLifecycle.test.ts +337 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +2 -3
  53. package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipment.test.ts +2 -4
  54. package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipmentRefundRace.test.ts +143 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +2 -4
  56. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +2 -4
  57. package/vona/src/suite/cabloy-basic/modules/basic-metrics/package.json +54 -0
  58. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/.metadata/index.ts +189 -0
  59. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/.metadata/this.ts +1 -0
  60. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/bean/ssrMenu.metrics.ts +23 -0
  61. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/controller/metrics.ts +21 -0
  62. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/dto/metricsQueue.ts +25 -0
  63. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/dto/metricsRuntime.ts +36 -0
  64. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/dto/metricsSnapshot.ts +21 -0
  65. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/index.ts +1 -0
  66. package/vona/src/suite/cabloy-basic/modules/basic-metrics/src/service/metrics.ts +11 -0
  67. package/vona/src/suite/cabloy-basic/modules/basic-metrics/tsconfig.build.json +11 -0
  68. package/vona/src/suite/cabloy-basic/modules/basic-metrics/tsconfig.json +5 -0
  69. package/vona/src/suite/cabloy-basic/package.json +1 -0
  70. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  71. package/vona/src/suite-vendor/a-vona/modules/a-instance/package.json +1 -1
  72. package/vona/src/suite-vendor/a-vona/modules/a-instance/src/bean/middlewareSystem.app.ts +2 -0
  73. package/vona/src/suite-vendor/a-vona/modules/a-instance/src/bean/middlewareSystem.instance.ts +2 -0
  74. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +62 -0
  75. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/.metadata/index.ts +178 -0
  76. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/.metadata/this.ts +1 -0
  77. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/bean.metrics.ts +15 -0
  78. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +24 -0
  79. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.metrics.ts +51 -0
  80. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/config/config.ts +75 -0
  81. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/index.ts +2 -0
  82. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/monkey.ts +27 -0
  83. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/service/health.ts +53 -0
  84. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/service/metrics.ts +222 -0
  85. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/service/metricsRuntime.ts +69 -0
  86. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/types/context.ts +9 -0
  87. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/types/index.ts +2 -0
  88. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/types/metrics.ts +50 -0
  89. package/vona/src/suite-vendor/a-vona/modules/a-metrics/tsconfig.build.json +11 -0
  90. package/vona/src/suite-vendor/a-vona/modules/a-metrics/tsconfig.json +7 -0
  91. package/vona/src/suite-vendor/a-vona/package.json +2 -1
  92. package/zova/pnpm-lock.yaml +104 -89
  93. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +4 -0
  94. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/component/tableCellActionRefund.ts +31 -0
  95. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +35 -6
  96. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeOrder.ts +104 -0
  97. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +16 -0
  98. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +231 -2
  99. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeOrder.ts +20 -0
  100. package/zova/src/suite/a-commerce/modules/commerce-trade/src/bean/tableCell.actionRefund.tsx +40 -0
  101. package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionRefund/controller.tsx +106 -0
  102. package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +7 -0
  103. package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +7 -0
  104. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +45 -1
  105. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/orderMine.ts +15 -0
  106. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/order/controller.tsx +41 -0
  107. package/zova/src/suite/cabloy-basic/modules/basic-metrics/package.json +53 -0
  108. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/.metadata/index.ts +151 -0
  109. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/.metadata/page/dashboard.ts +9 -0
  110. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/.metadata/this.ts +1 -0
  111. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/api/basicMetricsMetrics.ts +41 -0
  112. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/index.ts +2 -0
  113. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/model/metrics.ts +16 -0
  114. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/page/dashboard/controller.tsx +95 -0
  115. package/zova/src/suite/cabloy-basic/modules/basic-metrics/src/routes.ts +5 -0
  116. package/zova/src/suite/cabloy-basic/modules/basic-metrics/tsconfig.build.json +13 -0
  117. package/zova/src/suite/cabloy-basic/modules/basic-metrics/tsconfig.json +5 -0
  118. package/zova/src/suite/cabloy-basic/package.json +1 -0
  119. package/vona/src/suite/a-commerce/modules/commerce-trade/test/testLock.ts +0 -11
@@ -1,6 +1,11 @@
1
1
  import type { TableIdentity } from 'table-identity';
2
2
  import type { IQueryParams } from 'vona-module-a-orm';
3
3
  import type { EntityPaymentAttempt } from 'vona-module-commerce-payment';
4
+ import type {
5
+ EntityRefundAttempt,
6
+ EntityRefundAudit,
7
+ EntityRefundRequest,
8
+ } from 'vona-module-commerce-payment';
4
9
 
5
10
  import { BeanBase } from 'vona';
6
11
  import { Service } from 'vona-module-a-bean';
@@ -19,6 +24,10 @@ import type { DtoOrderSummary } from '../dto/orderSummary.tsx';
19
24
  import type { DtoOrderView } from '../dto/orderView.tsx';
20
25
  import type { DtoPaymentOutcomeCreate } from '../dto/paymentOutcomeCreate.tsx';
21
26
  import type { DtoPaymentOutcomeResult } from '../dto/paymentOutcomeResult.tsx';
27
+ import type { DtoRefundOutcomeCreate } from '../dto/refundOutcomeCreate.tsx';
28
+ import type { DtoRefundRequestCreate } from '../dto/refundRequestCreate.tsx';
29
+ import type { DtoRefundResult } from '../dto/refundResult.tsx';
30
+ import type { DtoRefundReview } from '../dto/refundReview.tsx';
22
31
  import type { DtoShipmentView } from '../dto/shipmentView.tsx';
23
32
  import type { EntityOrder } from '../entity/order.tsx';
24
33
  import type { EntityOrderLine } from '../entity/orderLine.tsx';
@@ -29,7 +38,11 @@ const maxOrderCents = 2_147_483_647;
29
38
  const customerVisibleOrderStates: EntityOrder['state'][] = [
30
39
  'awaiting_payment',
31
40
  'paid',
41
+ 'refund_requested',
42
+ 'refund_approved',
43
+ 'refund_rejected',
32
44
  'shipped',
45
+ 'refunded',
33
46
  'cancelled',
34
47
  'expired',
35
48
  ];
@@ -88,6 +101,25 @@ export type ShipmentFailureStage = 'afterShipmentInsert' | 'afterOrderState' | '
88
101
 
89
102
  type ShipmentStageCallback = (stage: ShipmentFailureStage) => void | Promise<void>;
90
103
 
104
+ export type RefundFailureStage =
105
+ | 'afterRefundRequestInsert'
106
+ | 'afterRefundRequestOrderState'
107
+ | 'afterRefundRequestAudit'
108
+ | 'afterRefundRequestOrderAudit'
109
+ | 'afterRefundReviewRequestState'
110
+ | 'afterRefundAttemptInsert'
111
+ | 'afterRefundReviewOrderState'
112
+ | 'afterRefundReviewAudit'
113
+ | 'afterRefundReviewOrderAudit'
114
+ | 'afterRefundAttemptState'
115
+ | 'afterRefundOutcomeRequestState'
116
+ | 'afterRefundOutcomeOrderState'
117
+ | 'afterRefundStockRestore'
118
+ | 'afterRefundOutcomeOrderAudit'
119
+ | 'afterRefundOutcomeAudit';
120
+
121
+ type RefundStageCallback = (stage: RefundFailureStage) => void | Promise<void>;
122
+
91
123
  interface IPreparedOrderLine {
92
124
  index: number;
93
125
  skuId: TableIdentity;
@@ -421,6 +453,205 @@ export class ServiceOrder extends BeanBase {
421
453
  return this._paymentOutcomeResult({ ...order, state: orderState }, finalizedAttempt);
422
454
  }
423
455
 
456
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
457
+ @Core.retryable(serializationRetryOptions)
458
+ async requestRefund(
459
+ orderId: TableIdentity,
460
+ command: DtoRefundRequestCreate,
461
+ ): Promise<DtoRefundResult> {
462
+ return await this._requestRefund(orderId, command);
463
+ }
464
+
465
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
466
+ @Core.retryable(serializationRetryOptions)
467
+ async requestRefundForTest(
468
+ orderId: TableIdentity,
469
+ command: DtoRefundRequestCreate,
470
+ onStage: RefundStageCallback,
471
+ ): Promise<DtoRefundResult> {
472
+ return await this._requestRefund(orderId, command, onStage);
473
+ }
474
+
475
+ private async _requestRefund(
476
+ orderId: TableIdentity,
477
+ command: DtoRefundRequestCreate,
478
+ onStage?: RefundStageCallback,
479
+ ): Promise<DtoRefundResult> {
480
+ const reason = command.reason.trim();
481
+ const idempotencyKey = command.idempotencyKey.trim();
482
+ if (!reason) this.app.throw(400, 'refund reason is required');
483
+ if (!idempotencyKey) this.app.throw(400, 'refund idempotencyKey is required');
484
+ const userId = this.bean.passport.currentUser!.id;
485
+ const order = await this.scope.model.order.getByIdForUpdate(orderId);
486
+ if (!order || String(order.userId) !== String(userId)) this.app.throw(404, 'order not found');
487
+
488
+ const existingAudit = await this.$scope.commercePayment.model.refundAudit.get({
489
+ orderId: order.id,
490
+ idempotencyKey,
491
+ });
492
+ if (existingAudit) return await this._requestReplayResult(order, existingAudit, reason);
493
+
494
+ const activeRequest = await this.$scope.commercePayment.model.refundRequest.getForUpdate({
495
+ orderId: order.id,
496
+ state: ['requested', 'approved'],
497
+ });
498
+ if (activeRequest) this.app.throw(409, 'order already has an active refund request');
499
+ if (order.state !== 'paid') this.app.throw(409, 'order is not available for refund');
500
+
501
+ const correlationId = `${order.correlationId}:refund:${idempotencyKey}`;
502
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.insert({
503
+ orderId: order.id,
504
+ userId,
505
+ state: 'requested',
506
+ currency: order.currency,
507
+ amountCents: order.payableTotalCents,
508
+ correlationId,
509
+ reason,
510
+ });
511
+ await onStage?.('afterRefundRequestInsert');
512
+ await this.scope.model.order.updateById(order.id, { state: 'refund_requested' });
513
+ await onStage?.('afterRefundRequestOrderState');
514
+ await this._appendRefundAudit({
515
+ refundRequest,
516
+ order,
517
+ toRefundState: 'requested',
518
+ idempotencyKey,
519
+ correlationId,
520
+ reason,
521
+ });
522
+ await onStage?.('afterRefundRequestAudit');
523
+ await this._appendAudit({
524
+ orderId: order.id,
525
+ operation: 'refund_requested',
526
+ fromState: 'paid',
527
+ toState: 'refund_requested',
528
+ correlationId,
529
+ reason,
530
+ });
531
+ await onStage?.('afterRefundRequestOrderAudit');
532
+ return this._refundResult({ ...order, state: 'refund_requested' }, refundRequest);
533
+ }
534
+
535
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
536
+ @Core.retryable(serializationRetryOptions)
537
+ async approveRefund(orderId: TableIdentity, command: DtoRefundReview): Promise<DtoRefundResult> {
538
+ return await this._reviewRefund(orderId, command, 'approved');
539
+ }
540
+
541
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
542
+ @Core.retryable(serializationRetryOptions)
543
+ async rejectRefund(orderId: TableIdentity, command: DtoRefundReview): Promise<DtoRefundResult> {
544
+ return await this._reviewRefund(orderId, command, 'rejected');
545
+ }
546
+
547
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
548
+ @Core.retryable(serializationRetryOptions)
549
+ async reviewRefundForTest(
550
+ orderId: TableIdentity,
551
+ command: DtoRefundReview,
552
+ decision: 'approved' | 'rejected',
553
+ onStage: RefundStageCallback,
554
+ ): Promise<DtoRefundResult> {
555
+ return await this._reviewRefund(orderId, command, decision, onStage);
556
+ }
557
+
558
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
559
+ @Core.retryable(serializationRetryOptions)
560
+ async applyRefundOutcome(
561
+ orderId: TableIdentity,
562
+ command: DtoRefundOutcomeCreate,
563
+ ): Promise<DtoRefundResult> {
564
+ return await this._applyRefundOutcome(orderId, command);
565
+ }
566
+
567
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
568
+ @Core.retryable(serializationRetryOptions)
569
+ async applyRefundOutcomeForTest(
570
+ orderId: TableIdentity,
571
+ command: DtoRefundOutcomeCreate,
572
+ onStage: RefundStageCallback,
573
+ ): Promise<DtoRefundResult> {
574
+ return await this._applyRefundOutcome(orderId, command, onStage);
575
+ }
576
+
577
+ private async _applyRefundOutcome(
578
+ orderId: TableIdentity,
579
+ command: DtoRefundOutcomeCreate,
580
+ onStage?: RefundStageCallback,
581
+ ): Promise<DtoRefundResult> {
582
+ const idempotencyKey = command.idempotencyKey.trim();
583
+ if (!idempotencyKey) this.app.throw(400, 'refund idempotencyKey is required');
584
+ const order = await this.scope.model.order.getByIdForUpdate(orderId);
585
+ if (!order) this.app.throw(404, 'order not found');
586
+ const existingAudit = await this.$scope.commercePayment.model.refundAudit.get({
587
+ orderId: order.id,
588
+ idempotencyKey,
589
+ });
590
+ if (existingAudit)
591
+ return await this._outcomeReplayResult(order, existingAudit, command.outcome);
592
+
593
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.getForUpdate({
594
+ orderId: order.id,
595
+ state: 'approved',
596
+ });
597
+ if (!refundRequest) this.app.throw(404, 'approved refund request not found');
598
+ const refundAttempt = await this.$scope.commercePayment.model.refundAttempt.getForUpdate({
599
+ refundRequestId: refundRequest.id,
600
+ });
601
+ if (!refundAttempt) this.app.throw(404, 'refund attempt not found');
602
+ if (order.state !== 'refund_approved' || refundAttempt.state !== 'created') {
603
+ this.app.throw(409, 'refund is not available for execution');
604
+ }
605
+
606
+ const correlationId = `${order.correlationId}:refund:${idempotencyKey}`;
607
+ const now = new Date();
608
+ const isSucceeded = command.outcome === 'succeeded';
609
+ const refundState = isSucceeded ? 'refunded' : 'failed';
610
+ const orderState = isSucceeded ? 'refunded' : 'paid';
611
+ const reason = isSucceeded ? 'mock refund succeeded' : 'mock refund failed';
612
+ await this.$scope.commercePayment.model.refundAttempt.updateById(refundAttempt.id, {
613
+ state: command.outcome,
614
+ finalizedAt: now,
615
+ });
616
+ await onStage?.('afterRefundAttemptState');
617
+ await this.$scope.commercePayment.model.refundRequest.updateById(refundRequest.id, {
618
+ state: refundState,
619
+ finalizedAt: now,
620
+ });
621
+ await onStage?.('afterRefundOutcomeRequestState');
622
+ await this.scope.model.order.updateById(order.id, { state: orderState });
623
+ await onStage?.('afterRefundOutcomeOrderState');
624
+ if (isSucceeded) {
625
+ await this._restoreConsumedOrderStock(order, reason);
626
+ await onStage?.('afterRefundStockRestore');
627
+ }
628
+ await this._appendAudit({
629
+ orderId: order.id,
630
+ operation: isSucceeded ? 'refunded' : 'refund_failed',
631
+ fromState: 'refund_approved',
632
+ toState: orderState,
633
+ correlationId,
634
+ reason,
635
+ });
636
+ await onStage?.('afterRefundOutcomeOrderAudit');
637
+ await this._appendRefundAudit({
638
+ refundRequest,
639
+ refundAttempt,
640
+ order,
641
+ toRefundState: refundState,
642
+ attemptState: command.outcome,
643
+ idempotencyKey,
644
+ correlationId,
645
+ reason,
646
+ });
647
+ await onStage?.('afterRefundOutcomeAudit');
648
+ return this._refundResult(
649
+ { ...order, state: orderState },
650
+ { ...refundRequest, state: refundState },
651
+ { ...refundAttempt, state: command.outcome },
652
+ );
653
+ }
654
+
424
655
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
425
656
  @Core.retryable(serializationRetryOptions)
426
657
  async ship(orderId: TableIdentity, command: DtoOrderShip): Promise<DtoShipmentView> {
@@ -534,13 +765,7 @@ export class ServiceOrder extends BeanBase {
534
765
  const snapshot = await this._viewMineSnapshot(id);
535
766
  if (!snapshot) return undefined;
536
767
  const { order, lines } = snapshot;
537
- if (
538
- order.state !== 'awaiting_payment' &&
539
- order.state !== 'paid' &&
540
- order.state !== 'shipped' &&
541
- order.state !== 'cancelled' &&
542
- order.state !== 'expired'
543
- ) {
768
+ if (!customerVisibleOrderStates.includes(order.state)) {
544
769
  this.app.throw(409, 'order state is not available to customers');
545
770
  }
546
771
  const shipment = await this.scope.model.shipment.get({ orderId: order.id });
@@ -584,6 +809,220 @@ export class ServiceOrder extends BeanBase {
584
809
  return await this._viewSnapshot(order);
585
810
  }
586
811
 
812
+ private async _reviewRefund(
813
+ orderId: TableIdentity,
814
+ command: DtoRefundReview,
815
+ decision: 'approved' | 'rejected',
816
+ onStage?: RefundStageCallback,
817
+ ): Promise<DtoRefundResult> {
818
+ const reason = command.reason.trim();
819
+ const idempotencyKey = command.idempotencyKey.trim();
820
+ if (!reason) this.app.throw(400, 'refund review reason is required');
821
+ if (!idempotencyKey) this.app.throw(400, 'refund idempotencyKey is required');
822
+ const order = await this.scope.model.order.getByIdForUpdate(orderId);
823
+ if (!order) this.app.throw(404, 'order not found');
824
+
825
+ const existingAudit = await this.$scope.commercePayment.model.refundAudit.get({
826
+ orderId: order.id,
827
+ idempotencyKey,
828
+ });
829
+ if (existingAudit)
830
+ return await this._reviewReplayResult(order, existingAudit, decision, reason);
831
+
832
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.getForUpdate({
833
+ orderId: order.id,
834
+ state: 'requested',
835
+ });
836
+ if (!refundRequest) this.app.throw(404, 'refund request is not available for review');
837
+ if (order.state !== 'refund_requested')
838
+ this.app.throw(409, 'refund is not available for review');
839
+ if (await this.scope.model.shipment.get({ orderId: order.id })) {
840
+ this.app.throw(409, 'shipped order is not available for refund');
841
+ }
842
+
843
+ const actorId = this.bean.passport.currentUser!.id;
844
+ const now = new Date();
845
+ const correlationId = `${order.correlationId}:refund:${decision}:${idempotencyKey}`;
846
+ const orderState = decision === 'approved' ? 'refund_approved' : 'paid';
847
+ await this.$scope.commercePayment.model.refundRequest.updateById(refundRequest.id, {
848
+ state: decision,
849
+ reviewedBy: actorId,
850
+ reviewedAt: now,
851
+ });
852
+ await onStage?.('afterRefundReviewRequestState');
853
+ let refundAttempt:
854
+ | Awaited<ReturnType<typeof this.$scope.commercePayment.model.refundAttempt.insert>>
855
+ | undefined;
856
+ if (decision === 'approved') {
857
+ refundAttempt = await this.$scope.commercePayment.model.refundAttempt.insert({
858
+ refundRequestId: refundRequest.id,
859
+ orderId: order.id,
860
+ userId: refundRequest.userId,
861
+ state: 'created',
862
+ currency: refundRequest.currency,
863
+ amountCents: refundRequest.amountCents,
864
+ correlationId,
865
+ });
866
+ await onStage?.('afterRefundAttemptInsert');
867
+ }
868
+ await this.scope.model.order.updateById(order.id, { state: orderState });
869
+ await onStage?.('afterRefundReviewOrderState');
870
+ await this._appendRefundAudit({
871
+ refundRequest,
872
+ refundAttempt,
873
+ order,
874
+ toRefundState: decision,
875
+ attemptState: refundAttempt?.state,
876
+ idempotencyKey,
877
+ correlationId,
878
+ reason,
879
+ });
880
+ await onStage?.('afterRefundReviewAudit');
881
+ await this._appendAudit({
882
+ orderId: order.id,
883
+ operation: decision === 'approved' ? 'refund_approved' : 'refund_rejected',
884
+ fromState: 'refund_requested',
885
+ toState: orderState,
886
+ correlationId,
887
+ reason,
888
+ });
889
+ await onStage?.('afterRefundReviewOrderAudit');
890
+ return this._refundResult(
891
+ { ...order, state: orderState },
892
+ { ...refundRequest, state: decision },
893
+ refundAttempt,
894
+ );
895
+ }
896
+
897
+ private async _requestReplayResult(
898
+ order: EntityOrder,
899
+ audit: EntityRefundAudit,
900
+ reason: string,
901
+ ): Promise<DtoRefundResult> {
902
+ if (audit.toRefundState !== 'requested' || audit.reason !== reason) {
903
+ this.app.throw(409, 'refund idempotency key conflicts with an existing request');
904
+ }
905
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.getById(
906
+ audit.refundRequestId,
907
+ );
908
+ if (!refundRequest) this.app.throw(404, 'refund request not found');
909
+ const refundAttempt = await this.$scope.commercePayment.model.refundAttempt.get({
910
+ refundRequestId: refundRequest.id,
911
+ });
912
+ return this._refundResult(order, refundRequest, refundAttempt);
913
+ }
914
+
915
+ private async _reviewReplayResult(
916
+ order: EntityOrder,
917
+ audit: EntityRefundAudit,
918
+ decision: 'approved' | 'rejected',
919
+ reason: string,
920
+ ): Promise<DtoRefundResult> {
921
+ if (audit.toRefundState !== decision || audit.reason !== reason) {
922
+ this.app.throw(409, 'refund idempotency key conflicts with an existing review');
923
+ }
924
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.getById(
925
+ audit.refundRequestId,
926
+ );
927
+ if (!refundRequest) this.app.throw(404, 'refund request not found');
928
+ const refundAttempt = audit.refundAttemptId
929
+ ? await this.$scope.commercePayment.model.refundAttempt.getById(audit.refundAttemptId)
930
+ : undefined;
931
+ return this._refundResult(order, refundRequest, refundAttempt);
932
+ }
933
+
934
+ private async _outcomeReplayResult(
935
+ order: EntityOrder,
936
+ audit: EntityRefundAudit,
937
+ outcome: 'succeeded' | 'failed',
938
+ ): Promise<DtoRefundResult> {
939
+ if (audit.attemptState !== outcome) {
940
+ this.app.throw(409, 'refund idempotency key conflicts with an existing outcome');
941
+ }
942
+ const refundRequest = await this.$scope.commercePayment.model.refundRequest.getById(
943
+ audit.refundRequestId,
944
+ );
945
+ const refundAttempt = audit.refundAttemptId
946
+ ? await this.$scope.commercePayment.model.refundAttempt.getById(audit.refundAttemptId)
947
+ : undefined;
948
+ if (!refundRequest || !refundAttempt) this.app.throw(404, 'refund attempt not found');
949
+ return this._refundResult(order, refundRequest, refundAttempt);
950
+ }
951
+
952
+ private async _restoreConsumedOrderStock(order: EntityOrder, reason: string) {
953
+ const lines = await this.scope.model.orderLine.select({
954
+ where: { orderId: order.id },
955
+ orders: [['skuId', 'asc']],
956
+ });
957
+ for (const line of lines) {
958
+ const reservation = await this.scope.model.stockReservation.get({ orderLineId: line.id });
959
+ if (reservation)
960
+ await this.scope.service.stockBalance.restore({ reservationId: reservation.id, reason });
961
+ }
962
+ }
963
+
964
+ private _refundResult(
965
+ order: Pick<EntityOrder, 'id' | 'state' | 'currency'>,
966
+ refundRequest: Pick<EntityRefundRequest, 'id' | 'state' | 'amountCents'>,
967
+ refundAttempt?: Pick<EntityRefundAttempt, 'id' | 'state'>,
968
+ ): DtoRefundResult {
969
+ if (
970
+ order.state !== 'paid' &&
971
+ order.state !== 'refund_requested' &&
972
+ order.state !== 'refund_approved' &&
973
+ order.state !== 'refund_rejected' &&
974
+ order.state !== 'refunded'
975
+ ) {
976
+ this.app.throw(409, 'refund result is not available');
977
+ }
978
+ return {
979
+ orderId: order.id,
980
+ refundRequestId: refundRequest.id,
981
+ refundAttemptId: refundAttempt?.id,
982
+ orderState: order.state,
983
+ refundState: refundRequest.state,
984
+ refundAttemptState: refundAttempt?.state,
985
+ currency: order.currency,
986
+ amountCents: refundRequest.amountCents,
987
+ };
988
+ }
989
+
990
+ private async _appendRefundAudit({
991
+ refundRequest,
992
+ refundAttempt,
993
+ order,
994
+ toRefundState,
995
+ attemptState,
996
+ idempotencyKey,
997
+ correlationId,
998
+ reason,
999
+ }: {
1000
+ refundRequest: Pick<EntityRefundRequest, 'id' | 'userId'>;
1001
+ refundAttempt?: Pick<EntityRefundAttempt, 'id'>;
1002
+ order: Pick<EntityOrder, 'id'>;
1003
+ toRefundState: 'requested' | 'approved' | 'rejected' | 'refunded' | 'failed';
1004
+ attemptState?: 'created' | 'succeeded' | 'failed';
1005
+ idempotencyKey?: string;
1006
+ correlationId: string;
1007
+ reason: string;
1008
+ }) {
1009
+ await this.$scope.commercePayment.model.refundAudit.insert({
1010
+ refundRequestId: refundRequest.id,
1011
+ refundAttemptId: refundAttempt?.id,
1012
+ orderId: order.id,
1013
+ userId: refundRequest.userId,
1014
+ toRefundState,
1015
+ attemptState,
1016
+ idempotencyKey,
1017
+ correlationId,
1018
+ reason,
1019
+ actorId: this.bean.passport.currentUser?.anonymous
1020
+ ? undefined
1021
+ : this.bean.passport.currentUser?.id,
1022
+ processedAt: new Date(),
1023
+ });
1024
+ }
1025
+
587
1026
  private async _expireLockedOrder(order: EntityOrder) {
588
1027
  const correlationId = `${order.correlationId}:expiry`;
589
1028
  const reason = 'unpaid order expired';
@@ -810,9 +1249,19 @@ export class ServiceOrder extends BeanBase {
810
1249
  reason,
811
1250
  }: {
812
1251
  orderId: TableIdentity;
813
- operation: 'created' | 'paid' | 'cancelled' | 'expired' | 'shipped';
814
- fromState?: 'awaiting_payment' | 'paid' | 'cancelled' | 'expired' | 'shipped';
815
- toState: 'awaiting_payment' | 'paid' | 'cancelled' | 'expired' | 'shipped';
1252
+ operation:
1253
+ | 'created'
1254
+ | 'paid'
1255
+ | 'cancelled'
1256
+ | 'expired'
1257
+ | 'shipped'
1258
+ | 'refund_requested'
1259
+ | 'refund_approved'
1260
+ | 'refund_rejected'
1261
+ | 'refund_failed'
1262
+ | 'refunded';
1263
+ fromState?: EntityOrder['state'];
1264
+ toState: EntityOrder['state'];
816
1265
  correlationId: string;
817
1266
  reason: string;
818
1267
  }) {
@@ -4,9 +4,7 @@ import { catchError } from '@cabloy/utils';
4
4
  import assert from 'node:assert';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { after, before, describe, it } from 'node:test';
7
- import { app } from 'vona-mock';
8
-
9
- import { acquireTestLock } from './testLock.ts';
7
+ import { acquireTestLock, app } from 'vona-mock';
10
8
 
11
9
  const actionPath = '/commerce/trade/cart';
12
10
 
@@ -99,7 +97,7 @@ describe('cartOwnership.test.ts', { concurrency: false }, () => {
99
97
  let releaseTestLock: (() => void) | undefined;
100
98
 
101
99
  before(async () => {
102
- releaseTestLock = await acquireTestLock();
100
+ releaseTestLock = await acquireTestLock('a-commerce');
103
101
  });
104
102
 
105
103
  after(() => {
@@ -2,9 +2,7 @@ import { catchError } from '@cabloy/utils';
2
2
  import assert from 'node:assert';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { after, before, describe, it } from 'node:test';
5
- import { app } from 'vona-mock';
6
-
7
- import { acquireTestLock } from './testLock.ts';
5
+ import { acquireTestLock, app } from 'vona-mock';
8
6
 
9
7
  interface IFixture {
10
8
  categoryId?: number;
@@ -68,7 +66,7 @@ describe('checkoutReservation.test.ts', { concurrency: false }, () => {
68
66
  let releaseTestLock: (() => void) | undefined;
69
67
 
70
68
  before(async () => {
71
- releaseTestLock = await acquireTestLock();
69
+ releaseTestLock = await acquireTestLock('a-commerce');
72
70
  });
73
71
 
74
72
  after(() => {
@@ -2,12 +2,10 @@ import { catchError } from '@cabloy/utils';
2
2
  import assert from 'node:assert';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { after, before, describe, it } from 'node:test';
5
- import { app } from 'vona-mock';
5
+ import { acquireTestLock, app } from 'vona-mock';
6
6
 
7
7
  import type { CheckoutFailureStage } from '../src/service/order.ts';
8
8
 
9
- import { acquireTestLock } from './testLock.ts';
10
-
11
9
  interface IFixture {
12
10
  categoryId: number;
13
11
  productId: number;
@@ -158,7 +156,7 @@ describe('checkoutTransaction.test.ts', { concurrency: false, sequential: true }
158
156
  let releaseTestLock: (() => void) | undefined;
159
157
 
160
158
  before(async () => {
161
- releaseTestLock = await acquireTestLock();
159
+ releaseTestLock = await acquireTestLock('a-commerce');
162
160
  });
163
161
 
164
162
  after(() => {
@@ -2,9 +2,7 @@ import { catchError } from '@cabloy/utils';
2
2
  import assert from 'node:assert';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { after, before, describe, it } from 'node:test';
5
- import { app } from 'vona-mock';
6
-
7
- import { acquireTestLock } from './testLock.ts';
5
+ import { acquireTestLock, app } from 'vona-mock';
8
6
 
9
7
  interface IFixture {
10
8
  categoryId?: number;
@@ -58,7 +56,7 @@ describe('orderSnapshot.test.ts', { concurrency: false }, () => {
58
56
  let releaseTestLock: (() => void) | undefined;
59
57
 
60
58
  before(async () => {
61
- releaseTestLock = await acquireTestLock();
59
+ releaseTestLock = await acquireTestLock('a-commerce');
62
60
  });
63
61
 
64
62
  after(() => {
@@ -1,9 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { after, before, describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
5
-
6
- import { acquireTestLock } from './testLock.ts';
4
+ import { acquireTestLock, app } from 'vona-mock';
7
5
 
8
6
  interface IFixture {
9
7
  categoryId?: number;
@@ -161,7 +159,7 @@ describe('paymentOutcome.test.ts', { concurrency: false, sequential: true }, ()
161
159
  let releaseTestLock: (() => void) | undefined;
162
160
 
163
161
  before(async () => {
164
- releaseTestLock = await acquireTestLock();
162
+ releaseTestLock = await acquireTestLock('a-commerce');
165
163
  });
166
164
 
167
165
  after(() => {