@ -119,6 +119,138 @@ class InvoiceService {
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 ) {
if ( ! body ) return '' ;
if ( typeof body . transformToString === 'function' ) {
@ -161,7 +293,7 @@ class InvoiceService {
_buildInvoiceText ( { invoice , items , abonement , lang } ) {
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 ) ;
return [
@ -177,7 +309,7 @@ class InvoiceService {
_buildInvoiceMailText ( { invoice , items , abonement , lang } ) {
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 [
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.' ,
@ -191,7 +323,7 @@ class InvoiceService {
_buildInvoiceMailHtml ( { invoice , abonement , lang } ) {
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 || '' ;
return ` <!doctype html>
@ -252,12 +384,12 @@ class InvoiceService {
} ) . join ( '' ) ;
}
async _loadInvoiceHtmlTemplate ( ) {
async _loadInvoiceHtmlTemplate ( { lang = 'en' , userType = 'personal' } = { } ) {
// Load the latest active invoice template from the contract manager (S3)
try {
const templates = await DocumentTemplateService . getActiveTemplatesForUserType ( 'both' , 'invoice' ) ;
const templates = await DocumentTemplateService . getActiveTemplatesForUserType ( userType , 'invoice' ) ;
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 ;
const command = new GetObjectCommand ( {
Bucket : process . env . EXOSCALE _BUCKET ,
@ -332,6 +464,15 @@ class InvoiceService {
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' ;
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)
const bankAccountHolder = 'Profit Planet GmbH' ;
@ -365,8 +506,11 @@ class InvoiceService {
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
// For self subscriptions: "Bill To" = the subscriber
let customerName ;
let customerEmail = '' ;
let customerName = billingContext . customerName || invoice . buyer _name || '-' ;
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 = '' ;
if ( isGift ) {
@ -375,16 +519,16 @@ class InvoiceService {
const recipientEmail = abonement ? . email || invoice . buyer _email || '' ;
customerName = 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"
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 ) {
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> ` ;
}
} 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 } ) ;
@ -406,11 +550,14 @@ class InvoiceService {
companyPostalCity : this . _escapeHtml ( companyInfo . company _postal _city || '' ) ,
companyCountry : this . _escapeHtml ( companyInfo . company _country || 'Germany' ) ,
companyLogo : this . _buildCompanyLogoTag ( companyInfo ) ,
invoiceUserType : billingContext . userType ,
customerName : this . _escapeHtml ( customerName ) ,
customerEmail : this . _escapeHtml ( customerEmail ) ,
customerStreet : this . _escapeHtml ( invoice . buyer _street || '' ) ,
customerPostalCity : this . _escapeHtml ( [ invoice . buyer _postal _code , invoice . buyer _city ] . filter ( Boolean ) . join ( ' ' ) ) ,
customerCountry : this . _escapeHtml ( invoice . buyer _country || '' ) ,
customerStreet : this . _escapeHtml ( customerStreet ) ,
customerPostalCity : this . _escapeHtml ( customerPostalCity ) ,
customerCountry : this . _escapeHtml ( customerCountry ) ,
customerCompanyName : this . _escapeHtml ( billingContext . companyName || '' ) ,
customerUidNumber : this . _escapeHtml ( invoice ? . context ? . uid _number || billingContext . uidNumber || '' ) ,
orderedByBlock ,
issuedAt : this . _escapeHtml ( issuedAt ) ,
dueAt : this . _escapeHtml ( dueAt ) ,
@ -434,6 +581,10 @@ class InvoiceService {
: ( 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.' ) ,
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 ) ,
bankIban : this . _escapeHtml ( bankIban ) ,
bankBic : this . _escapeHtml ( bankBic ) ,
@ -447,7 +598,10 @@ class InvoiceService {
async _buildFallbackInvoiceHtml ( { 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 ) {
const varsForTemplate = this . _prepareVariablesForTemplate ( template , variables ) ;
return this . _renderTemplate ( template , varsForTemplate ) ;
@ -469,12 +623,12 @@ class InvoiceService {
< / h t m l > ` ;
}
async _loadInvoiceTemplateHtml ( { lang = 'en' } = { } ) {
async _loadInvoiceTemplateHtml ( { lang = 'en' , userType = 'personal' } = { } ) {
try {
const templates = await DocumentTemplateService . getActiveTemplatesForUserType ( 'both' , 'invoice' ) ;
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 ] ;
const selected = this . _selectInvoiceTemplate ( templates , { lang , userType } ) ;
if ( ! selected ? . storageKey ) return null ;
const command = new GetObjectCommand ( {
@ -543,7 +697,10 @@ class InvoiceService {
// 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 templateHtml = await this . _loadInvoiceTemplateHtml ( { lang } ) ;
const templateHtml = await this . _loadInvoiceTemplateHtml ( {
lang ,
userType : variables . invoiceUserType ,
} ) ;
let html = null ;
if ( templateHtml ) {
@ -606,18 +763,10 @@ class InvoiceService {
}
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 ;
return this . _loadInvoiceUserProfile ( userId ) ;
}
async resolveTaxDecisionForSubscription ( { buyerCountry , invoiceOwnerUserId } ) {
async resolveTaxDecisionForSubscription ( { buyerCountry , invoiceOwnerUserId , invoiceOwnerProfile = null } ) {
const uow = new UnitOfWork ( ) ;
await uow . start ( ) ;
@ -635,20 +784,22 @@ class InvoiceService {
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 normalizedUid = this . _normalizeUid ( uidCandidate ) ;
const hasValidUid = this . _isLikelyValidUid ( normalizedUid ) ;
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).
const isReverseCharge = Boolean ( hasValidUid && countryCode && countryCode !== 'AT' ) ;
const isReverseCharge = Boolean ( invoiceUserType === 'company' && hasValidUid && countryCode && countryCode !== 'AT' ) ;
return {
vatRate : isReverseCharge ? 0 : vatRate ,
isReverseCharge ,
countryCode : countryCode || null ,
uid : hasValidUid ? normalizedUid : null ,
userType : invoiceUserType ,
} ;
} catch ( e ) {
await uow . rollback ( ) ;
@ -675,20 +826,28 @@ class InvoiceService {
periodEnd ,
} ) ;
const buyerName = [ abonement . first _name , abonement . last _name ] . filter ( Boolean ) . join ( ' ' ) || null ;
const buyerEmail = abonement . email || null ;
const userIdForInvoice = actorUserId ? ? abonement . user _id ? ? abonement . purchaser _user _id ? ? 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 = {
street : abonement . street || null ,
postal _code : abonement . postal _code || null ,
city : abonement . city || null ,
country : abonement . country || null ,
street : billingContex t. street || null ,
postal _code : billingContext. postalC ode || null ,
city : billingContex t. city || null ,
country : billingContex t. country || null ,
} ;
const currency = abonement . currency || 'EUR' ;
// 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 ,
invoiceOwnerUserId : userIdForInvoice ,
invoiceOwnerProfile : invoiceUserProfile ,
} ) ;
const vat _rate = taxDecision ? . vatRate ? ? null ;
@ -703,11 +862,8 @@ class InvoiceService {
tax _mode : taxDecision ? . isReverseCharge ? 'reverse_charge' : 'standard' ,
customer _country _code : taxDecision ? . countryCode || 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 ) ;