diff --git a/package-lock.json b/package-lock.json index 663d587..ceb6f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2644,7 +2644,8 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dfa": { "version": "1.2.0", diff --git a/repositories/user/company/CompanyUserRepository.js b/repositories/user/company/CompanyUserRepository.js index c42af48..00b07f9 100644 --- a/repositories/user/company/CompanyUserRepository.js +++ b/repositories/user/company/CompanyUserRepository.js @@ -146,6 +146,7 @@ class CompanyUserRepository { branch, numberOfEmployees, registrationNumber, + atuNumber, businessType, iban, accountHolderName, @@ -157,12 +158,13 @@ class CompanyUserRepository { await conn.query( `INSERT INTO company_profiles ( - user_id, company_name, registration_number, phone, address, zip_code, city, country, + user_id, company_name, registration_number, atu_number, phone, address, zip_code, city, country, branch, number_of_employees, business_type, contact_person_name, contact_person_phone, account_holder_name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE company_name = VALUES(company_name), registration_number = VALUES(registration_number), + atu_number = VALUES(atu_number), phone = VALUES(phone), address = VALUES(address), zip_code = VALUES(zip_code), @@ -178,6 +180,7 @@ class CompanyUserRepository { userId, companyName, registrationNumber || null, + atuNumber || null, companyPhone || null, address || null, zip_code || null, diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index c4c6d88..1f25dc8 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,7 +3,6 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - return; // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 6a3bbf7..714836b 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -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 diff --git a/services/profile/company/CompanyProfileService.js b/services/profile/company/CompanyProfileService.js index cf07b82..495dbac 100644 --- a/services/profile/company/CompanyProfileService.js +++ b/services/profile/company/CompanyProfileService.js @@ -13,7 +13,6 @@ class CompanyProfileService { { key: 'zip_code', label: 'Postal code' }, { key: 'city', label: 'City' }, { key: 'country', label: 'Country' }, - { key: 'registrationNumber', label: 'VAT' }, { key: 'accountHolderName', label: 'Account holder' }, { key: 'iban', label: 'IBAN' } ]; @@ -26,6 +25,8 @@ class CompanyProfileService { } profileData.companyName = (profileData.companyName || '').toString().trim(); + profileData.registrationNumber = (profileData.registrationNumber || '').toString().trim() || null; + profileData.atuNumber = (profileData.atuNumber || profileData.uidNumber || '').toString().trim() || null; // Pass all profileData including country to repository const repo = new CompanyUserRepository(unitOfWork); diff --git a/templates/abo/abo-contract-template-new.html b/templates/abo/abo-contract-template-new.html index 133cb45..84664be 100644 --- a/templates/abo/abo-contract-template-new.html +++ b/templates/abo/abo-contract-template-new.html @@ -344,8 +344,8 @@