|
|
|
|
@ -1,6 +1,5 @@
|
|
|
|
|
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');
|
|
|
|
|
@ -10,6 +9,7 @@ const { logger } = require('../../middleware/logger');
|
|
|
|
|
const puppeteer = require('puppeteer');
|
|
|
|
|
const fs = require('fs/promises');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const pool = require('../../database/database');
|
|
|
|
|
|
|
|
|
|
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
|
|
|
|
|
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
|
|
|
|
|
@ -344,6 +344,8 @@ class InvoiceService {
|
|
|
|
|
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;
|
|
|
|
|
const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
|
|
|
|
|
const isReverseCharge = taxMode === 'reverse_charge';
|
|
|
|
|
|
|
|
|
|
// Hardcoded bank info (Profit Planet)
|
|
|
|
|
const bankAccountHolder = 'Profit Planet GmbH';
|
|
|
|
|
@ -429,16 +431,20 @@ class InvoiceService {
|
|
|
|
|
totalHeader: isDe ? 'Gesamt' : 'Total',
|
|
|
|
|
itemsRows: this._buildItemsTableRows(items, invoice.currency),
|
|
|
|
|
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
|
|
|
|
|
taxLabel: isDe ? 'MwSt.' : 'Tax',
|
|
|
|
|
vatRateDisplay: vatRate ? `${vatRate}%` : '0%',
|
|
|
|
|
taxLabel: isReverseCharge ? (isDe ? 'Reverse Charge' : 'Reverse charge') : (isDe ? 'MwSt.' : 'Tax'),
|
|
|
|
|
vatRateDisplay: isReverseCharge ? (isDe ? 'nicht ausgewiesen' : 'not charged') : (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.',
|
|
|
|
|
? (isReverseCharge
|
|
|
|
|
? 'Reverse-Charge-Verfahren: Steuerschuldnerschaft des Leistungsempfängers. Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
|
|
|
|
|
: 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.')
|
|
|
|
|
: (isReverseCharge
|
|
|
|
|
? 'Reverse charge applies: VAT liability shifts to the recipient. Please transfer the total amount stating the invoice number as reference.'
|
|
|
|
|
: 'Please transfer the total amount stating the invoice number as reference.'),
|
|
|
|
|
bankAccountHolder: this._escapeHtml(bankAccountHolder),
|
|
|
|
|
bankIban: this._escapeHtml(bankIban),
|
|
|
|
|
bankBic: this._escapeHtml(bankBic),
|
|
|
|
|
@ -598,31 +604,92 @@ class InvoiceService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NEW: resolve current standard VAT rate for a buyer country code
|
|
|
|
|
async resolveVatRateForCountry(countryCode) {
|
|
|
|
|
if (!countryCode) return null;
|
|
|
|
|
async _resolveCountryByInput(conn, countryInput) {
|
|
|
|
|
const raw = String(countryInput || '').trim();
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
|
|
|
|
const code = raw.toUpperCase();
|
|
|
|
|
const [byCode] = await conn.query(
|
|
|
|
|
`SELECT id, country_code, country_name FROM countries WHERE UPPER(country_code) = ? LIMIT 1`,
|
|
|
|
|
[code],
|
|
|
|
|
);
|
|
|
|
|
if (byCode?.[0]) return byCode[0];
|
|
|
|
|
|
|
|
|
|
const [byName] = await conn.query(
|
|
|
|
|
`SELECT id, country_code, country_name FROM countries WHERE LOWER(country_name) = LOWER(?) LIMIT 1`,
|
|
|
|
|
[raw],
|
|
|
|
|
);
|
|
|
|
|
return byName?.[0] || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_normalizeUid(value) {
|
|
|
|
|
return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_isLikelyValidUid(value) {
|
|
|
|
|
const uid = this._normalizeUid(value);
|
|
|
|
|
return /^[A-Z]{2}[A-Z0-9]{6,14}$/.test(uid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _loadCompanyTaxProfile(userId) {
|
|
|
|
|
if (!userId) return null;
|
|
|
|
|
const [rows] = await pool.query(
|
|
|
|
|
`SELECT registration_number, atu_number, country
|
|
|
|
|
FROM company_profiles
|
|
|
|
|
WHERE user_id = ?
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
[userId],
|
|
|
|
|
);
|
|
|
|
|
return rows?.[0] || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId }) {
|
|
|
|
|
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;
|
|
|
|
|
const country = await this._resolveCountryByInput(uow.getConnection(), buyerCountry);
|
|
|
|
|
|
|
|
|
|
let vatRate = null;
|
|
|
|
|
if (country?.id) {
|
|
|
|
|
const [rows] = await uow.getConnection().query(
|
|
|
|
|
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
|
|
|
|
|
[country.id],
|
|
|
|
|
);
|
|
|
|
|
vatRate = rows?.[0]?.standard_rate == null ? null : Number(rows[0].standard_rate);
|
|
|
|
|
}
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
const companyProfile = await this._loadCompanyTaxProfile(invoiceOwnerUserId);
|
|
|
|
|
const uidCandidate = companyProfile?.atu_number || companyProfile?.registration_number || '';
|
|
|
|
|
const normalizedUid = this._normalizeUid(uidCandidate);
|
|
|
|
|
const hasValidUid = this._isLikelyValidUid(normalizedUid);
|
|
|
|
|
const countryCode = String(country?.country_code || '').toUpperCase();
|
|
|
|
|
|
|
|
|
|
// Reverse charge for company customers with a valid UID outside seller country (AT).
|
|
|
|
|
const isReverseCharge = Boolean(hasValidUid && countryCode && countryCode !== 'AT');
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
vatRate: isReverseCharge ? 0 : vatRate,
|
|
|
|
|
isReverseCharge,
|
|
|
|
|
countryCode: countryCode || null,
|
|
|
|
|
uid: hasValidUid ? normalizedUid : null,
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
await uow.rollback();
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resolveVatRateForCountry(countryCode) {
|
|
|
|
|
const decision = await this.resolveTaxDecisionForSubscription({
|
|
|
|
|
buyerCountry: countryCode,
|
|
|
|
|
invoiceOwnerUserId: null,
|
|
|
|
|
});
|
|
|
|
|
return decision?.vatRate ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Issue invoice for a subscription period, with items from pack_breakdown
|
|
|
|
|
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) {
|
|
|
|
|
console.log('[INVOICE ISSUE] Inputs:', {
|
|
|
|
|
@ -644,8 +711,12 @@ class InvoiceService {
|
|
|
|
|
};
|
|
|
|
|
const currency = abonement.currency || 'EUR';
|
|
|
|
|
|
|
|
|
|
// NEW: resolve invoice vat_rate (standard) from buyer country
|
|
|
|
|
const vat_rate = await this.resolveVatRateForCountry(addr.country);
|
|
|
|
|
// CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
|
|
|
|
|
const taxDecision = await this.resolveTaxDecisionForSubscription({
|
|
|
|
|
buyerCountry: addr.country,
|
|
|
|
|
invoiceOwnerUserId: actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null,
|
|
|
|
|
});
|
|
|
|
|
const vat_rate = taxDecision?.vatRate ?? null;
|
|
|
|
|
|
|
|
|
|
const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang });
|
|
|
|
|
|
|
|
|
|
@ -655,6 +726,9 @@ class InvoiceService {
|
|
|
|
|
period_start: periodStart,
|
|
|
|
|
period_end: periodEnd,
|
|
|
|
|
referred_by: abonement.referred_by || null,
|
|
|
|
|
tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
|
|
|
|
|
customer_country_code: taxDecision?.countryCode || null,
|
|
|
|
|
uid_number: taxDecision?.uid || null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// CHANGED: prioritize token user id for invoice ownership
|
|
|
|
|
|