@gravito/satellite-invoice 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/REFACTOR_PLAN.md +238 -0
  3. package/dist/index.js +1097 -56
  4. package/package.json +4 -2
  5. package/package.json.bak +29 -0
  6. package/src/Application/Contexts/InvoiceAuditContext.ts +96 -0
  7. package/src/Application/Contexts/InvoiceCancellationContext.ts +60 -0
  8. package/src/Application/Contexts/InvoiceIssuanceContext.ts +42 -0
  9. package/src/Application/Roles/InvoiceCancellerRole.ts +97 -0
  10. package/src/Application/Roles/InvoiceIssuerRole.ts +72 -0
  11. package/src/Application/Roles/InvoiceTrackerRole.ts +112 -0
  12. package/src/Application/UseCases/CancelInvoice.ts +32 -0
  13. package/src/Application/UseCases/IssueInvoice.ts +13 -26
  14. package/src/Application/UseCases/QueryInvoiceStatus.ts +56 -0
  15. package/src/Domain/Contracts/IInvoiceRepository.ts +3 -0
  16. package/src/Domain/Entities/Invoice.ts +178 -20
  17. package/src/Domain/Errors/InvoiceError.ts +89 -0
  18. package/src/Domain/ValueObjects/InvoiceAmount.ts +86 -0
  19. package/src/Domain/ValueObjects/InvoiceNumber.ts +52 -0
  20. package/src/Domain/ValueObjects/InvoiceStatus.ts +131 -0
  21. package/src/Domain/ValueObjects/InvoiceTax.ts +71 -0
  22. package/src/Infrastructure/Persistence/AtlasInvoiceRepository.ts +135 -6
  23. package/src/Interface/Http/Controllers/AdminInvoiceController.ts +264 -18
  24. package/src/index.ts +57 -3
  25. package/tests/Application/Contexts/InvoiceAuditContext.test.ts +198 -0
  26. package/tests/Application/Contexts/InvoiceCancellationContext.test.ts +232 -0
  27. package/tests/Application/Contexts/InvoiceIssuanceContext.test.ts +180 -0
  28. package/tests/Application/Roles/InvoiceCancellerRole.test.ts +109 -0
  29. package/tests/Application/Roles/InvoiceIssuerRole.test.ts +66 -0
  30. package/tests/Application/Roles/InvoiceTrackerRole.test.ts +126 -0
  31. package/tests/Application/UseCases/CancelInvoice.test.ts +175 -0
  32. package/tests/Application/UseCases/IssueInvoice.test.ts +169 -0
  33. package/tests/Application/UseCases/QueryInvoiceStatus.test.ts +191 -0
  34. package/tests/Domain/Errors/InvoiceError.test.ts +96 -0
  35. package/tests/Domain/ValueObjects/InvoiceAmount.test.ts +84 -0
  36. package/tests/Domain/ValueObjects/InvoiceNumber.test.ts +60 -0
  37. package/tests/Domain/ValueObjects/InvoiceStatus.test.ts +89 -0
  38. package/tests/Domain/ValueObjects/InvoiceTax.test.ts +66 -0
  39. package/tests/Interface/Http/Controllers/AdminInvoiceController.test.ts +294 -0
  40. package/tests/domain.test.ts +244 -0
  41. package/tsconfig.json +11 -19
  42. package/dist/index.d.ts +0 -8
@@ -6,11 +6,25 @@ class InvoiceModel extends Model {
6
6
  static override table = 'invoices'
7
7
  }
8
8
 
9
+ /**
10
+ * Atlas ORM 發票 Repository
11
+ * 實現 IInvoiceRepository 介面
12
+ */
9
13
  export class AtlasInvoiceRepository implements IInvoiceRepository {
10
14
  async save(invoice: Invoice): Promise<void> {
15
+ const snapshot = invoice.toSnapshot()
11
16
  const data = {
12
- id: invoice.id,
13
- ...invoice.unpack(),
17
+ id: snapshot.id,
18
+ orderId: snapshot.orderId,
19
+ invoiceNumber: snapshot.invoiceNumber,
20
+ amount: snapshot.amount,
21
+ amountCurrency: snapshot.amountCurrency,
22
+ tax: snapshot.tax,
23
+ taxRate: snapshot.taxRate,
24
+ status: snapshot.status,
25
+ buyerIdentifier: snapshot.buyerIdentifier,
26
+ carrierId: snapshot.carrierId,
27
+ createdAt: snapshot.createdAt,
14
28
  }
15
29
 
16
30
  const existing = await InvoiceModel.find(invoice.id)
@@ -23,16 +37,131 @@ export class AtlasInvoiceRepository implements IInvoiceRepository {
23
37
 
24
38
  async findById(id: string): Promise<Invoice | null> {
25
39
  const row = await InvoiceModel.find(id)
26
- return row ? Invoice.create((row as any).props, (row as any).props.id) : null
40
+ if (!row) {
41
+ return null
42
+ }
43
+ const snapshot = row as any
44
+ return Invoice.fromSnapshot({
45
+ id: snapshot.id,
46
+ orderId: snapshot.orderId,
47
+ invoiceNumber: snapshot.invoiceNumber,
48
+ amount: snapshot.amount,
49
+ amountCurrency: snapshot.amountCurrency,
50
+ tax: snapshot.tax,
51
+ taxRate: snapshot.taxRate,
52
+ status: snapshot.status,
53
+ buyerIdentifier: snapshot.buyerIdentifier,
54
+ carrierId: snapshot.carrierId,
55
+ createdAt: snapshot.createdAt,
56
+ })
27
57
  }
28
58
 
29
59
  async findByOrderId(orderId: string): Promise<Invoice | null> {
30
- const row = await (InvoiceModel.query() as any).where('order_id', orderId).first()
31
- return row ? Invoice.create(row.props, row.props.id) : null
60
+ const row = await (InvoiceModel.query() as any).where('orderId', orderId).first()
61
+ if (!row) {
62
+ return null
63
+ }
64
+ const snapshot = row
65
+ return Invoice.fromSnapshot({
66
+ id: snapshot.id,
67
+ orderId: snapshot.orderId,
68
+ invoiceNumber: snapshot.invoiceNumber,
69
+ amount: snapshot.amount,
70
+ amountCurrency: snapshot.amountCurrency,
71
+ tax: snapshot.tax,
72
+ taxRate: snapshot.taxRate,
73
+ status: snapshot.status,
74
+ buyerIdentifier: snapshot.buyerIdentifier,
75
+ carrierId: snapshot.carrierId,
76
+ createdAt: snapshot.createdAt,
77
+ })
32
78
  }
33
79
 
34
80
  async findAll(): Promise<Invoice[]> {
35
81
  const rows = await InvoiceModel.all()
36
- return rows.map((row) => Invoice.create((row as any).props, (row as any).props.id))
82
+ return rows.map((row) =>
83
+ Invoice.fromSnapshot({
84
+ id: (row as any).id,
85
+ orderId: (row as any).orderId,
86
+ invoiceNumber: (row as any).invoiceNumber,
87
+ amount: (row as any).amount,
88
+ amountCurrency: (row as any).amountCurrency,
89
+ tax: (row as any).tax,
90
+ taxRate: (row as any).taxRate,
91
+ status: (row as any).status,
92
+ buyerIdentifier: (row as any).buyerIdentifier,
93
+ carrierId: (row as any).carrierId,
94
+ createdAt: (row as any).createdAt,
95
+ })
96
+ )
97
+ }
98
+
99
+ /**
100
+ * 按發票號碼查詢
101
+ */
102
+ async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
103
+ const row = await (InvoiceModel.query() as any).where('invoiceNumber', invoiceNumber).first()
104
+ if (!row) {
105
+ return null
106
+ }
107
+ const snapshot = row
108
+ return Invoice.fromSnapshot({
109
+ id: snapshot.id,
110
+ orderId: snapshot.orderId,
111
+ invoiceNumber: snapshot.invoiceNumber,
112
+ amount: snapshot.amount,
113
+ amountCurrency: snapshot.amountCurrency,
114
+ tax: snapshot.tax,
115
+ taxRate: snapshot.taxRate,
116
+ status: snapshot.status,
117
+ buyerIdentifier: snapshot.buyerIdentifier,
118
+ carrierId: snapshot.carrierId,
119
+ createdAt: snapshot.createdAt,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * 按狀態查詢
125
+ */
126
+ async findByStatus(status: string): Promise<Invoice[]> {
127
+ const rows = await (InvoiceModel.query() as any).where('status', status)
128
+ return rows.map((row: any) =>
129
+ Invoice.fromSnapshot({
130
+ id: row.id,
131
+ orderId: row.orderId,
132
+ invoiceNumber: row.invoiceNumber,
133
+ amount: row.amount,
134
+ amountCurrency: row.amountCurrency,
135
+ tax: row.tax,
136
+ taxRate: row.taxRate,
137
+ status: row.status,
138
+ buyerIdentifier: row.buyerIdentifier,
139
+ carrierId: row.carrierId,
140
+ createdAt: row.createdAt,
141
+ })
142
+ )
143
+ }
144
+
145
+ /**
146
+ * 按日期範圍查詢
147
+ */
148
+ async findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]> {
149
+ const rows = await (InvoiceModel.query() as any).whereBetween('createdAt', [startDate, endDate])
150
+
151
+ return rows.map((row: any) =>
152
+ Invoice.fromSnapshot({
153
+ id: row.id,
154
+ orderId: row.orderId,
155
+ invoiceNumber: row.invoiceNumber,
156
+ amount: row.amount,
157
+ amountCurrency: row.amountCurrency,
158
+ tax: row.tax,
159
+ taxRate: row.taxRate,
160
+ status: row.status,
161
+ buyerIdentifier: row.buyerIdentifier,
162
+ carrierId: row.carrierId,
163
+ createdAt: row.createdAt,
164
+ })
165
+ )
37
166
  }
38
167
  }
@@ -1,32 +1,278 @@
1
1
  import type { PlanetCore } from '@gravito/core'
2
+ import type { CancelInvoice } from '../../../Application/UseCases/CancelInvoice'
2
3
  import type { IssueInvoice } from '../../../Application/UseCases/IssueInvoice'
4
+ import type {
5
+ GenerateInvoiceReport,
6
+ QueryInvoiceStatus,
7
+ } from '../../../Application/UseCases/QueryInvoiceStatus'
3
8
  import type { Invoice } from '../../../Domain/Entities/Invoice'
9
+ import {
10
+ DuplicateInvoiceError,
11
+ InvalidCancellationError,
12
+ InvoiceError,
13
+ InvoiceNotFoundError,
14
+ } from '../../../Domain/Errors/InvoiceError'
4
15
 
16
+ /**
17
+ * 發票管理控制器
18
+ * 提供完整的 API 端點
19
+ */
5
20
  export class AdminInvoiceController {
6
21
  constructor(private core: PlanetCore) {}
7
22
 
23
+ /**
24
+ * GET /invoices - 列表查詢發票
25
+ */
8
26
  async index(ctx: any) {
9
- // 獲取所有發票清單
10
- const repo = this.core.container.make('invoice.repository') as any
11
- const invoices = await repo.findAll()
12
- return ctx.json(
13
- invoices.map((inv: Invoice) => ({
14
- id: inv.id,
15
- ...inv.unpack(),
16
- }))
17
- )
27
+ try {
28
+ const repo = this.core.container.make('invoice.repository') as any
29
+ const invoices = await repo.findAll()
30
+
31
+ return ctx.json({
32
+ success: true,
33
+ data: invoices.map((inv: Invoice) => inv.unpack()),
34
+ meta: {
35
+ total: invoices.length,
36
+ },
37
+ })
38
+ } catch (error) {
39
+ this.logError('Failed to list invoices', error)
40
+ return ctx.json(
41
+ {
42
+ success: false,
43
+ error: '無法獲取發票列表',
44
+ },
45
+ 500
46
+ )
47
+ }
18
48
  }
19
49
 
50
+ /**
51
+ * POST /invoices - 開立新發票
52
+ */
20
53
  async store(ctx: any) {
21
- const body = await ctx.req.json()
22
- const useCase = this.core.container.make<IssueInvoice>('invoice.usecase.issue')
23
- const invoice = await useCase.execute(body)
24
- return ctx.json({
25
- success: true,
26
- data: {
27
- id: invoice.id,
28
- number: invoice.invoiceNumber,
54
+ try {
55
+ const body = await ctx.req.json()
56
+
57
+ // 驗證必要欄位
58
+ if (!body.orderId || !body.amount) {
59
+ return ctx.json(
60
+ {
61
+ success: false,
62
+ error: '缺少必要欄位:orderId, amount',
63
+ },
64
+ 400
65
+ )
66
+ }
67
+
68
+ const useCase = this.core.container.make<IssueInvoice>('invoice.usecase.issue')
69
+ const invoice = await useCase.execute(body)
70
+
71
+ return ctx.json({
72
+ success: true,
73
+ data: {
74
+ id: invoice.id,
75
+ invoiceNumber: invoice.invoiceNumber,
76
+ orderId: invoice.orderId,
77
+ amount: invoice.amount,
78
+ status: invoice.status,
79
+ createdAt: invoice.createdAt,
80
+ },
81
+ })
82
+ } catch (error) {
83
+ return this.handleError(ctx, error, 'Failed to issue invoice')
84
+ }
85
+ }
86
+
87
+ /**
88
+ * GET /invoices/:id - 查詢發票狀態
89
+ */
90
+ async show(ctx: any) {
91
+ try {
92
+ const invoiceId = ctx.param('id')
93
+
94
+ if (!invoiceId) {
95
+ return ctx.json(
96
+ {
97
+ success: false,
98
+ error: '缺少發票 ID',
99
+ },
100
+ 400
101
+ )
102
+ }
103
+
104
+ const useCase = this.core.container.make<QueryInvoiceStatus>('invoice.usecase.query')
105
+ const result = await useCase.execute({ invoiceId })
106
+
107
+ return ctx.json({
108
+ success: true,
109
+ data: result,
110
+ })
111
+ } catch (error) {
112
+ return this.handleError(ctx, error, 'Failed to query invoice status')
113
+ }
114
+ }
115
+
116
+ /**
117
+ * POST /invoices/:id/cancel - 取消發票
118
+ */
119
+ async cancel(ctx: any) {
120
+ try {
121
+ const invoiceId = ctx.param('id')
122
+ const body = await ctx.req.json()
123
+
124
+ if (!invoiceId) {
125
+ return ctx.json(
126
+ {
127
+ success: false,
128
+ error: '缺少發票 ID',
129
+ },
130
+ 400
131
+ )
132
+ }
133
+
134
+ if (!body.reason) {
135
+ return ctx.json(
136
+ {
137
+ success: false,
138
+ error: '缺少取消原因',
139
+ },
140
+ 400
141
+ )
142
+ }
143
+
144
+ const useCase = this.core.container.make<CancelInvoice>('invoice.usecase.cancel')
145
+ const result = await useCase.execute({
146
+ invoiceId,
147
+ reason: body.reason,
148
+ notes: body.notes,
149
+ })
150
+
151
+ return ctx.json({
152
+ success: true,
153
+ data: result,
154
+ })
155
+ } catch (error) {
156
+ return this.handleError(ctx, error, 'Failed to cancel invoice')
157
+ }
158
+ }
159
+
160
+ /**
161
+ * GET /invoices/report - 生成發票報告
162
+ */
163
+ async report(ctx: any) {
164
+ try {
165
+ const startDate = ctx.query('startDate')
166
+ const endDate = ctx.query('endDate')
167
+
168
+ if (!startDate || !endDate) {
169
+ return ctx.json(
170
+ {
171
+ success: false,
172
+ error: '缺少日期範圍參數:startDate, endDate',
173
+ },
174
+ 400
175
+ )
176
+ }
177
+
178
+ const start = new Date(startDate)
179
+ const end = new Date(endDate)
180
+
181
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
182
+ return ctx.json(
183
+ {
184
+ success: false,
185
+ error: '無效的日期格式',
186
+ },
187
+ 400
188
+ )
189
+ }
190
+
191
+ const useCase = this.core.container.make<GenerateInvoiceReport>('invoice.usecase.report')
192
+ const report = await useCase.execute({
193
+ startDate: start,
194
+ endDate: end,
195
+ })
196
+
197
+ return ctx.json({
198
+ success: true,
199
+ data: report,
200
+ })
201
+ } catch (error) {
202
+ this.logError('Failed to generate report', error)
203
+ return ctx.json(
204
+ {
205
+ success: false,
206
+ error: '無法生成報告',
207
+ },
208
+ 500
209
+ )
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 處理錯誤
215
+ */
216
+ private handleError(ctx: any, error: unknown, message: string): any {
217
+ if (error instanceof InvoiceNotFoundError) {
218
+ return ctx.json(
219
+ {
220
+ success: false,
221
+ error: error.message,
222
+ },
223
+ 404
224
+ )
225
+ }
226
+
227
+ if (error instanceof DuplicateInvoiceError) {
228
+ return ctx.json(
229
+ {
230
+ success: false,
231
+ error: error.message,
232
+ },
233
+ 409
234
+ )
235
+ }
236
+
237
+ if (error instanceof InvalidCancellationError) {
238
+ return ctx.json(
239
+ {
240
+ success: false,
241
+ error: error.message,
242
+ },
243
+ 400
244
+ )
245
+ }
246
+
247
+ if (error instanceof InvoiceError) {
248
+ this.logError(message, error)
249
+ return ctx.json(
250
+ {
251
+ success: false,
252
+ error: error.message,
253
+ },
254
+ 400
255
+ )
256
+ }
257
+
258
+ this.logError(message, error)
259
+ return ctx.json(
260
+ {
261
+ success: false,
262
+ error: '伺服器內部錯誤',
29
263
  },
30
- })
264
+ 500
265
+ )
266
+ }
267
+
268
+ /**
269
+ * 記錄錯誤
270
+ */
271
+ private logError(message: string, error: unknown): void {
272
+ if (this.core?.logger) {
273
+ this.core.logger.error(
274
+ `[Invoice] ${message}: ${error instanceof Error ? error.message : String(error)}`
275
+ )
276
+ }
31
277
  }
32
278
  }
package/src/index.ts CHANGED
@@ -1,15 +1,57 @@
1
1
  import { type Container, ServiceProvider } from '@gravito/core'
2
+ import { CancelInvoice } from './Application/UseCases/CancelInvoice'
2
3
  import { IssueInvoice } from './Application/UseCases/IssueInvoice'
4
+ import {
5
+ GenerateInvoiceReport,
6
+ QueryInvoiceStatus,
7
+ } from './Application/UseCases/QueryInvoiceStatus'
3
8
  import { AtlasInvoiceRepository } from './Infrastructure/Persistence/AtlasInvoiceRepository'
4
9
  import { AdminInvoiceController } from './Interface/Http/Controllers/AdminInvoiceController'
5
10
 
11
+ // Infrastructure Exports
12
+ export type { IInvoiceRepository } from './Domain/Contracts/IInvoiceRepository'
13
+ // Domain Exports
14
+ export { Invoice, type InvoiceProps, type InvoiceSnapshot } from './Domain/Entities/Invoice'
15
+ export {
16
+ DuplicateInvoiceError,
17
+ InvalidAmountError,
18
+ InvalidCancellationError,
19
+ InvalidInvoiceNumberError,
20
+ InvalidTaxError,
21
+ InvalidTransitionError,
22
+ InvoiceError,
23
+ InvoiceNotFoundError,
24
+ } from './Domain/Errors/InvoiceError'
25
+ export { InvoiceAmount, type InvoiceAmountProps } from './Domain/ValueObjects/InvoiceAmount'
26
+ export { InvoiceNumber, type InvoiceNumberProps } from './Domain/ValueObjects/InvoiceNumber'
27
+ export { InvoiceStatus, type InvoiceStatusType } from './Domain/ValueObjects/InvoiceStatus'
28
+ export { InvoiceTax, type InvoiceTaxProps } from './Domain/ValueObjects/InvoiceTax'
29
+ export { AtlasInvoiceRepository } from './Infrastructure/Persistence/AtlasInvoiceRepository'
30
+
6
31
  export class InvoiceServiceProvider extends ServiceProvider {
7
32
  register(container: Container): void {
33
+ // 註冊 Repository
8
34
  container.singleton('invoice.repository', () => new AtlasInvoiceRepository())
35
+
36
+ // 註冊 UseCase
9
37
  container.bind(
10
38
  'invoice.usecase.issue',
11
39
  () => new IssueInvoice(container.make('invoice.repository'))
12
40
  )
41
+ container.bind(
42
+ 'invoice.usecase.cancel',
43
+ () => new CancelInvoice(container.make('invoice.repository'))
44
+ )
45
+ container.bind(
46
+ 'invoice.usecase.query',
47
+ () => new QueryInvoiceStatus(container.make('invoice.repository'))
48
+ )
49
+ container.bind(
50
+ 'invoice.usecase.report',
51
+ () => new GenerateInvoiceReport(container.make('invoice.repository'))
52
+ )
53
+
54
+ // 註冊 Controller
13
55
  container.singleton('invoice.controller.admin', () => new AdminInvoiceController(this.core!))
14
56
  }
15
57
 
@@ -25,8 +67,20 @@ export class InvoiceServiceProvider extends ServiceProvider {
25
67
 
26
68
  // 註冊管理路由
27
69
  core.router.prefix('/api/admin/v1/invoices').group((router) => {
70
+ // 發票列表
28
71
  router.get('/', (ctx) => controller.index(ctx))
72
+
73
+ // 開立發票
29
74
  router.post('/', (ctx) => controller.store(ctx))
75
+
76
+ // 查詢發票狀態
77
+ router.get('/:id', (ctx) => controller.show(ctx))
78
+
79
+ // 取消發票
80
+ router.post('/:id/cancel', (ctx) => controller.cancel(ctx))
81
+
82
+ // 生成報告
83
+ router.get('/report', (ctx) => controller.report(ctx))
30
84
  })
31
85
 
32
86
  /**
@@ -40,13 +94,13 @@ export class InvoiceServiceProvider extends ServiceProvider {
40
94
  const issueUseCase = core.container.make<IssueInvoice>('invoice.usecase.issue')
41
95
 
42
96
  try {
43
- const invoice = await issueUseCase.execute({
97
+ const invoice = (await issueUseCase.execute({
44
98
  orderId: payload.orderId,
45
99
  amount: payload.amount,
46
100
  buyerIdentifier: payload.buyer?.identifier,
47
101
  carrierId: payload.buyer?.carrierId,
48
- })
49
- core.logger.info(`[Invoice] Automatically issued: ${invoice.invoiceNumber}`)
102
+ })) as any
103
+ core.logger.info(`[Invoice] Automatically issued: ${invoice.invoiceNumber.value}`)
50
104
  } catch (error: any) {
51
105
  core.logger.error(`[Invoice] Auto-issue failed: ${error.message}`)
52
106
  }