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() {
|
||||
|
||||
Reference in New Issue
Block a user