1227 lines
49 KiB
JavaScript
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
|