dev #28
@ -197,11 +197,11 @@ class DocumentTemplateRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate other active invoice templates for the same language.
|
// Deactivate other active invoice templates for the same language and user_type.
|
||||||
// Invoices always use user_type='both', so only lang matters.
|
async deactivateOtherActiveInvoices({ excludeId, lang, user_type }, conn) {
|
||||||
async deactivateOtherActiveInvoices({ excludeId, lang }, conn) {
|
logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang, user_type });
|
||||||
logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang });
|
|
||||||
const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en';
|
const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en';
|
||||||
|
const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both';
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE document_templates
|
UPDATE document_templates
|
||||||
@ -209,9 +209,10 @@ class DocumentTemplateRepository {
|
|||||||
WHERE id <> ?
|
WHERE id <> ?
|
||||||
AND type = 'invoice'
|
AND type = 'invoice'
|
||||||
AND lang = ?
|
AND lang = ?
|
||||||
|
AND COALESCE(user_type, 'both') = ?
|
||||||
AND state = 'active'
|
AND state = 'active'
|
||||||
`;
|
`;
|
||||||
const params = [excludeId, safeLang];
|
const params = [excludeId, safeLang, safeUserType];
|
||||||
try {
|
try {
|
||||||
if (conn) {
|
if (conn) {
|
||||||
const [res] = await conn.execute(query, params);
|
const [res] = await conn.execute(query, params);
|
||||||
|
|||||||
@ -119,6 +119,138 @@ class InvoiceService {
|
|||||||
return `${numeric.toFixed(2)} ${currency}`;
|
return `${numeric.toFixed(2)} ${currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_firstNonEmpty(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeInvoiceUserType(value) {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'company' ? 'company' : 'personal';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadInvoiceUserProfile(userId) {
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT u.id, u.email, u.user_type,
|
||||||
|
cp.company_name, cp.registration_number, cp.atu_number, cp.country AS company_country
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON cp.user_id = u.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows?.[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('InvoiceService._loadInvoiceUserProfile:error', {
|
||||||
|
userId,
|
||||||
|
message: error?.message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _buildInvoiceBillingContext({ abonement, invoice = null, invoiceUserId = null, userProfile = null } = {}) {
|
||||||
|
const profile = userProfile || await this._loadInvoiceUserProfile(invoiceUserId);
|
||||||
|
const userType = this._normalizeInvoiceUserType(profile?.user_type);
|
||||||
|
|
||||||
|
const shippingFullName = this._firstNonEmpty(
|
||||||
|
[abonement?.first_name, abonement?.last_name].filter(Boolean).join(' '),
|
||||||
|
invoice?.buyer_name,
|
||||||
|
profile?.company_name,
|
||||||
|
abonement?.email,
|
||||||
|
invoice?.buyer_email,
|
||||||
|
'Customer',
|
||||||
|
);
|
||||||
|
|
||||||
|
const customerName = userType === 'company'
|
||||||
|
? this._firstNonEmpty(
|
||||||
|
abonement?.invoice_full_name,
|
||||||
|
profile?.company_name,
|
||||||
|
invoice?.buyer_name,
|
||||||
|
shippingFullName,
|
||||||
|
)
|
||||||
|
: this._firstNonEmpty(
|
||||||
|
abonement?.invoice_full_name,
|
||||||
|
invoice?.buyer_name,
|
||||||
|
shippingFullName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const email = this._firstNonEmpty(
|
||||||
|
abonement?.invoice_email,
|
||||||
|
invoice?.buyer_email,
|
||||||
|
abonement?.email,
|
||||||
|
profile?.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
const street = this._firstNonEmpty(
|
||||||
|
abonement?.invoice_street,
|
||||||
|
invoice?.buyer_street,
|
||||||
|
abonement?.street,
|
||||||
|
);
|
||||||
|
|
||||||
|
const postalCode = this._firstNonEmpty(
|
||||||
|
abonement?.invoice_postal_code,
|
||||||
|
invoice?.buyer_postal_code,
|
||||||
|
abonement?.postal_code,
|
||||||
|
);
|
||||||
|
|
||||||
|
const city = this._firstNonEmpty(
|
||||||
|
abonement?.invoice_city,
|
||||||
|
invoice?.buyer_city,
|
||||||
|
abonement?.city,
|
||||||
|
);
|
||||||
|
|
||||||
|
const country = this._firstNonEmpty(
|
||||||
|
invoice?.buyer_country,
|
||||||
|
abonement?.country,
|
||||||
|
profile?.company_country,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerName: customerName || shippingFullName || '-',
|
||||||
|
email,
|
||||||
|
street,
|
||||||
|
postalCode,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
postalCity: [postalCode, city].filter(Boolean).join(' '),
|
||||||
|
userType,
|
||||||
|
companyName: this._firstNonEmpty(profile?.company_name, abonement?.invoice_full_name),
|
||||||
|
uidNumber: this._normalizeUid(profile?.atu_number || profile?.registration_number || ''),
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal' } = {}) {
|
||||||
|
if (!Array.isArray(templates) || !templates.length) return null;
|
||||||
|
|
||||||
|
const safeLang = lang === 'de' ? 'de' : 'en';
|
||||||
|
const safeUserType = this._normalizeInvoiceUserType(userType);
|
||||||
|
const priorities = [
|
||||||
|
(template) => template?.lang === safeLang && template?.user_type === safeUserType,
|
||||||
|
(template) => template?.lang === safeLang && template?.user_type === 'both',
|
||||||
|
(template) => template?.lang === 'en' && template?.user_type === safeUserType,
|
||||||
|
(template) => template?.lang === 'en' && template?.user_type === 'both',
|
||||||
|
(template) => template?.user_type === safeUserType,
|
||||||
|
(template) => template?.user_type === 'both',
|
||||||
|
() => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const matches of priorities) {
|
||||||
|
const selected = templates.find(matches);
|
||||||
|
if (selected) return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates[0];
|
||||||
|
}
|
||||||
|
|
||||||
async _s3BodyToString(body) {
|
async _s3BodyToString(body) {
|
||||||
if (!body) return '';
|
if (!body) return '';
|
||||||
if (typeof body.transformToString === 'function') {
|
if (typeof body.transformToString === 'function') {
|
||||||
@ -161,7 +293,7 @@ class InvoiceService {
|
|||||||
|
|
||||||
_buildInvoiceText({ invoice, items, abonement, lang }) {
|
_buildInvoiceText({ invoice, items, abonement, lang }) {
|
||||||
const isDe = lang === 'de';
|
const isDe = lang === 'de';
|
||||||
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
|
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '-';
|
||||||
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -177,7 +309,7 @@ class InvoiceService {
|
|||||||
|
|
||||||
_buildInvoiceMailText({ invoice, items, abonement, lang }) {
|
_buildInvoiceMailText({ invoice, items, abonement, lang }) {
|
||||||
const isDe = lang === 'de';
|
const isDe = lang === 'de';
|
||||||
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
|
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '-';
|
||||||
return [
|
return [
|
||||||
isDe ? `Vielen Dank für Ihr Abonnement, ${customerName}.` : `Thank you for your subscription, ${customerName}.`,
|
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 ? 'Ihre Rechnung ist als PDF im Anhang enthalten.' : 'Your invoice is attached as a PDF.',
|
||||||
@ -191,7 +323,7 @@ class InvoiceService {
|
|||||||
|
|
||||||
_buildInvoiceMailHtml({ invoice, abonement, lang }) {
|
_buildInvoiceMailHtml({ invoice, abonement, lang }) {
|
||||||
const isDe = lang === 'de';
|
const isDe = lang === 'de';
|
||||||
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || 'Customer';
|
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || 'Customer';
|
||||||
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
|
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@ -252,12 +384,12 @@ class InvoiceService {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadInvoiceHtmlTemplate() {
|
async _loadInvoiceHtmlTemplate({ lang = 'en', userType = 'personal' } = {}) {
|
||||||
// Load the latest active invoice template from the contract manager (S3)
|
// Load the latest active invoice template from the contract manager (S3)
|
||||||
try {
|
try {
|
||||||
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
|
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice');
|
||||||
if (!Array.isArray(templates) || !templates.length) return null;
|
if (!Array.isArray(templates) || !templates.length) return null;
|
||||||
const selected = templates[0]; // latest active version
|
const selected = this._selectInvoiceTemplate(templates, { lang, userType });
|
||||||
if (!selected?.storageKey) return null;
|
if (!selected?.storageKey) return null;
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: process.env.EXOSCALE_BUCKET,
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
@ -332,6 +464,15 @@ class InvoiceService {
|
|||||||
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
|
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
|
||||||
const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
|
const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
|
||||||
const isReverseCharge = taxMode === 'reverse_charge';
|
const isReverseCharge = taxMode === 'reverse_charge';
|
||||||
|
const invoiceUserId = invoice?.user_id || abonement?.user_id || abonement?.purchaser_user_id || null;
|
||||||
|
const billingContext = await this._buildInvoiceBillingContext({
|
||||||
|
abonement,
|
||||||
|
invoice,
|
||||||
|
invoiceUserId,
|
||||||
|
});
|
||||||
|
const reverseChargeNoticeText = isDe
|
||||||
|
? 'Bei dieser Rechnung handelt es sich um eine Rechnung nach dem Reverse Charge Verfahren. Demnach wird keine Umsatzsteuer ausgewiesen. Die Steuerschuldnerschaft liegt beim Leistungsempfänger. Die Umsatzsteuer ist entsprechend vom Leistungsempfänger anzumelden und abzuführen.'
|
||||||
|
: 'This invoice is issued under the reverse charge procedure. Accordingly, no VAT is shown. The tax liability is transferred to the recipient of the service. The recipient must declare and pay the VAT in accordance with the applicable regulations.';
|
||||||
|
|
||||||
// Hardcoded bank info (Profit Planet)
|
// Hardcoded bank info (Profit Planet)
|
||||||
const bankAccountHolder = 'Profit Planet GmbH';
|
const bankAccountHolder = 'Profit Planet GmbH';
|
||||||
@ -365,8 +506,11 @@ class InvoiceService {
|
|||||||
|
|
||||||
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
|
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
|
||||||
// For self subscriptions: "Bill To" = the subscriber
|
// For self subscriptions: "Bill To" = the subscriber
|
||||||
let customerName;
|
let customerName = billingContext.customerName || invoice.buyer_name || '-';
|
||||||
let customerEmail = '';
|
let customerEmail = billingContext.email || '';
|
||||||
|
let customerStreet = billingContext.street || invoice.buyer_street || '';
|
||||||
|
let customerPostalCity = billingContext.postalCity || [invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ');
|
||||||
|
let customerCountry = billingContext.country || invoice.buyer_country || '';
|
||||||
let orderedByBlock = '';
|
let orderedByBlock = '';
|
||||||
const taxTreatmentBlock = isReverseCharge
|
const taxTreatmentBlock = isReverseCharge
|
||||||
? ''
|
? ''
|
||||||
@ -378,16 +522,16 @@ class InvoiceService {
|
|||||||
const recipientEmail = abonement?.email || invoice.buyer_email || '';
|
const recipientEmail = abonement?.email || invoice.buyer_email || '';
|
||||||
customerName = recipientName || recipientEmail || '-';
|
customerName = recipientName || recipientEmail || '-';
|
||||||
customerEmail = recipientName ? recipientEmail : '';
|
customerEmail = recipientName ? recipientEmail : '';
|
||||||
|
customerStreet = abonement?.street || invoice?.buyer_street || '';
|
||||||
|
customerPostalCity = [abonement?.postal_code || invoice?.buyer_postal_code, abonement?.city || invoice?.buyer_city].filter(Boolean).join(' ');
|
||||||
|
customerCountry = abonement?.country || invoice?.buyer_country || '';
|
||||||
|
|
||||||
// Purchaser info for "Ordered by"
|
// Purchaser info for "Ordered by"
|
||||||
const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
|
const purchaserName = billingContext.customerName || invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
|
||||||
if (purchaserName) {
|
if (purchaserName) {
|
||||||
const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By';
|
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>`;
|
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 || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrCodeImage = await this._buildQrCodeImageTag({ abonement });
|
const qrCodeImage = await this._buildQrCodeImageTag({ abonement });
|
||||||
@ -409,11 +553,14 @@ class InvoiceService {
|
|||||||
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
|
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
|
||||||
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
|
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
|
||||||
companyLogo: this._buildCompanyLogoTag(companyInfo),
|
companyLogo: this._buildCompanyLogoTag(companyInfo),
|
||||||
|
invoiceUserType: billingContext.userType,
|
||||||
customerName: this._escapeHtml(customerName),
|
customerName: this._escapeHtml(customerName),
|
||||||
customerEmail: this._escapeHtml(customerEmail),
|
customerEmail: this._escapeHtml(customerEmail),
|
||||||
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
|
customerStreet: this._escapeHtml(customerStreet),
|
||||||
customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')),
|
customerPostalCity: this._escapeHtml(customerPostalCity),
|
||||||
customerCountry: this._escapeHtml(invoice.buyer_country || ''),
|
customerCountry: this._escapeHtml(customerCountry),
|
||||||
|
customerCompanyName: this._escapeHtml(billingContext.companyName || ''),
|
||||||
|
customerUidNumber: this._escapeHtml(invoice?.context?.uid_number || billingContext.uidNumber || ''),
|
||||||
orderedByBlock,
|
orderedByBlock,
|
||||||
taxTreatmentBlock,
|
taxTreatmentBlock,
|
||||||
issuedAt: this._escapeHtml(issuedAt),
|
issuedAt: this._escapeHtml(issuedAt),
|
||||||
@ -438,6 +585,10 @@ class InvoiceService {
|
|||||||
: (isReverseCharge
|
: (isReverseCharge
|
||||||
? 'Reverse charge applies: VAT liability shifts to the recipient. Please transfer the total amount stating the invoice number as reference.'
|
? '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.'),
|
: 'Please transfer the total amount stating the invoice number as reference.'),
|
||||||
|
reverseChargeClass: isReverseCharge ? 'reverse-charge-active' : 'reverse-charge-inactive',
|
||||||
|
reverseChargeSectionClass: isReverseCharge ? '' : 'is-hidden',
|
||||||
|
standardTaxSectionClass: isReverseCharge ? 'is-hidden' : '',
|
||||||
|
reverseChargeNoticeText: isReverseCharge ? this._escapeHtml(reverseChargeNoticeText) : '',
|
||||||
bankAccountHolder: this._escapeHtml(bankAccountHolder),
|
bankAccountHolder: this._escapeHtml(bankAccountHolder),
|
||||||
bankIban: this._escapeHtml(bankIban),
|
bankIban: this._escapeHtml(bankIban),
|
||||||
bankBic: this._escapeHtml(bankBic),
|
bankBic: this._escapeHtml(bankBic),
|
||||||
@ -451,7 +602,10 @@ class InvoiceService {
|
|||||||
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
|
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
|
||||||
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
|
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
|
||||||
|
|
||||||
const template = await this._loadInvoiceHtmlTemplate();
|
const template = await this._loadInvoiceHtmlTemplate({
|
||||||
|
lang,
|
||||||
|
userType: variables.invoiceUserType,
|
||||||
|
});
|
||||||
if (template) {
|
if (template) {
|
||||||
const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
|
const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
|
||||||
return this._renderTemplate(template, varsForTemplate);
|
return this._renderTemplate(template, varsForTemplate);
|
||||||
@ -473,12 +627,12 @@ class InvoiceService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadInvoiceTemplateHtml({ lang = 'en' } = {}) {
|
async _loadInvoiceTemplateHtml({ lang = 'en', userType = 'personal' } = {}) {
|
||||||
try {
|
try {
|
||||||
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
|
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice');
|
||||||
if (!Array.isArray(templates) || !templates.length) return null;
|
if (!Array.isArray(templates) || !templates.length) return null;
|
||||||
|
|
||||||
const selected = templates.find((t) => t.lang === lang) || templates.find((t) => t.lang === 'en') || templates[0];
|
const selected = this._selectInvoiceTemplate(templates, { lang, userType });
|
||||||
if (!selected?.storageKey) return null;
|
if (!selected?.storageKey) return null;
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
@ -547,7 +701,10 @@ class InvoiceService {
|
|||||||
// Build the full set of template variables once – used by both S3 and local paths
|
// 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 variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
|
||||||
|
|
||||||
const templateHtml = await this._loadInvoiceTemplateHtml({ lang });
|
const templateHtml = await this._loadInvoiceTemplateHtml({
|
||||||
|
lang,
|
||||||
|
userType: variables.invoiceUserType,
|
||||||
|
});
|
||||||
let html = null;
|
let html = null;
|
||||||
|
|
||||||
if (templateHtml) {
|
if (templateHtml) {
|
||||||
@ -610,18 +767,10 @@ class InvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _loadCompanyTaxProfile(userId) {
|
async _loadCompanyTaxProfile(userId) {
|
||||||
if (!userId) return null;
|
return this._loadInvoiceUserProfile(userId);
|
||||||
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 }) {
|
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId, invoiceOwnerProfile = null }) {
|
||||||
const uow = new UnitOfWork();
|
const uow = new UnitOfWork();
|
||||||
await uow.start();
|
await uow.start();
|
||||||
|
|
||||||
@ -639,20 +788,22 @@ class InvoiceService {
|
|||||||
|
|
||||||
await uow.commit();
|
await uow.commit();
|
||||||
|
|
||||||
const companyProfile = await this._loadCompanyTaxProfile(invoiceOwnerUserId);
|
const companyProfile = invoiceOwnerProfile || await this._loadCompanyTaxProfile(invoiceOwnerUserId);
|
||||||
const uidCandidate = companyProfile?.atu_number || companyProfile?.registration_number || '';
|
const uidCandidate = companyProfile?.atu_number || companyProfile?.registration_number || '';
|
||||||
const normalizedUid = this._normalizeUid(uidCandidate);
|
const normalizedUid = this._normalizeUid(uidCandidate);
|
||||||
const hasValidUid = this._isLikelyValidUid(normalizedUid);
|
const hasValidUid = this._isLikelyValidUid(normalizedUid);
|
||||||
const countryCode = String(country?.country_code || '').toUpperCase();
|
const countryCode = String(country?.country_code || '').toUpperCase();
|
||||||
|
const invoiceUserType = this._normalizeInvoiceUserType(companyProfile?.user_type);
|
||||||
|
|
||||||
// Reverse charge for company customers with a valid UID outside seller country (AT).
|
// Reverse charge for company customers with a valid UID outside seller country (AT).
|
||||||
const isReverseCharge = Boolean(hasValidUid && countryCode && countryCode !== 'AT');
|
const isReverseCharge = Boolean(invoiceUserType === 'company' && hasValidUid && countryCode && countryCode !== 'AT');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vatRate: isReverseCharge ? 0 : vatRate,
|
vatRate: isReverseCharge ? 0 : vatRate,
|
||||||
isReverseCharge,
|
isReverseCharge,
|
||||||
countryCode: countryCode || null,
|
countryCode: countryCode || null,
|
||||||
uid: hasValidUid ? normalizedUid : null,
|
uid: hasValidUid ? normalizedUid : null,
|
||||||
|
userType: invoiceUserType,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await uow.rollback();
|
await uow.rollback();
|
||||||
@ -679,20 +830,28 @@ class InvoiceService {
|
|||||||
periodEnd,
|
periodEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
|
const userIdForInvoice = actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
|
||||||
const buyerEmail = abonement.email || null;
|
const invoiceUserProfile = await this._loadInvoiceUserProfile(userIdForInvoice);
|
||||||
|
const billingContext = await this._buildInvoiceBillingContext({
|
||||||
|
abonement,
|
||||||
|
invoiceUserId: userIdForInvoice,
|
||||||
|
userProfile: invoiceUserProfile,
|
||||||
|
});
|
||||||
|
const buyerName = billingContext.customerName || null;
|
||||||
|
const buyerEmail = billingContext.email || null;
|
||||||
const addr = {
|
const addr = {
|
||||||
street: abonement.street || null,
|
street: billingContext.street || null,
|
||||||
postal_code: abonement.postal_code || null,
|
postal_code: billingContext.postalCode || null,
|
||||||
city: abonement.city || null,
|
city: billingContext.city || null,
|
||||||
country: abonement.country || null,
|
country: billingContext.country || null,
|
||||||
};
|
};
|
||||||
const currency = abonement.currency || 'EUR';
|
const currency = abonement.currency || 'EUR';
|
||||||
|
|
||||||
// CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
|
// CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
|
||||||
const taxDecision = await this.resolveTaxDecisionForSubscription({
|
const taxDecision = await this.resolveTaxDecisionForSubscription({
|
||||||
buyerCountry: addr.country,
|
buyerCountry: addr.country,
|
||||||
invoiceOwnerUserId: actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null,
|
invoiceOwnerUserId: userIdForInvoice,
|
||||||
|
invoiceOwnerProfile: invoiceUserProfile,
|
||||||
});
|
});
|
||||||
const vat_rate = taxDecision?.vatRate ?? null;
|
const vat_rate = taxDecision?.vatRate ?? null;
|
||||||
|
|
||||||
@ -707,12 +866,9 @@ class InvoiceService {
|
|||||||
tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
|
tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
|
||||||
customer_country_code: taxDecision?.countryCode || null,
|
customer_country_code: taxDecision?.countryCode || null,
|
||||||
uid_number: taxDecision?.uid || null,
|
uid_number: taxDecision?.uid || null,
|
||||||
|
invoice_user_type: billingContext.userType,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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);
|
console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice);
|
||||||
|
|
||||||
const invoice = await this.repo.createInvoiceWithItems({
|
const invoice = await this.repo.createInvoiceWithItems({
|
||||||
|
|||||||
@ -140,11 +140,12 @@ class DocumentTemplateService {
|
|||||||
}, uow.connection);
|
}, uow.connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce singleton active invoice template per lang
|
// Enforce singleton active invoice template per (lang + user_type)
|
||||||
if (state === 'active' && current.type === 'invoice') {
|
if (state === 'active' && current.type === 'invoice') {
|
||||||
await DocumentTemplateRepository.deactivateOtherActiveInvoices({
|
await DocumentTemplateRepository.deactivateOtherActiveInvoices({
|
||||||
excludeId: id,
|
excludeId: id,
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
|
user_type: current.user_type || 'both',
|
||||||
}, uow.connection);
|
}, uow.connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,12 +200,13 @@ class DocumentTemplateService {
|
|||||||
}, uow.connection);
|
}, uow.connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If new template is active and is an invoice template, deactivate other active invoices for same lang
|
// If new template is active and is an invoice template, deactivate other active invoices for same lang + user_type
|
||||||
const effectiveType = data.type || previous.type;
|
const effectiveType = data.type || previous.type;
|
||||||
if (nextState === 'active' && effectiveType === 'invoice') {
|
if (nextState === 'active' && effectiveType === 'invoice') {
|
||||||
await DocumentTemplateRepository.deactivateOtherActiveInvoices({
|
await DocumentTemplateRepository.deactivateOtherActiveInvoices({
|
||||||
excludeId: created.id,
|
excludeId: created.id,
|
||||||
lang: (data.lang || previous.lang),
|
lang: (data.lang || previous.lang),
|
||||||
|
user_type: (data.user_type || previous.user_type || 'both'),
|
||||||
}, uow.connection);
|
}, uow.connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -157,6 +157,21 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #1C2B4A;
|
color: #1C2B4A;
|
||||||
}
|
}
|
||||||
|
.reverse-charge-note {
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fdba74;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.reverse-charge-note p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9a3412;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Payment Info ──────────────────────────── */
|
/* ── Payment Info ──────────────────────────── */
|
||||||
.payment-info {
|
.payment-info {
|
||||||
@ -305,6 +320,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="reverse-charge-note {{reverseChargeClass}} {{reverseChargeSectionClass}}">
|
||||||
|
<p>{{reverseChargeNoticeText}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Payment Info -->
|
<!-- Payment Info -->
|
||||||
<div class="payment-info">
|
<div class="payment-info">
|
||||||
<h3>PAYMENT INFORMATION</h3>
|
<h3>PAYMENT INFORMATION</h3>
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
border: 1px solid #dbe3f0;
|
border: 1px solid #dbe3f0;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: none;
|
box-shadow: 0 24px 60px -38px rgba(15, 23, 42, 0.35);
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
padding: 34px 38px 28px;
|
padding: 34px 38px 28px;
|
||||||
@ -285,19 +285,14 @@
|
|||||||
margin: 12mm;
|
margin: 12mm;
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
html,
|
|
||||||
body {
|
|
||||||
width: 210mm;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 10.8px;
|
font-size: 11.2px;
|
||||||
line-height: 1.3;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.page {
|
.page {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@ -305,59 +300,59 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
padding: 14px 16px 12px;
|
padding: 16px 18px 14px;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 22px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
.invoice-card {
|
.invoice-card {
|
||||||
min-width: 160px;
|
min-width: 180px;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.invoice-card .number {
|
.invoice-card .number {
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 14px 16px 16px;
|
padding: 16px 18px 18px;
|
||||||
}
|
}
|
||||||
.info-grid,
|
.info-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.info-grid {
|
.info-grid {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.info-card,
|
.info-card,
|
||||||
.payment-card,
|
.payment-card,
|
||||||
.totals-card,
|
.totals-card,
|
||||||
.tax-banner {
|
.tax-banner {
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.tax-banner {
|
.tax-banner {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.items-table {
|
.items-table {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.items-table thead th,
|
.items-table thead th,
|
||||||
.items-table tbody td {
|
.items-table tbody td {
|
||||||
padding: 6px 7px;
|
padding: 8px 9px;
|
||||||
}
|
}
|
||||||
.bank-list {
|
.bank-list {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.totals-row {
|
.totals-row {
|
||||||
padding: 4px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
.totals-row.total {
|
.totals-row.total {
|
||||||
margin-top: 3px;
|
margin-top: 4px;
|
||||||
padding-top: 6px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 10px;
|
margin-top: 14px;
|
||||||
padding-top: 8px;
|
padding-top: 10px;
|
||||||
font-size: 9.4px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.hero,
|
.hero,
|
||||||
.info-grid,
|
.info-grid,
|
||||||
@ -370,9 +365,6 @@
|
|||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.items-table {
|
.items-table {
|
||||||
page-break-inside: auto;
|
page-break-inside: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,20 @@
|
|||||||
.tax-banner:empty {
|
.tax-banner:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.reverse-charge-note {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
padding: 15px 18px;
|
||||||
|
border: 1px solid #fdba74;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, #fff7ed 0%, #fffbeb 100%);
|
||||||
|
}
|
||||||
|
.reverse-charge-note p {
|
||||||
|
margin: 0;
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
.items-table {
|
.items-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@ -436,6 +450,10 @@
|
|||||||
|
|
||||||
{{taxTreatmentBlock}}
|
{{taxTreatmentBlock}}
|
||||||
|
|
||||||
|
<div class="reverse-charge-note {{reverseChargeClass}} {{reverseChargeSectionClass}}">
|
||||||
|
<p>{{reverseChargeNoticeText}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="items-table">
|
<table class="items-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user