@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,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
DefaultInvoiceCanceller,
|
|
4
|
+
injectInvoiceCanceller,
|
|
5
|
+
} from '../../../src/Application/Roles/InvoiceCancellerRole'
|
|
6
|
+
import { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
7
|
+
|
|
8
|
+
describe('InvoiceCancellerRole', () => {
|
|
9
|
+
it('應該驗證 ISSUED 狀態的發票可被取消', () => {
|
|
10
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
11
|
+
const invoice = Invoice.create({
|
|
12
|
+
orderId: 'order-123',
|
|
13
|
+
invoiceNumber: 'GX-12345678',
|
|
14
|
+
amount: 1000,
|
|
15
|
+
tax: 50,
|
|
16
|
+
status: 'ISSUED',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const canCancel = canceller.validateCancellationEligibility(invoice)
|
|
20
|
+
|
|
21
|
+
expect(canCancel).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('應該驗證 CANCELLED 狀態的發票不可被取消', () => {
|
|
25
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
26
|
+
const invoice = Invoice.create({
|
|
27
|
+
orderId: 'order-123',
|
|
28
|
+
invoiceNumber: 'GX-12345678',
|
|
29
|
+
amount: 1000,
|
|
30
|
+
tax: 50,
|
|
31
|
+
status: 'ISSUED',
|
|
32
|
+
})
|
|
33
|
+
const cancelledInvoice = invoice.cancel()
|
|
34
|
+
|
|
35
|
+
const canCancel = canceller.validateCancellationEligibility(cancelledInvoice)
|
|
36
|
+
|
|
37
|
+
expect(canCancel).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('應該記錄取消原因', () => {
|
|
41
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
42
|
+
const record = canceller.recordCancellationReason(
|
|
43
|
+
'invoice-123',
|
|
44
|
+
'CUSTOMER_REQUEST',
|
|
45
|
+
'Customer changed mind'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(record.invoiceId).toBe('invoice-123')
|
|
49
|
+
expect(record.reason).toBe('CUSTOMER_REQUEST')
|
|
50
|
+
expect(record.notes).toBe('Customer changed mind')
|
|
51
|
+
expect(record.timestamp).toBeInstanceOf(Date)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('應該支援各種取消原因', () => {
|
|
55
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
56
|
+
const reasons = [
|
|
57
|
+
'CUSTOMER_REQUEST',
|
|
58
|
+
'PAYMENT_FAILED',
|
|
59
|
+
'ORDER_CANCELLED',
|
|
60
|
+
'DUPLICATE',
|
|
61
|
+
'OTHER',
|
|
62
|
+
] as const
|
|
63
|
+
|
|
64
|
+
for (const reason of reasons) {
|
|
65
|
+
const record = canceller.recordCancellationReason('invoice-123', reason)
|
|
66
|
+
expect(record.reason).toBe(reason)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('應該取得已記錄的取消記錄', () => {
|
|
71
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
72
|
+
canceller.recordCancellationReason('invoice-123', 'CUSTOMER_REQUEST')
|
|
73
|
+
|
|
74
|
+
const record = (canceller as any).getCancellationRecord('invoice-123')
|
|
75
|
+
|
|
76
|
+
expect(record).toBeDefined()
|
|
77
|
+
expect(record.invoiceId).toBe('invoice-123')
|
|
78
|
+
expect(record.reason).toBe('CUSTOMER_REQUEST')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('應該在沒有取消記錄時通知服務時拋出錯誤', async () => {
|
|
82
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await canceller.notifyRelatedServices('invoice-123')
|
|
86
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
87
|
+
} catch (error) {
|
|
88
|
+
expect((error as Error).message).toContain('No cancellation record found')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('應該能在已記錄後通知相關服務', async () => {
|
|
93
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
94
|
+
canceller.recordCancellationReason('invoice-123', 'CUSTOMER_REQUEST')
|
|
95
|
+
|
|
96
|
+
// 應該不拋出錯誤
|
|
97
|
+
await canceller.notifyRelatedServices('invoice-123')
|
|
98
|
+
expect(true).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('應該能透過注入函式取得角色', () => {
|
|
102
|
+
const canceller = injectInvoiceCanceller()
|
|
103
|
+
|
|
104
|
+
expect(canceller).toBeDefined()
|
|
105
|
+
expect(canceller.validateCancellationEligibility).toBeDefined()
|
|
106
|
+
expect(canceller.recordCancellationReason).toBeDefined()
|
|
107
|
+
expect(canceller.notifyRelatedServices).toBeDefined()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
DefaultInvoiceIssuer,
|
|
4
|
+
injectInvoiceIssuer,
|
|
5
|
+
} from '../../../src/Application/Roles/InvoiceIssuerRole'
|
|
6
|
+
|
|
7
|
+
describe('InvoiceIssuerRole', () => {
|
|
8
|
+
it('應該生成有效的發票號碼', () => {
|
|
9
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
10
|
+
const invoiceNumber = issuer.generateInvoiceNumber()
|
|
11
|
+
|
|
12
|
+
expect(invoiceNumber.value).toMatch(/^GX-\d{8}$/)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('應該生成不同的發票號碼', () => {
|
|
16
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
17
|
+
const number1 = issuer.generateInvoiceNumber()
|
|
18
|
+
const number2 = issuer.generateInvoiceNumber()
|
|
19
|
+
|
|
20
|
+
expect(number1.value).not.toBe(number2.value)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('應該計算預設稅額(5%)', () => {
|
|
24
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
25
|
+
const tax = issuer.calculateTax(1000)
|
|
26
|
+
|
|
27
|
+
expect(tax.value).toBe(50)
|
|
28
|
+
expect(tax.rate).toBe(0.05)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('應該計算自訂稅率的稅額', () => {
|
|
32
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
33
|
+
const tax = issuer.calculateTax(1000, 0.1)
|
|
34
|
+
|
|
35
|
+
expect(tax.value).toBe(100)
|
|
36
|
+
expect(tax.rate).toBe(0.1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('應該驗證有效的訂單', async () => {
|
|
40
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
41
|
+
const result = await issuer.validateOrderForInvoicing('order-123')
|
|
42
|
+
|
|
43
|
+
expect(result).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('應該拒絕無效的訂單 ID', async () => {
|
|
47
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
48
|
+
|
|
49
|
+
const result1 = await issuer.validateOrderForInvoicing('')
|
|
50
|
+
const result2 = await issuer.validateOrderForInvoicing(null as any)
|
|
51
|
+
const result3 = await issuer.validateOrderForInvoicing(undefined as any)
|
|
52
|
+
|
|
53
|
+
expect(result1).toBe(false)
|
|
54
|
+
expect(result2).toBe(false)
|
|
55
|
+
expect(result3).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('應該能透過注入函式取得角色', () => {
|
|
59
|
+
const issuer = injectInvoiceIssuer()
|
|
60
|
+
|
|
61
|
+
expect(issuer).toBeDefined()
|
|
62
|
+
expect(issuer.generateInvoiceNumber).toBeDefined()
|
|
63
|
+
expect(issuer.calculateTax).toBeDefined()
|
|
64
|
+
expect(issuer.validateOrderForInvoicing).toBeDefined()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
DefaultInvoiceTracker,
|
|
4
|
+
injectInvoiceTracker,
|
|
5
|
+
} from '../../../src/Application/Roles/InvoiceTrackerRole'
|
|
6
|
+
import { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
7
|
+
import { InvoiceNumber } from '../../../src/Domain/ValueObjects/InvoiceNumber'
|
|
8
|
+
|
|
9
|
+
describe('InvoiceTrackerRole', () => {
|
|
10
|
+
it('應該追蹤現存的發票狀態', async () => {
|
|
11
|
+
const tracker = new DefaultInvoiceTracker()
|
|
12
|
+
const invoice = Invoice.create({
|
|
13
|
+
orderId: 'order-123',
|
|
14
|
+
invoiceNumber: 'GX-12345678',
|
|
15
|
+
amount: 1000,
|
|
16
|
+
tax: 50,
|
|
17
|
+
status: 'ISSUED',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
tracker.trackInvoice(invoice)
|
|
21
|
+
|
|
22
|
+
const status = await tracker.trackInvoiceStatus(invoice.id)
|
|
23
|
+
|
|
24
|
+
expect(status).toBeDefined()
|
|
25
|
+
expect(status).toBe('ISSUED')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('應該在發票不存在時返回 null', async () => {
|
|
29
|
+
const tracker = new DefaultInvoiceTracker()
|
|
30
|
+
|
|
31
|
+
const status = await tracker.trackInvoiceStatus('non-existent-id')
|
|
32
|
+
|
|
33
|
+
expect(status).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('應該取得審計日誌', async () => {
|
|
37
|
+
const tracker = new DefaultInvoiceTracker()
|
|
38
|
+
const invoice = Invoice.create({
|
|
39
|
+
orderId: 'order-123',
|
|
40
|
+
invoiceNumber: 'GX-12345678',
|
|
41
|
+
amount: 1000,
|
|
42
|
+
tax: 50,
|
|
43
|
+
status: 'ISSUED',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
tracker.trackInvoice(invoice)
|
|
47
|
+
tracker.recordAuditLog({
|
|
48
|
+
invoiceId: invoice.id,
|
|
49
|
+
action: 'CREATED',
|
|
50
|
+
timestamp: new Date(),
|
|
51
|
+
details: { amount: 1000 },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const logs = await tracker.auditTrail(invoice.id)
|
|
55
|
+
|
|
56
|
+
expect(logs.length).toBe(1)
|
|
57
|
+
expect(logs[0].action).toBe('CREATED')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('應該在沒有日誌時返回空陣列', async () => {
|
|
61
|
+
const tracker = new DefaultInvoiceTracker()
|
|
62
|
+
|
|
63
|
+
const logs = await tracker.auditTrail('non-existent-id')
|
|
64
|
+
|
|
65
|
+
expect(logs).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('應該生成報告', async () => {
|
|
69
|
+
const tracker = new DefaultInvoiceTracker()
|
|
70
|
+
|
|
71
|
+
// 建立多張發票
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
const invoice = Invoice.create({
|
|
74
|
+
orderId: `order-${i}`,
|
|
75
|
+
invoiceNumber: `GX-${String(i).padStart(8, '0')}`,
|
|
76
|
+
amount: 1000 * (i + 1),
|
|
77
|
+
tax: 50 * (i + 1),
|
|
78
|
+
status: 'ISSUED',
|
|
79
|
+
})
|
|
80
|
+
tracker.trackInvoice(invoice)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const now = new Date()
|
|
84
|
+
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) // 昨天
|
|
85
|
+
const endDate = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 明天
|
|
86
|
+
|
|
87
|
+
const report = await tracker.generateReport(startDate, endDate)
|
|
88
|
+
|
|
89
|
+
expect(report.summary.total).toBe(3)
|
|
90
|
+
expect(report.summary.issued).toBe(3)
|
|
91
|
+
expect(report.summary.cancelled).toBe(0)
|
|
92
|
+
expect(report.summary.returned).toBe(0)
|
|
93
|
+
expect(report.summary.totalAmount).toBe(1000 + 2000 + 3000)
|
|
94
|
+
expect(report.invoices.length).toBe(3)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('應該在日期範圍外篩選發票', async () => {
|
|
98
|
+
const tracker = new DefaultInvoiceTracker()
|
|
99
|
+
const invoice = Invoice.create({
|
|
100
|
+
orderId: 'order-123',
|
|
101
|
+
invoiceNumber: 'GX-12345678',
|
|
102
|
+
amount: 1000,
|
|
103
|
+
tax: 50,
|
|
104
|
+
status: 'ISSUED',
|
|
105
|
+
})
|
|
106
|
+
tracker.trackInvoice(invoice)
|
|
107
|
+
|
|
108
|
+
// 查詢過去時間範圍(發票在未來)
|
|
109
|
+
const pastStart = new Date('2020-01-01')
|
|
110
|
+
const pastEnd = new Date('2020-12-31')
|
|
111
|
+
|
|
112
|
+
const report = await tracker.generateReport(pastStart, pastEnd)
|
|
113
|
+
|
|
114
|
+
expect(report.summary.total).toBe(0)
|
|
115
|
+
expect(report.invoices.length).toBe(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('應該能透過注入函式取得角色', () => {
|
|
119
|
+
const tracker = injectInvoiceTracker()
|
|
120
|
+
|
|
121
|
+
expect(tracker).toBeDefined()
|
|
122
|
+
expect(tracker.trackInvoiceStatus).toBeDefined()
|
|
123
|
+
expect(tracker.auditTrail).toBeDefined()
|
|
124
|
+
expect(tracker.generateReport).toBeDefined()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { CancelInvoice } from '../../../src/Application/UseCases/CancelInvoice'
|
|
3
|
+
import type { IInvoiceRepository } from '../../../src/Domain/Contracts/IInvoiceRepository'
|
|
4
|
+
import { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
5
|
+
import {
|
|
6
|
+
InvalidCancellationError,
|
|
7
|
+
InvoiceNotFoundError,
|
|
8
|
+
} from '../../../src/Domain/Errors/InvoiceError'
|
|
9
|
+
import { InvoiceNumber } from '../../../src/Domain/ValueObjects/InvoiceNumber'
|
|
10
|
+
|
|
11
|
+
// Mock Repository
|
|
12
|
+
class MockRepository implements IInvoiceRepository {
|
|
13
|
+
private invoices: Map<string, Invoice> = new Map()
|
|
14
|
+
|
|
15
|
+
async save(invoice: Invoice): Promise<void> {
|
|
16
|
+
this.invoices.set(invoice.id, invoice)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findById(id: string): Promise<Invoice | null> {
|
|
20
|
+
return this.invoices.get(id) || null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findByOrderId(orderId: string): Promise<Invoice | null> {
|
|
24
|
+
for (const invoice of this.invoices.values()) {
|
|
25
|
+
if (invoice.orderId === orderId) {
|
|
26
|
+
return invoice
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async findAll(): Promise<Invoice[]> {
|
|
33
|
+
return Array.from(this.invoices.values())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
|
|
37
|
+
for (const invoice of this.invoices.values()) {
|
|
38
|
+
if (invoice.invoiceNumber === invoiceNumber) {
|
|
39
|
+
return invoice
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async findByStatus(status: string): Promise<Invoice[]> {
|
|
46
|
+
return Array.from(this.invoices.values()).filter((inv) => inv.status === status)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]> {
|
|
50
|
+
return Array.from(this.invoices.values()).filter(
|
|
51
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('CancelInvoice UseCase', () => {
|
|
57
|
+
let useCase: CancelInvoice
|
|
58
|
+
let repository: MockRepository
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
repository = new MockRepository()
|
|
62
|
+
useCase = new CancelInvoice(repository)
|
|
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
|
+
|
|
78
|
+
const result = await useCase.execute({
|
|
79
|
+
invoiceId: 'inv-123',
|
|
80
|
+
reason: 'CUSTOMER_REQUEST',
|
|
81
|
+
notes: 'Customer changed their mind',
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(result.id).toBe('inv-123')
|
|
85
|
+
expect(result.previousStatus).toBe('ISSUED')
|
|
86
|
+
expect(result.newStatus).toBe('CANCELLED')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('應該支援各種取消原因', async () => {
|
|
90
|
+
const reasons = [
|
|
91
|
+
'CUSTOMER_REQUEST',
|
|
92
|
+
'PAYMENT_FAILED',
|
|
93
|
+
'ORDER_CANCELLED',
|
|
94
|
+
'DUPLICATE',
|
|
95
|
+
'OTHER',
|
|
96
|
+
] as const
|
|
97
|
+
|
|
98
|
+
for (const reason of reasons) {
|
|
99
|
+
const invoice = Invoice.create({
|
|
100
|
+
orderId: `order-${reason}`,
|
|
101
|
+
invoiceNumber: InvoiceNumber.generate().value,
|
|
102
|
+
amount: 1000,
|
|
103
|
+
tax: 50,
|
|
104
|
+
status: 'ISSUED',
|
|
105
|
+
})
|
|
106
|
+
await repository.save(invoice)
|
|
107
|
+
|
|
108
|
+
const result = await useCase.execute({
|
|
109
|
+
invoiceId: invoice.id,
|
|
110
|
+
reason,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(result.newStatus).toBe('CANCELLED')
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('應該在發票不存在時拋出錯誤', async () => {
|
|
118
|
+
try {
|
|
119
|
+
await useCase.execute({
|
|
120
|
+
invoiceId: 'non-existent',
|
|
121
|
+
reason: 'CUSTOMER_REQUEST',
|
|
122
|
+
})
|
|
123
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
124
|
+
} catch (error) {
|
|
125
|
+
expect(error).toBeInstanceOf(InvoiceNotFoundError)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('應該拒絕取消已取消的發票', async () => {
|
|
130
|
+
const invoice = Invoice.create(
|
|
131
|
+
{
|
|
132
|
+
orderId: 'order-123',
|
|
133
|
+
invoiceNumber: 'GX-12345678',
|
|
134
|
+
amount: 1000,
|
|
135
|
+
tax: 50,
|
|
136
|
+
status: 'ISSUED',
|
|
137
|
+
},
|
|
138
|
+
'inv-123'
|
|
139
|
+
)
|
|
140
|
+
const cancelledInvoice = invoice.cancel()
|
|
141
|
+
await repository.save(cancelledInvoice)
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await useCase.execute({
|
|
145
|
+
invoiceId: 'inv-123',
|
|
146
|
+
reason: 'CUSTOMER_REQUEST',
|
|
147
|
+
})
|
|
148
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
149
|
+
} catch (error) {
|
|
150
|
+
expect(error).toBeInstanceOf(InvalidCancellationError)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('應該更新發票狀態在 Repository 中', async () => {
|
|
155
|
+
const invoice = Invoice.create(
|
|
156
|
+
{
|
|
157
|
+
orderId: 'order-123',
|
|
158
|
+
invoiceNumber: 'GX-12345678',
|
|
159
|
+
amount: 1000,
|
|
160
|
+
tax: 50,
|
|
161
|
+
status: 'ISSUED',
|
|
162
|
+
},
|
|
163
|
+
'inv-123'
|
|
164
|
+
)
|
|
165
|
+
await repository.save(invoice)
|
|
166
|
+
|
|
167
|
+
await useCase.execute({
|
|
168
|
+
invoiceId: 'inv-123',
|
|
169
|
+
reason: 'CUSTOMER_REQUEST',
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const updated = await repository.findById('inv-123')
|
|
173
|
+
expect(updated?.status).toBe('CANCELLED')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { IssueInvoice } from '../../../src/Application/UseCases/IssueInvoice'
|
|
3
|
+
import type { IInvoiceRepository } from '../../../src/Domain/Contracts/IInvoiceRepository'
|
|
4
|
+
import type { Invoice } from '../../../src/Domain/Entities/Invoice'
|
|
5
|
+
import { DuplicateInvoiceError, InvoiceError } from '../../../src/Domain/Errors/InvoiceError'
|
|
6
|
+
|
|
7
|
+
// Mock Repository
|
|
8
|
+
class MockRepository implements IInvoiceRepository {
|
|
9
|
+
private invoices: Map<string, Invoice> = new Map()
|
|
10
|
+
|
|
11
|
+
async save(invoice: Invoice): Promise<void> {
|
|
12
|
+
this.invoices.set(invoice.id, invoice)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findById(id: string): Promise<Invoice | null> {
|
|
16
|
+
return this.invoices.get(id) || null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findByOrderId(orderId: string): Promise<Invoice | null> {
|
|
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<Invoice[]> {
|
|
29
|
+
return Array.from(this.invoices.values())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null> {
|
|
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<Invoice[]> {
|
|
42
|
+
return Array.from(this.invoices.values()).filter((inv) => inv.status === status)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]> {
|
|
46
|
+
return Array.from(this.invoices.values()).filter(
|
|
47
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('IssueInvoice UseCase (薄殼委派)', () => {
|
|
53
|
+
let useCase: IssueInvoice
|
|
54
|
+
let repository: MockRepository
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
repository = new MockRepository()
|
|
58
|
+
useCase = new IssueInvoice(repository)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('應該成功開立發票', async () => {
|
|
62
|
+
const result = await useCase.execute({
|
|
63
|
+
orderId: 'order-123',
|
|
64
|
+
amount: 1000,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(result.id).toBeDefined()
|
|
68
|
+
expect(result.orderId).toBe('order-123')
|
|
69
|
+
expect(result.invoiceNumber).toMatch(/^GX-\d{8}$/)
|
|
70
|
+
expect(result.amount).toBe(1000)
|
|
71
|
+
expect(result.status).toBe('ISSUED')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('應該帶著買方和運送商識別碼開立發票', async () => {
|
|
75
|
+
const result = await useCase.execute({
|
|
76
|
+
orderId: 'order-123',
|
|
77
|
+
amount: 1000,
|
|
78
|
+
buyerIdentifier: 'buyer-123',
|
|
79
|
+
carrierId: 'carrier-456',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(result.buyerIdentifier).toBe('buyer-123')
|
|
83
|
+
expect(result.carrierId).toBe('carrier-456')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('應該保存發票到 Repository', async () => {
|
|
87
|
+
const result = await useCase.execute({
|
|
88
|
+
orderId: 'order-123',
|
|
89
|
+
amount: 1000,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const saved = await repository.findById(result.id)
|
|
93
|
+
expect(saved).toBeDefined()
|
|
94
|
+
expect(saved?.orderId).toBe('order-123')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('應該拒絕重複的訂單 ID', async () => {
|
|
98
|
+
// 第一次開票成功
|
|
99
|
+
await useCase.execute({
|
|
100
|
+
orderId: 'order-123',
|
|
101
|
+
amount: 1000,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// 第二次應該失敗
|
|
105
|
+
try {
|
|
106
|
+
await useCase.execute({
|
|
107
|
+
orderId: 'order-123',
|
|
108
|
+
amount: 1000,
|
|
109
|
+
})
|
|
110
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
111
|
+
} catch (error) {
|
|
112
|
+
expect(error).toBeInstanceOf(DuplicateInvoiceError)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('應該在無效的訂單時拋出錯誤', async () => {
|
|
117
|
+
try {
|
|
118
|
+
await useCase.execute({
|
|
119
|
+
orderId: '',
|
|
120
|
+
amount: 1000,
|
|
121
|
+
})
|
|
122
|
+
expect(true).toBe(false) // 應該拋出錯誤
|
|
123
|
+
} catch (error) {
|
|
124
|
+
expect(error).toBeInstanceOf(InvoiceError)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('應該計算正確的稅額(5%)', async () => {
|
|
129
|
+
const result = await useCase.execute({
|
|
130
|
+
orderId: 'order-123',
|
|
131
|
+
amount: 1000,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(result.tax).toBe(50)
|
|
135
|
+
expect(result.taxObject.rate).toBe(0.05)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('應該生成不同的發票號碼', async () => {
|
|
139
|
+
const result1 = await useCase.execute({
|
|
140
|
+
orderId: 'order-1',
|
|
141
|
+
amount: 1000,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const result2 = await useCase.execute({
|
|
145
|
+
orderId: 'order-2',
|
|
146
|
+
amount: 1000,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(result1.invoiceNumber).not.toBe(result2.invoiceNumber)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('應該通過 Context 委派進行完整流程', async () => {
|
|
153
|
+
const result = await useCase.execute({
|
|
154
|
+
orderId: 'order-123',
|
|
155
|
+
amount: 1000,
|
|
156
|
+
buyerIdentifier: 'buyer-123',
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// 驗證委派流程正常工作
|
|
160
|
+
expect(result).toBeDefined()
|
|
161
|
+
expect(result.id).toBeDefined()
|
|
162
|
+
expect(result.invoiceNumber).toBeDefined()
|
|
163
|
+
|
|
164
|
+
// 驗證發票已保存
|
|
165
|
+
const saved = await repository.findById(result.id)
|
|
166
|
+
expect(saved).toBeDefined()
|
|
167
|
+
expect(saved?.buyerIdentifier).toBe('buyer-123')
|
|
168
|
+
})
|
|
169
|
+
})
|