@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,198 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { InvoiceAuditContext } from '../../../src/Application/Contexts/InvoiceAuditContext'
|
|
3
|
+
import { DefaultInvoiceTracker } from '../../../src/Application/Roles/InvoiceTrackerRole'
|
|
4
|
+
import type { IInvoiceRepository } from '../../../src/Domain/Contracts/IInvoiceRepository'
|
|
5
|
+
import { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
6
|
+
import { InvoiceNotFoundError } from '../../../src/Domain/Errors/InvoiceError'
|
|
7
|
+
import { InvoiceNumber } from '../../../src/Domain/ValueObjects/InvoiceNumber'
|
|
8
|
+
|
|
9
|
+
// Mock Repository
|
|
10
|
+
class MockRepository implements IInvoiceRepository {
|
|
11
|
+
private invoices: Map<string, Invoice> = new Map()
|
|
12
|
+
|
|
13
|
+
async save(invoice: Invoice): Promise<void> {
|
|
14
|
+
this.invoices.set(invoice.id, invoice)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async findById(id: string): Promise<Invoice | null> {
|
|
18
|
+
return this.invoices.get(id) || null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async findByOrderId(orderId: string): Promise<Invoice | null> {
|
|
22
|
+
for (const invoice of this.invoices.values()) {
|
|
23
|
+
if (invoice.orderId === orderId) {
|
|
24
|
+
return invoice
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async findAll(): Promise<Invoice[]> {
|
|
31
|
+
return Array.from(this.invoices.values())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
|
|
35
|
+
for (const invoice of this.invoices.values()) {
|
|
36
|
+
if (invoice.invoiceNumber === invoiceNumber) {
|
|
37
|
+
return invoice
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findByStatus(status: string): Promise<Invoice[]> {
|
|
44
|
+
return Array.from(this.invoices.values()).filter((inv) => inv.status === status)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]> {
|
|
48
|
+
return Array.from(this.invoices.values()).filter(
|
|
49
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('InvoiceAuditContext', () => {
|
|
55
|
+
let context: InvoiceAuditContext
|
|
56
|
+
let repository: MockRepository
|
|
57
|
+
let tracker: DefaultInvoiceTracker
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
repository = new MockRepository()
|
|
61
|
+
tracker = new DefaultInvoiceTracker()
|
|
62
|
+
context = new InvoiceAuditContext(repository, tracker)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('應該查詢發票狀態', async () => {
|
|
66
|
+
const invoice = Invoice.create(
|
|
67
|
+
{
|
|
68
|
+
orderId: 'order-123',
|
|
69
|
+
invoiceNumber: 'GX-12345678',
|
|
70
|
+
amount: 1000,
|
|
71
|
+
tax: 50,
|
|
72
|
+
status: 'ISSUED',
|
|
73
|
+
},
|
|
74
|
+
'inv-123'
|
|
75
|
+
)
|
|
76
|
+
await repository.save(invoice)
|
|
77
|
+
tracker.trackInvoice(invoice)
|
|
78
|
+
|
|
79
|
+
const result = await context.queryStatus({ invoiceId: 'inv-123' })
|
|
80
|
+
|
|
81
|
+
expect(result.id).toBe('inv-123')
|
|
82
|
+
expect(result.invoiceNumber).toBe('GX-12345678')
|
|
83
|
+
expect(result.orderId).toBe('order-123')
|
|
84
|
+
expect(result.status).toBe('ISSUED')
|
|
85
|
+
expect(result.amount).toBe(1000)
|
|
86
|
+
expect(result.createdAt).toBeInstanceOf(Date)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('應該在發票不存在時拋出錯誤', async () => {
|
|
90
|
+
try {
|
|
91
|
+
await context.queryStatus({ invoiceId: 'non-existent' })
|
|
92
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
93
|
+
} catch (error) {
|
|
94
|
+
expect(error).toBeInstanceOf(InvoiceNotFoundError)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('應該取得審計追蹤', async () => {
|
|
99
|
+
const invoice = Invoice.create(
|
|
100
|
+
{
|
|
101
|
+
orderId: 'order-123',
|
|
102
|
+
invoiceNumber: 'GX-12345678',
|
|
103
|
+
amount: 1000,
|
|
104
|
+
tax: 50,
|
|
105
|
+
status: 'ISSUED',
|
|
106
|
+
},
|
|
107
|
+
'inv-123'
|
|
108
|
+
)
|
|
109
|
+
await repository.save(invoice)
|
|
110
|
+
tracker.trackInvoice(invoice)
|
|
111
|
+
|
|
112
|
+
tracker.recordAuditLog({
|
|
113
|
+
invoiceId: 'inv-123',
|
|
114
|
+
action: 'CREATED',
|
|
115
|
+
timestamp: new Date(),
|
|
116
|
+
details: { amount: 1000 },
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const result = await context.getAuditTrail({ invoiceId: 'inv-123' })
|
|
120
|
+
|
|
121
|
+
expect(result.invoiceId).toBe('inv-123')
|
|
122
|
+
expect(result.logs.length).toBe(1)
|
|
123
|
+
expect((result.logs[0] as any).action).toBe('CREATED')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('應該在發票不存在時取得審計追蹤時拋出錯誤', async () => {
|
|
127
|
+
try {
|
|
128
|
+
await context.getAuditTrail({ invoiceId: 'non-existent' })
|
|
129
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
130
|
+
} catch (error) {
|
|
131
|
+
expect(error).toBeInstanceOf(InvoiceNotFoundError)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('應該生成期間報告', async () => {
|
|
136
|
+
// 建立多張發票
|
|
137
|
+
for (let i = 0; i < 3; i++) {
|
|
138
|
+
const invoice = Invoice.create({
|
|
139
|
+
orderId: `order-${i}`,
|
|
140
|
+
invoiceNumber: InvoiceNumber.generate().value,
|
|
141
|
+
amount: 1000 * (i + 1),
|
|
142
|
+
tax: 50 * (i + 1),
|
|
143
|
+
status: 'ISSUED',
|
|
144
|
+
})
|
|
145
|
+
await repository.save(invoice)
|
|
146
|
+
tracker.trackInvoice(invoice)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const now = new Date()
|
|
150
|
+
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
151
|
+
const endDate = new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
|
152
|
+
|
|
153
|
+
const report = await context.generateReport({ startDate, endDate })
|
|
154
|
+
|
|
155
|
+
expect(report.period.startDate).toEqual(startDate)
|
|
156
|
+
expect(report.period.endDate).toEqual(endDate)
|
|
157
|
+
expect(report.summary.total).toBe(3)
|
|
158
|
+
expect(report.summary.issued).toBe(3)
|
|
159
|
+
expect(report.summary.cancelled).toBe(0)
|
|
160
|
+
expect(report.summary.returned).toBe(0)
|
|
161
|
+
expect(report.summary.totalAmount).toBe(6000) // 1000 + 2000 + 3000
|
|
162
|
+
expect(report.invoices.length).toBe(3)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('應該在空期間返回空報告', async () => {
|
|
166
|
+
const startDate = new Date('2020-01-01')
|
|
167
|
+
const endDate = new Date('2020-12-31')
|
|
168
|
+
|
|
169
|
+
const report = await context.generateReport({ startDate, endDate })
|
|
170
|
+
|
|
171
|
+
expect(report.summary.total).toBe(0)
|
|
172
|
+
expect(report.invoices.length).toBe(0)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('應該在報告中包含正確的發票詳情', async () => {
|
|
176
|
+
const invoice = Invoice.create({
|
|
177
|
+
orderId: 'order-123',
|
|
178
|
+
invoiceNumber: 'GX-12345678',
|
|
179
|
+
amount: 5000,
|
|
180
|
+
tax: 250,
|
|
181
|
+
status: 'ISSUED',
|
|
182
|
+
})
|
|
183
|
+
await repository.save(invoice)
|
|
184
|
+
tracker.trackInvoice(invoice)
|
|
185
|
+
|
|
186
|
+
const now = new Date()
|
|
187
|
+
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
188
|
+
const endDate = new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
|
189
|
+
|
|
190
|
+
const report = await context.generateReport({ startDate, endDate })
|
|
191
|
+
|
|
192
|
+
expect(report.invoices[0].id).toBe(invoice.id)
|
|
193
|
+
expect(report.invoices[0].number).toBe('GX-12345678')
|
|
194
|
+
expect(report.invoices[0].orderId).toBe('order-123')
|
|
195
|
+
expect(report.invoices[0].amount).toBe(5000)
|
|
196
|
+
expect(report.invoices[0].status).toBe('ISSUED')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { InvoiceCancellationContext } from '../../../src/Application/Contexts/InvoiceCancellationContext'
|
|
3
|
+
import { DefaultInvoiceCanceller } from '../../../src/Application/Roles/InvoiceCancellerRole'
|
|
4
|
+
import type { IInvoiceRepository } from '../../../src/Domain/Contracts/IInvoiceRepository'
|
|
5
|
+
import { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
6
|
+
import {
|
|
7
|
+
InvalidCancellationError,
|
|
8
|
+
InvoiceNotFoundError,
|
|
9
|
+
} from '../../../src/Domain/Errors/InvoiceError'
|
|
10
|
+
import { InvoiceNumber } from '../../../src/Domain/ValueObjects/InvoiceNumber'
|
|
11
|
+
|
|
12
|
+
// Mock Repository
|
|
13
|
+
class MockRepository implements IInvoiceRepository {
|
|
14
|
+
private invoices: Map<string, Invoice> = new Map()
|
|
15
|
+
|
|
16
|
+
async save(invoice: Invoice): Promise<void> {
|
|
17
|
+
this.invoices.set(invoice.id, invoice)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async findById(id: string): Promise<Invoice | null> {
|
|
21
|
+
return this.invoices.get(id) || null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async findByOrderId(orderId: string): Promise<Invoice | null> {
|
|
25
|
+
for (const invoice of this.invoices.values()) {
|
|
26
|
+
if (invoice.orderId === orderId) {
|
|
27
|
+
return invoice
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async findAll(): Promise<Invoice[]> {
|
|
34
|
+
return Array.from(this.invoices.values())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
|
|
38
|
+
for (const invoice of this.invoices.values()) {
|
|
39
|
+
if (invoice.invoiceNumber === invoiceNumber) {
|
|
40
|
+
return invoice
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async findByStatus(status: string): Promise<Invoice[]> {
|
|
47
|
+
return Array.from(this.invoices.values()).filter((inv) => inv.status === status)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]> {
|
|
51
|
+
return Array.from(this.invoices.values()).filter(
|
|
52
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('InvoiceCancellationContext', () => {
|
|
58
|
+
let context: InvoiceCancellationContext
|
|
59
|
+
let repository: MockRepository
|
|
60
|
+
let canceller: DefaultInvoiceCanceller
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
repository = new MockRepository()
|
|
64
|
+
canceller = new DefaultInvoiceCanceller()
|
|
65
|
+
context = new InvoiceCancellationContext(repository, canceller)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('應該成功取消已發行的發票', async () => {
|
|
69
|
+
// 先建立一張發票
|
|
70
|
+
const invoice = Invoice.create(
|
|
71
|
+
{
|
|
72
|
+
orderId: 'order-123',
|
|
73
|
+
invoiceNumber: 'GX-12345678',
|
|
74
|
+
amount: 1000,
|
|
75
|
+
tax: 50,
|
|
76
|
+
status: 'ISSUED',
|
|
77
|
+
},
|
|
78
|
+
'inv-123'
|
|
79
|
+
)
|
|
80
|
+
await repository.save(invoice)
|
|
81
|
+
|
|
82
|
+
// 取消發票
|
|
83
|
+
const result = await context.orchestrate({
|
|
84
|
+
invoiceId: 'inv-123',
|
|
85
|
+
reason: 'CUSTOMER_REQUEST',
|
|
86
|
+
notes: 'Customer changed their mind',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(result.id).toBe('inv-123')
|
|
90
|
+
expect(result.invoiceNumber).toBe('GX-12345678')
|
|
91
|
+
expect(result.previousStatus).toBe('ISSUED')
|
|
92
|
+
expect(result.newStatus).toBe('CANCELLED')
|
|
93
|
+
expect(result.cancelledAt).toBeInstanceOf(Date)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('應該支援各種取消原因', async () => {
|
|
97
|
+
const reasons = [
|
|
98
|
+
'CUSTOMER_REQUEST',
|
|
99
|
+
'PAYMENT_FAILED',
|
|
100
|
+
'ORDER_CANCELLED',
|
|
101
|
+
'DUPLICATE',
|
|
102
|
+
'OTHER',
|
|
103
|
+
] as const
|
|
104
|
+
|
|
105
|
+
for (const reason of reasons) {
|
|
106
|
+
const invoice = Invoice.create({
|
|
107
|
+
orderId: `order-${reason}`,
|
|
108
|
+
invoiceNumber: InvoiceNumber.generate().value,
|
|
109
|
+
amount: 1000,
|
|
110
|
+
tax: 50,
|
|
111
|
+
status: 'ISSUED',
|
|
112
|
+
})
|
|
113
|
+
await repository.save(invoice)
|
|
114
|
+
|
|
115
|
+
const result = await context.orchestrate({
|
|
116
|
+
invoiceId: invoice.id,
|
|
117
|
+
reason,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(result.previousStatus).toBe('ISSUED')
|
|
121
|
+
expect(result.newStatus).toBe('CANCELLED')
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('應該在發票不存在時拋出錯誤', async () => {
|
|
126
|
+
try {
|
|
127
|
+
await context.orchestrate({
|
|
128
|
+
invoiceId: 'non-existent',
|
|
129
|
+
reason: 'CUSTOMER_REQUEST',
|
|
130
|
+
})
|
|
131
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
132
|
+
} catch (error) {
|
|
133
|
+
expect(error).toBeInstanceOf(InvoiceNotFoundError)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('應該拒絕取消已取消的發票', async () => {
|
|
138
|
+
// 先建立並取消一張發票
|
|
139
|
+
const invoice = Invoice.create(
|
|
140
|
+
{
|
|
141
|
+
orderId: 'order-123',
|
|
142
|
+
invoiceNumber: 'GX-12345678',
|
|
143
|
+
amount: 1000,
|
|
144
|
+
tax: 50,
|
|
145
|
+
status: 'ISSUED',
|
|
146
|
+
},
|
|
147
|
+
'inv-123'
|
|
148
|
+
)
|
|
149
|
+
const cancelledInvoice = invoice.cancel()
|
|
150
|
+
await repository.save(cancelledInvoice)
|
|
151
|
+
|
|
152
|
+
// 嘗試再次取消
|
|
153
|
+
try {
|
|
154
|
+
await context.orchestrate({
|
|
155
|
+
invoiceId: 'inv-123',
|
|
156
|
+
reason: 'CUSTOMER_REQUEST',
|
|
157
|
+
})
|
|
158
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
159
|
+
} catch (error) {
|
|
160
|
+
expect(error).toBeInstanceOf(InvalidCancellationError)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('應該記錄取消的 notes', async () => {
|
|
165
|
+
const invoice = Invoice.create(
|
|
166
|
+
{
|
|
167
|
+
orderId: 'order-123',
|
|
168
|
+
invoiceNumber: 'GX-12345678',
|
|
169
|
+
amount: 1000,
|
|
170
|
+
tax: 50,
|
|
171
|
+
status: 'ISSUED',
|
|
172
|
+
},
|
|
173
|
+
'inv-123'
|
|
174
|
+
)
|
|
175
|
+
await repository.save(invoice)
|
|
176
|
+
|
|
177
|
+
const result = await context.orchestrate({
|
|
178
|
+
invoiceId: 'inv-123',
|
|
179
|
+
reason: 'CUSTOMER_REQUEST',
|
|
180
|
+
notes: 'Special handling requested',
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(result.id).toBe('inv-123')
|
|
184
|
+
// 驗證記錄已保存
|
|
185
|
+
const record = (canceller as any).getCancellationRecord('inv-123')
|
|
186
|
+
expect(record.notes).toBe('Special handling requested')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('應該更新 Repository 中的發票狀態', async () => {
|
|
190
|
+
const invoice = Invoice.create(
|
|
191
|
+
{
|
|
192
|
+
orderId: 'order-123',
|
|
193
|
+
invoiceNumber: 'GX-12345678',
|
|
194
|
+
amount: 1000,
|
|
195
|
+
tax: 50,
|
|
196
|
+
status: 'ISSUED',
|
|
197
|
+
},
|
|
198
|
+
'inv-123'
|
|
199
|
+
)
|
|
200
|
+
await repository.save(invoice)
|
|
201
|
+
|
|
202
|
+
await context.orchestrate({
|
|
203
|
+
invoiceId: 'inv-123',
|
|
204
|
+
reason: 'CUSTOMER_REQUEST',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// 驗證已保存的發票狀態已更新
|
|
208
|
+
const updated = await repository.findById('inv-123')
|
|
209
|
+
expect(updated?.status).toBe('CANCELLED')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('應該能取消沒有 notes 的發票', async () => {
|
|
213
|
+
const invoice = Invoice.create(
|
|
214
|
+
{
|
|
215
|
+
orderId: 'order-123',
|
|
216
|
+
invoiceNumber: 'GX-12345678',
|
|
217
|
+
amount: 1000,
|
|
218
|
+
tax: 50,
|
|
219
|
+
status: 'ISSUED',
|
|
220
|
+
},
|
|
221
|
+
'inv-123'
|
|
222
|
+
)
|
|
223
|
+
await repository.save(invoice)
|
|
224
|
+
|
|
225
|
+
const result = await context.orchestrate({
|
|
226
|
+
invoiceId: 'inv-123',
|
|
227
|
+
reason: 'CUSTOMER_REQUEST',
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(result.newStatus).toBe('CANCELLED')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { InvoiceIssuanceContext } from '../../../src/Application/Contexts/InvoiceIssuanceContext'
|
|
3
|
+
import { DefaultInvoiceIssuer } from '../../../src/Application/Roles/InvoiceIssuerRole'
|
|
4
|
+
import type { IInvoiceRepository } from '../../../src/Domain/Contracts/IInvoiceRepository'
|
|
5
|
+
import { DuplicateInvoiceError, InvoiceError } from '../../../src/Domain/Errors/InvoiceError'
|
|
6
|
+
|
|
7
|
+
// Mock Repository
|
|
8
|
+
class MockRepository implements IInvoiceRepository {
|
|
9
|
+
private invoices: Map<string, any> = new Map()
|
|
10
|
+
|
|
11
|
+
async save(invoice: any): Promise<void> {
|
|
12
|
+
this.invoices.set(invoice.id, invoice)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findById(id: string): Promise<any> {
|
|
16
|
+
return this.invoices.get(id) || null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findByOrderId(orderId: string): Promise<any> {
|
|
20
|
+
for (const invoice of this.invoices.values()) {
|
|
21
|
+
if (invoice.orderId === orderId) {
|
|
22
|
+
return invoice
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async findAll(): Promise<any[]> {
|
|
29
|
+
return Array.from(this.invoices.values())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async findByInvoiceNumber(invoiceNumber: string): Promise<any> {
|
|
33
|
+
for (const invoice of this.invoices.values()) {
|
|
34
|
+
if (invoice.invoiceNumber === invoiceNumber) {
|
|
35
|
+
return invoice
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async findByStatus(status: string): Promise<any[]> {
|
|
42
|
+
return Array.from(this.invoices.values()).filter((inv) => inv.status === status)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async findByDateRange(startDate: Date, endDate: Date): Promise<any[]> {
|
|
46
|
+
return Array.from(this.invoices.values()).filter(
|
|
47
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('InvoiceIssuanceContext', () => {
|
|
53
|
+
let context: InvoiceIssuanceContext
|
|
54
|
+
let repository: MockRepository
|
|
55
|
+
let issuer: DefaultInvoiceIssuer
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
repository = new MockRepository()
|
|
59
|
+
issuer = new DefaultInvoiceIssuer()
|
|
60
|
+
context = new InvoiceIssuanceContext(repository, issuer)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('應該成功開立發票', async () => {
|
|
64
|
+
const result = await context.orchestrate({
|
|
65
|
+
orderId: 'order-123',
|
|
66
|
+
amount: 1000,
|
|
67
|
+
currency: 'TWD',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(result.id).toBeDefined()
|
|
71
|
+
expect(result.invoiceNumber).toMatch(/^GX-\d{8}$/)
|
|
72
|
+
expect(result.orderId).toBe('order-123')
|
|
73
|
+
expect(result.amount).toBe(1000)
|
|
74
|
+
expect(result.currency).toBe('TWD')
|
|
75
|
+
expect(result.status).toBe('ISSUED')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('應該使用預設幣別 TWD', async () => {
|
|
79
|
+
const result = await context.orchestrate({
|
|
80
|
+
orderId: 'order-123',
|
|
81
|
+
amount: 1000,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(result.currency).toBe('TWD')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('應該帶著買方和運送商識別碼開立發票', async () => {
|
|
88
|
+
const result = await context.orchestrate({
|
|
89
|
+
orderId: 'order-123',
|
|
90
|
+
amount: 1000,
|
|
91
|
+
buyerIdentifier: 'buyer-123',
|
|
92
|
+
carrierId: 'carrier-456',
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(result.id).toBeDefined()
|
|
96
|
+
// 驗證保存的發票包含這些資訊
|
|
97
|
+
const saved = await repository.findById(result.id)
|
|
98
|
+
expect(saved).toBeDefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('應該計算正確的稅額', async () => {
|
|
102
|
+
const result = await context.orchestrate({
|
|
103
|
+
orderId: 'order-123',
|
|
104
|
+
amount: 1000,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// 驗證金額
|
|
108
|
+
expect(result.amount).toBe(1000)
|
|
109
|
+
// 稅額應該是 50 (5% of 1000)
|
|
110
|
+
const saved = await repository.findById(result.id)
|
|
111
|
+
expect(saved.tax).toBe(50)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('應該拒絕重複的訂單 ID', async () => {
|
|
115
|
+
// 第一次開票成功
|
|
116
|
+
await context.orchestrate({
|
|
117
|
+
orderId: 'order-123',
|
|
118
|
+
amount: 1000,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// 第二次應該失敗
|
|
122
|
+
try {
|
|
123
|
+
await context.orchestrate({
|
|
124
|
+
orderId: 'order-123',
|
|
125
|
+
amount: 1000,
|
|
126
|
+
})
|
|
127
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
128
|
+
} catch (error) {
|
|
129
|
+
expect(error).toBeInstanceOf(DuplicateInvoiceError)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('應該在無效的訂單時拋出錯誤', async () => {
|
|
134
|
+
try {
|
|
135
|
+
await context.orchestrate({
|
|
136
|
+
orderId: '',
|
|
137
|
+
amount: 1000,
|
|
138
|
+
})
|
|
139
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
140
|
+
} catch (error) {
|
|
141
|
+
expect(error).toBeInstanceOf(InvoiceError)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('應該保存發票到 Repository', async () => {
|
|
146
|
+
const result = await context.orchestrate({
|
|
147
|
+
orderId: 'order-123',
|
|
148
|
+
amount: 1000,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const saved = await repository.findById(result.id)
|
|
152
|
+
expect(saved).toBeDefined()
|
|
153
|
+
expect(saved.orderId).toBe('order-123')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('應該能透過 orderId 查詢已開立的發票', async () => {
|
|
157
|
+
const result = await context.orchestrate({
|
|
158
|
+
orderId: 'order-123',
|
|
159
|
+
amount: 1000,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const saved = await repository.findByOrderId('order-123')
|
|
163
|
+
expect(saved).toBeDefined()
|
|
164
|
+
expect(saved.id).toBe(result.id)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('應該生成不同的發票號碼', async () => {
|
|
168
|
+
const result1 = await context.orchestrate({
|
|
169
|
+
orderId: 'order-1',
|
|
170
|
+
amount: 1000,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const result2 = await context.orchestrate({
|
|
174
|
+
orderId: 'order-2',
|
|
175
|
+
amount: 1000,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
expect(result1.invoiceNumber).not.toBe(result2.invoiceNumber)
|
|
179
|
+
})
|
|
180
|
+
})
|