@gravito/satellite-payment 0.1.1
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/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +11 -0
- package/Dockerfile +25 -0
- package/WHITEPAPER.md +43 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +238 -0
- package/package.json +32 -0
- package/src/Application/UseCases/CreatePayment.ts +22 -0
- package/src/Application/UseCases/ProcessPayment.ts +34 -0
- package/src/Application/UseCases/RefundPayment.ts +18 -0
- package/src/Domain/Contracts/IPaymentGateway.ts +26 -0
- package/src/Domain/Contracts/IPaymentRepository.ts +6 -0
- package/src/Domain/Entities/Payment.ts +26 -0
- package/src/Domain/Entities/Transaction.ts +81 -0
- package/src/Infrastructure/Gateways/StripeGateway.ts +53 -0
- package/src/Infrastructure/PaymentManager.ts +32 -0
- package/src/Infrastructure/Persistence/AtlasPaymentRepository.ts +24 -0
- package/src/Interface/Http/Controllers/StripeWebhookController.ts +34 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +66 -0
- package/src/manifest.json +15 -0
- package/tests/unit.test.ts +7 -0
- package/tsconfig.json +26 -0
package/.dockerignore
ADDED
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Application
|
|
2
|
+
APP_NAME="payment"
|
|
3
|
+
APP_ENV=development
|
|
4
|
+
APP_KEY=
|
|
5
|
+
APP_DEBUG=true
|
|
6
|
+
APP_URL=http://localhost:3000
|
|
7
|
+
|
|
8
|
+
# Server
|
|
9
|
+
PORT=3000
|
|
10
|
+
|
|
11
|
+
# Database
|
|
12
|
+
DB_CONNECTION=sqlite
|
|
13
|
+
DB_DATABASE=database/database.sqlite
|
|
14
|
+
|
|
15
|
+
# Cache
|
|
16
|
+
CACHE_DRIVER=memory
|
|
17
|
+
|
|
18
|
+
# Logging
|
|
19
|
+
LOG_LEVEL=debug
|
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# payment Satellite Architecture
|
|
2
|
+
|
|
3
|
+
This satellite follows the Gravito Satellite Specification v1.0.
|
|
4
|
+
|
|
5
|
+
## Design
|
|
6
|
+
- **DDD**: Domain logic is separated from framework concerns.
|
|
7
|
+
- **Dogfooding**: Uses official Gravito modules (@gravito/atlas, @gravito/stasis).
|
|
8
|
+
- **Decoupled**: Inter-satellite communication happens via Contracts and Events.
|
|
9
|
+
|
|
10
|
+
## Layers
|
|
11
|
+
- **Domain**: Pure business rules.
|
|
12
|
+
- **Application**: Orchestration of domain tasks.
|
|
13
|
+
- **Infrastructure**: Implementation of persistence and external services.
|
|
14
|
+
- **Interface**: HTTP and Event entry points.
|
package/CHANGELOG.md
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
FROM oven/bun:1.0 AS base
|
|
2
|
+
WORKDIR /usr/src/app
|
|
3
|
+
|
|
4
|
+
# Install dependencies
|
|
5
|
+
FROM base AS install
|
|
6
|
+
RUN mkdir -p /temp/dev
|
|
7
|
+
COPY package.json bun.lockb /temp/dev/
|
|
8
|
+
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
9
|
+
|
|
10
|
+
# Build application
|
|
11
|
+
FROM base AS build
|
|
12
|
+
COPY --from=install /temp/dev/node_modules node_modules
|
|
13
|
+
COPY . .
|
|
14
|
+
ENV NODE_ENV=production
|
|
15
|
+
RUN bun run build
|
|
16
|
+
|
|
17
|
+
# Final production image
|
|
18
|
+
FROM base AS release
|
|
19
|
+
COPY --from=build /usr/src/app/dist/bootstrap.js index.js
|
|
20
|
+
COPY --from=build /usr/src/app/package.json .
|
|
21
|
+
|
|
22
|
+
# Create a non-root user for security
|
|
23
|
+
USER bun
|
|
24
|
+
EXPOSE 3000/tcp
|
|
25
|
+
ENTRYPOINT [ "bun", "run", "index.js" ]
|
package/WHITEPAPER.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Gravito Payment Satellite - Technical Whitepaper
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
The Payment Satellite is the financial clearing house of the Gravito ecosystem. It provides a standardized, asynchronous, and secure way to handle monetary transactions using the **GASS (Gravito Asynchronous Satellite Specification)**.
|
|
5
|
+
|
|
6
|
+
## Core Concepts
|
|
7
|
+
|
|
8
|
+
### 1. Transaction Ledger
|
|
9
|
+
Unlike simple payment callbacks, the Payment satellite maintains an immutable ledger of transactions. Every payment intent, authorization, and capture is logged as a state transition in the `Transaction` entity.
|
|
10
|
+
|
|
11
|
+
### 2. Dual-Phase Clearing (Authorize & Capture)
|
|
12
|
+
To support high-trust commerce, the satellite supports a two-step clearing process:
|
|
13
|
+
- **Authorize**: Reserve funds on the user's payment method (triggered by order creation).
|
|
14
|
+
- **Capture**: Finalize the transfer (triggered by shipping or digital delivery).
|
|
15
|
+
|
|
16
|
+
## Propulsion Stages
|
|
17
|
+
|
|
18
|
+
### Stage 1: Standard (SQL)
|
|
19
|
+
- Records all ledger entries in relational tables.
|
|
20
|
+
- Ensures strict ACID compliance for financial auditing.
|
|
21
|
+
|
|
22
|
+
### Stage 2: Sport (In-Memory)
|
|
23
|
+
- **Intent Pre-warming**: When a user enters the checkout page, the satellite pre-computes the PaymentIntent and caches it in memory.
|
|
24
|
+
- Reduces checkout latency by ~200ms.
|
|
25
|
+
|
|
26
|
+
### Stage 3: Turbo (Asynchronous Webhooks)
|
|
27
|
+
- Utilizes `gravito-echo` to handle high-frequency webhooks from Stripe/PayPal.
|
|
28
|
+
- Processes clearing and fraud checks in background queues to keep the main thread responsive.
|
|
29
|
+
|
|
30
|
+
## Event Driven Architecture
|
|
31
|
+
|
|
32
|
+
### Outbound Events
|
|
33
|
+
- `payment:intent:ready`: Notifies Commerce that the payment session is ready for the UI.
|
|
34
|
+
- `payment:succeeded`: Notifies Commerce to mark the order as `PAID`.
|
|
35
|
+
- `payment:refund:succeeded`: Notifies Commerce to mark the order as `REFUNDED`.
|
|
36
|
+
|
|
37
|
+
### Inbound Actions
|
|
38
|
+
- `commerce:order:created`: Automatically initiates the payment flow.
|
|
39
|
+
- `commerce:order:refund-requested`: Automatically initiates the refund gateway call.
|
|
40
|
+
|
|
41
|
+
## Security
|
|
42
|
+
- **Strict Isolation**: No raw card data ever touches the Gravito core. All sensitive data is handled via Gateway Tokens (Stripe Tokens/Secrets).
|
|
43
|
+
- **Idempotency**: All `pay()` and `refund()` calls require an idempotency key to prevent double-charging.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { ServiceProvider } from "@gravito/core";
|
|
3
|
+
|
|
4
|
+
// src/Application/UseCases/ProcessPayment.ts
|
|
5
|
+
import { UseCase } from "@gravito/enterprise";
|
|
6
|
+
|
|
7
|
+
// src/Domain/Entities/Transaction.ts
|
|
8
|
+
import { Entity } from "@gravito/enterprise";
|
|
9
|
+
var Transaction = class _Transaction extends Entity {
|
|
10
|
+
constructor(id, props) {
|
|
11
|
+
super(id);
|
|
12
|
+
this.props = props;
|
|
13
|
+
}
|
|
14
|
+
static create(id, props) {
|
|
15
|
+
return new _Transaction(id, {
|
|
16
|
+
...props,
|
|
17
|
+
status: "pending" /* PENDING */,
|
|
18
|
+
metadata: {},
|
|
19
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
authorize(gatewayId) {
|
|
23
|
+
if (this.props.status !== "pending" /* PENDING */) {
|
|
24
|
+
throw new Error("Only pending transactions can be authorized");
|
|
25
|
+
}
|
|
26
|
+
this.props.status = "authorized" /* AUTHORIZED */;
|
|
27
|
+
this.props.gatewayTransactionId = gatewayId;
|
|
28
|
+
}
|
|
29
|
+
capture() {
|
|
30
|
+
if (this.props.status !== "authorized" /* AUTHORIZED */) {
|
|
31
|
+
throw new Error("Only authorized transactions can be captured");
|
|
32
|
+
}
|
|
33
|
+
this.props.status = "captured" /* CAPTURED */;
|
|
34
|
+
}
|
|
35
|
+
refund() {
|
|
36
|
+
if (this.props.status !== "captured" /* CAPTURED */) {
|
|
37
|
+
throw new Error("Only captured transactions can be refunded");
|
|
38
|
+
}
|
|
39
|
+
this.props.status = "refunded" /* REFUNDED */;
|
|
40
|
+
}
|
|
41
|
+
fail(reason) {
|
|
42
|
+
this.props.status = "failed" /* FAILED */;
|
|
43
|
+
this.props.metadata.failReason = reason;
|
|
44
|
+
}
|
|
45
|
+
get orderId() {
|
|
46
|
+
return this.props.orderId;
|
|
47
|
+
}
|
|
48
|
+
get amount() {
|
|
49
|
+
return this.props.amount;
|
|
50
|
+
}
|
|
51
|
+
get status() {
|
|
52
|
+
return this.props.status;
|
|
53
|
+
}
|
|
54
|
+
get gateway() {
|
|
55
|
+
return this.props.gateway;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/Application/UseCases/ProcessPayment.ts
|
|
60
|
+
var ProcessPayment = class extends UseCase {
|
|
61
|
+
constructor(manager) {
|
|
62
|
+
super();
|
|
63
|
+
this.manager = manager;
|
|
64
|
+
}
|
|
65
|
+
async execute(input) {
|
|
66
|
+
const transaction = Transaction.create(crypto.randomUUID(), {
|
|
67
|
+
orderId: input.orderId,
|
|
68
|
+
amount: input.amount,
|
|
69
|
+
currency: input.currency,
|
|
70
|
+
gateway: input.gateway || "default"
|
|
71
|
+
});
|
|
72
|
+
const gateway = this.manager.driver(input.gateway);
|
|
73
|
+
const intent = await gateway.createIntent(transaction);
|
|
74
|
+
transaction.authorize(intent.gatewayTransactionId);
|
|
75
|
+
return intent;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/Application/UseCases/RefundPayment.ts
|
|
80
|
+
import { UseCase as UseCase2 } from "@gravito/enterprise";
|
|
81
|
+
var RefundPayment = class extends UseCase2 {
|
|
82
|
+
constructor(gateway) {
|
|
83
|
+
super();
|
|
84
|
+
this.gateway = gateway;
|
|
85
|
+
}
|
|
86
|
+
async execute(input) {
|
|
87
|
+
console.log(`[Payment] Processing refund for transaction: ${input.gatewayTransactionId}`);
|
|
88
|
+
return await this.gateway.refund(input.gatewayTransactionId, input.amount);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// src/Infrastructure/Gateways/StripeGateway.ts
|
|
93
|
+
import Stripe from "stripe";
|
|
94
|
+
var StripeGateway = class {
|
|
95
|
+
stripe;
|
|
96
|
+
constructor(apiKey) {
|
|
97
|
+
this.stripe = new Stripe(apiKey, {
|
|
98
|
+
apiVersion: "2025-01-27"
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
getName() {
|
|
102
|
+
return "stripe";
|
|
103
|
+
}
|
|
104
|
+
async createIntent(transaction) {
|
|
105
|
+
const rawProps = transaction.props;
|
|
106
|
+
const intent = await this.stripe.paymentIntents.create({
|
|
107
|
+
amount: Math.round(transaction.amount * 100),
|
|
108
|
+
currency: rawProps.currency.toLowerCase(),
|
|
109
|
+
metadata: {
|
|
110
|
+
orderId: transaction.orderId,
|
|
111
|
+
transactionId: transaction.id
|
|
112
|
+
},
|
|
113
|
+
automatic_payment_methods: {
|
|
114
|
+
enabled: true
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
gatewayTransactionId: intent.id,
|
|
119
|
+
clientSecret: intent.client_secret || "",
|
|
120
|
+
status: intent.status
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async capture(gatewayTransactionId) {
|
|
124
|
+
const intent = await this.stripe.paymentIntents.capture(gatewayTransactionId);
|
|
125
|
+
return intent.status === "succeeded";
|
|
126
|
+
}
|
|
127
|
+
async refund(gatewayTransactionId, amount) {
|
|
128
|
+
const refund = await this.stripe.refunds.create({
|
|
129
|
+
payment_intent: gatewayTransactionId,
|
|
130
|
+
...amount && { amount: Math.round(amount * 100) }
|
|
131
|
+
});
|
|
132
|
+
return refund.status === "succeeded";
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// src/Infrastructure/PaymentManager.ts
|
|
137
|
+
var PaymentManager = class {
|
|
138
|
+
constructor(core) {
|
|
139
|
+
this.core = core;
|
|
140
|
+
}
|
|
141
|
+
drivers = /* @__PURE__ */ new Map();
|
|
142
|
+
/**
|
|
143
|
+
* 註冊金流驅動器 (這就是日後掛載 ECPay, LINE Pay 的入口)
|
|
144
|
+
*/
|
|
145
|
+
extend(name, resolver) {
|
|
146
|
+
this.core.logger.info(`[PaymentManager] Driver registered: ${name}`);
|
|
147
|
+
this.drivers.set(name, resolver);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 取得指定或預設的驅動器
|
|
151
|
+
*/
|
|
152
|
+
driver(name) {
|
|
153
|
+
const driverName = name || this.core.config.get("payment.default", "stripe");
|
|
154
|
+
const resolver = this.drivers.get(driverName);
|
|
155
|
+
if (!resolver) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Payment driver [${driverName}] is not registered. Did you forget to mount the gateway satellite?`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return resolver();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// src/Interface/Http/Controllers/StripeWebhookController.ts
|
|
165
|
+
import Stripe2 from "stripe";
|
|
166
|
+
var StripeWebhookController = class {
|
|
167
|
+
async handle(c) {
|
|
168
|
+
const core = c.get("core");
|
|
169
|
+
const stripeKey = core.config.get("payment.stripe.secret") || "";
|
|
170
|
+
const webhookSecret = core.config.get("payment.stripe.webhook_secret") || "";
|
|
171
|
+
const stripe = new Stripe2(stripeKey);
|
|
172
|
+
const signature = c.req.header("stripe-signature") || "";
|
|
173
|
+
try {
|
|
174
|
+
const body = await c.req.text();
|
|
175
|
+
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
|
176
|
+
if (event.type === "payment_intent.succeeded") {
|
|
177
|
+
const intent = event.data.object;
|
|
178
|
+
core.logger.info(`[Payment] Webhook: Payment succeeded for ${intent.id}`);
|
|
179
|
+
await core.hooks.doAction("payment:succeeded", {
|
|
180
|
+
gatewayTransactionId: intent.id,
|
|
181
|
+
orderId: intent.metadata.orderId,
|
|
182
|
+
amount: intent.amount / 100
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return c.json({ received: true });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
core.logger.error(`[Payment] Webhook Error: ${err.message}`);
|
|
188
|
+
return c.json({ error: "Webhook signature verification failed" }, 400);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/index.ts
|
|
194
|
+
var PaymentServiceProvider = class extends ServiceProvider {
|
|
195
|
+
register(container) {
|
|
196
|
+
container.singleton("payment.manager", (_c) => new PaymentManager(this.core));
|
|
197
|
+
container.singleton("payment.process", (c) => {
|
|
198
|
+
return new ProcessPayment(c.make("payment.manager"));
|
|
199
|
+
});
|
|
200
|
+
container.singleton("payment.refund", (c) => {
|
|
201
|
+
return new RefundPayment(c.make("payment.manager"));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
boot() {
|
|
205
|
+
const core = this.core;
|
|
206
|
+
if (!core) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const manager = core.container.make("payment.manager");
|
|
210
|
+
manager.extend("stripe", () => {
|
|
211
|
+
const apiKey = core.config.get("payment.stripe.secret") || "mock_key";
|
|
212
|
+
return new StripeGateway(apiKey);
|
|
213
|
+
});
|
|
214
|
+
const webhookCtrl = new StripeWebhookController();
|
|
215
|
+
core.router.post("/webhooks/payment/stripe", (c) => webhookCtrl.handle(c));
|
|
216
|
+
core.logger.info("\u{1F6F0}\uFE0F Satellite Payment is operational (Manager Ready)");
|
|
217
|
+
core.hooks.addAction("commerce:order:created", async (payload) => {
|
|
218
|
+
const processPayment = core.container.make("payment.process");
|
|
219
|
+
try {
|
|
220
|
+
const intent = await processPayment.execute({
|
|
221
|
+
orderId: payload.order.id,
|
|
222
|
+
amount: payload.order.totalAmount,
|
|
223
|
+
currency: payload.order.currency
|
|
224
|
+
// gateway: 'stripe' // 可以由配置或 Payload 決定
|
|
225
|
+
});
|
|
226
|
+
await core.hooks.doAction("payment:intent:ready", { orderId: payload.order.id, intent });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
core.logger.error(`[Payment] Process error: ${error.message}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
core.hooks.addAction("payment:succeeded", async (payload) => {
|
|
232
|
+
core.logger.info(`[Payment] Order confirmed as PAID: ${payload.orderId}`);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
export {
|
|
237
|
+
PaymentServiceProvider
|
|
238
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/satellite-payment",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@gravito/atlas": "workspace:*",
|
|
15
|
+
"@gravito/enterprise": "workspace:*",
|
|
16
|
+
"@gravito/stasis": "workspace:*",
|
|
17
|
+
"@gravito/core": "workspace:*",
|
|
18
|
+
"stripe": "^20.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"tsup": "^8.0.0",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
30
|
+
"directory": "satellites/payment"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { IPaymentRepository } from '../../Domain/Contracts/IPaymentRepository'
|
|
3
|
+
import { Payment } from '../../Domain/Entities/Payment'
|
|
4
|
+
|
|
5
|
+
export interface CreatePaymentInput {
|
|
6
|
+
name: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class CreatePayment extends UseCase<CreatePaymentInput, string> {
|
|
10
|
+
constructor(private repository: IPaymentRepository) {
|
|
11
|
+
super()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async execute(input: CreatePaymentInput): Promise<string> {
|
|
15
|
+
const id = crypto.randomUUID()
|
|
16
|
+
const entity = Payment.create(id, input.name)
|
|
17
|
+
|
|
18
|
+
await this.repository.save(entity)
|
|
19
|
+
|
|
20
|
+
return id
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { PaymentIntent } from '../../Domain/Contracts/IPaymentGateway'
|
|
3
|
+
import { Transaction } from '../../Domain/Entities/Transaction'
|
|
4
|
+
import type { PaymentManager } from '../../Infrastructure/PaymentManager'
|
|
5
|
+
|
|
6
|
+
export interface ProcessPaymentInput {
|
|
7
|
+
orderId: string
|
|
8
|
+
amount: number
|
|
9
|
+
currency: string
|
|
10
|
+
gateway?: string // 現在可以動態指定
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ProcessPayment extends UseCase<ProcessPaymentInput, PaymentIntent> {
|
|
14
|
+
constructor(private manager: PaymentManager) {
|
|
15
|
+
super()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async execute(input: ProcessPaymentInput): Promise<PaymentIntent> {
|
|
19
|
+
const transaction = Transaction.create(crypto.randomUUID(), {
|
|
20
|
+
orderId: input.orderId,
|
|
21
|
+
amount: input.amount,
|
|
22
|
+
currency: input.currency,
|
|
23
|
+
gateway: input.gateway || 'default',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// 從 Manager 取得指定的金流驅動器
|
|
27
|
+
const gateway = this.manager.driver(input.gateway)
|
|
28
|
+
const intent = await gateway.createIntent(transaction)
|
|
29
|
+
|
|
30
|
+
transaction.authorize(intent.gatewayTransactionId)
|
|
31
|
+
|
|
32
|
+
return intent
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { IPaymentGateway } from '../../Domain/Contracts/IPaymentGateway'
|
|
3
|
+
|
|
4
|
+
export interface RefundPaymentInput {
|
|
5
|
+
gatewayTransactionId: string
|
|
6
|
+
amount?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class RefundPayment extends UseCase<RefundPaymentInput, boolean> {
|
|
10
|
+
constructor(private gateway: IPaymentGateway) {
|
|
11
|
+
super()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async execute(input: RefundPaymentInput): Promise<boolean> {
|
|
15
|
+
console.log(`[Payment] Processing refund for transaction: ${input.gatewayTransactionId}`)
|
|
16
|
+
return await this.gateway.refund(input.gatewayTransactionId, input.amount)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Transaction } from '../Entities/Transaction'
|
|
2
|
+
|
|
3
|
+
export interface PaymentIntent {
|
|
4
|
+
gatewayTransactionId: string
|
|
5
|
+
clientSecret: string // 用於前端付款(如 Stripe Elements)
|
|
6
|
+
status: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IPaymentGateway {
|
|
10
|
+
getName(): string
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 建立支付意向 (Create Intent)
|
|
14
|
+
*/
|
|
15
|
+
createIntent(transaction: Transaction): Promise<PaymentIntent>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 清算授權金額 (Capture)
|
|
19
|
+
*/
|
|
20
|
+
capture(gatewayTransactionId: string): Promise<boolean>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 退款 (Refund)
|
|
24
|
+
*/
|
|
25
|
+
refund(gatewayTransactionId: string, amount?: number): Promise<boolean>
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface PaymentProps {
|
|
4
|
+
name: string
|
|
5
|
+
createdAt: Date
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Payment extends Entity<string> {
|
|
9
|
+
constructor(
|
|
10
|
+
id: string,
|
|
11
|
+
private props: PaymentProps
|
|
12
|
+
) {
|
|
13
|
+
super(id)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static create(id: string, name: string): Payment {
|
|
17
|
+
return new Payment(id, {
|
|
18
|
+
name,
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get name() {
|
|
24
|
+
return this.props.name
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export enum TransactionStatus {
|
|
4
|
+
PENDING = 'pending',
|
|
5
|
+
AUTHORIZED = 'authorized',
|
|
6
|
+
CAPTURED = 'captured',
|
|
7
|
+
FAILED = 'failed',
|
|
8
|
+
REFUNDED = 'refunded',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TransactionProps {
|
|
12
|
+
orderId: string
|
|
13
|
+
amount: number
|
|
14
|
+
currency: string
|
|
15
|
+
gateway: string
|
|
16
|
+
gatewayTransactionId?: string
|
|
17
|
+
status: TransactionStatus
|
|
18
|
+
metadata: Record<string, any>
|
|
19
|
+
createdAt: Date
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Transaction extends Entity<string> {
|
|
23
|
+
constructor(
|
|
24
|
+
id: string,
|
|
25
|
+
private props: TransactionProps
|
|
26
|
+
) {
|
|
27
|
+
super(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static create(
|
|
31
|
+
id: string,
|
|
32
|
+
props: Omit<TransactionProps, 'status' | 'createdAt' | 'metadata'>
|
|
33
|
+
): Transaction {
|
|
34
|
+
return new Transaction(id, {
|
|
35
|
+
...props,
|
|
36
|
+
status: TransactionStatus.PENDING,
|
|
37
|
+
metadata: {},
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
authorize(gatewayId: string): void {
|
|
43
|
+
if (this.props.status !== TransactionStatus.PENDING) {
|
|
44
|
+
throw new Error('Only pending transactions can be authorized')
|
|
45
|
+
}
|
|
46
|
+
this.props.status = TransactionStatus.AUTHORIZED
|
|
47
|
+
this.props.gatewayTransactionId = gatewayId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
capture(): void {
|
|
51
|
+
if (this.props.status !== TransactionStatus.AUTHORIZED) {
|
|
52
|
+
throw new Error('Only authorized transactions can be captured')
|
|
53
|
+
}
|
|
54
|
+
this.props.status = TransactionStatus.CAPTURED
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
refund(): void {
|
|
58
|
+
if (this.props.status !== TransactionStatus.CAPTURED) {
|
|
59
|
+
throw new Error('Only captured transactions can be refunded')
|
|
60
|
+
}
|
|
61
|
+
this.props.status = TransactionStatus.REFUNDED
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fail(reason: string): void {
|
|
65
|
+
this.props.status = TransactionStatus.FAILED
|
|
66
|
+
this.props.metadata.failReason = reason
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get orderId() {
|
|
70
|
+
return this.props.orderId
|
|
71
|
+
}
|
|
72
|
+
get amount() {
|
|
73
|
+
return this.props.amount
|
|
74
|
+
}
|
|
75
|
+
get status() {
|
|
76
|
+
return this.props.status
|
|
77
|
+
}
|
|
78
|
+
get gateway() {
|
|
79
|
+
return this.props.gateway
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
import type { IPaymentGateway, PaymentIntent } from '../../Domain/Contracts/IPaymentGateway'
|
|
3
|
+
import type { Transaction } from '../../Domain/Entities/Transaction'
|
|
4
|
+
|
|
5
|
+
export class StripeGateway implements IPaymentGateway {
|
|
6
|
+
private stripe: Stripe
|
|
7
|
+
|
|
8
|
+
constructor(apiKey: string) {
|
|
9
|
+
this.stripe = new Stripe(apiKey, {
|
|
10
|
+
apiVersion: '2025-01-27' as any,
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getName(): string {
|
|
15
|
+
return 'stripe'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async createIntent(transaction: Transaction): Promise<PaymentIntent> {
|
|
19
|
+
// 透過 any 訪問私有屬性以解決跨包訪問問題
|
|
20
|
+
const rawProps = (transaction as any).props
|
|
21
|
+
|
|
22
|
+
const intent = await this.stripe.paymentIntents.create({
|
|
23
|
+
amount: Math.round(transaction.amount * 100),
|
|
24
|
+
currency: rawProps.currency.toLowerCase(),
|
|
25
|
+
metadata: {
|
|
26
|
+
orderId: transaction.orderId,
|
|
27
|
+
transactionId: transaction.id,
|
|
28
|
+
},
|
|
29
|
+
automatic_payment_methods: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
gatewayTransactionId: intent.id,
|
|
36
|
+
clientSecret: intent.client_secret || '',
|
|
37
|
+
status: intent.status,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async capture(gatewayTransactionId: string): Promise<boolean> {
|
|
42
|
+
const intent = await this.stripe.paymentIntents.capture(gatewayTransactionId)
|
|
43
|
+
return intent.status === 'succeeded'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async refund(gatewayTransactionId: string, amount?: number): Promise<boolean> {
|
|
47
|
+
const refund = await this.stripe.refunds.create({
|
|
48
|
+
payment_intent: gatewayTransactionId,
|
|
49
|
+
...(amount && { amount: Math.round(amount * 100) }),
|
|
50
|
+
})
|
|
51
|
+
return refund.status === 'succeeded'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import type { IPaymentGateway } from '../Domain/Contracts/IPaymentGateway'
|
|
3
|
+
|
|
4
|
+
export class PaymentManager {
|
|
5
|
+
private drivers = new Map<string, () => IPaymentGateway>()
|
|
6
|
+
|
|
7
|
+
constructor(private core: PlanetCore) {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 註冊金流驅動器 (這就是日後掛載 ECPay, LINE Pay 的入口)
|
|
11
|
+
*/
|
|
12
|
+
extend(name: string, resolver: () => IPaymentGateway): void {
|
|
13
|
+
this.core.logger.info(`[PaymentManager] Driver registered: ${name}`)
|
|
14
|
+
this.drivers.set(name, resolver)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 取得指定或預設的驅動器
|
|
19
|
+
*/
|
|
20
|
+
driver(name?: string): IPaymentGateway {
|
|
21
|
+
const driverName = name || this.core.config.get<string>('payment.default', 'stripe')
|
|
22
|
+
const resolver = this.drivers.get(driverName)
|
|
23
|
+
|
|
24
|
+
if (!resolver) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Payment driver [${driverName}] is not registered. Did you forget to mount the gateway satellite?`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return resolver()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { IPaymentRepository } from '../../Domain/Contracts/IPaymentRepository'
|
|
2
|
+
import type { Payment } from '../../Domain/Entities/Payment'
|
|
3
|
+
|
|
4
|
+
export class AtlasPaymentRepository implements IPaymentRepository {
|
|
5
|
+
async save(entity: Payment): Promise<void> {
|
|
6
|
+
// Dogfooding: Use @gravito/atlas for persistence
|
|
7
|
+
console.log('[Atlas] Saving entity:', entity.id)
|
|
8
|
+
// await DB.table('payments').insert({ ... })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async findById(_id: string): Promise<Payment | null> {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findAll(): Promise<Payment[]> {
|
|
16
|
+
return []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async delete(_id: string): Promise<void> {}
|
|
20
|
+
|
|
21
|
+
async exists(_id: string): Promise<boolean> {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GravitoContext, PlanetCore } from '@gravito/core'
|
|
2
|
+
import Stripe from 'stripe'
|
|
3
|
+
|
|
4
|
+
export class StripeWebhookController {
|
|
5
|
+
async handle(c: GravitoContext) {
|
|
6
|
+
const core = c.get('core') as PlanetCore
|
|
7
|
+
const stripeKey = core.config.get<string>('payment.stripe.secret') || ''
|
|
8
|
+
const webhookSecret = core.config.get<string>('payment.stripe.webhook_secret') || ''
|
|
9
|
+
|
|
10
|
+
const stripe = new Stripe(stripeKey)
|
|
11
|
+
const signature = c.req.header('stripe-signature') || ''
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const body = await c.req.text()
|
|
15
|
+
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
|
16
|
+
|
|
17
|
+
if (event.type === 'payment_intent.succeeded') {
|
|
18
|
+
const intent = event.data.object as Stripe.PaymentIntent
|
|
19
|
+
core.logger.info(`[Payment] Webhook: Payment succeeded for ${intent.id}`)
|
|
20
|
+
|
|
21
|
+
await core.hooks.doAction('payment:succeeded', {
|
|
22
|
+
gatewayTransactionId: intent.id,
|
|
23
|
+
orderId: intent.metadata.orderId,
|
|
24
|
+
amount: intent.amount / 100,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return c.json({ received: true })
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
core.logger.error(`[Payment] Webhook Error: ${err.message}`)
|
|
31
|
+
return c.json({ error: 'Webhook signature verification failed' }, 400)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/env.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Container, GravitoContext } from '@gravito/core'
|
|
2
|
+
import { ServiceProvider } from '@gravito/core'
|
|
3
|
+
import { ProcessPayment } from './Application/UseCases/ProcessPayment'
|
|
4
|
+
import { RefundPayment } from './Application/UseCases/RefundPayment'
|
|
5
|
+
import { StripeGateway } from './Infrastructure/Gateways/StripeGateway'
|
|
6
|
+
import { PaymentManager } from './Infrastructure/PaymentManager'
|
|
7
|
+
import { StripeWebhookController } from './Interface/Http/Controllers/StripeWebhookController'
|
|
8
|
+
|
|
9
|
+
export class PaymentServiceProvider extends ServiceProvider {
|
|
10
|
+
register(container: Container): void {
|
|
11
|
+
// 1. 綁定管理器
|
|
12
|
+
container.singleton('payment.manager', (_c) => new PaymentManager(this.core!))
|
|
13
|
+
|
|
14
|
+
// 2. 綁定 UseCases (傳入 Manager 而非具體 Gateway)
|
|
15
|
+
container.singleton('payment.process', (c) => {
|
|
16
|
+
return new ProcessPayment(c.make('payment.manager'))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
container.singleton('payment.refund', (c) => {
|
|
20
|
+
return new RefundPayment(c.make('payment.manager'))
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override boot(): void {
|
|
25
|
+
const core = this.core
|
|
26
|
+
if (!core) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const manager = core.container.make<PaymentManager>('payment.manager')
|
|
31
|
+
|
|
32
|
+
// 🌟 註冊 Stripe 驅動器 (這就是「掛載」動作)
|
|
33
|
+
// 雖然代碼在 Infrastructure 裡,但只有這裡被呼叫時才會建立實例
|
|
34
|
+
manager.extend('stripe', () => {
|
|
35
|
+
const apiKey = core.config.get<string>('payment.stripe.secret') || 'mock_key'
|
|
36
|
+
return new StripeGateway(apiKey)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const webhookCtrl = new StripeWebhookController()
|
|
40
|
+
core.router.post('/webhooks/payment/stripe', (c: GravitoContext) => webhookCtrl.handle(c))
|
|
41
|
+
|
|
42
|
+
core.logger.info('🛰️ Satellite Payment is operational (Manager Ready)')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* GASS 聯動:監聽訂單建立
|
|
46
|
+
*/
|
|
47
|
+
core.hooks.addAction('commerce:order:created', async (payload: any) => {
|
|
48
|
+
const processPayment = core.container.make<ProcessPayment>('payment.process')
|
|
49
|
+
try {
|
|
50
|
+
const intent = await processPayment.execute({
|
|
51
|
+
orderId: payload.order.id,
|
|
52
|
+
amount: payload.order.totalAmount,
|
|
53
|
+
currency: payload.order.currency,
|
|
54
|
+
// gateway: 'stripe' // 可以由配置或 Payload 決定
|
|
55
|
+
})
|
|
56
|
+
await core.hooks.doAction('payment:intent:ready', { orderId: payload.order.id, intent })
|
|
57
|
+
} catch (error: any) {
|
|
58
|
+
core.logger.error(`[Payment] Process error: ${error.message}`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
core.hooks.addAction('payment:succeeded', async (payload: any) => {
|
|
63
|
+
core.logger.info(`[Payment] Order confirmed as PAID: ${payload.orderId}`)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"@gravito/core": [
|
|
8
|
+
"../../packages/core/src/index.ts"
|
|
9
|
+
],
|
|
10
|
+
"@gravito/*": [
|
|
11
|
+
"../../packages/*/src/index.ts"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"types": [
|
|
15
|
+
"bun-types"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"**/*.test.ts"
|
|
25
|
+
]
|
|
26
|
+
}
|