/** * SEPA mandate — shared server helpers for both flows: * (a) annex of the contract package (signed inside the /sign portal) * (b) standalone send/fill/sign from the partner or lead page (/sepa portal) * * The creditor identity (LogYou, Gläubiger-ID, pre-notification days) comes * from runtimeConfig.sepa. The signed mandate is attached to the record it was * sent from (C_BPartner → table C_BPartner, lead → Lead_User) with the service * token, exactly like the signed contract. */ import { buildSepaMandatePdf, sepaFilename, type SepaFilled } from './sepaPdf' import { buildMandateReference, sepaAcceptanceTexts, type SepaMandateData, type SepaScheme, type SepaLang } from './sepaTexts' import { attachToStrapi } from '../inbox/strapiAttach' import { logContactActivity, strapiPublicFileUrl } from './contactActivity' import { patchOfferConditionsSection } from './offerConditions' import { writeSigningFile, readSigningFile, markSigningRequestSigned, sha256Hex, type SigningRequest, type SigningDocumentMeta } from './contractSigningDb' import { mailTransport, brandedEmail, escapeHtml, formatDateTimeHuman, recordLink } from './contractSigning' import { validateIban, validateBic, normalizeIban, formatIbanDisplay } from '../../../app/utils/iban' export const APP_BASE_URL = 'https://app.logship.de' export const NOTIFY_EMAIL = 'info@logyou.de' export const sepaLink = (token: string) => `${APP_BASE_URL}/sepa/${token}` export const sepaDocumentLink = (token: string, key: string) => `${APP_BASE_URL}/api/public/sepa/${token}/document/${key}` export const creditorFromConfig = () => { const cfg: any = (useRuntimeConfig() as any).sepa || {} return { name: String(cfg.creditorName || 'LogYou GmbH'), street: String(cfg.creditorStreet || 'Mühlenweg 4'), zipCity: String(cfg.creditorZipCity || '35510 Butzbach'), country: String(cfg.creditorCountry || 'Deutschland'), creditorId: String(cfg.creditorId || '').trim(), prenotificationDays: Number(cfg.prenotificationDays || 5) } } export interface SepaSetup { scheme?: SepaScheme recurring?: boolean mandateReference?: string prenotificationDays?: number } /** Mandate data for a record (reference generated when not given). */ export const buildMandateData = (source: 'lead' | 'partner', recordId: number, setup: SepaSetup = {}, debtor: SepaMandateData['debtor'] = {}): SepaMandateData => { const creditor = creditorFromConfig() if (setup.prenotificationDays && setup.prenotificationDays > 0) creditor.prenotificationDays = Number(setup.prenotificationDays) const ref = String(setup.mandateReference || '').trim().toUpperCase().replace(/[^A-Z0-9\-/]/g, '').slice(0, 35) return { scheme: setup.scheme === 'CORE' ? 'CORE' : 'B2B', recurring: setup.recurring !== false, mandateReference: ref || buildMandateReference(source, recordId, Math.random().toString(36).slice(2, 6)), creditor, debtor: debtor || {} } } /** Debtor input from a portal (validated + normalised). Returns { ok, message, values }. */ export const validateDebtorInput = (raw: any, lang: SepaLang): { ok: boolean; message: string; values: SepaFilled } => { const en = lang === 'en' const s = (v: any, n: number) => String(v ?? '').trim().slice(0, n) const values: SepaFilled = { company: s(raw?.company, 200), accountHolder: s(raw?.accountHolder, 200), street: s(raw?.street, 200), zipCity: s(raw?.zipCity, 200), country: s(raw?.country, 80), iban: normalizeIban(raw?.iban), bic: s(raw?.bic, 11).toUpperCase().replace(/\s+/g, ''), bank: s(raw?.bank, 120), place: s(raw?.place, 120), date: s(raw?.date, 20) } if (!values.accountHolder) return { ok: false, message: en ? 'Account holder is required' : 'Kontoinhaber ist ein Pflichtfeld', values } const iban = validateIban(values.iban) if (!iban.valid) { const why = iban.reason === 'length' ? (en ? `an ${iban.country} IBAN has ${iban.expectedLength} characters` : `eine ${iban.country}-IBAN hat ${iban.expectedLength} Zeichen`) : iban.reason === 'checksum' ? (en ? 'check digits do not match' : 'Prüfziffer stimmt nicht') : iban.reason === 'country' ? (en ? 'unknown country code' : 'unbekanntes Länderkürzel') : (en ? 'invalid format' : 'ungültiges Format') return { ok: false, message: en ? `Invalid IBAN (${why})` : `Ungültige IBAN (${why})`, values } } if (!iban.sepa) return { ok: false, message: en ? 'The IBAN is not from a SEPA country' : 'Die IBAN stammt nicht aus einem SEPA-Land', values } if (values.bic && !validateBic(values.bic)) return { ok: false, message: en ? 'Invalid BIC' : 'Ungültiger BIC', values } return { ok: true, message: '', values } } const maskIban = (iban: string) => { const n = normalizeIban(iban) if (n.length < 8) return n return formatIbanDisplay(n.slice(0, 4) + '*'.repeat(n.length - 8) + n.slice(-4)) } export interface SepaFinalizeResult { signedAt: string signedDocuments: SigningDocumentMeta[] signedFilename: string attachedUrl: string warnings: string[] } /** * Render the filled + signed mandate, store it under the signing token and * attach it to the record. Shared by finalizeSignature (contract annex) and * the standalone /sepa portal. Never throws after the PDF exists — every * downstream step is fail-soft and reported in `warnings`. */ export const renderAndAttachSignedMandate = async (event: any, req: SigningRequest, mandate: SepaMandateData, values: SepaFilled, signature: { imageDataUrl: string; signedAt: string; signerName: string; ip: string }, blankSha256?: string): Promise<{ meta: SigningDocumentMeta; buffer: Buffer; attachedUrl: string; warnings: string[] }> => { const warnings: string[] = [] const lang: SepaLang = req.language === 'en' ? 'en' : 'de' const buffer = await buildSepaMandatePdf({ data: mandate, language: lang, filled: { ...values, iban: formatIbanDisplay(values.iban || ''), date: values.date || new Date(signature.signedAt).toLocaleDateString(lang === 'en' ? 'en-GB' : 'de-DE', { timeZone: 'Europe/Berlin' }) }, signature: { imageDataUrl: signature.imageDataUrl, signedAt: signature.signedAt, signerName: signature.signerName, ip: signature.ip, token: req.token, sha256OfBlank: blankSha256 } }) const filename = sepaFilename(lang, mandate.mandateReference, lang === 'en' ? '_signed' : '_unterschrieben') writeSigningFile(req.token, filename, buffer) const meta: SigningDocumentMeta = { key: 'sepa', title: lang === 'en' ? 'SEPA Direct Debit Mandate (signed)' : 'SEPA-Lastschriftmandat (unterschrieben)', filename, sha256: sha256Hex(buffer), size: buffer.length, signed: true } let attachedUrl = '' try { if (!(req.recordId > 0)) throw new Error('no record id') const config: any = useRuntimeConfig() const up = await attachToStrapi(event, config.api?.idempieretoken || '', { tableName: req.source === 'lead' ? 'Lead_User' : 'C_BPartner', recordId: req.recordId, recordUu: req.recordUu || undefined, buffer, filename, mimeType: 'application/pdf' }) attachedUrl = strapiPublicFileUrl(up.fileUrl) } catch (err: any) { console.error('[SEPA] attach signed mandate failed:', err?.message || err) warnings.push('attach') } return { meta, buffer, attachedUrl, warnings } } /** Summary persisted on offer_conditions.sepa (never the full IBAN). */ export const mandateSummary = (mandate: SepaMandateData, values: SepaFilled, signedAt: string, filename: string, token: string, attachedUrl: string) => ({ signedAt, mandateReference: mandate.mandateReference, scheme: mandate.scheme, recurring: mandate.recurring, accountHolder: values.accountHolder || '', company: values.company || '', ibanMasked: maskIban(values.iban || ''), ibanCountry: normalizeIban(values.iban || '').slice(0, 2), bic: values.bic || '', bank: values.bank || '', filename, token, attachedUrl }) /** * Standalone flow: the customer completed the /sepa portal. Renders, attaches, * marks the request signed, patches offer_conditions.sepa, logs an activity and * mails both sides. */ export const finalizeSepaMandate = async (event: any, req: SigningRequest, input: { debtor: SepaFilled; signerName: string; email: string }, signatureDataUrl: string, meta: { ip: string; userAgent: string }, acceptances: Array<{ key: string; text: string; acceptedAt: string }>): Promise => { const signedAt = new Date().toISOString() const lang: SepaLang = req.language === 'en' ? 'en' : 'de' const en = lang === 'en' const mandate: SepaMandateData = req.payload?.mandate if (!mandate) throw new Error('mandate payload missing') const blank = req.documents.find((d) => d.key === 'sepa') const rendered = await renderAndAttachSignedMandate(event, req, mandate, input.debtor, { imageDataUrl: signatureDataUrl, signedAt, signerName: input.signerName, ip: meta.ip }, blank?.sha256) const warnings = [...rendered.warnings] const signedDocuments: SigningDocumentMeta[] = [rendered.meta] const signerRecord = { contactName: input.signerName, company: input.debtor.company || req.company, email: input.email, accountHolder: input.debtor.accountHolder, ibanMasked: maskIban(input.debtor.iban || ''), bic: input.debtor.bic || '', bank: input.debtor.bank || '', signedAt, ip: meta.ip, userAgent: meta.userAgent, acceptances } markSigningRequestSigned(req.token, signerRecord, signedDocuments, signedAt) const config: any = useRuntimeConfig() const serviceToken: string = config.api?.idempieretoken || '' // offer_conditions.sepa summary (masked IBAN only) try { if (!(req.recordId > 0)) throw new Error('no record id') await patchOfferConditionsSection(event, serviceToken, req.source, req.recordId, 'sepa', mandateSummary(mandate, input.debtor, signedAt, rendered.meta.filename, req.token, rendered.attachedUrl)) } catch (err: any) { console.error('[SEPA] offer_conditions save skipped:', err?.message || err) warnings.push('conditions') } // activity try { const created = await logContactActivity(event, serviceToken, { source: req.source, recordId: req.recordId, type: 'EM', contactEmail: req.email, description: en ? `SEPA mandate granted online: ${input.debtor.accountHolder}` : `SEPA-Lastschriftmandat online erteilt: ${input.debtor.accountHolder}`, comments: en ? `${input.signerName} granted the SEPA ${mandate.scheme} direct debit mandate ${mandate.mandateReference} on ${formatDateTimeHuman(signedAt, 'en')} via the online portal.\nAccount holder: ${input.debtor.accountHolder} · IBAN: ${maskIban(input.debtor.iban || '')}${input.debtor.bic ? ` · BIC: ${input.debtor.bic}` : ''}${input.debtor.bank ? ` · Bank: ${input.debtor.bank}` : ''}\nE-mail: ${input.email}` : `${input.signerName} hat das SEPA-${mandate.scheme === 'B2B' ? 'Firmenlastschrift' : 'Basislastschrift'}mandat ${mandate.mandateReference} am ${formatDateTimeHuman(signedAt, 'de')} über das Online-Portal erteilt.\nKontoinhaber: ${input.debtor.accountHolder} · IBAN: ${maskIban(input.debtor.iban || '')}${input.debtor.bic ? ` · BIC: ${input.debtor.bic}` : ''}${input.debtor.bank ? ` · Bank: ${input.debtor.bank}` : ''}\nE-Mail: ${input.email}`, documents: [{ title: en ? 'SEPA mandate (signed, PDF)' : 'SEPA-Mandat (unterschrieben, PDF)', url: rendered.attachedUrl || sepaDocumentLink(req.token, 'sepa') }] }) if (!created) warnings.push('activity') } catch (err: any) { console.error('[SEPA] activity failed:', err?.message || err) warnings.push('activity') } // mails: copy to the customer, notice to LogYou const content = readSigningFile(req.token, rendered.meta.filename) const attachments = content ? [{ filename: rendered.meta.filename, content, contentType: 'application/pdf' }] : [] try { await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: input.email || req.email, subject: en ? `Your SEPA direct debit mandate ${mandate.mandateReference}` : `Ihr SEPA-Lastschriftmandat ${mandate.mandateReference}`, html: brandedEmail( en ? 'SEPA mandate granted' : 'SEPA-Mandat erteilt', en ? `

Dear ${escapeHtml(input.signerName)},

thank you — your SEPA direct debit mandate ${escapeHtml(mandate.mandateReference)} (account holder ${escapeHtml(input.debtor.accountHolder || '')}, IBAN ${escapeHtml(maskIban(input.debtor.iban || ''))}) was granted on ${escapeHtml(formatDateTimeHuman(signedAt, 'en'))}. The signed mandate is attached for your records.

` : `

Guten Tag ${escapeHtml(input.signerName)},

vielen Dank – Ihr SEPA-Lastschriftmandat ${escapeHtml(mandate.mandateReference)} (Kontoinhaber ${escapeHtml(input.debtor.accountHolder || '')}, IBAN ${escapeHtml(maskIban(input.debtor.iban || ''))}) wurde am ${escapeHtml(formatDateTimeHuman(signedAt, 'de'))} erteilt. Das unterschriebene Mandat finden Sie im Anhang.

`, en ? 'You can revoke the mandate in writing at any time.' : 'Sie können das Mandat jederzeit schriftlich widerrufen.' ), attachments }) } catch (err: any) { console.error('[SEPA] customer mail failed:', err?.message || err) warnings.push('mail') } try { await mailTransport().sendMail({ from: NOTIFY_EMAIL, to: NOTIFY_EMAIL, subject: `SEPA-Mandat erteilt: ${req.company} (${mandate.mandateReference})`, html: brandedEmail('SEPA-Mandat erteilt', `

${escapeHtml(req.company)} – ${escapeHtml(input.signerName)} hat das Mandat ${escapeHtml(mandate.mandateReference)} (${mandate.scheme}) am ${escapeHtml(formatDateTimeHuman(signedAt, 'de'))} erteilt.

Kontoinhaber: ${escapeHtml(input.debtor.accountHolder || '')}
IBAN: ${escapeHtml(formatIbanDisplay(input.debtor.iban || ''))}${input.debtor.bic ? `
BIC: ${escapeHtml(input.debtor.bic)}` : ''}${input.debtor.bank ? `
Bank: ${escapeHtml(input.debtor.bank)}` : ''}

Datensatz öffnen

`, `IP ${escapeHtml(meta.ip)}`), attachments }) } catch (err: any) { console.error('[SEPA] internal mail failed:', err?.message || err) } return { signedAt, signedDocuments, signedFilename: rendered.meta.filename, attachedUrl: rendered.attachedUrl, warnings } } export { sepaAcceptanceTexts }