apps/recallassess/recallassess-api/src/api/admin/invoice/invoice.service.ts
BNestBaseModuleService
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, systemLogService: SystemLogService, stripeService: StripeService, stripePaymentService: StripePaymentService, invoicePdfService: InvoicePdfService)
|
||||||||||||||||||
|
Parameters :
|
| Async add | ||||||
add(data: any)
|
||||||
|
Override add method to log creation
Parameters :
Returns :
Promise<any>
|
| Private decimalLikeToNumber | ||||||
decimalLikeToNumber(v: unknown)
|
||||||
|
Parameters :
Returns :
number
|
| Async delete | ||||||
delete(id: number)
|
||||||
|
Override delete method to log deletion
Parameters :
Returns :
Promise<void>
|
| Private Async ensureTotalFromLineItemsIfZero | ||||||
ensureTotalFromLineItemsIfZero(invoiceId: number)
|
||||||
|
When Stripe totals are 0/missing but line items were saved correctly, align total_amount for display.
Parameters :
Returns :
Promise<void>
|
| Async generatePdf |
generatePdf(id: number, authorization?: string)
|
|
Generate or retrieve invoice PDF via the shared InvoicePdfService (Angular SSR fluid invoice report + Puppeteer — same as dashboard invoice PDF).
Returns :
Promise<InvoiceGeneratePdfResponseDto>
|
| Async getDetail | ||||||
getDetail(id: number)
|
||||||
|
Override getDetail to ensure proper data transformation with company and subscription names
Parameters :
|
| Async getPdfBuffer |
getPdfBuffer(id: number, authorization?: string)
|
|
Get PDF buffer and filename for streaming download. Delegates to the shared InvoicePdfService; used by the download-pdf endpoint so the file is served by our API (avoids opening S3 URL in the browser on UAT).
Returns :
Promise<literal type>
|
| Async refreshFromStripe | ||||||
refreshFromStripe(id: number)
|
||||||
|
Admin action: refresh a local Invoice row from Stripe using its Stripe ids.
Parameters :
|
| Async save |
save(id: number, data: any)
|
|
Override save: invoices are otherwise read-only in admin UI — only billing_reference_notes may be updated.
Returns :
Promise<any>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(InvoiceService.name)
|
import { InvoicePdfService } from "@api/shared/invoice/invoice-pdf.service";
import { SystemLogService } from "@api/shared/services";
import { StripeService } from "@api/shared/stripe/services/stripe.service";
import { StripePaymentService } from "@api/shared/stripe/services/stripe-payment.service";
import { buildListResponseData } from "@bish-nest/core";
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { PaginationOptions } from "@bish-nest/core/data/pagination/pagination-options.interface";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { ListResponseDataInterface } from "@bish-nest/core/interfaces/list-response-data.interface";
import { BNestPrismaService } from "@bish-nest/core/services";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";
import Stripe from "stripe";
import { InvoiceGeneratePdfResponseDto } from "./dto/invoice-generate-pdf-response.dto";
@Injectable()
export class InvoiceService extends BNestBaseModuleService {
private readonly logger = new Logger(InvoiceService.name);
constructor(
protected prisma: BNestPrismaService,
private readonly systemLogService: SystemLogService,
private readonly stripeService: StripeService,
private readonly stripePaymentService: StripePaymentService,
private readonly invoicePdfService: InvoicePdfService,
) {
super();
}
/**
* Override add method to log creation
*/
async add(data: any): Promise<any> {
const addResponse = await super.add(data);
const invoice = addResponse.data;
// Log the creation
await this.systemLogService.logInsert(
SystemLogEntityType.INVOICE,
invoice.id,
invoice as Record<string, unknown>,
{ company_id: invoice.company_id },
);
return addResponse;
}
/**
* Override save: invoices are otherwise read-only in admin UI — only {@link billing_reference_notes} may be updated.
*/
async save(id: number, data: any): Promise<any> {
const oldInvoice = await this.prisma.client.invoice.findUnique({
where: { id },
});
if (!oldInvoice) {
throw new NotFoundException(`Invoice with ID ${id} not found`);
}
if (!data || typeof data !== "object" || !("billing_reference_notes" in data)) {
return this.getDetail(id);
}
const raw = data["billing_reference_notes"];
const billing_reference_notes = raw === null || raw === undefined || raw === "" ? null : String(raw);
await this.prisma.client.invoice.update({
where: { id },
data: { billing_reference_notes },
});
const newRow = await this.prisma.client.invoice.findUnique({ where: { id } });
const changedFields = SystemLogService.calculateChangedFields(
oldInvoice as Record<string, unknown>,
(newRow ?? oldInvoice) as Record<string, unknown>,
);
if (Object.keys(changedFields).length > 0) {
await this.systemLogService.logUpdate(
SystemLogEntityType.INVOICE,
id,
oldInvoice as Record<string, unknown>,
(newRow ?? oldInvoice) as Record<string, unknown>,
changedFields,
{ company_id: oldInvoice.company_id },
);
}
return this.getDetail(id);
}
/**
* Override delete method to log deletion
*/
async delete(id: number): Promise<void> {
// Get invoice data before deletion
const invoice = await this.prisma.client.invoice.findUnique({
where: { id },
});
if (!invoice) {
throw new NotFoundException(`Invoice with ID ${id} not found`);
}
// Call parent delete method
await super.delete(id);
// Log the deletion
await this.systemLogService.logDelete(SystemLogEntityType.INVOICE, id, invoice as Record<string, unknown>, {
company_id: invoice.company_id,
});
}
/**
* Generate or retrieve invoice PDF via the shared {@link InvoicePdfService}
* (Angular SSR fluid **invoice** report + Puppeteer — same as dashboard invoice PDF).
*/
async generatePdf(id: number, authorization?: string): Promise<InvoiceGeneratePdfResponseDto> {
const url = await this.invoicePdfService.generateSignedUrl(id, 3600, authorization);
return { url };
}
/**
* Get PDF buffer and filename for streaming download. Delegates to the
* shared {@link InvoicePdfService}; used by the download-pdf endpoint so
* the file is served by our API (avoids opening S3 URL in the browser on UAT).
*/
async getPdfBuffer(id: number, authorization?: string): Promise<{ buffer: Buffer; filename: string }> {
return this.invoicePdfService.getPdfBuffer(id, authorization);
}
/**
* Override getDetail to ensure proper data transformation with company and subscription names
*/
async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
const repoName = moduleCurrentCfg.repoName;
const repo = this.commonMethods.getRepo(repoName);
const include = {
company: {
select: {
id: true,
name: true,
},
},
subscription: {
select: {
id: true,
company_id: true,
package_id: true,
status: true,
billing_cycle: true,
package: {
select: {
id: true,
name: true,
},
},
},
},
// Pull the parent invoice so the admin UI can render a "Relates to
// #INV-…" link for amendment rows (UPGRADE_PRORATION, CYCLE_CHANGE,
// DOWNGRADE_CREDIT, REFUND) in a single request.
parentInvoice: {
select: {
id: true,
invoice_number: true,
},
},
userCreatedBy: {
select: {
id: true,
first_name: true,
last_name: true,
},
},
userUpdatedBy: {
select: {
id: true,
first_name: true,
last_name: true,
},
},
};
const findParams = {
where: { id },
include,
};
// Get the data
let data: any = await repo.findUnique(findParams);
if (!data) {
throw new Error("The record you are looking for is not found.");
}
// Sanitize data to remove Decimal fields and set display names
const sanitizedData: Record<string, unknown> = { ...data };
// Extract company fields
const company = data["company"] as { id?: number; name?: string } | null | undefined;
if (company) {
sanitizedData["company"] = {
id: company.id,
name: company.name || "",
};
sanitizedData["company_name"] = company.name || "";
} else {
sanitizedData["company_name"] = "";
}
// Extract subscription fields
const subscription = data["subscription"] as
| {
id?: number;
package?: { id?: number; name?: string } | null;
}
| null
| undefined;
if (subscription) {
sanitizedData["subscription"] = {
id: subscription.id,
};
// Add subscription display field with package name (e.g., "Trial - Subscription")
const packageName = subscription.package?.name || "";
if (packageName) {
sanitizedData["subscription_display"] = `${packageName} - Subscription`;
} else {
sanitizedData["subscription_display"] = "Subscription";
}
} else {
sanitizedData["subscription_display"] = "";
}
// Flatten parent invoice relation -> `parent_invoice_number` for the DTO,
// so the admin UI can show "Relates to #INV-…" for amendment invoices
// without a second lookup.
const parentInvoice = data["parentInvoice"] as
| { id?: number; invoice_number?: string }
| null
| undefined;
sanitizedData["parent_invoice_number"] = parentInvoice?.invoice_number ?? null;
// Normalize Decimal fields (convert Prisma Decimal to number, handle undefined) before plainToInstance
const decimalFields = [
"unit_price_per_license",
"license_quantity",
"proration_ratio",
"proration_amount",
"discount_amount",
"processing_fee_percentage",
"processing_fee",
"vat_fee_percentage",
"vat_fee",
"gross_license_amount",
"subtotal_amount",
"pre_vat_total_amount",
"total_amount",
];
decimalFields.forEach((field) => {
const value = (sanitizedData as any)[field];
if (value === undefined || value === null) {
// Optional money/percentage fields become null; required totals default to 0
if (
field === "proration_ratio" ||
field === "proration_amount" ||
field === "discount_amount" ||
field === "processing_fee_percentage" ||
field === "processing_fee" ||
field === "vat_fee_percentage" ||
field === "vat_fee" ||
field === "gross_license_amount" ||
field === "pre_vat_total_amount"
) {
(sanitizedData as any)[field] = null;
} else {
(sanitizedData as any)[field] = 0;
}
} else if (typeof value === "object" && value !== null && "toNumber" in value) {
// Prisma Decimal
(sanitizedData as any)[field] = (value as { toNumber: () => number }).toNumber();
} else if (typeof value === "string") {
(sanitizedData as any)[field] = parseFloat(value) || 0;
}
});
// Transform to DTO with excludeExtraneousValues to properly handle @Exclude() and @Expose()
try {
data = plainToInstance(moduleCurrentCfg.detailDto, sanitizedData, {
excludeExtraneousValues: true,
});
} catch (error) {
this.logger.error("Error transforming invoice detail data to DTO:", error);
// Return sanitized data directly if transformation fails
return this.moduleMethods.getReturnDataForDetail(sanitizedData);
}
return this.moduleMethods.getReturnDataForDetail(data);
}
private decimalLikeToNumber(v: unknown): number {
if (v === null || v === undefined) return 0;
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
if (typeof v === "bigint") return Number(v);
if (typeof v === "string") {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
if (
typeof v === "object" &&
v !== null &&
"toNumber" in v &&
typeof (v as { toNumber: () => number }).toNumber === "function"
) {
const n = (v as { toNumber: () => number }).toNumber();
return Number.isFinite(n) ? n : 0;
}
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* When Stripe totals are 0/missing but line items were saved correctly, align total_amount for display.
*/
private async ensureTotalFromLineItemsIfZero(invoiceId: number): Promise<void> {
const row = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
select: {
total_amount: true,
subtotal_amount: true,
processing_fee: true,
vat_fee: true,
pre_vat_total_amount: true,
gross_license_amount: true,
discount_amount: true,
proration_amount: true,
},
});
if (!row) return;
const total = this.decimalLikeToNumber(row["total_amount"]);
if (total > 0.001) return;
const preVatStored = this.decimalLikeToNumber(row["pre_vat_total_amount"]);
const sub = this.decimalLikeToNumber(row["subtotal_amount"]);
const proc = this.decimalLikeToNumber(row["processing_fee"]);
const vat = this.decimalLikeToNumber(row["vat_fee"]);
const proration = this.decimalLikeToNumber(row["proration_amount"]);
const discount = this.decimalLikeToNumber(row["discount_amount"]);
const preVat =
preVatStored > 0.001 ? preVatStored : Math.round((sub + proration + proc) * 100) / 100;
const computed = Math.round((preVat + vat) * 100) / 100;
if (computed > 0.001) {
const patch: {
total_amount: number;
pre_vat_total_amount: number;
gross_license_amount?: number;
} = {
total_amount: computed,
pre_vat_total_amount: preVat,
};
if (this.decimalLikeToNumber(row["gross_license_amount"]) <= 0.001) {
patch.gross_license_amount = Math.round((sub + discount) * 100) / 100;
}
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: patch,
});
this.logger.log(
`Invoice ${invoiceId}: set total_amount=${computed.toFixed(2)} from subtotal+fees (Stripe totals were unusable).`,
);
}
}
/**
* Admin action: refresh a local Invoice row from Stripe using its Stripe ids.
* - If the invoice has a subscription-based Stripe invoice id, we delegate to StripePaymentService.handleInvoicePaid
* so renewal math + fields stay consistent.
* - If only a bare stripe_invoice_id exists (no subscription link), we still use Stripe to align amounts.
*/
async refreshFromStripe(id: number): Promise<DetailResponseDataInterface<unknown>> {
const invoice = await this.prisma.client.invoice.findUnique({
where: { id },
select: {
id: true,
company_id: true,
stripe_invoice_id: true,
total_amount: true,
},
});
if (!invoice) {
throw new NotFoundException(`Invoice with ID ${id} not found`);
}
if (!invoice.stripe_invoice_id) {
throw new BadRequestException("Invoice has no stripe_invoice_id to refresh from.");
}
if (!this.stripeService.isConfigured()) {
throw new BadRequestException("Stripe is not configured.");
}
// Fetch latest Stripe invoice.
const stripeInv = (await this.stripeService.getInvoiceExpanded(
invoice.stripe_invoice_id,
)) as Stripe.Invoice & { subscription?: string | Stripe.Subscription | null };
const pickPositive = (v: number | null | undefined): number | null =>
typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
// Stripe can report amount_paid=0 for paid invoices covered by credits/balance; total still reflects invoice amount.
const stripeTotalCents =
pickPositive(stripeInv.total) ??
pickPositive(stripeInv.amount_paid) ??
pickPositive(stripeInv.amount_due) ??
0;
// If this is a subscription invoice, reuse the webhook handler so logic stays consistent.
const subRef = stripeInv.subscription;
const subscriptionId = typeof subRef === "string" ? subRef : (subRef?.id ?? null);
if (subscriptionId) {
await this.stripePaymentService.handleInvoicePaid(stripeInv);
// Safety net: if webhook-style handler kept/left total at 0 but Stripe total is known, align local value.
if (stripeTotalCents > 0) {
const existingTotal =
typeof invoice.total_amount === "number"
? invoice.total_amount
: invoice.total_amount && typeof (invoice.total_amount as any).toNumber === "function"
? (invoice.total_amount as any).toNumber()
: Number(invoice.total_amount ?? 0);
if (!Number.isFinite(existingTotal) || existingTotal <= 0) {
await this.prisma.client.invoice.update({
where: { id },
data: { total_amount: stripeTotalCents / 100 },
});
}
}
} else {
// One-off / standalone invoice (no subscription): align total_amount with Stripe total when known.
if (stripeTotalCents > 0) {
await this.prisma.client.invoice.update({
where: { id },
data: {
total_amount: stripeTotalCents / 100,
},
});
}
}
const extractedIds = this.stripePaymentService.extractStripePaymentIdsFromInvoice(stripeInv);
const { paymentIntentId } = extractedIds;
let { chargeId, checkoutSessionId } = extractedIds;
if (paymentIntentId && !chargeId) {
try {
const pi = await this.stripeService.retrievePaymentIntent(paymentIntentId, ["latest_charge"]);
const lc = pi.latest_charge;
if (typeof lc === "string") {
chargeId = lc;
} else if (lc && typeof lc === "object" && "id" in lc) {
chargeId = String((lc as Stripe.Charge).id);
}
const cs = (
pi as Stripe.PaymentIntent & {
checkout_session?: string | Stripe.Checkout.Session | null;
}
).checkout_session;
if (!checkoutSessionId) {
checkoutSessionId =
typeof cs === "string" ? cs : cs && typeof cs === "object" && "id" in cs ? cs.id : null;
}
} catch (e) {
this.logger.warn(
`refreshFromStripe: could not load PaymentIntent ${paymentIntentId} for charge id: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
if (!chargeId) {
const invCharge = (stripeInv as Stripe.Invoice & { charge?: string | Stripe.Charge | null }).charge;
if (typeof invCharge === "string") {
chargeId = invCharge;
} else if (invCharge && typeof invCharge === "object" && "id" in invCharge) {
chargeId = String(invCharge.id);
}
}
const chargeCheckoutPatch: { stripe_charge_id?: string; stripe_checkout_session_id?: string } = {};
if (chargeId) chargeCheckoutPatch.stripe_charge_id = chargeId;
if (checkoutSessionId) chargeCheckoutPatch.stripe_checkout_session_id = checkoutSessionId;
if (Object.keys(chargeCheckoutPatch).length > 0) {
try {
await this.prisma.client.invoice.update({
where: { id },
data: chargeCheckoutPatch,
});
} catch (e) {
this.logger.warn(
`refreshFromStripe: could not persist stripe_charge_id for invoice ${id}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
if (paymentIntentId) {
try {
await this.prisma.client.invoice.update({
where: { id },
data: { stripe_payment_intent_id: paymentIntentId },
});
} catch (e) {
this.logger.warn(
`refreshFromStripe: could not persist stripe_payment_intent_id for invoice ${id} (unique conflict or read-only): ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
await this.ensureTotalFromLineItemsIfZero(id);
// Return updated detail using the existing method (includes company/subscription display fields).
return this.getDetail(id);
}
/**
* Override getList method to transform raw data and include company/subscription names
*/
async getList(paginationOptions: PaginationOptions): Promise<ListResponseDataInterface<unknown>> {
const { page, limit } = paginationOptions;
// Get raw data from parent method's moduleMethods
const [rawData, totalCount] = await this.moduleMethods.getListRows(paginationOptions);
// Debug: Log if no data is returned
if (!rawData || rawData.length === 0) {
console.log("InvoiceService.getList: No raw data returned from getListRows");
console.log("Pagination options:", paginationOptions);
} else {
console.log(`InvoiceService.getList: Found ${rawData.length} invoices, totalCount: ${totalCount}`);
}
const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
const packageRepo = this.commonMethods.getRepo("package");
// Collect unique package IDs to fetch package names
const packageIds = new Set<number>();
rawData.forEach((row: Record<string, unknown>) => {
const subscription = row["subscription"] as { package_id?: number } | null | undefined;
if (subscription?.package_id) {
packageIds.add(subscription.package_id);
}
});
// Fetch all packages in one query
const packages =
packageIds.size > 0
? await packageRepo.findMany({
where: { id: { in: Array.from(packageIds) } },
select: { id: true, name: true },
})
: [];
// Create a map of package_id -> package name
const packageMap = new Map<number, string>();
packages.forEach((pkg: { id: number; name: string }) => {
packageMap.set(pkg.id, pkg.name);
});
// Transform raw data to add company_name and subscription info
const sanitizedData = rawData.map((row: Record<string, unknown>) => {
const sanitizedRow: Record<string, unknown> = { ...row };
// Extract company name from company relation
const company = row["company"] as { id?: number; name?: string } | null | undefined;
if (company) {
sanitizedRow["company"] = {
id: company.id,
name: company.name || "",
};
sanitizedRow["company_name"] = company.name || "";
} else {
sanitizedRow["company_name"] = "";
}
// Extract subscription info
const subscription = row["subscription"] as
| {
id?: number;
status?: string;
package_id?: number;
}
| null
| undefined;
if (subscription) {
sanitizedRow["subscription"] = {
id: subscription.id,
status: subscription.status,
package_id: subscription.package_id,
};
// Add subscription display field with package name (e.g., "Growth Package - Subscription")
const packageName = subscription.package_id ? packageMap.get(subscription.package_id) || "" : "";
if (packageName) {
sanitizedRow["subscription_display"] = `${packageName} - Subscription`;
} else {
sanitizedRow["subscription_display"] = "Subscription";
}
} else {
sanitizedRow["subscription_display"] = "";
}
// Ensure all Decimal fields have valid values (convert Prisma Decimal to number, handle undefined)
const decimalFields = [
"unit_price_per_license",
"license_quantity",
"proration_ratio",
"proration_amount",
"discount_amount",
"processing_fee_percentage",
"processing_fee",
"vat_fee_percentage",
"vat_fee",
"gross_license_amount",
"subtotal_amount",
"pre_vat_total_amount",
"total_amount",
];
decimalFields.forEach((field) => {
const value = sanitizedRow[field];
if (value === undefined || value === null) {
// Set default based on field type
if (
field === "proration_ratio" ||
field === "proration_amount" ||
field === "discount_amount" ||
field === "processing_fee_percentage" ||
field === "processing_fee" ||
field === "vat_fee_percentage" ||
field === "vat_fee" ||
field === "gross_license_amount" ||
field === "pre_vat_total_amount"
) {
sanitizedRow[field] = null;
} else {
sanitizedRow[field] = 0;
}
} else if (typeof value === "object" && value !== null && "toNumber" in value) {
// Prisma Decimal object - convert to number
sanitizedRow[field] = (value as { toNumber: () => number }).toNumber();
} else if (typeof value === "string") {
// String representation of decimal - convert to number
sanitizedRow[field] = parseFloat(value) || 0;
}
// If it's already a number, leave it as is
});
return sanitizedRow;
});
// Convert the sanitized data to appropriate DTOs
let data: any[] = sanitizedData;
try {
if (paginationOptions.vl) {
if (moduleCurrentCfg.simpleDto) {
data = sanitizedData.map((row: any) =>
plainToInstance(moduleCurrentCfg.simpleDto, row, { excludeExtraneousValues: true }),
);
}
} else {
if (moduleCurrentCfg.listDto) {
data = sanitizedData.map((row: any) =>
plainToInstance(moduleCurrentCfg.listDto, row, { excludeExtraneousValues: true }),
);
}
}
} catch (error) {
this.logger.error("Error transforming invoice data to DTO:", error);
// Return raw data if transformation fails
data = sanitizedData;
}
return buildListResponseData(data, page || 1, limit || 10, totalCount);
}
}