Files
alrahma_sunday_school_api/apps/api/dist/modules/admin/admin.billing.service.js
T
2026-06-11 03:22:12 -04:00

1227 lines
49 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.listBillingAccounts = listBillingAccounts;
exports.getBillingAccountDetail = getBillingAccountDetail;
exports.updateBillingAccount = updateBillingAccount;
exports.setDunningPaused = setDunningPaused;
exports.createDraftInvoice = createDraftInvoice;
exports.finalizeInvoice = finalizeInvoice;
exports.payInvoice = payInvoice;
exports.retryInvoicePayment = retryInvoicePayment;
exports.voidInvoice = voidInvoice;
exports.markInvoiceUncollectible = markInvoiceUncollectible;
exports.issueCreditNote = issueCreditNote;
exports.issueRefund = issueRefund;
exports.getInvoicePdf = getInvoicePdf;
const prisma_1 = require("../../lib/prisma");
const errors_1 = require("../../http/errors");
const invoicePdfService_1 = require("../../services/invoicePdfService");
const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL',
'SUBSCRIPTION_RENEWAL',
'TRIAL_CONVERSION',
'SUBSCRIPTION_UPGRADE',
]);
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID']);
function toSequenceNumber(value) {
if (typeof value === 'bigint')
return Number(value);
if (typeof value === 'number')
return value;
if (typeof value === 'string')
return Number(value);
throw new Error('Invalid invoice sequence value');
}
function buildSequentialInvoiceNumber(sequence, date) {
return `INV-${date.getFullYear()}-${String(sequence).padStart(6, '0')}`;
}
function isBlockingInvoiceType(invoiceType) {
return BLOCKING_INVOICE_TYPES.has(invoiceType);
}
function getInvoiceTermsDays(invoiceTerms, explicitNetTermsDays) {
if (explicitNetTermsDays > 0)
return explicitNetTermsDays;
const map = {
DUE_ON_RECEIPT: 0,
NET_7: 7,
NET_15: 15,
NET_30: 30,
NET_45: 45,
NET_60: 60,
};
return map[invoiceTerms] ?? 0;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function normalizeAddress(address) {
if (!address)
return null;
if (typeof address === 'string')
return { formatted: address };
return address;
}
function fmtLegacyInvoiceType(_invoice) {
return 'SUBSCRIPTION_INITIAL';
}
function mapLegacyInvoiceStatus(status) {
switch (status) {
case 'PAID':
return 'PAID';
case 'FAILED':
return 'PAST_DUE';
case 'REFUNDED':
return 'REFUNDED';
case 'VOIDED':
return 'VOID';
default:
return 'OPEN';
}
}
function calculateLineAmounts(items) {
const subtotalAmount = items
.filter((item) => !['DISCOUNT', 'CREDIT', 'TAX'].includes(item.type))
.reduce((sum, item) => sum + item.amount, 0);
const discountAmount = Math.abs(items.filter((item) => item.type === 'DISCOUNT').reduce((sum, item) => sum + item.amount, 0));
const creditAmount = Math.abs(items.filter((item) => item.type === 'CREDIT').reduce((sum, item) => sum + item.amount, 0));
const taxAmount = items.filter((item) => item.type === 'TAX').reduce((sum, item) => sum + item.amount, 0);
const totalAmount = Math.max(subtotalAmount - discountAmount - creditAmount + taxAmount, 0);
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount };
}
async function createBillingEvent(tx, data) {
await tx.billingEvent.create({
data: {
billingAccountId: data.billingAccountId ?? null,
invoiceId: data.invoiceId ?? null,
subscriptionId: data.subscriptionId ?? null,
companyId: data.companyId ?? null,
eventType: data.eventType,
source: data.source,
payload: data.payload ?? {},
occurredAt: new Date(),
},
});
}
async function createAuditLog(data) {
await prisma_1.prisma.auditLog.create({ data: data });
}
async function ensurePrimaryBillingAccount(companyId, tx = prisma_1.prisma) {
const existing = await tx.billingAccount.findFirst({
where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
});
if (existing)
return existing;
const company = await tx.company.findUniqueOrThrow({
where: { id: companyId },
include: { accountingSettings: true, contractSettings: true, subscription: true },
});
const created = await tx.billingAccount.create({
data: {
companyId,
isPrimary: true,
legalName: company.name,
billingEmail: company.email,
billingAddress: normalizeAddress(company.address),
defaultCurrency: company.accountingSettings?.currency ?? 'MAD',
invoiceTerms: 'DUE_ON_RECEIPT',
netTermsDays: 0,
metadata: {},
},
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
});
await tx.billingCreditBalance.create({
data: {
billingAccountId: created.id,
currency: created.defaultCurrency,
balanceAmount: 0,
},
});
await createBillingEvent(tx, {
billingAccountId: created.id,
companyId,
eventType: 'billing_account.created',
source: 'system',
payload: { companyId },
});
return tx.billingAccount.findUniqueOrThrow({
where: { id: created.id },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
});
}
async function getNextInvoiceSequence(tx) {
const latest = await tx.billingInvoice.findFirst({
where: { invoiceSequence: { not: null } },
select: { invoiceSequence: true },
orderBy: { invoiceSequence: 'desc' },
});
return (latest?.invoiceSequence ?? 0) + 1;
}
async function maybeRestoreSubscription(tx, invoice) {
if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking)
return;
const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } });
if (!subscription)
return;
if (['PAYMENT_PENDING', 'PAST_DUE', 'SUSPENDED', 'TRIALING', 'UNPAID'].includes(subscription.status)) {
await tx.subscription.update({
where: { id: subscription.id },
data: {
status: 'ACTIVE',
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
});
}
}
async function maybeCancelSubscriptionForWriteoff(tx, invoice) {
if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking)
return;
const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } });
if (!subscription || ['CANCELLED', 'EXPIRED'].includes(subscription.status))
return;
await tx.subscription.update({
where: { id: subscription.id },
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
endedAt: new Date(),
},
});
}
async function syncOneLegacyInvoice(tx, legacy) {
const account = await ensurePrimaryBillingAccount(legacy.companyId, tx);
const status = mapLegacyInvoiceStatus(legacy.status);
const sequence = await getNextInvoiceSequence(tx);
const invoiceDate = legacy.createdAt ?? new Date();
const invoiceNumber = buildSequentialInvoiceNumber(sequence, invoiceDate);
const amountPaid = ['PAID', 'REFUNDED'].includes(status) ? legacy.amount : 0;
const amountDue = ['OPEN', 'PAST_DUE', 'PAYMENT_PENDING'].includes(status) ? legacy.amount : 0;
const billingInvoice = await tx.billingInvoice.create({
data: {
billingAccountId: account.id,
companyId: legacy.companyId,
subscriptionId: legacy.subscriptionId,
invoiceNumber,
invoiceSequence: sequence,
invoiceType: fmtLegacyInvoiceType(legacy),
status,
currency: legacy.currency,
subtotalAmount: legacy.amount,
totalAmount: legacy.amount,
amountPaid,
amountDue,
invoiceDate,
dueAt: legacy.dueAt ?? legacy.createdAt ?? new Date(),
finalizedAt: legacy.createdAt ?? new Date(),
paidAt: legacy.paidAt,
voidedAt: legacy.voidedAt,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: account.billingAddress,
paymentProvider: legacy.paymentProvider,
isSubscriptionBlocking: true,
metadata: { legacySubscriptionInvoiceId: legacy.id },
},
});
await tx.billingInvoiceLineItem.create({
data: {
invoiceId: billingInvoice.id,
subscriptionId: legacy.subscriptionId,
plan: legacy.subscription?.plan ?? null,
type: 'SUBSCRIPTION_FEE',
description: `${legacy.subscription?.plan ?? 'Subscription'} legacy charge`,
quantity: 1,
unitAmount: legacy.amount,
amount: legacy.amount,
currency: legacy.currency,
periodStart: legacy.subscription?.currentPeriodStart ?? null,
periodEnd: legacy.subscription?.currentPeriodEnd ?? null,
metadata: { legacySubscriptionInvoiceId: legacy.id },
},
});
for (const attempt of legacy.attempts ?? []) {
await tx.billingPaymentAttempt.create({
data: {
invoiceId: billingInvoice.id,
billingAccountId: account.id,
providerPaymentId: attempt.providerPaymentId,
status: attempt.status === 'succeeded' ? 'SUCCEEDED' : attempt.status === 'failed' ? 'FAILED' : 'PENDING',
amount: legacy.amount,
currency: legacy.currency,
failureCode: attempt.failureCode,
failureMessage: attempt.failureMessage,
attemptedAt: attempt.attemptedAt,
metadata: { legacyPaymentAttemptId: attempt.id },
},
});
}
await tx.subscriptionInvoice.update({
where: { id: legacy.id },
data: { billingInvoiceId: billingInvoice.id },
});
await createBillingEvent(tx, {
billingAccountId: account.id,
invoiceId: billingInvoice.id,
subscriptionId: legacy.subscriptionId,
companyId: legacy.companyId,
eventType: 'invoice.created',
source: 'legacy_sync',
payload: { legacySubscriptionInvoiceId: legacy.id, status },
});
}
async function syncLegacySubscriptionInvoices(companyId) {
const unsynced = await prisma_1.prisma.subscriptionInvoice.findMany({
where: {
billingInvoiceId: null,
...(companyId ? { companyId } : {}),
},
include: {
subscription: true,
attempts: true,
},
orderBy: { createdAt: 'asc' },
take: companyId ? 100 : 250,
});
for (const legacy of unsynced) {
await prisma_1.prisma.$transaction(async (tx) => {
const latest = await tx.subscriptionInvoice.findUnique({
where: { id: legacy.id },
include: { subscription: true, attempts: true },
});
if (!latest || latest.billingInvoiceId)
return;
await syncOneLegacyInvoice(tx, latest);
});
}
}
function buildBillingAccountWhere(query) {
const where = {};
if (query.q) {
where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } },
{ billingEmail: { contains: query.q, mode: 'insensitive' } },
{ company: { name: { contains: query.q, mode: 'insensitive' } } },
{ company: { email: { contains: query.q, mode: 'insensitive' } } },
{ company: { slug: { contains: query.q, mode: 'insensitive' } } },
];
}
if (query.status || query.plan) {
where.company = {
subscription: {
...(query.status ? { status: query.status } : {}),
...(query.plan ? { plan: query.plan } : {}),
},
};
}
return where;
}
async function listBillingAccounts(query) {
await syncLegacySubscriptionInvoices();
const matchingCompanies = await prisma_1.prisma.company.findMany({
where: {
...(query.q
? {
OR: [
{ name: { contains: query.q, mode: 'insensitive' } },
{ email: { contains: query.q, mode: 'insensitive' } },
{ slug: { contains: query.q, mode: 'insensitive' } },
],
}
: {}),
...(query.status || query.plan
? {
subscription: {
...(query.status ? { status: query.status } : {}),
...(query.plan ? { plan: query.plan } : {}),
},
}
: {}),
},
select: { id: true },
take: 250,
});
for (const company of matchingCompanies) {
await ensurePrimaryBillingAccount(company.id);
}
const where = buildBillingAccountWhere(query);
const [accounts, total, invoiceAgg] = await Promise.all([
prisma_1.prisma.billingAccount.findMany({
where,
include: {
company: {
select: {
id: true,
name: true,
email: true,
slug: true,
status: true,
subscription: {
select: {
id: true,
plan: true,
billingPeriod: true,
status: true,
currentPeriodEnd: true,
cancelAtPeriodEnd: true,
},
},
},
},
creditBalances: true,
invoices: {
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
invoiceNumber: true,
invoiceType: true,
status: true,
totalAmount: true,
amountDue: true,
amountPaid: true,
currency: true,
dueAt: true,
paidAt: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma_1.prisma.billingAccount.count({ where }),
prisma_1.prisma.billingInvoice.groupBy({
by: ['status'],
_sum: { amountDue: true, totalAmount: true },
_count: { _all: true },
}),
]);
const invoiceAggItems = invoiceAgg;
const stats = {
billingAccountCount: total,
openInvoiceCount: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + item._count._all, 0),
pastDueInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAST_DUE')
.reduce((sum, item) => sum + item._count._all, 0),
paidInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAID')
.reduce((sum, item) => sum + item._count._all, 0),
accountsReceivableTotal: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0),
recognizedRevenueTotal: invoiceAggItems
.filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
};
const data = accounts.map((account) => {
const openBalance = account.invoices
.filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountDue, 0);
const paidBalance = account.invoices
.filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountPaid, 0);
return {
...account,
openBalance,
paidBalance,
creditBalance: account.creditBalances.reduce((sum, item) => sum + item.balanceAmount, 0),
};
});
return { data, total, stats };
}
async function getBillingAccountDetail(companyId) {
await syncLegacySubscriptionInvoices(companyId);
await ensurePrimaryBillingAccount(companyId);
const account = await prisma_1.prisma.billingAccount.findFirst({
where: { companyId, isPrimary: true },
include: {
company: {
include: {
subscription: true,
contractSettings: true,
accountingSettings: true,
},
},
paymentMethods: true,
creditBalances: true,
creditLedgerEntries: {
orderBy: { createdAt: 'desc' },
take: 50,
},
events: {
orderBy: { createdAt: 'desc' },
take: 50,
},
invoices: {
orderBy: { createdAt: 'desc' },
take: 50,
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
},
},
},
});
if (!account)
throw new errors_1.NotFoundError('Billing account not found');
return account;
}
async function updateBillingAccount(billingAccountId, data, adminId, ip) {
const before = await prisma_1.prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } });
const updated = await prisma_1.prisma.billingAccount.update({
where: { id: billingAccountId },
data: {
...(data.legalName !== undefined ? { legalName: data.legalName } : {}),
...(data.billingEmail !== undefined ? { billingEmail: data.billingEmail } : {}),
...(data.billingAddress !== undefined ? { billingAddress: normalizeAddress(data.billingAddress) } : {}),
...(data.taxId !== undefined ? { taxId: data.taxId } : {}),
...(data.taxExempt !== undefined ? { taxExempt: data.taxExempt } : {}),
...(data.invoiceTerms !== undefined ? { invoiceTerms: data.invoiceTerms } : {}),
...(data.netTermsDays !== undefined ? { netTermsDays: data.netTermsDays } : {}),
},
});
await createAuditLog({
adminUserId: adminId,
action: 'UPDATE_BILLING_ACCOUNT',
resource: 'BillingAccount',
resourceId: billingAccountId,
companyId: before.companyId,
before,
after: updated,
ipAddress: ip,
});
await prisma_1.prisma.billingEvent.create({
data: {
billingAccountId,
companyId: before.companyId,
eventType: 'billing_account.updated',
source: 'admin',
payload: data,
occurredAt: new Date(),
},
});
return updated;
}
async function setDunningPaused(billingAccountId, paused, adminId, ip) {
const account = await prisma_1.prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } });
const updated = await prisma_1.prisma.billingAccount.update({
where: { id: billingAccountId },
data: {
dunningPaused: paused,
dunningPausedAt: paused ? new Date() : null,
dunningPausedBy: paused ? adminId : null,
},
});
await createAuditLog({
adminUserId: adminId,
action: paused ? 'PAUSE_DUNNING' : 'RESUME_DUNNING',
resource: 'BillingAccount',
resourceId: billingAccountId,
companyId: account.companyId,
after: { dunningPaused: paused },
ipAddress: ip,
});
await prisma_1.prisma.billingEvent.create({
data: {
billingAccountId,
companyId: account.companyId,
eventType: paused ? 'dunning.paused' : 'dunning.resumed',
source: 'admin',
payload: { pausedBy: adminId },
occurredAt: new Date(),
},
});
return updated;
}
async function createDraftInvoice(billingAccountId, data, adminId, ip) {
if (!data.lineItems.length)
throw new errors_1.ValidationError('Invoice requires at least one line item');
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const account = await tx.billingAccount.findUniqueOrThrow({
where: { id: billingAccountId },
include: { company: true },
});
const created = await tx.billingInvoice.create({
data: {
billingAccountId,
companyId: account.companyId,
subscriptionId: data.subscriptionId ?? null,
invoiceType: data.invoiceType,
status: 'DRAFT',
currency: data.currency ?? account.defaultCurrency,
dueAt: data.dueAt ? new Date(data.dueAt) : null,
isSubscriptionBlocking: data.isSubscriptionBlocking ?? isBlockingInvoiceType(data.invoiceType),
adminReason: data.adminReason ?? null,
createdByAdminId: adminId,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: normalizeAddress(account.billingAddress) ?? undefined,
metadata: {},
lineItems: {
create: data.lineItems.map((item) => ({
type: item.type,
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
amount: item.quantity * item.unitAmount,
currency: data.currency ?? account.defaultCurrency,
periodStart: item.periodStart ? new Date(item.periodStart) : null,
periodEnd: item.periodEnd ? new Date(item.periodEnd) : null,
})),
},
},
include: { lineItems: true },
});
await createBillingEvent(tx, {
billingAccountId,
invoiceId: created.id,
subscriptionId: created.subscriptionId,
companyId: account.companyId,
eventType: 'invoice.created',
source: 'admin',
payload: { invoiceType: created.invoiceType, lineItemCount: data.lineItems.length },
});
return created;
});
await createAuditLog({
adminUserId: adminId,
action: 'CREATE_BILLING_INVOICE',
resource: 'BillingInvoice',
resourceId: invoice.id,
companyId: invoice.companyId,
after: invoice,
note: data.adminReason ?? undefined,
ipAddress: ip,
});
return invoice;
}
async function finalizeInvoice(invoiceId, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
billingAccount: {
include: {
company: { include: { contractSettings: true } },
creditBalances: true,
},
},
lineItems: true,
},
});
if (!current)
throw new errors_1.NotFoundError('Invoice not found');
if (current.status !== 'DRAFT')
throw new errors_1.ValidationError('Only draft invoices can be finalized');
if (!current.lineItems.length)
throw new errors_1.ValidationError('Invoice requires at least one line item');
const account = current.billingAccount;
const currency = current.currency || account.defaultCurrency;
const existingCreditAmount = Math.abs(current.lineItems.filter((item) => item.type === 'CREDIT').reduce((sum, item) => sum + item.amount, 0));
const { subtotalAmount, discountAmount } = calculateLineAmounts(current.lineItems);
const availableBalance = account.creditBalances.find((balance) => balance.currency === currency)?.balanceAmount ?? 0;
const preTaxBase = Math.max(subtotalAmount - discountAmount - existingCreditAmount, 0);
const autoCreditToApply = Math.min(availableBalance, preTaxBase);
if (autoCreditToApply > 0) {
await tx.billingInvoiceLineItem.create({
data: {
invoiceId: current.id,
type: 'CREDIT',
description: 'Account credit applied',
quantity: 1,
unitAmount: -autoCreditToApply,
amount: -autoCreditToApply,
currency,
metadata: { autoApplied: true },
},
});
await tx.billingCreditBalance.update({
where: {
billingAccountId_currency: {
billingAccountId: account.id,
currency,
},
},
data: {
balanceAmount: { decrement: autoCreditToApply },
},
});
await tx.billingCreditLedgerEntry.create({
data: {
billingAccountId: account.id,
invoiceId: current.id,
type: 'applied',
amount: -autoCreditToApply,
currency,
reason: 'Applied automatically on invoice finalization',
createdBy: adminId,
},
});
}
const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0);
const taxBase = Math.max(preTaxBase - autoCreditToApply, 0);
const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0;
if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({
data: {
invoiceId: current.id,
type: 'TAX',
description: `Tax (${taxRate}%)`,
quantity: 1,
unitAmount: taxAmount,
amount: taxAmount,
currency,
},
});
await tx.billingTaxRecord.create({
data: {
invoiceId: current.id,
taxRate,
taxAmount,
taxType: 'VAT',
taxExempt: false,
metadata: {},
},
});
}
else if (account.taxExempt) {
await tx.billingTaxRecord.create({
data: {
invoiceId: current.id,
taxRate: 0,
taxAmount: 0,
taxType: 'EXEMPT',
taxExempt: true,
exemptionReason: 'Billing account marked tax exempt',
metadata: {},
},
});
}
const finalLineItems = await tx.billingInvoiceLineItem.findMany({ where: { invoiceId: current.id } });
const amounts = calculateLineAmounts(finalLineItems);
const finalizedAt = new Date();
const sequence = await getNextInvoiceSequence(tx);
const invoiceNumber = buildSequentialInvoiceNumber(sequence, finalizedAt);
const dueAt = current.dueAt ?? addDays(finalizedAt, getInvoiceTermsDays(account.invoiceTerms, account.netTermsDays));
const status = amounts.totalAmount === 0 ? 'PAID' : 'OPEN';
const updated = await tx.billingInvoice.update({
where: { id: current.id },
data: {
invoiceNumber,
invoiceSequence: sequence,
status,
subtotalAmount: amounts.subtotalAmount,
discountAmount: amounts.discountAmount,
creditAmount: amounts.creditAmount,
taxAmount: amounts.taxAmount,
totalAmount: amounts.totalAmount,
amountPaid: 0,
amountDue: amounts.totalAmount,
invoiceDate: finalizedAt,
dueAt,
finalizedAt,
paidAt: status === 'PAID' ? finalizedAt : null,
billingName: account.legalName,
billingEmail: account.billingEmail,
billingAddress: normalizeAddress(account.billingAddress) ?? undefined,
},
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: account.id,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'invoice.finalized',
source: 'admin',
payload: { invoiceNumber, totalAmount: updated.totalAmount },
});
if (updated.status === 'PAID') {
await createBillingEvent(tx, {
billingAccountId: account.id,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'invoice.paid',
source: 'system',
payload: { method: 'credit_balance' },
});
await maybeRestoreSubscription(tx, updated);
}
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'FINALIZE_BILLING_INVOICE',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
after: { status: invoice.status, invoiceNumber: invoice.invoiceNumber },
ipAddress: ip,
});
return invoice;
}
async function payInvoice(invoiceId, data, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
billingAccount: true,
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
},
});
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new errors_1.ValidationError('Invoice is not payable in its current state');
}
const amount = data.amount ?? current.amountDue;
if (amount <= 0 || amount > current.amountDue) {
throw new errors_1.ValidationError('Payment amount must be greater than zero and no more than the invoice balance');
}
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentMethodId: data.paymentMethodId ?? null,
providerPaymentIntentId: data.providerPaymentId ?? null,
status: 'SUCCEEDED',
amount,
currency: current.currency,
metadata: { source: 'admin_manual_collection' },
},
});
await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
paymentMethodId: data.paymentMethodId ?? null,
providerPaymentId: data.providerPaymentId ?? null,
status: 'SUCCEEDED',
amount,
currency: current.currency,
attemptedAt: new Date(),
metadata: { source: 'admin_manual_collection' },
},
});
const nextAmountPaid = current.amountPaid + amount;
const nextAmountDue = Math.max(current.amountDue - amount, 0);
const nextStatus = nextAmountDue === 0 ? 'PAID' : 'PARTIALLY_PAID';
const updated = await tx.billingInvoice.update({
where: { id: current.id },
data: {
amountPaid: nextAmountPaid,
amountDue: nextAmountDue,
status: nextStatus,
paidAt: nextAmountDue === 0 ? new Date() : current.paidAt,
},
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: nextAmountDue === 0 ? 'invoice.paid' : 'invoice.partially_paid',
source: 'admin',
payload: { amount, amountPaid: nextAmountPaid, amountDue: nextAmountDue },
});
if (nextAmountDue === 0) {
await maybeRestoreSubscription(tx, updated);
}
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'PAY_BILLING_INVOICE',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
after: { amountPaid: invoice.amountPaid, amountDue: invoice.amountDue, status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function retryInvoicePayment(invoiceId, data, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { billingAccount: true },
});
if (!['OPEN', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new errors_1.ValidationError('Only open or overdue invoices can be retried');
}
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null,
status: 'PROCESSING',
amount: current.amountDue,
currency: current.currency,
metadata: { source: 'admin_retry' },
},
});
await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null,
status: 'PENDING',
amount: current.amountDue,
currency: current.currency,
attemptedAt: new Date(),
metadata: { source: 'admin_retry' },
},
});
const updated = await tx.billingInvoice.update({
where: { id: current.id },
data: { status: 'PAYMENT_PENDING' },
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'invoice.payment_started',
source: 'admin',
payload: { amountDue: updated.amountDue },
});
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'RETRY_BILLING_INVOICE_PAYMENT',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
after: { status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function voidInvoice(invoiceId, reason, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } });
if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new errors_1.ValidationError('Only unpaid invoices can be voided');
}
if (current.amountPaid > 0) {
throw new errors_1.ValidationError('Paid invoices cannot be voided through this workflow');
}
const updated = await tx.billingInvoice.update({
where: { id: invoiceId },
data: {
status: 'VOID',
amountDue: 0,
voidedAt: new Date(),
},
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'invoice.voided',
source: 'admin',
payload: { reason },
});
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'VOID_BILLING_INVOICE',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
note: reason,
after: { status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function markInvoiceUncollectible(invoiceId, reason, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } });
if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) {
throw new errors_1.ValidationError('Only open invoices can be marked uncollectible');
}
const updated = await tx.billingInvoice.update({
where: { id: invoiceId },
data: {
status: 'UNCOLLECTIBLE',
markedUncollectibleAt: new Date(),
},
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'invoice.marked_uncollectible',
source: 'admin',
payload: { reason },
});
await maybeCancelSubscriptionForWriteoff(tx, updated);
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'MARK_BILLING_INVOICE_UNCOLLECTIBLE',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
note: reason,
after: { status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function issueCreditNote(invoiceId, data, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { creditNotes: true },
});
if (!EDITABLE_BILLING_STATUSES.has(current.status)) {
throw new errors_1.ValidationError('Credit notes can only be issued against active receivable invoices');
}
if (data.amount <= 0 || data.amount > current.amountDue) {
throw new errors_1.ValidationError('Credit note amount must be positive and no more than the outstanding balance');
}
await tx.billingCreditNote.create({
data: {
invoiceId,
billingAccountId: current.billingAccountId,
amount: data.amount,
currency: current.currency,
reason: data.reason,
status: 'APPLIED',
createdBy: adminId,
},
});
const nextTotalAmount = Math.max(current.totalAmount - data.amount, 0);
const nextAmountDue = Math.max(current.amountDue - data.amount, 0);
const updated = await tx.billingInvoice.update({
where: { id: invoiceId },
data: {
creditAmount: current.creditAmount + data.amount,
totalAmount: nextTotalAmount,
amountDue: nextAmountDue,
status: nextAmountDue === 0 ? 'PAID' : current.status,
paidAt: nextAmountDue === 0 ? new Date() : current.paidAt,
},
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: true,
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'credit.applied',
source: 'admin',
payload: { amount: data.amount, reason: data.reason },
});
if (nextAmountDue === 0) {
await maybeRestoreSubscription(tx, updated);
}
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'ISSUE_BILLING_CREDIT_NOTE',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
note: data.reason,
after: { creditAmount: invoice.creditAmount, amountDue: invoice.amountDue, status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function issueRefund(invoiceId, data, adminId, ip) {
const invoice = await prisma_1.prisma.$transaction(async (tx) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
refunds: true,
},
});
if (!['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(current.status)) {
throw new errors_1.ValidationError('Only paid invoices can be refunded');
}
const refundedSoFar = current.refunds
.filter((refund) => refund.status === 'SUCCEEDED')
.reduce((sum, refund) => sum + refund.amount, 0);
const refundableAmount = Math.max(current.totalAmount - refundedSoFar, 0);
if (data.amount <= 0 || data.amount > refundableAmount) {
throw new errors_1.ValidationError('Refund amount must be positive and within the refundable amount');
}
const latestSuccessfulAttempt = current.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? null;
await tx.billingRefund.create({
data: {
invoiceId,
paymentAttemptId: latestSuccessfulAttempt?.id ?? null,
billingAccountId: current.billingAccountId,
amount: data.amount,
currency: current.currency,
reason: data.reason,
status: 'SUCCEEDED',
createdBy: adminId,
},
});
const totalRefunded = refundedSoFar + data.amount;
const nextStatus = totalRefunded >= current.totalAmount ? 'REFUNDED' : 'PARTIALLY_REFUNDED';
const updated = await tx.billingInvoice.update({
where: { id: invoiceId },
data: { status: nextStatus },
include: {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: true,
refunds: { orderBy: { createdAt: 'desc' } },
},
});
await createBillingEvent(tx, {
billingAccountId: updated.billingAccountId,
invoiceId: updated.id,
subscriptionId: updated.subscriptionId,
companyId: updated.companyId,
eventType: 'refund.succeeded',
source: 'admin',
payload: { amount: data.amount, reason: data.reason },
});
return updated;
});
await createAuditLog({
adminUserId: adminId,
action: 'ISSUE_BILLING_REFUND',
resource: 'BillingInvoice',
resourceId: invoiceId,
companyId: invoice.companyId,
note: data.reason,
after: { status: invoice.status },
ipAddress: ip,
});
return invoice;
}
async function getInvoicePdf(invoiceId) {
await syncLegacySubscriptionInvoices();
const invoice = await prisma_1.prisma.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
company: true,
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
taxRecords: true,
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
},
});
if (!invoice)
throw new errors_1.NotFoundError('Invoice not found');
if (!invoice.invoiceNumber)
throw new errors_1.ValidationError('Invoice must be finalized before a PDF can be generated');
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null;
const pdfBuffer = await (0, invoicePdfService_1.generateInvoicePdf)({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
dueDate: invoice.dueAt?.toISOString() ?? null,
company: {
name: invoice.billingName ?? invoice.company.name,
email: invoice.billingEmail ?? invoice.company.email,
phone: invoice.company.phone,
address: invoice.billingAddress ?? invoice.company.address,
},
subscription: invoice.subscription
? {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
}
: undefined,
amount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item) => ({
description: item.description,
amount: item.amount,
currency: item.currency,
quantity: item.quantity,
unitAmount: item.unitAmount,
periodStart: item.periodStart?.toISOString() ?? null,
periodEnd: item.periodEnd?.toISOString() ?? null,
})),
totals: {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
amountDue: invoice.amountDue,
},
});
return { pdfBuffer, invoiceNumber: invoice.invoiceNumber };
}
//# sourceMappingURL=admin.billing.service.js.map