@gravito/satellite-commerce 0.1.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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 扣減庫存 Use Case
3
+ *
4
+ * 當支付成功時執行此 Use Case:
5
+ * 1. 驗證訂單存在且狀態為 PAID
6
+ * 2. 呼叫 Flash-Sale Satellite 扣減庫存
7
+ * 3. 更新訂單狀態為 CONFIRMED
8
+ * 4. 發送 OrderConfirmed 事件
9
+ */
10
+
11
+ import type { PlanetCore } from '@gravito/core'
12
+ import { DeductInventoryRequest, type OrderStatusTransition } from '../../Domain/Models'
13
+
14
+ export interface DeductInventoryResponse {
15
+ success: boolean
16
+ orderId: string
17
+ message: string
18
+ newStatus: string
19
+ }
20
+
21
+ /**
22
+ * DeductInventory Use Case
23
+ *
24
+ * 這是 Commerce ← Payment 流程的核心
25
+ * 當支付服務通知支付成功時,此 Use Case 執行庫存扣減
26
+ */
27
+ export class DeductInventory {
28
+ constructor(private core: PlanetCore) {}
29
+
30
+ /**
31
+ * 執行 Use Case
32
+ *
33
+ * @param orderId - 訂單 ID
34
+ * @param productId - 商品 ID
35
+ * @param quantity - 扣減數量
36
+ */
37
+ async execute(
38
+ orderId: string,
39
+ productId: string,
40
+ quantity: number
41
+ ): Promise<DeductInventoryResponse> {
42
+ // 1. 驗證請求
43
+ const request = new DeductInventoryRequest(orderId, productId, quantity)
44
+ const validationErrors = request.validate()
45
+
46
+ if (validationErrors.length > 0) {
47
+ throw new Error(`Validation failed: ${validationErrors.join(', ')}`)
48
+ }
49
+
50
+ this.core.logger.info(`[Commerce] Starting inventory deduction for order ${orderId}`)
51
+
52
+ // 2. TODO: 查詢訂單驗證狀態為 PAID
53
+ // const order = await this.orderRepository.findById(orderId)
54
+ // if (!order) {
55
+ // throw new Error(`Order not found: ${orderId}`)
56
+ // }
57
+ // if (order.status !== OrderStatus.PAID) {
58
+ // throw new Error(`Order not in PAID status: ${order.status}`)
59
+ // }
60
+
61
+ // 3. TODO: 呼叫 Flash-Sale Satellite 扣減庫存
62
+ // const productService = this.core.container.make('product.service')
63
+ // await productService.deductStock(productId, quantity)
64
+
65
+ // 4. TODO: 發送訂單狀態轉移事件
66
+ // const transition: OrderStatusTransition = {
67
+ // orderId,
68
+ // fromStatus: OrderStatus.PAID,
69
+ // toStatus: OrderStatus.CONFIRMED,
70
+ // timestamp: new Date(),
71
+ // }
72
+ // this.core.events.dispatch(new OrderStatusChanged(transition))
73
+
74
+ // 5. TODO: 更新訂單狀態
75
+ // await this.orderRepository.updateStatus(orderId, OrderStatus.CONFIRMED)
76
+
77
+ this.core.logger.info(`[Commerce] Inventory deducted for order ${orderId}`)
78
+
79
+ return {
80
+ success: true,
81
+ orderId,
82
+ message: 'Inventory deducted successfully',
83
+ newStatus: 'CONFIRMED',
84
+ }
85
+ }
86
+ }
@@ -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,59 @@
1
+ /**
2
+ * Commerce Service Provider
3
+ *
4
+ * 註冊訂單管理系統的所有服務與事件監聽
5
+ */
6
+
7
+ import type { Container, PlanetCore } from '@gravito/core'
8
+ import { ServiceProvider } from '@gravito/core'
9
+
10
+ /**
11
+ * CommerceServiceProvider
12
+ *
13
+ * 負責:
14
+ * 1. 訂單生命週期管理
15
+ * 2. 庫存扣減協調
16
+ * 3. 跨 Satellite 事件通訊
17
+ */
18
+ export class CommerceServiceProvider extends ServiceProvider {
19
+ /**
20
+ * 註冊階段
21
+ */
22
+ register(container: Container): void {
23
+ // TODO: 當 Repository 實現時解開註解
24
+ // container.singleton('order.repository', () => {
25
+ // return new AtlasOrderRepository()
26
+ // })
27
+ // TODO: 註冊 Use Cases
28
+ // container.bind('commerce.usecase.deductInventory', (c) => {
29
+ // return new DeductInventory(this.core!)
30
+ // })
31
+ }
32
+
33
+ /**
34
+ * 啟動階段
35
+ */
36
+ override boot(): void {
37
+ const core = this.core
38
+ if (!core) {
39
+ return
40
+ }
41
+
42
+ core.logger.info('🛰️ Satellite Commerce is booting')
43
+
44
+ // 監聽支付成功事件(來自 Payment Satellite)
45
+ // core.hooks.addAction('payment:succeeded', async (orderId: string, amount: number) => {
46
+ // core.logger.info(`[Commerce] Payment succeeded for order ${orderId}`)
47
+ // // 執行庫存扣減
48
+ // // 更新訂單狀態
49
+ // })
50
+
51
+ // 監聽訂單建立事件(來自 Flash-Sale Satellite)
52
+ // core.events.listen(OrderCreated, async (event: OrderCreated) => {
53
+ // core.logger.info(`[Commerce] Order created: ${event.order.id}`)
54
+ // // 記錄訂單事件
55
+ // })
56
+
57
+ core.logger.info('✅ Satellite Commerce booted successfully')
58
+ }
59
+ }
@@ -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,95 @@
1
+ /**
2
+ * Commerce Satellite 領域模型
3
+ *
4
+ * 專注於訂單的狀態管理與庫存扣減流程
5
+ */
6
+
7
+ /**
8
+ * 庫存扣減請求
9
+ *
10
+ * 當支付成功時,Commerce 會發送此請求到 Flash-Sale Satellite
11
+ * 來扣減庫存
12
+ */
13
+ export class DeductInventoryRequest {
14
+ constructor(
15
+ public orderId: string,
16
+ public productId: string,
17
+ public quantity: number
18
+ ) {}
19
+
20
+ validate(): string[] {
21
+ const errors: string[] = []
22
+
23
+ if (!this.orderId?.trim()) {
24
+ errors.push('orderId is required')
25
+ }
26
+
27
+ if (!this.productId?.trim()) {
28
+ errors.push('productId is required')
29
+ }
30
+
31
+ if (this.quantity <= 0) {
32
+ errors.push('quantity must be greater than 0')
33
+ }
34
+
35
+ return errors
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 訂單狀態轉移事件
41
+ */
42
+ export interface OrderStatusTransition {
43
+ orderId: string
44
+ fromStatus: string
45
+ toStatus: string
46
+ reason?: string
47
+ timestamp: Date
48
+ }
49
+
50
+ /**
51
+ * 訂單確認請求
52
+ *
53
+ * 當庫存扣減成功時,Commerce 會建立訂單確認
54
+ */
55
+ export class ConfirmOrderRequest {
56
+ constructor(
57
+ public orderId: string,
58
+ public inventoryLockId?: string
59
+ ) {}
60
+
61
+ validate(): string[] {
62
+ const errors: string[] = []
63
+
64
+ if (!this.orderId?.trim()) {
65
+ errors.push('orderId is required')
66
+ }
67
+
68
+ return errors
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 訂單退款請求
74
+ */
75
+ export class RefundOrderRequest {
76
+ constructor(
77
+ public orderId: string,
78
+ public reason: string,
79
+ public restoreInventory = true
80
+ ) {}
81
+
82
+ validate(): string[] {
83
+ const errors: string[] = []
84
+
85
+ if (!this.orderId?.trim()) {
86
+ errors.push('orderId is required')
87
+ }
88
+
89
+ if (!this.reason?.trim()) {
90
+ errors.push('reason is required')
91
+ }
92
+
93
+ return errors
94
+ }
95
+ }
@@ -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
+ }