@gravito/satellite-payment 0.1.5 → 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/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@gravito/satellite-payment",
3
- "version": "0.1.5",
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 --dts --clean --external @gravito/atlas --external @gravito/enterprise --external @gravito/stasis --external @gravito/core --external stripe",
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
13
  "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
12
14
  "test:unit": "bun test",
@@ -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
+ }
@@ -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/dist/index.d.ts DELETED
@@ -1,8 +0,0 @@
1
- import { ServiceProvider, Container } from '@gravito/core';
2
-
3
- declare class PaymentServiceProvider extends ServiceProvider {
4
- register(container: Container): void;
5
- boot(): void;
6
- }
7
-
8
- export { PaymentServiceProvider };