@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
@@ -0,0 +1,294 @@
1
+ import { beforeEach, describe, expect, it } from 'bun:test'
2
+ import { Invoice } from '../../../../src/Domain/Entities/Invoice'
3
+ import { InvoiceAmount } from '../../../../src/Domain/ValueObjects/InvoiceAmount'
4
+ import { InvoiceNumber } from '../../../../src/Domain/ValueObjects/InvoiceNumber'
5
+ import { InvoiceTax } from '../../../../src/Domain/ValueObjects/InvoiceTax'
6
+ import { AdminInvoiceController } from '../../../../src/Interface/Http/Controllers/AdminInvoiceController'
7
+
8
+ // Mock PlanetCore
9
+ class MockCore {
10
+ logger = {
11
+ info: (msg: string) => console.log(msg),
12
+ error: (msg: string) => console.error(msg),
13
+ }
14
+
15
+ container = {
16
+ make: (key: string) => {
17
+ if (key === 'invoice.repository') {
18
+ return new MockRepository()
19
+ }
20
+ if (key === 'invoice.usecase.issue') {
21
+ return new MockIssueUseCase(new MockRepository())
22
+ }
23
+ if (key === 'invoice.usecase.cancel') {
24
+ return new MockCancelUseCase(new MockRepository())
25
+ }
26
+ if (key === 'invoice.usecase.query') {
27
+ return new MockQueryUseCase(new MockRepository())
28
+ }
29
+ if (key === 'invoice.usecase.report') {
30
+ return new MockReportUseCase(new MockRepository())
31
+ }
32
+ return null
33
+ },
34
+ }
35
+ }
36
+
37
+ // Mock Repository
38
+ class MockRepository {
39
+ private invoices: Map<string, Invoice> = new Map()
40
+
41
+ async save(invoice: Invoice): Promise<void> {
42
+ this.invoices.set(invoice.id, invoice)
43
+ }
44
+
45
+ async findById(id: string): Promise<Invoice | null> {
46
+ return this.invoices.get(id) || null
47
+ }
48
+
49
+ async findByOrderId(orderId: string): Promise<Invoice | null> {
50
+ for (const invoice of this.invoices.values()) {
51
+ if (invoice.orderId === orderId) {
52
+ return invoice
53
+ }
54
+ }
55
+ return null
56
+ }
57
+
58
+ async findAll(): Promise<Invoice[]> {
59
+ return Array.from(this.invoices.values())
60
+ }
61
+ }
62
+
63
+ // Mock UseCase
64
+ class MockIssueUseCase {
65
+ constructor(private repository: MockRepository) {}
66
+
67
+ async execute(input: any) {
68
+ const invoice = Invoice.create({
69
+ orderId: input.orderId,
70
+ invoiceNumber: InvoiceNumber.generate().value,
71
+ amount: input.amount,
72
+ tax: input.amount * 0.05,
73
+ status: 'ISSUED',
74
+ })
75
+ await this.repository.save(invoice)
76
+ return invoice
77
+ }
78
+ }
79
+
80
+ class MockCancelUseCase {
81
+ constructor(private repository: MockRepository) {}
82
+
83
+ async execute(input: any) {
84
+ const invoice = await this.repository.findById(input.invoiceId)
85
+ if (!invoice) {
86
+ throw new Error(`Invoice with id ${input.invoiceId} not found`)
87
+ }
88
+ const cancelled = invoice.cancel()
89
+ await this.repository.save(cancelled)
90
+ return {
91
+ id: cancelled.id,
92
+ invoiceNumber: cancelled.invoiceNumber,
93
+ previousStatus: 'ISSUED',
94
+ newStatus: 'CANCELLED',
95
+ cancelledAt: new Date(),
96
+ }
97
+ }
98
+ }
99
+
100
+ class MockQueryUseCase {
101
+ constructor(private repository: MockRepository) {}
102
+
103
+ async execute(input: any) {
104
+ const invoice = await this.repository.findById(input.invoiceId)
105
+ if (!invoice) {
106
+ throw new Error(`Invoice with id ${input.invoiceId} not found`)
107
+ }
108
+ return {
109
+ id: invoice.id,
110
+ invoiceNumber: invoice.invoiceNumber,
111
+ orderId: invoice.orderId,
112
+ status: invoice.status,
113
+ amount: invoice.amount,
114
+ createdAt: invoice.createdAt,
115
+ }
116
+ }
117
+ }
118
+
119
+ class MockReportUseCase {
120
+ constructor(private repository: MockRepository) {}
121
+
122
+ async execute(input: any) {
123
+ const invoices = await this.repository.findAll()
124
+ const inRange = invoices.filter((inv) => {
125
+ return inv.createdAt >= input.startDate && inv.createdAt <= input.endDate
126
+ })
127
+ return {
128
+ period: { startDate: input.startDate, endDate: input.endDate },
129
+ summary: {
130
+ total: inRange.length,
131
+ issued: inRange.filter((inv) => inv.statusObject.isIssued()).length,
132
+ cancelled: inRange.filter((inv) => inv.statusObject.isCancelled()).length,
133
+ returned: inRange.filter((inv) => inv.statusObject.isReturned()).length,
134
+ totalAmount: inRange.reduce((sum, inv) => sum + inv.amount, 0),
135
+ },
136
+ invoices: inRange.map((inv) => ({
137
+ id: inv.id,
138
+ number: inv.invoiceNumber,
139
+ orderId: inv.orderId,
140
+ amount: inv.amount,
141
+ status: inv.status,
142
+ createdAt: inv.createdAt,
143
+ })),
144
+ }
145
+ }
146
+ }
147
+
148
+ // Mock Context
149
+ class MockContext {
150
+ private body: any = {}
151
+ private params: Map<string, string> = new Map()
152
+ private queryParams: Map<string, string> = new Map()
153
+ private responseData: any = null
154
+ private responseStatus = 200
155
+ req: any
156
+
157
+ constructor() {
158
+ this.req = {
159
+ json: async () => this.body,
160
+ }
161
+ }
162
+
163
+ param(key: string): string | undefined {
164
+ return this.params.get(key)
165
+ }
166
+
167
+ query(key: string): string | undefined {
168
+ return this.queryParams.get(key)
169
+ }
170
+
171
+ json(data: any, status = 200): any {
172
+ this.responseData = data
173
+ this.responseStatus = status
174
+ return data
175
+ }
176
+
177
+ setBody(body: any) {
178
+ this.body = body
179
+ }
180
+
181
+ setParam(key: string, value: string) {
182
+ this.params.set(key, value)
183
+ }
184
+
185
+ setQuery(key: string, value: string) {
186
+ this.queryParams.set(key, value)
187
+ }
188
+
189
+ getResponse() {
190
+ return { data: this.responseData, status: this.responseStatus }
191
+ }
192
+ }
193
+
194
+ describe('AdminInvoiceController', () => {
195
+ let controller: AdminInvoiceController
196
+ let core: MockCore
197
+
198
+ beforeEach(() => {
199
+ core = new MockCore() as any
200
+ controller = new AdminInvoiceController(core as any)
201
+ })
202
+
203
+ it('應該列出所有發票', async () => {
204
+ const ctx = new MockContext()
205
+ const mockRepo = new MockRepository()
206
+
207
+ // 建立測試發票
208
+ const invoice = Invoice.create({
209
+ orderId: 'order-1',
210
+ invoiceNumber: InvoiceNumber.generate().value,
211
+ amount: 1000,
212
+ tax: 50,
213
+ status: 'ISSUED',
214
+ })
215
+ await mockRepo.save(invoice)
216
+
217
+ const response = await controller.index(ctx as any)
218
+
219
+ expect(response.success).toBe(true)
220
+ expect(response.data).toBeDefined()
221
+ expect(response.meta.total).toBeGreaterThanOrEqual(0)
222
+ })
223
+
224
+ it('應該驗證開立發票的必要欄位', async () => {
225
+ const ctx = new MockContext()
226
+ ctx.setBody({ orderId: 'order-1' }) // 缺少 amount
227
+
228
+ const response = await controller.store(ctx as any)
229
+
230
+ expect(response.success).toBe(false)
231
+ expect(response.error).toContain('缺少必要欄位')
232
+ })
233
+
234
+ it('應該成功開立發票', async () => {
235
+ const ctx = new MockContext()
236
+ ctx.setBody({ orderId: 'order-123', amount: 1000 })
237
+
238
+ const response = await controller.store(ctx as any)
239
+
240
+ expect(response.success).toBe(true)
241
+ expect(response.data.id).toBeDefined()
242
+ expect(response.data.invoiceNumber).toMatch(/^GX-\d{8}$/)
243
+ expect(response.data.orderId).toBe('order-123')
244
+ expect(response.data.amount).toBe(1000)
245
+ })
246
+
247
+ it('應該驗證查詢發票時需要 ID', async () => {
248
+ const ctx = new MockContext()
249
+
250
+ const response = await controller.show(ctx as any)
251
+
252
+ expect(response.success).toBe(false)
253
+ expect(response.error).toContain('缺少發票 ID')
254
+ })
255
+
256
+ it('應該查詢不存在的發票時返回 404', async () => {
257
+ const ctx = new MockContext()
258
+ ctx.setParam('id', 'non-existent')
259
+
260
+ const response = await controller.show(ctx as any)
261
+
262
+ expect(response.success).toBe(false)
263
+ })
264
+
265
+ it('應該驗證取消發票時需要 ID 和原因', async () => {
266
+ const ctx = new MockContext()
267
+ ctx.setBody({})
268
+
269
+ const response = await controller.cancel(ctx as any)
270
+
271
+ expect(response.success).toBe(false)
272
+ expect(response.error).toContain('缺少發票 ID')
273
+ })
274
+
275
+ it('應該驗證生成報告時需要日期', async () => {
276
+ const ctx = new MockContext()
277
+
278
+ const response = await controller.report(ctx as any)
279
+
280
+ expect(response.success).toBe(false)
281
+ expect(response.error).toContain('缺少日期範圍參數')
282
+ })
283
+
284
+ it('應該驗證生成報告時日期格式', async () => {
285
+ const ctx = new MockContext()
286
+ ctx.setQuery('startDate', 'invalid-date')
287
+ ctx.setQuery('endDate', 'invalid-date')
288
+
289
+ const response = await controller.report(ctx as any)
290
+
291
+ expect(response.success).toBe(false)
292
+ expect(response.error).toContain('無效的日期格式')
293
+ })
294
+ })
@@ -0,0 +1,244 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { Invoice } from '../src/Domain/Entities/Invoice'
3
+
4
+ describe('Invoice Domain Entity', () => {
5
+ describe('Invoice Creation', () => {
6
+ it('應該能建立新發票且預設狀態為 ISSUED', () => {
7
+ const invoice = Invoice.create({
8
+ orderId: 'ord-123',
9
+ invoiceNumber: 'INV-2025-001',
10
+ amount: 1000,
11
+ tax: 100,
12
+ status: 'ISSUED',
13
+ })
14
+
15
+ expect(invoice.id).toBeDefined()
16
+ expect(invoice.orderId).toBe('ord-123')
17
+ expect(invoice.invoiceNumber).toBe('INV-2025-001')
18
+ expect(invoice.amount).toBe(1000)
19
+ expect(invoice.status).toBe('ISSUED')
20
+ })
21
+
22
+ it('應該能帶自定義 ID 建立', () => {
23
+ const invoice = Invoice.create(
24
+ {
25
+ orderId: 'ord-123',
26
+ invoiceNumber: 'INV-001',
27
+ amount: 1000,
28
+ tax: 100,
29
+ status: 'ISSUED',
30
+ },
31
+ 'inv-custom-123'
32
+ )
33
+
34
+ expect(invoice.id).toBe('inv-custom-123')
35
+ })
36
+
37
+ it('應該能設置發票號碼和稅額', () => {
38
+ const invoice = Invoice.create({
39
+ orderId: 'ord-456',
40
+ invoiceNumber: 'INV-2025-999',
41
+ amount: 5000,
42
+ tax: 500,
43
+ status: 'ISSUED',
44
+ })
45
+
46
+ expect(invoice.invoiceNumber).toBe('INV-2025-999')
47
+ const props = invoice.unpack()
48
+ expect(props.tax).toBe(500)
49
+ })
50
+
51
+ it('應該自動設置 createdAt', () => {
52
+ const invoice = Invoice.create({
53
+ orderId: 'ord-123',
54
+ invoiceNumber: 'INV-001',
55
+ amount: 1000,
56
+ tax: 100,
57
+ status: 'ISSUED',
58
+ })
59
+
60
+ const props = invoice.unpack()
61
+ expect(props.createdAt).toBeInstanceOf(Date)
62
+ })
63
+
64
+ it('應該能設置買方和運送商識別碼', () => {
65
+ const invoice = Invoice.create({
66
+ orderId: 'ord-123',
67
+ invoiceNumber: 'INV-001',
68
+ amount: 1000,
69
+ tax: 100,
70
+ buyerIdentifier: 'buyer-123',
71
+ carrierId: 'carrier-456',
72
+ status: 'ISSUED',
73
+ })
74
+
75
+ const props = invoice.unpack()
76
+ expect(props.buyerIdentifier).toBe('buyer-123')
77
+ expect(props.carrierId).toBe('carrier-456')
78
+ })
79
+ })
80
+
81
+ describe('Invoice Status Transitions', () => {
82
+ it('應該能取消發票', () => {
83
+ const invoice = Invoice.create({
84
+ orderId: 'ord-123',
85
+ invoiceNumber: 'INV-001',
86
+ amount: 1000,
87
+ tax: 100,
88
+ status: 'ISSUED',
89
+ })
90
+
91
+ expect(invoice.status).toBe('ISSUED')
92
+ invoice.cancel()
93
+ expect(invoice.status).toBe('CANCELLED')
94
+ })
95
+
96
+ it('應該支援所有狀態類型', () => {
97
+ const statuses: Array<'ISSUED' | 'CANCELLED' | 'RETURNED'> = [
98
+ 'ISSUED',
99
+ 'CANCELLED',
100
+ 'RETURNED',
101
+ ]
102
+
103
+ statuses.forEach((status) => {
104
+ const invoice = Invoice.create({
105
+ orderId: `ord-${status}`,
106
+ invoiceNumber: `INV-${status}`,
107
+ amount: 1000,
108
+ tax: 100,
109
+ status,
110
+ })
111
+
112
+ expect(invoice.status).toBe(status)
113
+ })
114
+ })
115
+ })
116
+
117
+ describe('Invoice Properties', () => {
118
+ it('unpack() 應回傳完整的發票屬性', () => {
119
+ const now = new Date()
120
+ const invoice = Invoice.create({
121
+ orderId: 'ord-123',
122
+ invoiceNumber: 'INV-001',
123
+ amount: 5000,
124
+ tax: 500,
125
+ buyerIdentifier: 'buyer-123',
126
+ carrierId: 'carrier-456',
127
+ status: 'ISSUED',
128
+ createdAt: now,
129
+ })
130
+
131
+ const props = invoice.unpack()
132
+
133
+ expect(props.orderId).toBe('ord-123')
134
+ expect(props.invoiceNumber).toBe('INV-001')
135
+ expect(props.amount).toBe(5000)
136
+ expect(props.tax).toBe(500)
137
+ expect(props.buyerIdentifier).toBe('buyer-123')
138
+ expect(props.carrierId).toBe('carrier-456')
139
+ expect(props.status).toBe('ISSUED')
140
+ expect(props.createdAt).toBe(now)
141
+ })
142
+
143
+ it('unpack() 應回傳深層副本', () => {
144
+ const invoice = Invoice.create({
145
+ orderId: 'ord-123',
146
+ invoiceNumber: 'INV-001',
147
+ amount: 1000,
148
+ tax: 100,
149
+ status: 'ISSUED',
150
+ })
151
+
152
+ const props1 = invoice.unpack()
153
+ const props2 = invoice.unpack()
154
+
155
+ expect(props1).not.toBe(props2)
156
+ expect(props1).toEqual(props2)
157
+ })
158
+ })
159
+
160
+ describe('Invoice Calculations', () => {
161
+ it('應該能計算含稅金額', () => {
162
+ const invoice = Invoice.create({
163
+ orderId: 'ord-123',
164
+ invoiceNumber: 'INV-001',
165
+ amount: 1000,
166
+ tax: 100,
167
+ status: 'ISSUED',
168
+ })
169
+
170
+ const props = invoice.unpack()
171
+ const totalWithTax = props.amount + props.tax
172
+ expect(totalWithTax).toBe(1100)
173
+ })
174
+
175
+ it('應該支援不同的稅率', () => {
176
+ const invoices = [
177
+ { amount: 1000, tax: 0 }, // 免稅
178
+ { amount: 1000, tax: 50 }, // 5% 稅率
179
+ { amount: 1000, tax: 100 }, // 10% 稅率
180
+ { amount: 1000, tax: 200 }, // 20% 稅率
181
+ ]
182
+
183
+ invoices.forEach((data, idx) => {
184
+ const invoice = Invoice.create({
185
+ orderId: `ord-${idx}`,
186
+ invoiceNumber: `INV-${idx}`,
187
+ amount: data.amount,
188
+ tax: data.tax,
189
+ status: 'ISSUED',
190
+ })
191
+
192
+ const props = invoice.unpack()
193
+ expect(props.amount + props.tax).toBe(data.amount + data.tax)
194
+ })
195
+ })
196
+ })
197
+
198
+ describe('Multiple Invoices', () => {
199
+ it('應該能獨立建立多張發票', () => {
200
+ const invoice1 = Invoice.create({
201
+ orderId: 'ord-1',
202
+ invoiceNumber: 'INV-001',
203
+ amount: 1000,
204
+ tax: 100,
205
+ status: 'ISSUED',
206
+ })
207
+
208
+ const invoice2 = Invoice.create({
209
+ orderId: 'ord-2',
210
+ invoiceNumber: 'INV-002',
211
+ amount: 2000,
212
+ tax: 200,
213
+ status: 'ISSUED',
214
+ })
215
+
216
+ expect(invoice1.id).not.toBe(invoice2.id)
217
+ expect(invoice1.invoiceNumber).toBe('INV-001')
218
+ expect(invoice2.invoiceNumber).toBe('INV-002')
219
+ })
220
+
221
+ it('不同發票的取消應互不影響', () => {
222
+ const invoice1 = Invoice.create({
223
+ orderId: 'ord-1',
224
+ invoiceNumber: 'INV-001',
225
+ amount: 1000,
226
+ tax: 100,
227
+ status: 'ISSUED',
228
+ })
229
+
230
+ const invoice2 = Invoice.create({
231
+ orderId: 'ord-2',
232
+ invoiceNumber: 'INV-002',
233
+ amount: 2000,
234
+ tax: 200,
235
+ status: 'ISSUED',
236
+ })
237
+
238
+ invoice1.cancel()
239
+
240
+ expect(invoice1.status).toBe('CANCELLED')
241
+ expect(invoice2.status).toBe('ISSUED')
242
+ })
243
+ })
244
+ })
package/tsconfig.json CHANGED
@@ -1,21 +1,13 @@
1
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
- ]
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "baseUrl": ".",
6
+ "paths": {
7
+ "@gravito/core": ["../../packages/core/src/index.ts"],
8
+ "@gravito/*": ["../../packages/*/src/index.ts"]
17
9
  },
18
- "include": [
19
- "src/**/*"
20
- ]
21
- }
10
+ "types": ["bun-types"]
11
+ },
12
+ "include": ["src/**/*"]
13
+ }
package/dist/index.d.ts DELETED
@@ -1,8 +0,0 @@
1
- import { ServiceProvider, Container } from '@gravito/core';
2
-
3
- declare class InvoiceServiceProvider extends ServiceProvider {
4
- register(container: Container): void;
5
- boot(): void;
6
- }
7
-
8
- export { InvoiceServiceProvider };