billing invoice
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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