billing invoice

This commit is contained in:
root
2026-05-25 05:35:55 -04:00
parent 8d0b7a5ceb
commit 33b6bb55f0
12 changed files with 5434 additions and 645 deletions
File diff suppressed because it is too large Load Diff
+96 -8
View File
@@ -10,9 +10,11 @@ import {
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -203,6 +205,24 @@ router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
ok(res, await service.getBillingAccountDetail(companyId))
} catch (err) { next(err) }
})
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
@@ -210,14 +230,82 @@ router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('F
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/billing/accounts/:billingAccountId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.updateBillingAccount(billingAccountId, parseBody(billingAccountUpdateSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/pause-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, true, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/resume-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
ok(res, await service.setDunningPaused(billingAccountId, false, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/accounts/:billingAccountId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { billingAccountId } = parseParams(billingAccountIdParamSchema, req)
created(res, await service.createBillingInvoice(billingAccountId, parseBody(createBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.finalizeBillingInvoice(invoiceId, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/retry-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.retryBillingInvoicePayment(invoiceId, parseBody(retryBillingInvoiceSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/void', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.voidBillingInvoice(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/uncollectible', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { reason } = parseBody(billingReasonSchema, req)
ok(res, await service.markBillingInvoiceUncollectible(invoiceId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingCreditNote(invoiceId, parseBody(billingCreditNoteSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
@@ -45,6 +45,7 @@ export const auditLogQuerySchema = z.object({
})
export const billingQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
plan: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
@@ -167,10 +168,91 @@ export const companyIdParamSchema = z.object({
companyId: z.string().min(1),
})
export const billingAccountIdParamSchema = z.object({
billingAccountId: z.string().min(1),
})
export const invoiceIdParamSchema = z.object({
invoiceId: z.string().min(1),
})
export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(),
billingEmail: z.string().email().optional(),
billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(),
invoiceTerms: z.enum(['DUE_ON_RECEIPT', 'NET_7', 'NET_15', 'NET_30', 'NET_45', 'NET_60']).optional(),
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
'SEAT_FEE',
'USAGE_FEE',
'SETUP_FEE',
'DISCOUNT',
'TAX',
'CREDIT',
'PRORATION',
'REFUND_ADJUSTMENT',
'MANUAL_ADJUSTMENT',
'PROFESSIONAL_SERVICES',
'CANCELLATION_FEE',
]),
description: z.string().min(1).max(255),
quantity: z.number().int().positive().max(100000),
unitAmount: z.number().int(),
periodStart: nullableDate,
periodEnd: nullableDate,
})
export const createBillingInvoiceSchema = z.object({
subscriptionId: z.union([z.string(), z.null()]).optional(),
invoiceType: z.enum([
'SUBSCRIPTION_INITIAL',
'SUBSCRIPTION_RENEWAL',
'TRIAL_CONVERSION',
'SUBSCRIPTION_UPGRADE',
'SUBSCRIPTION_DOWNGRADE_CREDIT',
'USAGE_BASED',
'ONE_TIME',
'MANUAL',
'CORRECTION',
'CANCELLATION_FEE',
]),
currency: z.string().min(3).max(8).optional(),
dueAt: nullableDate,
isSubscriptionBlocking: z.boolean().optional(),
adminReason: z.union([z.string().max(500), z.null()]).optional(),
lineItems: z.array(billingLineItemInputSchema).min(1),
})
export const payBillingInvoiceSchema = z.object({
amount: z.number().int().positive().optional(),
paymentMethodId: z.union([z.string(), z.null()]).optional(),
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
export const billingReasonSchema = z.object({
reason: z.string().min(1).max(500),
})
export const billingCreditNoteSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const billingRefundSchema = z.object({
amount: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
})
+83 -51
View File
@@ -4,19 +4,13 @@ import crypto from 'crypto'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { generateInvoicePdf, buildInvoiceNumber } from '../../services/invoicePdfService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
STARTER: 2990,
GROWTH: 3990,
PRO: 99900,
}
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
}
@@ -226,58 +220,96 @@ export async function updateAdminPermissions(id: string, permissions: any[]) {
return presenter.presentAdminUser(admin)
}
export async function getBilling(query: { status?: string; plan?: string; page: number; pageSize: number }) {
const [{ data, total }, activeSubscriptions, stats] = await Promise.all([
repo.listBillingPage(query),
repo.listActiveSubscriptionsForMrr(),
repo.getBillingStatusCounts(),
])
const mrr = activeSubscriptions.reduce((acc, subscription) => {
const monthlyAmount = PLAN_MONTHLY_AMOUNT[subscription.plan] ?? 0
return acc + (subscription.billingPeriod === 'ANNUAL' ? Math.round(monthlyAmount * 10 / 12) : monthlyAmount)
}, 0)
return presenter.presentPaginated(data, total, query.page, query.pageSize, {
stats: { mrr, ...stats },
})
export async function getBilling(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total, stats } = await billingService.listBillingAccounts(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize, { stats })
}
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
const { data, total } = await repo.listCompanyInvoicesPage(companyId, query)
const detail = await billingService.getBillingAccountDetail(companyId)
const start = (query.page - 1) * query.pageSize
const end = start + query.pageSize
const data = detail.invoices.slice(start, end)
const total = detail.invoices.length
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getInvoicePdf(invoiceId: string) {
const invoice = await repo.getInvoicePdfRecord(invoiceId)
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber,
issueDate: invoice.createdAt.toISOString(),
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
company: {
name: invoice.company.name,
email: invoice.company.email,
phone: invoice.company.phone,
address: invoice.company.address,
},
subscription: {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
},
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
transactionId,
paidAt: invoice.paidAt?.toISOString(),
})
return billingService.getInvoicePdf(invoiceId)
}
return { pdfBuffer, invoiceNumber }
export function getBillingAccountDetail(companyId: string) {
return billingService.getBillingAccountDetail(companyId)
}
export function updateBillingAccount(
billingAccountId: string,
data: Parameters<typeof billingService.updateBillingAccount>[1],
adminId: string,
ip?: string,
) {
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
export function createBillingInvoice(
billingAccountId: string,
data: Parameters<typeof billingService.createDraftInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.createDraftInvoice(billingAccountId, data, adminId, ip)
}
export function finalizeBillingInvoice(invoiceId: string, adminId: string, ip?: string) {
return billingService.finalizeInvoice(invoiceId, adminId, ip)
}
export function payBillingInvoice(
invoiceId: string,
data: Parameters<typeof billingService.payInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.payInvoice(invoiceId, data, adminId, ip)
}
export function retryBillingInvoicePayment(
invoiceId: string,
data: Parameters<typeof billingService.retryInvoicePayment>[1],
adminId: string,
ip?: string,
) {
return billingService.retryInvoicePayment(invoiceId, data, adminId, ip)
}
export function voidBillingInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.voidInvoice(invoiceId, reason, adminId, ip)
}
export function markBillingInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.markInvoiceUncollectible(invoiceId, reason, adminId, ip)
}
export function issueBillingCreditNote(
invoiceId: string,
data: Parameters<typeof billingService.issueCreditNote>[1],
adminId: string,
ip?: string,
) {
return billingService.issueCreditNote(invoiceId, data, adminId, ip)
}
export function issueBillingRefund(
invoiceId: string,
data: Parameters<typeof billingService.issueRefund>[1],
adminId: string,
ip?: string,
) {
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getMarketplaceHomepage() {
+95 -22
View File
@@ -11,7 +11,7 @@ interface InvoiceData {
phone?: string | null
address?: any
}
subscription: {
subscription?: {
plan: string
billingPeriod: string
currentPeriodStart?: string | null
@@ -24,6 +24,24 @@ interface InvoiceData {
paymentProvider: string
transactionId?: string | null
paidAt?: string | null
lineItems?: Array<{
description: string
amount: number
currency: string
quantity?: number
unitAmount?: number
periodStart?: string | null
periodEnd?: string | null
}>
totals?: {
subtotalAmount: number
discountAmount: number
creditAmount: number
taxAmount: number
totalAmount: number
amountPaid: number
amountDue: number
}
}
const PLAN_LABEL: Record<string, string> = {
@@ -40,8 +58,15 @@ const PERIOD_LABEL: Record<string, string> = {
const STATUS_COLORS: Record<string, string> = {
PAID: '#10b981',
PENDING: '#f59e0b',
OPEN: '#f59e0b',
PAYMENT_PENDING: '#f59e0b',
PARTIALLY_PAID: '#f59e0b',
PAST_DUE: '#ef4444',
FAILED: '#ef4444',
REFUNDED: '#6b7280',
PARTIALLY_REFUNDED: '#6b7280',
VOID: '#6b7280',
UNCOLLECTIBLE: '#6b7280',
}
const colors = {
@@ -307,12 +332,26 @@ function formatAddress(address: any): string {
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan
const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod
const description = `${planLabel} Plan — ${periodLabel} Subscription`
const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd
? `${fmtDate(data.subscription.currentPeriodStart)} ${fmtDate(data.subscription.currentPeriodEnd)}`
: '—'
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length
? data.lineItems
: [{
description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount,
currency: data.currency,
periodStart: data.subscription?.currentPeriodStart ?? null,
periodEnd: data.subscription?.currentPeriodEnd ?? null,
}]
const totals = data.totals ?? {
subtotalAmount: data.amount,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: data.amount,
amountPaid: data.paidAt ? data.amount : 0,
amountDue: data.paidAt ? 0 : data.amount,
}
return React.createElement(
Document,
@@ -397,12 +436,14 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
React.createElement(Text, { style: s.metaValue }, periodLabel),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
),
data.subscription
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
)
: null,
),
),
@@ -417,13 +458,21 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
),
React.createElement(
View,
{ style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(data.amount, data.currency)),
),
...lineItems.map((item) => {
const periodStr = item.periodStart && item.periodEnd
? `${fmtDate(item.periodStart)} ${fmtDate(item.periodEnd)}`
: '—'
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
: item.description
return React.createElement(
View,
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)),
)
}),
),
// ── Totals ───────────────────────────────────────────────────
@@ -431,13 +480,37 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)),
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
),
totals.discountAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Discounts'),
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.discountAmount, data.currency)}`),
)
: null,
totals.creditAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Credits'),
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
)
: null,
totals.taxAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
)
: null,
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)),
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
),
// ── Payment Info ─────────────────────────────────────────────
@@ -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)
})
})
})
+11
View File
@@ -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',