@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.
- package/CHANGELOG.md +8 -0
- package/REFACTOR_PLAN.md +238 -0
- package/dist/index.js +1097 -56
- package/package.json +4 -2
- package/package.json.bak +29 -0
- package/src/Application/Contexts/InvoiceAuditContext.ts +96 -0
- package/src/Application/Contexts/InvoiceCancellationContext.ts +60 -0
- package/src/Application/Contexts/InvoiceIssuanceContext.ts +42 -0
- package/src/Application/Roles/InvoiceCancellerRole.ts +97 -0
- package/src/Application/Roles/InvoiceIssuerRole.ts +72 -0
- package/src/Application/Roles/InvoiceTrackerRole.ts +112 -0
- package/src/Application/UseCases/CancelInvoice.ts +32 -0
- package/src/Application/UseCases/IssueInvoice.ts +13 -26
- package/src/Application/UseCases/QueryInvoiceStatus.ts +56 -0
- package/src/Domain/Contracts/IInvoiceRepository.ts +3 -0
- package/src/Domain/Entities/Invoice.ts +178 -20
- package/src/Domain/Errors/InvoiceError.ts +89 -0
- package/src/Domain/ValueObjects/InvoiceAmount.ts +86 -0
- package/src/Domain/ValueObjects/InvoiceNumber.ts +52 -0
- package/src/Domain/ValueObjects/InvoiceStatus.ts +131 -0
- package/src/Domain/ValueObjects/InvoiceTax.ts +71 -0
- package/src/Infrastructure/Persistence/AtlasInvoiceRepository.ts +135 -6
- package/src/Interface/Http/Controllers/AdminInvoiceController.ts +264 -18
- package/src/index.ts +57 -3
- package/tests/Application/Contexts/InvoiceAuditContext.test.ts +198 -0
- package/tests/Application/Contexts/InvoiceCancellationContext.test.ts +232 -0
- package/tests/Application/Contexts/InvoiceIssuanceContext.test.ts +180 -0
- package/tests/Application/Roles/InvoiceCancellerRole.test.ts +109 -0
- package/tests/Application/Roles/InvoiceIssuerRole.test.ts +66 -0
- package/tests/Application/Roles/InvoiceTrackerRole.test.ts +126 -0
- package/tests/Application/UseCases/CancelInvoice.test.ts +175 -0
- package/tests/Application/UseCases/IssueInvoice.test.ts +169 -0
- package/tests/Application/UseCases/QueryInvoiceStatus.test.ts +191 -0
- package/tests/Domain/Errors/InvoiceError.test.ts +96 -0
- package/tests/Domain/ValueObjects/InvoiceAmount.test.ts +84 -0
- package/tests/Domain/ValueObjects/InvoiceNumber.test.ts +60 -0
- package/tests/Domain/ValueObjects/InvoiceStatus.test.ts +89 -0
- package/tests/Domain/ValueObjects/InvoiceTax.test.ts +66 -0
- package/tests/Interface/Http/Controllers/AdminInvoiceController.test.ts +294 -0
- package/tests/domain.test.ts +244 -0
- package/tsconfig.json +11 -19
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
10
|
+
"types": ["bun-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"]
|
|
13
|
+
}
|