@gravito/satellite-payment 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/index.js +9 -2
- package/package.json +7 -3
- package/package.json.bak +34 -0
- package/src/Application/UseCases/ProcessPayment.ts +8 -1
- package/src/Infrastructure/Gateways/StripeGateway.ts +7 -3
- package/src/manifest.json +4 -10
- package/tests/domain-entities.test.ts +134 -0
- package/tests/use-cases.test.ts +381 -0
- package/tsconfig.json +12 -24
- package/dist/index.d.ts +0 -8
package/CHANGELOG.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -63,12 +63,17 @@ var ProcessPayment = class extends UseCase {
|
|
|
63
63
|
this.manager = manager;
|
|
64
64
|
}
|
|
65
65
|
async execute(input) {
|
|
66
|
-
const
|
|
66
|
+
const id = crypto.randomUUID();
|
|
67
|
+
const transaction = Transaction.create(id, {
|
|
67
68
|
orderId: input.orderId,
|
|
68
69
|
amount: input.amount,
|
|
69
70
|
currency: input.currency,
|
|
70
71
|
gateway: input.gateway || "default"
|
|
71
72
|
});
|
|
73
|
+
if (input.metadata) {
|
|
74
|
+
;
|
|
75
|
+
transaction.props.metadata = { ...input.metadata };
|
|
76
|
+
}
|
|
72
77
|
const gateway = this.manager.driver(input.gateway);
|
|
73
78
|
const intent = await gateway.createIntent(transaction);
|
|
74
79
|
transaction.authorize(intent.gatewayTransactionId);
|
|
@@ -108,7 +113,9 @@ var StripeGateway = class {
|
|
|
108
113
|
currency: rawProps.currency.toLowerCase(),
|
|
109
114
|
metadata: {
|
|
110
115
|
orderId: transaction.orderId,
|
|
111
|
-
transactionId: transaction.id
|
|
116
|
+
transactionId: transaction.id,
|
|
117
|
+
// 包含自定義 metadata(如 lockId)
|
|
118
|
+
...rawProps.metadata
|
|
112
119
|
},
|
|
113
120
|
automatic_payment_methods: {
|
|
114
121
|
enabled: true
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/satellite-payment",
|
|
3
|
-
"
|
|
3
|
+
"sideEffects": false,
|
|
4
|
+
"version": "0.2.0",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"module": "dist/index.js",
|
|
7
8
|
"types": "dist/index.d.ts",
|
|
8
9
|
"scripts": {
|
|
9
|
-
"build": "tsup src/index.ts --format esm --
|
|
10
|
+
"build": "tsup src/index.ts --format esm --clean --external @gravito/atlas --external @gravito/enterprise --external @gravito/stasis --external @gravito/core --external stripe",
|
|
11
|
+
"build:dts": "tsup src/index.ts --format esm --dts --clean --external @gravito/atlas --external @gravito/enterprise --external @gravito/stasis --external @gravito/core --external stripe",
|
|
10
12
|
"test": "bun test",
|
|
11
|
-
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
13
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
14
|
+
"test:unit": "bun test",
|
|
15
|
+
"test:integration": "bun test"
|
|
12
16
|
},
|
|
13
17
|
"dependencies": {
|
|
14
18
|
"@gravito/atlas": "workspace:*",
|
package/package.json.bak
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/satellite-payment",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format esm --dts --clean --external @gravito/atlas --external @gravito/enterprise --external @gravito/stasis --external @gravito/core --external stripe",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
12
|
+
"test:unit": "bun test",
|
|
13
|
+
"test:integration": "bun test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@gravito/atlas": "workspace:*",
|
|
17
|
+
"@gravito/enterprise": "workspace:*",
|
|
18
|
+
"@gravito/stasis": "workspace:*",
|
|
19
|
+
"@gravito/core": "workspace:*",
|
|
20
|
+
"stripe": "^20.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.0.0",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
32
|
+
"directory": "satellites/payment"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -8,6 +8,7 @@ export interface ProcessPaymentInput {
|
|
|
8
8
|
amount: number
|
|
9
9
|
currency: string
|
|
10
10
|
gateway?: string // 現在可以動態指定
|
|
11
|
+
metadata?: Record<string, string> // 自定義 metadata(如 lockId)
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export class ProcessPayment extends UseCase<ProcessPaymentInput, PaymentIntent> {
|
|
@@ -16,13 +17,19 @@ export class ProcessPayment extends UseCase<ProcessPaymentInput, PaymentIntent>
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
async execute(input: ProcessPaymentInput): Promise<PaymentIntent> {
|
|
19
|
-
const
|
|
20
|
+
const id = crypto.randomUUID()
|
|
21
|
+
const transaction = Transaction.create(id, {
|
|
20
22
|
orderId: input.orderId,
|
|
21
23
|
amount: input.amount,
|
|
22
24
|
currency: input.currency,
|
|
23
25
|
gateway: input.gateway || 'default',
|
|
24
26
|
})
|
|
25
27
|
|
|
28
|
+
// 設置自定義 metadata(如果提供)
|
|
29
|
+
if (input.metadata) {
|
|
30
|
+
;(transaction as any).props.metadata = { ...input.metadata }
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
// 從 Manager 取得指定的金流驅動器
|
|
27
34
|
const gateway = this.manager.driver(input.gateway)
|
|
28
35
|
const intent = await gateway.createIntent(transaction)
|
|
@@ -7,7 +7,7 @@ export class StripeGateway implements IPaymentGateway {
|
|
|
7
7
|
|
|
8
8
|
constructor(apiKey: string) {
|
|
9
9
|
this.stripe = new Stripe(apiKey, {
|
|
10
|
-
apiVersion: '2025-01-27' as
|
|
10
|
+
apiVersion: '2025-01-27' as never,
|
|
11
11
|
})
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -16,8 +16,10 @@ export class StripeGateway implements IPaymentGateway {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async createIntent(transaction: Transaction): Promise<PaymentIntent> {
|
|
19
|
-
//
|
|
20
|
-
const rawProps = (
|
|
19
|
+
// 透過類型斷言訪問私有屬性以解決跨包訪問問題
|
|
20
|
+
const rawProps = (
|
|
21
|
+
transaction as unknown as { props: { currency: string; metadata?: Record<string, unknown> } }
|
|
22
|
+
).props
|
|
21
23
|
|
|
22
24
|
const intent = await this.stripe.paymentIntents.create({
|
|
23
25
|
amount: Math.round(transaction.amount * 100),
|
|
@@ -25,6 +27,8 @@ export class StripeGateway implements IPaymentGateway {
|
|
|
25
27
|
metadata: {
|
|
26
28
|
orderId: transaction.orderId,
|
|
27
29
|
transactionId: transaction.id,
|
|
30
|
+
// 包含自定義 metadata(如 lockId)
|
|
31
|
+
...rawProps.metadata,
|
|
28
32
|
},
|
|
29
33
|
automatic_payment_methods: {
|
|
30
34
|
enabled: true,
|
package/src/manifest.json
CHANGED
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
"id": "payment",
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"description": "A Gravito Satellite",
|
|
6
|
-
"capabilities": [
|
|
7
|
-
|
|
8
|
-
]
|
|
9
|
-
|
|
10
|
-
"cache"
|
|
11
|
-
],
|
|
12
|
-
"hooks": [
|
|
13
|
-
"payment:created"
|
|
14
|
-
]
|
|
15
|
-
}
|
|
6
|
+
"capabilities": ["create-payment"],
|
|
7
|
+
"requirements": ["cache"],
|
|
8
|
+
"hooks": ["payment:created"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Transaction, TransactionStatus } from '../src/Domain/Entities/Transaction'
|
|
3
|
+
|
|
4
|
+
describe('Payment Domain - Transaction Entity', () => {
|
|
5
|
+
describe('Transaction Creation & Initial State', () => {
|
|
6
|
+
it('應該建立初始狀態為 PENDING 的交易', () => {
|
|
7
|
+
const tx = Transaction.create('tx-1', {
|
|
8
|
+
orderId: 'ord-123',
|
|
9
|
+
amount: 1000,
|
|
10
|
+
currency: 'TWD',
|
|
11
|
+
gateway: 'stripe',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(tx.id).toBe('tx-1')
|
|
15
|
+
expect(tx.orderId).toBe('ord-123')
|
|
16
|
+
expect(tx.amount).toBe(1000)
|
|
17
|
+
expect(tx.status).toBe(TransactionStatus.PENDING)
|
|
18
|
+
expect(tx.gateway).toBe('stripe')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('Transaction Status Transitions', () => {
|
|
23
|
+
it('PENDING → AUTHORIZED: authorize() 應轉移狀態並設置 gatewayId', () => {
|
|
24
|
+
const tx = Transaction.create('tx-1', {
|
|
25
|
+
orderId: 'ord-123',
|
|
26
|
+
amount: 1000,
|
|
27
|
+
currency: 'TWD',
|
|
28
|
+
gateway: 'stripe',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
tx.authorize('stripe_charge_xyz')
|
|
32
|
+
expect(tx.status).toBe(TransactionStatus.AUTHORIZED)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('AUTHORIZED → CAPTURED: capture() 應轉移狀態', () => {
|
|
36
|
+
const tx = Transaction.create('tx-1', {
|
|
37
|
+
orderId: 'ord-123',
|
|
38
|
+
amount: 1000,
|
|
39
|
+
currency: 'TWD',
|
|
40
|
+
gateway: 'stripe',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
tx.authorize('stripe_charge_xyz')
|
|
44
|
+
tx.capture()
|
|
45
|
+
expect(tx.status).toBe(TransactionStatus.CAPTURED)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('CAPTURED → REFUNDED: refund() 應轉移狀態', () => {
|
|
49
|
+
const tx = Transaction.create('tx-1', {
|
|
50
|
+
orderId: 'ord-123',
|
|
51
|
+
amount: 1000,
|
|
52
|
+
currency: 'TWD',
|
|
53
|
+
gateway: 'stripe',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
tx.authorize('stripe_charge_xyz')
|
|
57
|
+
tx.capture()
|
|
58
|
+
tx.refund()
|
|
59
|
+
expect(tx.status).toBe(TransactionStatus.REFUNDED)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('非 PENDING 時,authorize() 應拋出錯誤', () => {
|
|
63
|
+
const tx = Transaction.create('tx-1', {
|
|
64
|
+
orderId: 'ord-123',
|
|
65
|
+
amount: 1000,
|
|
66
|
+
currency: 'TWD',
|
|
67
|
+
gateway: 'stripe',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
tx.authorize('stripe_charge_xyz')
|
|
71
|
+
|
|
72
|
+
expect(() => tx.authorize('another_id')).toThrow(
|
|
73
|
+
'Only pending transactions can be authorized'
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('非 AUTHORIZED 時,capture() 應拋出錯誤', () => {
|
|
78
|
+
const tx = Transaction.create('tx-1', {
|
|
79
|
+
orderId: 'ord-123',
|
|
80
|
+
amount: 1000,
|
|
81
|
+
currency: 'TWD',
|
|
82
|
+
gateway: 'stripe',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(() => tx.capture()).toThrow('Only authorized transactions can be captured')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('非 CAPTURED 時,refund() 應拋出錯誤', () => {
|
|
89
|
+
const tx = Transaction.create('tx-1', {
|
|
90
|
+
orderId: 'ord-123',
|
|
91
|
+
amount: 1000,
|
|
92
|
+
currency: 'TWD',
|
|
93
|
+
gateway: 'stripe',
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
tx.authorize('stripe_charge_xyz')
|
|
97
|
+
|
|
98
|
+
expect(() => tx.refund()).toThrow('Only captured transactions can be refunded')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('fail() 應將狀態設為 FAILED 並儲存失敗原因', () => {
|
|
102
|
+
const tx = Transaction.create('tx-1', {
|
|
103
|
+
orderId: 'ord-123',
|
|
104
|
+
amount: 1000,
|
|
105
|
+
currency: 'TWD',
|
|
106
|
+
gateway: 'stripe',
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
tx.fail('Card declined')
|
|
110
|
+
expect(tx.status).toBe(TransactionStatus.FAILED)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('Multiple Transaction Types', () => {
|
|
115
|
+
it('應該支援不同的支付閘道', () => {
|
|
116
|
+
const stripeTx = Transaction.create('tx-1', {
|
|
117
|
+
orderId: 'ord-1',
|
|
118
|
+
amount: 1000,
|
|
119
|
+
currency: 'TWD',
|
|
120
|
+
gateway: 'stripe',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const paypalTx = Transaction.create('tx-2', {
|
|
124
|
+
orderId: 'ord-2',
|
|
125
|
+
amount: 2000,
|
|
126
|
+
currency: 'TWD',
|
|
127
|
+
gateway: 'paypal',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(stripeTx.gateway).toBe('stripe')
|
|
131
|
+
expect(paypalTx.gateway).toBe('paypal')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { ProcessPayment } from '../src/Application/UseCases/ProcessPayment'
|
|
3
|
+
import { RefundPayment } from '../src/Application/UseCases/RefundPayment'
|
|
4
|
+
import type { IPaymentGateway, PaymentIntent } from '../src/Domain/Contracts/IPaymentGateway'
|
|
5
|
+
import { Transaction, TransactionStatus } from '../src/Domain/Entities/Transaction'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mock PaymentGateway
|
|
9
|
+
*/
|
|
10
|
+
class MockPaymentGateway implements IPaymentGateway {
|
|
11
|
+
private intents: Map<string, PaymentIntent> = new Map()
|
|
12
|
+
|
|
13
|
+
getName(): string {
|
|
14
|
+
return 'mock'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async createIntent(transaction: Transaction): Promise<PaymentIntent> {
|
|
18
|
+
const intent: PaymentIntent = {
|
|
19
|
+
gatewayTransactionId: `mock_${transaction.id}`,
|
|
20
|
+
clientSecret: `secret_${transaction.id}`,
|
|
21
|
+
status: 'requires_action',
|
|
22
|
+
}
|
|
23
|
+
this.intents.set(intent.gatewayTransactionId, intent)
|
|
24
|
+
return intent
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async capture(gatewayTransactionId: string): Promise<boolean> {
|
|
28
|
+
const intent = this.intents.get(gatewayTransactionId)
|
|
29
|
+
if (!intent) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
intent.status = 'succeeded'
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async refund(gatewayTransactionId: string, amount?: number): Promise<boolean> {
|
|
37
|
+
const intent = this.intents.get(gatewayTransactionId)
|
|
38
|
+
if (!intent) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
intent.status = 'refunded'
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mock PaymentManager
|
|
48
|
+
*/
|
|
49
|
+
class MockPaymentManager {
|
|
50
|
+
private gateways: Map<string, IPaymentGateway> = new Map()
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
this.gateways.set('mock', new MockPaymentGateway())
|
|
54
|
+
this.gateways.set('stripe', new MockPaymentGateway())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
driver(name?: string): IPaymentGateway {
|
|
58
|
+
const driverName = name || 'stripe'
|
|
59
|
+
const gateway = this.gateways.get(driverName)
|
|
60
|
+
if (!gateway) {
|
|
61
|
+
throw new Error(`Payment driver [${driverName}] is not registered`)
|
|
62
|
+
}
|
|
63
|
+
return gateway
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getGateway(name: string): MockPaymentGateway {
|
|
67
|
+
return this.gateways.get(name) as MockPaymentGateway
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('Payment UseCases', () => {
|
|
72
|
+
describe('ProcessPayment', () => {
|
|
73
|
+
it('應該能成功建立支付意向', async () => {
|
|
74
|
+
const manager = new MockPaymentManager()
|
|
75
|
+
const useCase = new ProcessPayment(manager as any)
|
|
76
|
+
|
|
77
|
+
const result = await useCase.execute({
|
|
78
|
+
orderId: 'ord-123',
|
|
79
|
+
amount: 1000,
|
|
80
|
+
currency: 'TWD',
|
|
81
|
+
gateway: 'stripe',
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
85
|
+
expect(result.clientSecret).toBeDefined()
|
|
86
|
+
expect(result.status).toBe('requires_action')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('應該能設置自定義 metadata', async () => {
|
|
90
|
+
const manager = new MockPaymentManager()
|
|
91
|
+
const useCase = new ProcessPayment(manager as any)
|
|
92
|
+
|
|
93
|
+
const result = await useCase.execute({
|
|
94
|
+
orderId: 'ord-123',
|
|
95
|
+
amount: 1000,
|
|
96
|
+
currency: 'TWD',
|
|
97
|
+
gateway: 'stripe',
|
|
98
|
+
metadata: {
|
|
99
|
+
lockId: 'lock-xyz',
|
|
100
|
+
source: 'web',
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('應該使用預設驅動器當未指定時', async () => {
|
|
108
|
+
const manager = new MockPaymentManager()
|
|
109
|
+
const useCase = new ProcessPayment(manager as any)
|
|
110
|
+
|
|
111
|
+
const result = await useCase.execute({
|
|
112
|
+
orderId: 'ord-123',
|
|
113
|
+
amount: 1000,
|
|
114
|
+
currency: 'TWD',
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('應該在不同幣別間工作', async () => {
|
|
121
|
+
const manager = new MockPaymentManager()
|
|
122
|
+
const useCase = new ProcessPayment(manager as any)
|
|
123
|
+
|
|
124
|
+
const twdResult = await useCase.execute({
|
|
125
|
+
orderId: 'ord-1',
|
|
126
|
+
amount: 1000,
|
|
127
|
+
currency: 'TWD',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const usdResult = await useCase.execute({
|
|
131
|
+
orderId: 'ord-2',
|
|
132
|
+
amount: 30,
|
|
133
|
+
currency: 'USD',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(twdResult.gatewayTransactionId).toBeDefined()
|
|
137
|
+
expect(usdResult.gatewayTransactionId).toBeDefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('應該支援多個支付閘道', async () => {
|
|
141
|
+
const manager = new MockPaymentManager()
|
|
142
|
+
const useCase = new ProcessPayment(manager as any)
|
|
143
|
+
|
|
144
|
+
const stripeResult = await useCase.execute({
|
|
145
|
+
orderId: 'ord-1',
|
|
146
|
+
amount: 1000,
|
|
147
|
+
currency: 'TWD',
|
|
148
|
+
gateway: 'stripe',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const mockResult = await useCase.execute({
|
|
152
|
+
orderId: 'ord-2',
|
|
153
|
+
amount: 2000,
|
|
154
|
+
currency: 'TWD',
|
|
155
|
+
gateway: 'mock',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
expect(stripeResult.gatewayTransactionId).toBeDefined()
|
|
159
|
+
expect(mockResult.gatewayTransactionId).toBeDefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('當驅動器不存在時應拋出錯誤', async () => {
|
|
163
|
+
const manager = new MockPaymentManager()
|
|
164
|
+
const useCase = new ProcessPayment(manager as any)
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await useCase.execute({
|
|
168
|
+
orderId: 'ord-123',
|
|
169
|
+
amount: 1000,
|
|
170
|
+
currency: 'TWD',
|
|
171
|
+
gateway: 'nonexistent',
|
|
172
|
+
})
|
|
173
|
+
expect.unreachable('應該拋出錯誤')
|
|
174
|
+
} catch (error: any) {
|
|
175
|
+
expect(error.message).toContain('not registered')
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('RefundPayment', () => {
|
|
181
|
+
it('應該能成功退款', async () => {
|
|
182
|
+
const gateway = new MockPaymentGateway()
|
|
183
|
+
|
|
184
|
+
// 先建立一筆交易
|
|
185
|
+
const tx = Transaction.create('tx-1', {
|
|
186
|
+
orderId: 'ord-123',
|
|
187
|
+
amount: 1000,
|
|
188
|
+
currency: 'TWD',
|
|
189
|
+
gateway: 'mock',
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const intent = await gateway.createIntent(tx)
|
|
193
|
+
|
|
194
|
+
// 執行退款
|
|
195
|
+
const useCase = new RefundPayment(gateway)
|
|
196
|
+
const result = await useCase.execute({
|
|
197
|
+
gatewayTransactionId: intent.gatewayTransactionId,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
expect(result).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('應該能支援部分退款', async () => {
|
|
204
|
+
const gateway = new MockPaymentGateway()
|
|
205
|
+
|
|
206
|
+
const tx = Transaction.create('tx-1', {
|
|
207
|
+
orderId: 'ord-123',
|
|
208
|
+
amount: 1000,
|
|
209
|
+
currency: 'TWD',
|
|
210
|
+
gateway: 'mock',
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const intent = await gateway.createIntent(tx)
|
|
214
|
+
|
|
215
|
+
const useCase = new RefundPayment(gateway)
|
|
216
|
+
const result = await useCase.execute({
|
|
217
|
+
gatewayTransactionId: intent.gatewayTransactionId,
|
|
218
|
+
amount: 500,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
expect(result).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('當交易不存在時應返回 false', async () => {
|
|
225
|
+
const gateway = new MockPaymentGateway()
|
|
226
|
+
const useCase = new RefundPayment(gateway)
|
|
227
|
+
|
|
228
|
+
const result = await useCase.execute({
|
|
229
|
+
gatewayTransactionId: 'nonexistent_tx',
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(result).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('應該能連續退款多筆交易', async () => {
|
|
236
|
+
const gateway = new MockPaymentGateway()
|
|
237
|
+
|
|
238
|
+
// 建立多筆交易
|
|
239
|
+
const tx1 = Transaction.create('tx-1', {
|
|
240
|
+
orderId: 'ord-1',
|
|
241
|
+
amount: 1000,
|
|
242
|
+
currency: 'TWD',
|
|
243
|
+
gateway: 'mock',
|
|
244
|
+
})
|
|
245
|
+
const tx2 = Transaction.create('tx-2', {
|
|
246
|
+
orderId: 'ord-2',
|
|
247
|
+
amount: 2000,
|
|
248
|
+
currency: 'TWD',
|
|
249
|
+
gateway: 'mock',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const intent1 = await gateway.createIntent(tx1)
|
|
253
|
+
const intent2 = await gateway.createIntent(tx2)
|
|
254
|
+
|
|
255
|
+
const useCase = new RefundPayment(gateway)
|
|
256
|
+
|
|
257
|
+
const result1 = await useCase.execute({
|
|
258
|
+
gatewayTransactionId: intent1.gatewayTransactionId,
|
|
259
|
+
})
|
|
260
|
+
const result2 = await useCase.execute({
|
|
261
|
+
gatewayTransactionId: intent2.gatewayTransactionId,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
expect(result1).toBe(true)
|
|
265
|
+
expect(result2).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('Transaction State Machine with UseCases', () => {
|
|
270
|
+
it('應該能從 PENDING 經過 AUTHORIZED 到 CAPTURED', async () => {
|
|
271
|
+
const gateway = new MockPaymentGateway()
|
|
272
|
+
|
|
273
|
+
// 1. 建立交易(ProcessPayment)
|
|
274
|
+
const tx = Transaction.create('tx-1', {
|
|
275
|
+
orderId: 'ord-123',
|
|
276
|
+
amount: 1000,
|
|
277
|
+
currency: 'TWD',
|
|
278
|
+
gateway: 'mock',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(tx.status).toBe(TransactionStatus.PENDING)
|
|
282
|
+
|
|
283
|
+
// 2. 建立支付意向
|
|
284
|
+
const intent = await gateway.createIntent(tx)
|
|
285
|
+
tx.authorize(intent.gatewayTransactionId)
|
|
286
|
+
|
|
287
|
+
expect(tx.status).toBe(TransactionStatus.AUTHORIZED)
|
|
288
|
+
|
|
289
|
+
// 3. 清算
|
|
290
|
+
await gateway.capture(intent.gatewayTransactionId)
|
|
291
|
+
tx.capture()
|
|
292
|
+
|
|
293
|
+
expect(tx.status).toBe(TransactionStatus.CAPTURED)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('應該能在已清算狀態退款', async () => {
|
|
297
|
+
const gateway = new MockPaymentGateway()
|
|
298
|
+
|
|
299
|
+
// 建立並處理交易
|
|
300
|
+
const tx = Transaction.create('tx-1', {
|
|
301
|
+
orderId: 'ord-123',
|
|
302
|
+
amount: 1000,
|
|
303
|
+
currency: 'TWD',
|
|
304
|
+
gateway: 'mock',
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const intent = await gateway.createIntent(tx)
|
|
308
|
+
tx.authorize(intent.gatewayTransactionId)
|
|
309
|
+
tx.capture()
|
|
310
|
+
|
|
311
|
+
// 執行退款
|
|
312
|
+
const useCase = new RefundPayment(gateway)
|
|
313
|
+
const result = await useCase.execute({
|
|
314
|
+
gatewayTransactionId: intent.gatewayTransactionId,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
tx.refund()
|
|
318
|
+
|
|
319
|
+
expect(result).toBe(true)
|
|
320
|
+
expect(tx.status).toBe(TransactionStatus.REFUNDED)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('應該防止無效的狀態轉移', async () => {
|
|
324
|
+
const tx = Transaction.create('tx-1', {
|
|
325
|
+
orderId: 'ord-123',
|
|
326
|
+
amount: 1000,
|
|
327
|
+
currency: 'TWD',
|
|
328
|
+
gateway: 'mock',
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// 不能在 PENDING 狀態捕獲
|
|
332
|
+
expect(() => tx.capture()).toThrow('Only authorized transactions can be captured')
|
|
333
|
+
|
|
334
|
+
tx.authorize('gateway_id')
|
|
335
|
+
|
|
336
|
+
// 不能在 AUTHORIZED 狀態退款
|
|
337
|
+
expect(() => tx.refund()).toThrow('Only captured transactions can be refunded')
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('Edge Cases', () => {
|
|
342
|
+
it('應該能處理大金額交易', async () => {
|
|
343
|
+
const manager = new MockPaymentManager()
|
|
344
|
+
const useCase = new ProcessPayment(manager as any)
|
|
345
|
+
|
|
346
|
+
const result = await useCase.execute({
|
|
347
|
+
orderId: 'ord-999999',
|
|
348
|
+
amount: 999999999,
|
|
349
|
+
currency: 'TWD',
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('應該能處理極小金額交易', async () => {
|
|
356
|
+
const manager = new MockPaymentManager()
|
|
357
|
+
const useCase = new ProcessPayment(manager as any)
|
|
358
|
+
|
|
359
|
+
const result = await useCase.execute({
|
|
360
|
+
orderId: 'ord-1',
|
|
361
|
+
amount: 1,
|
|
362
|
+
currency: 'TWD',
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('應該能處理 0 金額(測試場景)', async () => {
|
|
369
|
+
const manager = new MockPaymentManager()
|
|
370
|
+
const useCase = new ProcessPayment(manager as any)
|
|
371
|
+
|
|
372
|
+
const result = await useCase.execute({
|
|
373
|
+
orderId: 'ord-free',
|
|
374
|
+
amount: 0,
|
|
375
|
+
currency: 'TWD',
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
expect(result.gatewayTransactionId).toBeDefined()
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
],
|
|
10
|
-
"@gravito/*": [
|
|
11
|
-
"../../packages/*/src/index.ts"
|
|
12
|
-
]
|
|
13
|
-
},
|
|
14
|
-
"types": [
|
|
15
|
-
"bun-types"
|
|
16
|
-
]
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@gravito/core": ["../../packages/core/src/index.ts"],
|
|
8
|
+
"@gravito/*": ["../../packages/*/src/index.ts"]
|
|
17
9
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"dist",
|
|
24
|
-
"**/*.test.ts"
|
|
25
|
-
]
|
|
26
|
-
}
|
|
10
|
+
"types": ["bun-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"],
|
|
13
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
14
|
+
}
|