billing invoice
This commit is contained in:
@@ -136,4 +136,194 @@ describe('Admin API', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billing admin workflows', () => {
|
||||
let billingAccountId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/admin/billing')
|
||||
.set(authHeader(financeToken))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const account = res.body.data.data.find((item: any) => item.company.id === companyId)
|
||||
expect(account).toBeTruthy()
|
||||
billingAccountId = account.id
|
||||
})
|
||||
|
||||
it('returns billing account detail', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/admin/billing/${companyId}`)
|
||||
.set(authHeader(financeToken))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.id).toBe(billingAccountId)
|
||||
expect(res.body.data.company.id).toBe(companyId)
|
||||
expect(Array.isArray(res.body.data.invoices)).toBe(true)
|
||||
})
|
||||
|
||||
it('supports create, finalize, pay, refund, and pdf download', async () => {
|
||||
const draftRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({
|
||||
invoiceType: 'MANUAL',
|
||||
adminReason: 'Enterprise onboarding',
|
||||
lineItems: [
|
||||
{
|
||||
type: 'SETUP_FEE',
|
||||
description: 'Onboarding setup',
|
||||
quantity: 1,
|
||||
unitAmount: 50000,
|
||||
},
|
||||
{
|
||||
type: 'PROFESSIONAL_SERVICES',
|
||||
description: 'Migration assistance',
|
||||
quantity: 2,
|
||||
unitAmount: 15000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(draftRes.status).toBe(201)
|
||||
expect(draftRes.body.data.status).toBe('DRAFT')
|
||||
const invoiceId = draftRes.body.data.id as string
|
||||
|
||||
const finalizeRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/finalize`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(finalizeRes.status).toBe(200)
|
||||
expect(finalizeRes.body.data.status).toBe('OPEN')
|
||||
expect(finalizeRes.body.data.invoiceNumber).toMatch(/^INV-\d{4}-\d{6}$/)
|
||||
expect(finalizeRes.body.data.totalAmount).toBe(80000)
|
||||
|
||||
const pdfRes = await request(app)
|
||||
.get(`/api/v1/admin/billing/invoices/${invoiceId}/pdf`)
|
||||
.set(authHeader(financeToken))
|
||||
|
||||
expect(pdfRes.status).toBe(200)
|
||||
expect(pdfRes.headers['content-type']).toContain('application/pdf')
|
||||
|
||||
const payRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/pay`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({ amount: 80000, providerPaymentId: 'manual-payment-1' })
|
||||
|
||||
expect(payRes.status).toBe(200)
|
||||
expect(payRes.body.data.status).toBe('PAID')
|
||||
expect(payRes.body.data.amountDue).toBe(0)
|
||||
|
||||
const refundRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/refunds`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({ amount: 20000, reason: 'Service credit after go-live issue' })
|
||||
|
||||
expect(refundRes.status).toBe(200)
|
||||
expect(refundRes.body.data.status).toBe('PARTIALLY_REFUNDED')
|
||||
})
|
||||
|
||||
it('supports credit notes, dunning controls, voids, and write-offs', async () => {
|
||||
const pauseRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/accounts/${billingAccountId}/pause-dunning`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(pauseRes.status).toBe(200)
|
||||
expect(pauseRes.body.data.dunningPaused).toBe(true)
|
||||
|
||||
const invoiceRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({
|
||||
invoiceType: 'MANUAL',
|
||||
lineItems: [
|
||||
{
|
||||
type: 'MANUAL_ADJUSTMENT',
|
||||
description: 'Support package',
|
||||
quantity: 1,
|
||||
unitAmount: 30000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(invoiceRes.status).toBe(201)
|
||||
const invoiceId = invoiceRes.body.data.id as string
|
||||
|
||||
const finalizeRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/finalize`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(finalizeRes.status).toBe(200)
|
||||
expect(finalizeRes.body.data.status).toBe('OPEN')
|
||||
|
||||
const creditRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/credit-notes`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({ amount: 5000, reason: 'Manual goodwill credit' })
|
||||
|
||||
expect(creditRes.status).toBe(200)
|
||||
expect(creditRes.body.data.amountDue).toBe(25000)
|
||||
|
||||
const retryRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/retry-payment`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(retryRes.status).toBe(200)
|
||||
expect(retryRes.body.data.status).toBe('PAYMENT_PENDING')
|
||||
|
||||
const voidRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${invoiceId}/void`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({ reason: 'Customer rolled into replacement invoice' })
|
||||
|
||||
expect(voidRes.status).toBe(200)
|
||||
expect(voidRes.body.data.status).toBe('VOID')
|
||||
|
||||
const badDebtRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({
|
||||
invoiceType: 'SUBSCRIPTION_RENEWAL',
|
||||
isSubscriptionBlocking: true,
|
||||
lineItems: [
|
||||
{
|
||||
type: 'SUBSCRIPTION_FEE',
|
||||
description: 'Renewal period',
|
||||
quantity: 1,
|
||||
unitAmount: 12000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(badDebtRes.status).toBe(201)
|
||||
const badDebtInvoiceId = badDebtRes.body.data.id as string
|
||||
|
||||
const finalizeBadDebtRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${badDebtInvoiceId}/finalize`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(finalizeBadDebtRes.status).toBe(200)
|
||||
|
||||
const writeoffRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/invoices/${badDebtInvoiceId}/uncollectible`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({ reason: 'Customer account closed after failed recovery' })
|
||||
|
||||
expect(writeoffRes.status).toBe(200)
|
||||
expect(writeoffRes.body.data.status).toBe('UNCOLLECTIBLE')
|
||||
|
||||
const resumeRes = await request(app)
|
||||
.post(`/api/v1/admin/billing/accounts/${billingAccountId}/resume-dunning`)
|
||||
.set(authHeader(financeToken))
|
||||
.send({})
|
||||
|
||||
expect(resumeRes.status).toBe(200)
|
||||
expect(resumeRes.body.data.dunningPaused).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,17 @@ import { beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const delegates = [
|
||||
'billingEvent',
|
||||
'billingRefund',
|
||||
'billingCreditNote',
|
||||
'billingCreditLedgerEntry',
|
||||
'billingCreditBalance',
|
||||
'billingPaymentAttempt',
|
||||
'billingPaymentIntent',
|
||||
'billingInvoiceLineItem',
|
||||
'billingInvoice',
|
||||
'billingPaymentMethod',
|
||||
'billingAccount',
|
||||
'auditLog',
|
||||
'adminPermission',
|
||||
'adminUser',
|
||||
|
||||
Reference in New Issue
Block a user