dev #23

Merged
Seazn merged 16 commits from dev into main 2026-05-21 17:34:48 +00:00
6 changed files with 106 additions and 28 deletions
Showing only changes of commit f5a95843af - Show all commits

3
package-lock.json generated
View File

@ -2644,7 +2644,8 @@
"version": "0.0.1581282", "version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"peer": true
}, },
"node_modules/dfa": { "node_modules/dfa": {
"version": "1.2.0", "version": "1.2.0",

View File

@ -146,6 +146,7 @@ class CompanyUserRepository {
branch, branch,
numberOfEmployees, numberOfEmployees,
registrationNumber, registrationNumber,
atuNumber,
businessType, businessType,
iban, iban,
accountHolderName, accountHolderName,
@ -157,12 +158,13 @@ class CompanyUserRepository {
await conn.query( await conn.query(
`INSERT INTO company_profiles ( `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 branch, number_of_employees, business_type, contact_person_name, contact_person_phone, account_holder_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
company_name = VALUES(company_name), company_name = VALUES(company_name),
registration_number = VALUES(registration_number), registration_number = VALUES(registration_number),
atu_number = VALUES(atu_number),
phone = VALUES(phone), phone = VALUES(phone),
address = VALUES(address), address = VALUES(address),
zip_code = VALUES(zip_code), zip_code = VALUES(zip_code),
@ -178,6 +180,7 @@ class CompanyUserRepository {
userId, userId,
companyName, companyName,
registrationNumber || null, registrationNumber || null,
atuNumber || null,
companyPhone || null, companyPhone || null,
address || null, address || null,
zip_code || null, zip_code || null,

View File

@ -3,7 +3,6 @@ const UnitOfWork = require('../database/UnitOfWork');
const argon2 = require('argon2'); const argon2 = require('argon2');
async function createAdminUser() { async function createAdminUser() {
return;
// const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; // 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 || 'alexander.ibrahim.ai@gmail.com';
// const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com';

View File

@ -1,6 +1,5 @@
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository'); const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW const UnitOfWork = require('../../database/UnitOfWork'); // NEW
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
const PoolInflowService = require('../pool/PoolInflowService'); const PoolInflowService = require('../pool/PoolInflowService');
const DocumentTemplateService = require('../template/DocumentTemplateService'); const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService'); const MailService = require('../email/MailService');
@ -10,6 +9,7 @@ const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const fs = require('fs/promises'); const fs = require('fs/promises');
const path = require('path'); const path = require('path');
const pool = require('../../database/database');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService'); 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 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 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 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) // Hardcoded bank info (Profit Planet)
const bankAccountHolder = 'Profit Planet GmbH'; const bankAccountHolder = 'Profit Planet GmbH';
@ -429,16 +431,20 @@ class InvoiceService {
totalHeader: isDe ? 'Gesamt' : 'Total', totalHeader: isDe ? 'Gesamt' : 'Total',
itemsRows: this._buildItemsTableRows(items, invoice.currency), itemsRows: this._buildItemsTableRows(items, invoice.currency),
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)', subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
taxLabel: isDe ? 'MwSt.' : 'Tax', taxLabel: isReverseCharge ? (isDe ? 'Reverse Charge' : 'Reverse charge') : (isDe ? 'MwSt.' : 'Tax'),
vatRateDisplay: vatRate ? `${vatRate}%` : '0%', vatRateDisplay: isReverseCharge ? (isDe ? 'nicht ausgewiesen' : 'not charged') : (vatRate ? `${vatRate}%` : '0%'),
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)), totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)), totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)', totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)',
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)), totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information', paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information',
paymentInfoText: isDe paymentInfoText: isDe
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.' ? (isReverseCharge
: 'Please transfer the total amount stating the invoice number as reference.', ? '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), bankAccountHolder: this._escapeHtml(bankAccountHolder),
bankIban: this._escapeHtml(bankIban), bankIban: this._escapeHtml(bankIban),
bankBic: this._escapeHtml(bankBic), bankBic: this._escapeHtml(bankBic),
@ -598,31 +604,92 @@ class InvoiceService {
} }
// NEW: resolve current standard VAT rate for a buyer country code // NEW: resolve current standard VAT rate for a buyer country code
async resolveVatRateForCountry(countryCode) { async _resolveCountryByInput(conn, countryInput) {
if (!countryCode) return null; 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(); const uow = new UnitOfWork();
await uow.start(); await uow.start();
const taxRepo = new TaxRepository(uow);
try { try {
const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase()); const country = await this._resolveCountryByInput(uow.getConnection(), buyerCountry);
if (!country) {
await uow.commit(); let vatRate = null;
return 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(); 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) { } catch (e) {
await uow.rollback(); await uow.rollback();
throw e; 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 // Issue invoice for a subscription period, with items from pack_breakdown
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) { async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) {
console.log('[INVOICE ISSUE] Inputs:', { console.log('[INVOICE ISSUE] Inputs:', {
@ -644,8 +711,12 @@ class InvoiceService {
}; };
const currency = abonement.currency || 'EUR'; const currency = abonement.currency || 'EUR';
// NEW: resolve invoice vat_rate (standard) from buyer country // CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
const vat_rate = await this.resolveVatRateForCountry(addr.country); 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 }); const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang });
@ -655,6 +726,9 @@ class InvoiceService {
period_start: periodStart, period_start: periodStart,
period_end: periodEnd, period_end: periodEnd,
referred_by: abonement.referred_by || null, 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 // CHANGED: prioritize token user id for invoice ownership

View File

@ -13,7 +13,6 @@ class CompanyProfileService {
{ key: 'zip_code', label: 'Postal code' }, { key: 'zip_code', label: 'Postal code' },
{ key: 'city', label: 'City' }, { key: 'city', label: 'City' },
{ key: 'country', label: 'Country' }, { key: 'country', label: 'Country' },
{ key: 'registrationNumber', label: 'VAT' },
{ key: 'accountHolderName', label: 'Account holder' }, { key: 'accountHolderName', label: 'Account holder' },
{ key: 'iban', label: 'IBAN' } { key: 'iban', label: 'IBAN' }
]; ];
@ -26,6 +25,8 @@ class CompanyProfileService {
} }
profileData.companyName = (profileData.companyName || '').toString().trim(); 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 // Pass all profileData including country to repository
const repo = new CompanyUserRepository(unitOfWork); const repo = new CompanyUserRepository(unitOfWork);

View File

@ -344,8 +344,8 @@
</div> </div>
<div class="company"> <div class="company">
<div><strong>PROFIT PLANET GMBH</strong></div> <div><strong>PROFIT PLANET GMBH</strong></div>
<div>Liebenauer Hauptstraße 82c</div> <div>Kärntner Straße 227</div>
<div>A-8041 Graz</div> <div>8053 Graz</div>
<div class="muted">FN-649474 i</div> <div class="muted">FN-649474 i</div>
<div class="muted" style="margin-top:6px;">IBAN: AT16 2081 5000 4639 9507</div> <div class="muted" style="margin-top:6px;">IBAN: AT16 2081 5000 4639 9507</div>
<div class="muted">Swift/BIC Code: STSPAT2GXXX</div> <div class="muted">Swift/BIC Code: STSPAT2GXXX</div>