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');
class InvoiceService {
constructor() {
this.repo = new InvoiceRepository();
}
_escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
_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 '
Subscription item';
}
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 `${desc} — ${qty} x ${this._escapeHtml(unit)} = ${this._escapeHtml(total)}`;
}).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');
}
_buildFallbackInvoiceHtml({ 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 `
${this._escapeHtml(invoice.invoice_number)}
${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}
${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}
${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}
${isDe ? 'Positionen' : 'Items'}
${this._buildItemsHtml(items, invoice.currency)}
${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}
`;
}
async _loadInvoiceTemplateHtml({ userType = 'personal', lang = 'en' } = {}) {
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, '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 _storeInvoiceHtml(invoice, html) {
if (!html) return null;
const safeUser = invoice.user_id || 'unknown';
const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.html`;
await sharedExoscaleClient.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: Buffer.from(html, 'utf8'),
ContentType: 'text/html; charset=utf-8',
}));
await this.repo.updateStorageKey(invoice.id, key);
return key;
}
async _sendInvoiceEmail({ invoice, abonement, userType = 'personal', 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._buildInvoiceText({ invoice, items, abonement, lang });
const subject = this._getEmailSubject(lang, invoice.invoice_number);
const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang });
let html = null;
if (templateHtml) {
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);
const variables = {
invoiceNumber: this._escapeHtml(invoice.invoice_number || ''),
customerName: this._escapeHtml(customerName),
issuedAt: this._escapeHtml(issuedAt),
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
itemsHtml: this._buildItemsHtml(items, invoice.currency),
};
html = this._renderTemplate(templateHtml, variables);
if (html && !html.includes('')) {
html += `
${lang === 'de' ? 'Positionen' : 'Items'}
`;
}
}
const htmlForStorage = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
await this._storeInvoiceHtml(invoice, htmlForStorage);
await MailService.sendInvoiceEmail({
email: recipientEmail,
subject,
text,
html,
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, userType = 'personal', 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,
userType,
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 });
try {
const inflowResult = await PoolInflowService.bookForPaidInvoice({
invoiceId: paidInvoice?.id,
paidAt: paid_at,
actorUserId: null,
});
console.log('[INVOICE PAID] Pool inflow booking result:', {
invoiceId: paidInvoice?.id,
...inflowResult,
});
} catch (e) {
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
}
return paidInvoice;
}
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
return this.repo.listByUser(userId, { status, limit, offset });
}
async adminList({ status, limit = 200, offset = 0 } = {}) {
return this.repo.listAll({ status, limit, offset });
}
}
module.exports = InvoiceService;