CentralBackend/services/invoice/InvoiceService.js

597 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
const PoolInflowService = require('../pool/PoolInflowService');
const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService');
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
class InvoiceService {
constructor() {
this.repo = new InvoiceRepository();
}
_escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_formatAmount(amount, currency = 'EUR') {
const numeric = Number(amount || 0);
return `${numeric.toFixed(2)} ${currency}`;
}
async _s3BodyToString(body) {
if (!body) return '';
if (typeof body.transformToString === 'function') {
return body.transformToString();
}
const chunks = [];
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
_getEmailSubject(lang, invoiceNumber) {
return lang === 'de'
? `ProfitPlanet Rechnung ${invoiceNumber}`
: `ProfitPlanet Invoice ${invoiceNumber}`;
}
_buildItemsText(items, currency) {
if (!Array.isArray(items) || !items.length) return '- Subscription item';
return items.map((item, index) => {
const qty = Number(item.quantity || 0);
const lineGross = Number(item.line_gross || 0);
return `${index + 1}. ${item.description || 'Coffee'} | qty: ${qty} | total: ${this._formatAmount(lineGross, currency)}`;
}).join('\n');
}
_buildItemsHtml(items, currency) {
if (!Array.isArray(items) || !items.length) {
return '<li>Subscription item</li>';
}
return items.map((item) => {
const desc = this._escapeHtml(item.description || 'Coffee');
const qty = Number(item.quantity || 0);
const unit = this._formatAmount(item.unit_price || 0, currency);
const total = this._formatAmount(item.line_gross || 0, currency);
return `<li><strong>${desc}</strong> — ${qty} x ${this._escapeHtml(unit)} = ${this._escapeHtml(total)}</li>`;
}).join('');
}
_buildInvoiceText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
return [
isDe ? 'Ihre Rechnung wurde erstellt.' : 'Your invoice has been created.',
`${isDe ? 'Rechnungsnummer' : 'Invoice number'}: ${invoice.invoice_number}`,
`${isDe ? 'Kunde' : 'Customer'}: ${customerName}`,
`${isDe ? 'Datum' : 'Date'}: ${issuedAt}`,
`${isDe ? 'Positionen' : 'Items'}:`,
this._buildItemsText(items, invoice.currency),
`${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._formatAmount(invoice.total_gross, invoice.currency)}`,
].join('\n');
}
_buildInvoiceMailText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
return [
isDe ? `Vielen Dank für Ihr Abonnement, ${customerName}.` : `Thank you for your subscription, ${customerName}.`,
isDe ? 'Ihre Rechnung ist als PDF im Anhang enthalten.' : 'Your invoice is attached as a PDF.',
`${isDe ? 'Rechnungsnummer' : 'Invoice number'}: ${invoice.invoice_number}`,
`${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._formatAmount(invoice.total_gross, invoice.currency)}`,
'',
`${isDe ? 'Positionen' : 'Items'}:`,
this._buildItemsText(items, invoice.currency),
].join('\n');
}
_buildInvoiceMailHtml({ invoice, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || 'Customer';
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this._escapeHtml(invoice.invoice_number)}</title>
</head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#111827;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Danke für Ihr Abonnement!' : 'Thank you for your subscription!'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${this._escapeHtml(customerName)},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe ? 'vielen Dank für Ihr Abonnement. Ihre Rechnung haben wir als PDF angehängt.' : 'thank you for your subscription. We attached your invoice as a PDF.'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(invoice.invoice_number || '')}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Gesamtbetrag' : 'Total'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</td>
</tr>
</table>
<p style="margin:0;font-size:13px;color:#6b7280;">${isDe ? 'Falls diese E-Mail nicht korrekt angezeigt wird, nutzen Sie bitte den Textinhalt oder kontaktieren Sie unseren Support.' : 'If this email is not displayed correctly, please use the text version or contact support.'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
_buildItemsTableRows(items, currency) {
if (!Array.isArray(items) || !items.length) {
return `<tr><td>1</td><td>Subscription item</td><td>1</td><td>-</td><td>-</td></tr>`;
}
return items.map((item, i) => {
const desc = this._escapeHtml(item.description || 'Coffee');
const qty = Number(item.quantity || 0);
const unit = this._formatAmount(item.unit_price || 0, currency);
const total = this._formatAmount(item.line_gross || 0, currency);
return `<tr><td>${i + 1}</td><td>${desc}</td><td>${qty}</td><td>${unit}</td><td>${total}</td></tr>`;
}).join('');
}
async _loadInvoiceHtmlTemplate() {
// Load the latest active invoice template from the contract manager (S3)
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
if (!Array.isArray(templates) || !templates.length) return null;
const selected = templates[0]; // latest active version
if (!selected?.storageKey) return null;
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: selected.storageKey,
});
const obj = await sharedExoscaleClient.send(command);
return await this._s3BodyToString(obj.Body) || null;
} catch (e) {
logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message });
return null;
}
}
async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const isGift = abonement?.details?.is_for_self === false;
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-';
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
// Load company info from DB
let companyInfo = { company_name: 'ProfitPlanet GmbH', company_street: '', company_postal_city: '', company_country: 'Germany' };
try {
const repo = new CompanySettingsRepository();
const row = await repo.get();
if (row) companyInfo = row;
} catch (e) {
logger.warn('InvoiceService._buildInvoiceTemplateVariables:company_settings_error', { message: e?.message });
}
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
// For self subscriptions: "Bill To" = the subscriber
let customerName;
let customerEmail = '';
let orderedByBlock = '';
if (isGift) {
// Recipient info for "Bill To"
const recipientName = abonement?.details?.recipient_name || '';
const recipientEmail = abonement?.email || invoice.buyer_email || '';
customerName = recipientName || recipientEmail || '-';
customerEmail = recipientName ? recipientEmail : '';
// Purchaser info for "Ordered by"
const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
if (purchaserName) {
const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By';
orderedByBlock = `<div class="meta-block"><h3>${this._escapeHtml(orderedByLabel)}</h3><p><span class="highlight">${this._escapeHtml(purchaserName)}</span></p></div>`;
}
} else {
customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
customerEmail = abonement?.email || invoice.buyer_email || '';
}
return {
lang: isDe ? 'de' : 'en',
documentTitle: isDe ? 'Rechnung' : 'Invoice',
invoiceNumber: this._escapeHtml(invoice.invoice_number || ''),
invoiceNumberLabel: isDe ? 'Rechnungsnummer' : 'Invoice Number',
fromLabel: isDe ? 'Von' : 'From',
toLabel: isDe ? 'An' : 'Bill To',
detailsLabel: isDe ? 'Details' : 'Details',
dateLabel: isDe ? 'Datum' : 'Date',
dueDateLabel: isDe ? 'Fällig am' : 'Due Date',
statusLabel: 'Status',
invoiceStatus: this._escapeHtml((invoice.status || 'issued').toUpperCase()),
companyName: this._escapeHtml(companyInfo.company_name || 'ProfitPlanet GmbH'),
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
customerName: this._escapeHtml(customerName),
customerEmail: this._escapeHtml(customerEmail),
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')),
customerCountry: this._escapeHtml(invoice.buyer_country || ''),
orderedByBlock,
issuedAt: this._escapeHtml(issuedAt),
dueAt: this._escapeHtml(dueAt),
descriptionHeader: isDe ? 'Beschreibung' : 'Description',
qtyHeader: isDe ? 'Menge' : 'Qty',
unitPriceHeader: isDe ? 'Stückpreis' : 'Unit Price',
totalHeader: isDe ? 'Gesamt' : 'Total',
itemsRows: this._buildItemsTableRows(items, invoice.currency),
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
taxLabel: isDe ? 'MwSt.' : 'Tax',
vatRateDisplay: vatRate ? `${vatRate}%` : '0%',
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)',
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information',
paymentInfoText: isDe
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
: 'Please transfer the total amount stating the invoice number as reference.',
footerText: isDe
? 'Vielen Dank für Ihr Vertrauen.'
: 'Thank you for your business.',
// Legacy key used by S3-stored templates
itemsHtml: this._buildItemsHtml(items, invoice.currency),
};
}
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const template = await this._loadInvoiceHtmlTemplate();
if (template) {
return this._renderTemplate(template, variables);
}
// Absolute fallback if template file is missing
const isDe = lang === 'de';
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head>
<body>
<h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2>
<p>${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}</p>
<p>${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}</p>
<h3>${isDe ? 'Positionen' : 'Items'}</h3>
<ul>${variables.itemsHtml}</ul>
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}</strong></p>
</body>
</html>`;
}
async _loadInvoiceTemplateHtml({ lang = 'en' } = {}) {
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
if (!Array.isArray(templates) || !templates.length) return null;
const selected = templates.find((t) => t.lang === lang) || templates.find((t) => t.lang === 'en') || templates[0];
if (!selected?.storageKey) return null;
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: selected.storageKey,
});
const obj = await sharedExoscaleClient.send(command);
const html = await this._s3BodyToString(obj.Body);
return html || null;
} catch (error) {
logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message });
return null;
}
}
_renderTemplate(template, variables) {
if (!template) return null;
return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => variables[key] ?? '');
}
async _renderPdfFromHtml(html) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' }
});
return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
}
async _storeInvoicePdf(invoice, pdfBuffer) {
if (!pdfBuffer) return null;
const safeUser = invoice.user_id || 'unknown';
const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`;
await sharedExoscaleClient.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}));
await this.repo.updateStorageKey(invoice.id, key);
return key;
}
async _sendInvoiceEmail({ invoice, abonement, lang = 'en' }) {
const recipientEmail = invoice.buyer_email || abonement?.email;
if (!recipientEmail) {
logger.warn('InvoiceService._sendInvoiceEmail:missing_recipient', { invoiceId: invoice.id });
return;
}
const items = await this.repo.getItemsByInvoiceId(invoice.id);
const text = this._buildInvoiceMailText({ invoice, items, abonement, lang });
const subject = this._getEmailSubject(lang, invoice.invoice_number);
// Build the full set of template variables once used by both S3 and local paths
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const templateHtml = await this._loadInvoiceTemplateHtml({ lang });
let html = null;
if (templateHtml) {
html = this._renderTemplate(templateHtml, variables);
}
const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
await this._storeInvoicePdf(invoice, pdfBuffer);
const mailHtml = this._buildInvoiceMailHtml({ invoice, abonement, lang });
await MailService.sendInvoiceEmail({
email: recipientEmail,
subject,
text,
html: mailHtml,
attachments: [{
name: `${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`,
content: pdfBuffer.toString('base64')
}],
lang,
});
}
// NEW: resolve current standard VAT rate for a buyer country code
async resolveVatRateForCountry(countryCode) {
if (!countryCode) return null;
const uow = new UnitOfWork();
await uow.start();
const taxRepo = new TaxRepository(uow);
try {
const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase());
if (!country) {
await uow.commit();
return null;
}
// get current vat row for this country
const [rows] = await taxRepo.conn.query(
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
[country.id]
);
await uow.commit();
const rate = rows?.[0]?.standard_rate;
return rate == null ? null : Number(rate);
} catch (e) {
await uow.rollback();
throw e;
}
}
// Issue invoice for a subscription period, with items from pack_breakdown
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) {
console.log('[INVOICE ISSUE] Inputs:', {
abonement_id: abonement?.id,
abonement_user_id: abonement?.user_id,
abonement_purchaser_user_id: abonement?.purchaser_user_id,
actorUserId,
periodStart,
periodEnd,
});
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
const buyerEmail = abonement.email || null;
const addr = {
street: abonement.street || null,
postal_code: abonement.postal_code || null,
city: abonement.city || null,
country: abonement.country || null,
};
const currency = abonement.currency || 'EUR';
// NEW: resolve invoice vat_rate (standard) from buyer country
const vat_rate = await this.resolveVatRateForCountry(addr.country);
const breakdown = Array.isArray(abonement.pack_breakdown) ? abonement.pack_breakdown : [];
const items = breakdown.length
? breakdown.map((b) => ({
product_id: Number(b.coffee_table_id) || null,
sku: `COFFEE-${b.coffee_table_id || 'N/A'}`,
description: b.coffee_title || `Coffee subscription: ${b.coffee_table_id}`,
quantity: Number(b.packs || 1),
unit_price: Number(b.price_per_pack || 0),
tax_rate: b.tax_rate != null ? Number(b.tax_rate) : vat_rate, // CHANGED: default to invoice vat_rate
}))
: [
{
product_id: null,
sku: 'SUBSCRIPTION',
description: `Subscription ${abonement.pack_group || ''}`,
quantity: 1,
unit_price: Number(abonement.price || 0),
tax_rate: vat_rate, // CHANGED
},
];
const context = {
source: 'abonement',
pack_group: abonement.pack_group || null,
period_start: periodStart,
period_end: periodEnd,
referred_by: abonement.referred_by || null,
};
// CHANGED: prioritize token user id for invoice ownership
const userIdForInvoice =
actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice);
const invoice = await this.repo.createInvoiceWithItems({
source_type: 'subscription',
source_id: abonement.id,
user_id: userIdForInvoice,
buyer_name: buyerName,
buyer_email: buyerEmail,
buyer_street: addr.street,
buyer_postal_code: addr.postal_code,
buyer_city: addr.city,
buyer_country: addr.country,
currency,
items,
status: 'issued',
issued_at: new Date(),
due_at: periodEnd,
context,
vat_rate, // NEW: persist on invoice
});
console.log('[INVOICE ISSUE] Created invoice:', {
id: invoice?.id,
user_id: invoice?.user_id,
source_type: invoice?.source_type,
source_id: invoice?.source_id,
total_net: invoice?.total_net,
total_tax: invoice?.total_tax,
total_gross: invoice?.total_gross,
});
try {
await this._sendInvoiceEmail({
invoice,
abonement,
lang,
});
logger.info('InvoiceService.issueForAbonement:invoice_email_sent', {
invoiceId: invoice?.id,
userId: invoice?.user_id,
hasEmail: Boolean(invoice?.buyer_email || abonement?.email),
});
} catch (mailError) {
logger.error('InvoiceService.issueForAbonement:invoice_email_error', {
invoiceId: invoice?.id,
message: mailError?.message,
});
}
return invoice;
}
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
let poolResult = null;
try {
const inflowResult = await PoolInflowService.bookForPaidInvoice({
invoiceId: paidInvoice?.id,
paidAt: paid_at,
actorUserId: null,
});
poolResult = inflowResult;
console.log('[INVOICE PAID] Pool inflow booking result:', {
invoiceId: paidInvoice?.id,
...inflowResult,
});
} catch (e) {
poolResult = { error: e?.message || 'Pool inflow booking failed' };
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
}
// Attach pool result to returned data so the frontend can display it
if (paidInvoice) {
paidInvoice._poolResult = poolResult;
}
return paidInvoice;
}
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
return this.repo.listByUser(userId, { status, limit, offset });
}
async listByAbonement(abonementId) {
return this.repo.findByAbonement(abonementId);
}
async adminList({ status, limit = 200, offset = 0 } = {}) {
return this.repo.listAll({ status, limit, offset });
}
async updateStatus(invoiceId, newStatus) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
// If transitioning to 'paid', use the full markPaid flow for pool inflow booking
if (newStatus === 'paid' && invoice.status !== 'paid') {
return this.markPaid(invoiceId, {
payment_method: 'admin_manual',
amount: invoice.total_gross ?? 0,
paid_at: new Date(),
});
}
return this.repo.updateStatus(invoiceId, newStatus);
}
async getInvoiceDetail(invoiceId) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
const items = await this.repo.getItemsByInvoiceId(invoiceId);
const payments = await this.repo.getPaymentsByInvoiceId(invoiceId);
return { invoice, items, payments };
}
}
module.exports = InvoiceService;