@gravito/satellite-commerce 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.
@@ -0,0 +1,46 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { CacheManager } from '@gravito/stasis'
3
+
4
+ export interface ProductSnapshot {
5
+ id: string
6
+ sku: string
7
+ name: string
8
+ price: number
9
+ }
10
+
11
+ export class ProductResolver {
12
+ constructor(private cache: CacheManager) {}
13
+
14
+ async resolve(variantId: string, useCache: boolean): Promise<ProductSnapshot> {
15
+ const cacheKey = `product:variant:${variantId}`
16
+
17
+ if (useCache) {
18
+ const cached = await this.cache.get<ProductSnapshot>(cacheKey)
19
+ if (cached) {
20
+ return cached
21
+ }
22
+ }
23
+
24
+ const variant = (await DB.table('product_variants')
25
+ .where('id', variantId)
26
+ .select('id', 'sku', 'name', 'price')
27
+ .first()) as any
28
+
29
+ if (!variant) {
30
+ throw new Error(`Product variant ${variantId} not found`)
31
+ }
32
+
33
+ const snapshot: ProductSnapshot = {
34
+ id: String(variant.id),
35
+ sku: String(variant.sku),
36
+ name: String(variant.name || 'Unnamed'),
37
+ price: Number(variant.price),
38
+ }
39
+
40
+ if (useCache) {
41
+ await this.cache.put(cacheKey, snapshot, 60)
42
+ }
43
+
44
+ return snapshot
45
+ }
46
+ }
@@ -0,0 +1,27 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { PlanetCore } from '@gravito/core'
3
+
4
+ export class RewardSubscriber {
5
+ constructor(private core: PlanetCore) {}
6
+
7
+ async handleOrderPlaced(payload: { orderId: string }) {
8
+ const logger = this.core.logger
9
+ const order = (await DB.table('orders').where('id', payload.orderId).first()) as any
10
+
11
+ if (!order || !order.member_id) {
12
+ return
13
+ }
14
+
15
+ const points = Math.floor(Number(order.total_amount) / 100)
16
+
17
+ if (points > 0) {
18
+ logger.info(
19
+ `🎁 [Rewards] 為會員 ${order.member_id} 分配 ${points} 點紅利 (訂單: ${payload.orderId})`
20
+ )
21
+ await this.core.hooks.doAction('rewards:assigned', {
22
+ memberId: order.member_id,
23
+ points,
24
+ })
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,34 @@
1
+ import { UseCase } from '@gravito/enterprise'
2
+
3
+ export class AdminListOrders extends UseCase<any, any[]> {
4
+ async execute(): Promise<any[]> {
5
+ // 模擬從資料庫獲取所有訂單
6
+ // 真實情境應注入 IOrderRepository
7
+ return [
8
+ {
9
+ id: 'ORD-2025122901',
10
+ customerName: 'Carl',
11
+ totalAmount: 1250,
12
+ paymentStatus: 'PAID',
13
+ shippingStatus: 'SHIPPED',
14
+ createdAt: new Date('2025-12-29T10:00:00Z'),
15
+ },
16
+ {
17
+ id: 'ORD-2025122902',
18
+ customerName: 'Alice',
19
+ totalAmount: 3200,
20
+ paymentStatus: 'PAID',
21
+ shippingStatus: 'PENDING',
22
+ createdAt: new Date('2025-12-29T11:30:00Z'),
23
+ },
24
+ {
25
+ id: 'ORD-2025122903',
26
+ customerName: 'Bob',
27
+ totalAmount: 450,
28
+ paymentStatus: 'UNPAID',
29
+ shippingStatus: 'PENDING',
30
+ createdAt: new Date('2025-12-29T14:20:00Z'),
31
+ },
32
+ ]
33
+ }
34
+ }
@@ -0,0 +1,122 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import type { PlanetCore } from '@gravito/core'
3
+ import { UseCase } from '@gravito/enterprise'
4
+ import type { CacheManager } from '@gravito/stasis'
5
+ import { LineItem, Order } from '../../Domain/Entities/Order'
6
+ import { AdjustmentCalculator } from '../Services/AdjustmentCalculator'
7
+ import { ProductResolver } from '../Services/ProductResolver'
8
+
9
+ export interface PlaceOrderInput {
10
+ memberId: string | null
11
+ idempotencyKey?: string
12
+ items: {
13
+ variantId: string
14
+ quantity: number
15
+ }[]
16
+ }
17
+
18
+ export class PlaceOrder extends UseCase<PlaceOrderInput, any> {
19
+ constructor(private core: PlanetCore) {
20
+ super()
21
+ }
22
+
23
+ async execute(input: PlaceOrderInput): Promise<any> {
24
+ const adjCalculator = new AdjustmentCalculator(this.core)
25
+ const mode = process.env.COMMERCE_MODE || 'standard'
26
+ const useCache = mode === 'sport'
27
+
28
+ const cache = this.core.container.make<CacheManager>('cache')
29
+ const productResolver = new ProductResolver(cache)
30
+
31
+ return await DB.transaction(async (db) => {
32
+ const order = Order.create(crypto.randomUUID(), input.memberId)
33
+
34
+ for (const reqItem of input.items) {
35
+ const variantInfo = await productResolver.resolve(reqItem.variantId, useCache)
36
+
37
+ const variant = (await db
38
+ .table('product_variants')
39
+ .where('id', reqItem.variantId)
40
+ .select('stock', 'version', 'sku', 'price')
41
+ .first()) as any
42
+
43
+ if (!variant) {
44
+ throw new Error(`Variant ${reqItem.variantId} not found`)
45
+ }
46
+ if (Number(variant.stock) < reqItem.quantity) {
47
+ throw new Error('Insufficient stock')
48
+ }
49
+
50
+ const affectedRows = await db
51
+ .table('product_variants')
52
+ .where('id', reqItem.variantId)
53
+ .where('version', variant.version)
54
+ .update({
55
+ stock: Number(variant.stock) - reqItem.quantity,
56
+ version: Number(variant.version) + 1,
57
+ updated_at: new Date(),
58
+ })
59
+
60
+ if (affectedRows === 0) {
61
+ throw new Error('Concurrency conflict: Please try again')
62
+ }
63
+
64
+ const lineItem = new LineItem(crypto.randomUUID(), {
65
+ variantId: variantInfo.id,
66
+ sku: variantInfo.sku,
67
+ name: variantInfo.name,
68
+ unitPrice: variantInfo.price,
69
+ quantity: reqItem.quantity,
70
+ totalPrice: variantInfo.price * reqItem.quantity,
71
+ })
72
+ order.addItem(lineItem)
73
+ }
74
+
75
+ await adjCalculator.calculate(order)
76
+
77
+ await db.table('orders').insert({
78
+ id: order.id,
79
+ member_id: order.memberId,
80
+ idempotency_key: input.idempotencyKey,
81
+ status: order.status,
82
+ subtotal_amount: order.subtotalAmount,
83
+ adjustment_amount: order.adjustmentAmount,
84
+ total_amount: order.totalAmount,
85
+ created_at: order.createdAt,
86
+ })
87
+
88
+ for (const item of order.items) {
89
+ await db.table('order_items').insert({
90
+ id: item.id,
91
+ order_id: order.id,
92
+ variant_id: item.props.variantId,
93
+ sku: item.props.sku,
94
+ name: item.props.name,
95
+ unit_price: item.props.unitPrice,
96
+ quantity: item.props.quantity,
97
+ total_price: item.props.totalPrice,
98
+ })
99
+ }
100
+
101
+ for (const adj of order.adjustments) {
102
+ await db.table('order_adjustments').insert({
103
+ id: adj.id,
104
+ order_id: order.id,
105
+ label: adj.props.label,
106
+ amount: adj.props.amount,
107
+ source_type: adj.props.sourceType,
108
+ source_id: adj.props.sourceId,
109
+ })
110
+ }
111
+
112
+ await this.core.hooks.doAction('commerce:order-placed', { orderId: order.id })
113
+
114
+ return {
115
+ orderId: order.id,
116
+ status: order.status,
117
+ total: order.totalAmount,
118
+ adjustments: order.adjustments.map((a) => a.props.label),
119
+ }
120
+ })
121
+ }
122
+ }
@@ -0,0 +1,26 @@
1
+ import { Entity } from '@gravito/enterprise'
2
+
3
+ export interface CommerceProps {
4
+ name: string
5
+ createdAt: Date
6
+ }
7
+
8
+ export class Commerce extends Entity<string> {
9
+ constructor(
10
+ id: string,
11
+ private props: CommerceProps
12
+ ) {
13
+ super(id)
14
+ }
15
+
16
+ static create(id: string, name: string): Commerce {
17
+ return new Commerce(id, {
18
+ name,
19
+ createdAt: new Date(),
20
+ })
21
+ }
22
+
23
+ get name() {
24
+ return this.props.name
25
+ }
26
+ }
@@ -0,0 +1,164 @@
1
+ import { AggregateRoot, Entity } from '@gravito/enterprise'
2
+
3
+ export interface AdjustmentProps {
4
+ label: string
5
+ amount: number
6
+ sourceType: string | null
7
+ sourceId: string | null
8
+ }
9
+
10
+ export class Adjustment extends Entity<string> {
11
+ constructor(
12
+ id: string,
13
+ public readonly props: AdjustmentProps
14
+ ) {
15
+ super(id)
16
+ }
17
+ }
18
+
19
+ export interface LineItemProps {
20
+ variantId: string
21
+ sku: string
22
+ name: string
23
+ unitPrice: number
24
+ quantity: number
25
+ totalPrice: number
26
+ options?: Record<string, string>
27
+ }
28
+
29
+ export class LineItem extends Entity<string> {
30
+ constructor(
31
+ id: string,
32
+ public readonly props: LineItemProps
33
+ ) {
34
+ super(id)
35
+ }
36
+ }
37
+
38
+ export interface OrderProps {
39
+ memberId: string | null
40
+ idempotencyKey?: string
41
+ status:
42
+ | 'pending'
43
+ | 'paid'
44
+ | 'processing'
45
+ | 'shipped'
46
+ | 'completed'
47
+ | 'cancelled'
48
+ | 'requested_refund'
49
+ | 'refunded'
50
+ subtotalAmount: number
51
+ adjustmentAmount: number
52
+ totalAmount: number
53
+ currency: string
54
+ items: LineItem[]
55
+ adjustments: Adjustment[]
56
+ createdAt: Date
57
+ updatedAt?: Date
58
+ }
59
+
60
+ export class Order extends AggregateRoot<string> {
61
+ private constructor(
62
+ id: string,
63
+ private readonly props: OrderProps
64
+ ) {
65
+ super(id)
66
+ }
67
+
68
+ static create(id: string, memberId: string | null = null, currency = 'TWD'): Order {
69
+ return new Order(id, {
70
+ memberId,
71
+ status: 'pending',
72
+ subtotalAmount: 0,
73
+ adjustmentAmount: 0,
74
+ totalAmount: 0,
75
+ currency,
76
+ items: [],
77
+ adjustments: [],
78
+ createdAt: new Date(),
79
+ })
80
+ }
81
+
82
+ // Public Getters for Persistence & Logic
83
+ get memberId() {
84
+ return this.props.memberId
85
+ }
86
+ get status() {
87
+ return this.props.status
88
+ }
89
+ get subtotalAmount() {
90
+ return this.props.subtotalAmount
91
+ }
92
+ get adjustmentAmount() {
93
+ return this.props.adjustmentAmount
94
+ }
95
+ get totalAmount() {
96
+ return this.props.totalAmount
97
+ }
98
+ get createdAt() {
99
+ return this.props.createdAt
100
+ }
101
+ get items() {
102
+ return [...this.props.items]
103
+ }
104
+ get adjustments() {
105
+ return [...this.props.adjustments]
106
+ }
107
+
108
+ public addItem(item: LineItem): void {
109
+ if (this.props.status !== 'pending') {
110
+ throw new Error('Order is not in pending state')
111
+ }
112
+ this.props.items.push(item)
113
+ this.recalculate()
114
+ }
115
+
116
+ public addAdjustment(adj: Adjustment): void {
117
+ if (this.props.status !== 'pending') {
118
+ throw new Error('Order is not in pending state')
119
+ }
120
+ this.props.adjustments.push(adj)
121
+ this.recalculate()
122
+ }
123
+
124
+ private recalculate(): void {
125
+ ;(this.props as any).subtotalAmount = this.props.items.reduce(
126
+ (sum, item) => sum + item.props.totalPrice,
127
+ 0
128
+ )
129
+ ;(this.props as any).adjustmentAmount = this.props.adjustments.reduce(
130
+ (sum, adj) => sum + adj.props.amount,
131
+ 0
132
+ )
133
+ ;(this.props as any).totalAmount = Math.max(
134
+ 0,
135
+ this.props.subtotalAmount + this.props.adjustmentAmount
136
+ )
137
+ ;(this.props as any).updatedAt = new Date()
138
+ }
139
+
140
+ public markAsPaid(): void {
141
+ if (this.props.status !== 'pending') {
142
+ throw new Error('Invalid status transition')
143
+ }
144
+ ;(this.props as any).status = 'paid'
145
+ ;(this.props as any).updatedAt = new Date()
146
+ }
147
+
148
+ public requestRefund(): void {
149
+ const allowedStatuses = ['paid', 'processing', 'completed']
150
+ if (!allowedStatuses.includes(this.props.status)) {
151
+ throw new Error(`Refund cannot be requested for order in ${this.props.status} state`)
152
+ }
153
+ ;(this.props as any).status = 'requested_refund'
154
+ ;(this.props as any).updatedAt = new Date()
155
+ }
156
+
157
+ public markAsRefunded(): void {
158
+ if (this.props.status !== 'requested_refund') {
159
+ throw new Error('Order must be in requested_refund state')
160
+ }
161
+ ;(this.props as any).status = 'refunded'
162
+ ;(this.props as any).updatedAt = new Date()
163
+ }
164
+ }
@@ -0,0 +1,64 @@
1
+ import { type Blueprint, Schema } from '@gravito/atlas'
2
+
3
+ /**
4
+ * Migration to create Commerce tables and Patch inventory for Optimistic Locking.
5
+ */
6
+ export default {
7
+ async up() {
8
+ // 1. Orders Table
9
+ await Schema.create('orders', (table: Blueprint) => {
10
+ table.string('id').primary()
11
+ table.string('member_id').nullable()
12
+ table.string('idempotency_key').unique().nullable()
13
+ table.string('status').default('pending') // pending, paid, processing, shipped, completed, cancelled
14
+ table.decimal('subtotal_amount', 15, 2)
15
+ table.decimal('adjustment_amount', 15, 2).default(0)
16
+ table.decimal('total_amount', 15, 2)
17
+ table.string('currency').default('TWD')
18
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
19
+ table.timestamp('updated_at').nullable()
20
+ table.text('metadata').nullable()
21
+ })
22
+
23
+ // 2. Order Items (Snapshotting)
24
+ await Schema.create('order_items', (table: Blueprint) => {
25
+ table.string('id').primary()
26
+ table.string('order_id')
27
+ table.string('variant_id')
28
+ table.string('sku')
29
+ table.string('name')
30
+ table.decimal('unit_price', 15, 2)
31
+ table.integer('quantity')
32
+ table.decimal('total_price', 15, 2)
33
+ table.text('options').nullable()
34
+ })
35
+
36
+ // 3. Order Adjustments (Marketing/Tax/Shipping)
37
+ await Schema.create('order_adjustments', (table: Blueprint) => {
38
+ table.string('id').primary()
39
+ table.string('order_id')
40
+ table.string('label') // e.g., "Christmas Sale -10%", "Shipping Fee"
41
+ table.decimal('amount', 15, 2)
42
+ table.string('source_type').nullable() // e.g., "coupon", "tax"
43
+ table.string('source_id').nullable()
44
+ })
45
+
46
+ // 4. Patch Inventory for Optimistic Locking
47
+ // We add 'version' to support atomic updates:
48
+ // UPDATE ... SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?
49
+ try {
50
+ await Schema.table('product_variants', (table: Blueprint) => {
51
+ table.integer('version').default(1)
52
+ })
53
+ } catch (_e) {
54
+ // Column might already exist, ignore in review environment
55
+ }
56
+ },
57
+
58
+ async down() {
59
+ await Schema.dropIfExists('order_adjustments')
60
+ await Schema.dropIfExists('order_items')
61
+ await Schema.dropIfExists('orders')
62
+ // We don't drop 'version' column in down to avoid losing catalog data
63
+ },
64
+ }
@@ -0,0 +1,21 @@
1
+ import type { PlanetCore } from '@gravito/core'
2
+ import type { AdminListOrders } from '../../../Application/UseCases/AdminListOrders'
3
+
4
+ export class AdminOrderController {
5
+ constructor(private core: PlanetCore) {}
6
+
7
+ async index(ctx: any) {
8
+ try {
9
+ const useCase = this.core.container.make<AdminListOrders>('commerce.usecase.adminListOrders')
10
+ const orders = await useCase.execute()
11
+ return ctx.json(orders)
12
+ } catch (error: any) {
13
+ return ctx.json({ message: error.message }, 500)
14
+ }
15
+ }
16
+
17
+ async update(ctx: any) {
18
+ // 變更訂單狀態邏輯
19
+ return ctx.json({ success: true })
20
+ }
21
+ }
@@ -0,0 +1,50 @@
1
+ import type { GravitoContext } from '@gravito/core'
2
+ import type { PlaceOrder } from '../../../Application/UseCases/PlaceOrder'
3
+
4
+ export class CheckoutController {
5
+ /**
6
+ * 結帳下單接口
7
+ * POST /api/commerce/checkout
8
+ */
9
+ async store(c: GravitoContext) {
10
+ const core = c.get('core' as any) as any
11
+ const placeOrder = core.container.make('commerce.place-order') as PlaceOrder
12
+
13
+ // 獲取下單數據
14
+ const body = (await c.req.json()) as any
15
+
16
+ // 獲取冪等 Key
17
+ const idempotencyKey = c.req.header('X-Idempotency-Key') || body.idempotencyKey
18
+
19
+ // 獲取會員 ID (相容 Membership 插件)
20
+ const auth = c.get('auth' as any) as any
21
+ const memberId = auth?.user ? auth.user()?.id : null
22
+
23
+ try {
24
+ const result = await placeOrder.execute({
25
+ memberId,
26
+ idempotencyKey,
27
+ items: body.items,
28
+ })
29
+
30
+ return c.json(
31
+ {
32
+ success: true,
33
+ message: 'Order placed successfully',
34
+ data: result,
35
+ },
36
+ 201
37
+ )
38
+ } catch (error: any) {
39
+ // 處理併發衝突或庫存不足
40
+ const status = error.message.includes('Concurrency') ? 409 : 400
41
+ return c.json(
42
+ {
43
+ success: false,
44
+ error: error.message,
45
+ },
46
+ status
47
+ )
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,18 @@
1
+ export class PlaceOrderRequest {
2
+ /**
3
+ * 這是簡易版的驗證邏輯,未來可與 @gravito/impulse 深度整合
4
+ */
5
+ static validate(data: any) {
6
+ if (!data.items || !Array.isArray(data.items) || data.items.length === 0) {
7
+ throw new Error('Order items are required')
8
+ }
9
+
10
+ for (const item of data.items) {
11
+ if (!item.variantId || typeof item.quantity !== 'number' || item.quantity <= 0) {
12
+ throw new Error(
13
+ 'Invalid item structure: each item must have a variantId and a positive quantity'
14
+ )
15
+ }
16
+ }
17
+ }
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { type Container, ServiceProvider } from '@gravito/core'
4
+ import { RewardSubscriber } from './Application/Subscribers/RewardSubscriber'
5
+ import { AdminListOrders } from './Application/UseCases/AdminListOrders'
6
+ import { PlaceOrder } from './Application/UseCases/PlaceOrder'
7
+ import { AdminOrderController } from './Interface/Http/Controllers/AdminOrderController'
8
+ import { CheckoutController } from './Interface/Http/Controllers/CheckoutController'
9
+
10
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
11
+
12
+ export class CommerceServiceProvider extends ServiceProvider {
13
+ register(container: Container): void {
14
+ container.bind('commerce.usecase.adminListOrders', () => new AdminListOrders())
15
+ container.singleton('commerce.controller.admin', () => new AdminOrderController(this.core!))
16
+ // Bind the core order engine
17
+ container.singleton('commerce.place-order', () => {
18
+ return new PlaceOrder(this.core!)
19
+ })
20
+ }
21
+
22
+ getMigrationsPath(): string {
23
+ return `${__dirname}/Infrastructure/Persistence/Migrations`
24
+ }
25
+
26
+ override async boot(): Promise<void> {
27
+ const core = this.core
28
+ if (!core) {
29
+ return
30
+ }
31
+
32
+ const checkoutCtrl = new CheckoutController()
33
+ const rewardSub = new RewardSubscriber(core)
34
+
35
+ // 註冊事件監聽
36
+ core.hooks.addAction('commerce:order-placed', (payload: any) => {
37
+ rewardSub.handleOrderPlaced(payload as { orderId: string })
38
+ })
39
+
40
+ /**
41
+ * GASS 聯動:監聽支付成功 (來自 Payment 衛星)
42
+ */
43
+ core.hooks.addAction('payment:succeeded', async (payload: { orderId: string }) => {
44
+ core.logger.info(`[Commerce] Order ${payload.orderId} confirmed as PAID.`)
45
+ // 這裡通常會調用 Order.markAsPaid() 並持久化,目前暫由 Log 表現閉環
46
+ })
47
+
48
+ // Register Routes
49
+ core.router.prefix('/api/commerce').group((router) => {
50
+ router.post('/checkout', (c) => checkoutCtrl.store(c))
51
+ })
52
+
53
+ core.logger.info('🛰️ Satellite Commerce is operational')
54
+
55
+ const adminCtrl = core.container.make<AdminOrderController>('commerce.controller.admin')
56
+
57
+ // 管理端路由
58
+ core.router.prefix('/api/admin/v1/commerce').group((router) => {
59
+ router.get('/orders', (ctx) => adminCtrl.index(ctx))
60
+ router.patch('/orders/:id', (ctx) => adminCtrl.update(ctx))
61
+ })
62
+ }
63
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "id": "satellite-commerce",
3
+ "name": "Commerce Engine",
4
+ "version": "0.1.0",
5
+ "description": "High-performance transaction & order engine with Turbo support.",
6
+ "author": "Gravito Team",
7
+ "type": "satellite",
8
+ "hooks": {
9
+ "actions": ["commerce:order-placed", "rewards:assigned"],
10
+ "filters": ["commerce:order:adjustments"]
11
+ }
12
+ }