// ═══════════════════════════════════════════════════════════════════════════════════════ // ERP-ONBOARDING — FEATURE-FLAGGED MODULE, OFF BY DEFAULT // ───────────────────────────────────────────────────────────────────────────────────── // Server-to-server call into the Blut24/LogShip ERP once a registration has been // confirmed. It performs exactly two POSTs against the existing ERP routes: // // 1. POST {ERP_ONBOARDING_URL}/api/admin/merchant-onboarding/process // → creates C_BPartner, C_Location, C_BPartner_Location, AD_Org, AD_User, // M_Warehouse, M_Locator, AD_Role, AD_User_Roles and cleans up org access. // Returns { status, report, ctx: { bpartnerId, newOrgId, userId, … } }. // // 2. POST {ERP_ONBOARDING_URL}/api/admin/merchant-onboarding/send-credentials // with resetPassword: true → generates + persists a fresh password and mails // the credentials to the contact address. // // ACTIVATION: set BOTH env vars. While either is missing this module logs a single // line and no-ops — the ERP-side authentication gate for machine-to-machine calls // does not exist yet, so nothing here may ever throw into the confirmation flow. // // ERP_ONBOARDING_URL=https://app.blut24.com (no trailing slash) // ERP_SERVICE_TOKEN= // // Optional: ERP_ORGANIZATION_ID (operator org the records are created under — the ERP // route reads it from the logship_organization_id cookie, which we forward), // ERP_COUNTRY_ID, ERP_PRICE_LIST_ID, ERP_GROUP_CLINIC, ERP_GROUP_COURIER. // ═══════════════════════════════════════════════════════════════════════════════════════ import crypto from 'node:crypto' import type { RegistrationRow } from './registrationDb' import { isCourierType, registrationTypeLabelDe } from './registrationTypes' export type ErpResult = { enabled: boolean ok: boolean status: 'disabled' | 'success' | 'error' message: string credentialsSent: boolean ctx: any report: any } const env = (key: string, configKey: string): string => { const fromEnv = process.env[key] if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim() const value = (useRuntimeConfig() as any)[configKey] return value ? String(value).trim() : '' } export const erpSettings = () => ({ url: env('ERP_ONBOARDING_URL', 'erpOnboardingUrl').replace(/\/+$/, ''), token: env('ERP_SERVICE_TOKEN', 'erpServiceToken'), organizationId: env('ERP_ORGANIZATION_ID', 'erpOrganizationId'), countryId: env('ERP_COUNTRY_ID', 'erpCountryId'), priceListId: env('ERP_PRICE_LIST_ID', 'erpPriceListId'), groupClinic: env('ERP_GROUP_CLINIC', 'erpGroupClinic'), groupCourier: env('ERP_GROUP_COURIER', 'erpGroupCourier'), }) export const isErpOnboardingEnabled = (): boolean => { const s = erpSettings() return !!(s.url && s.token) } /** ASCII slug used as AD_Org.Value / AD_User.Value (the login name). */ export const buildShortName = (orgName: string, stoid: string | null | undefined): string => { const slug = String(orgName || '') .toLowerCase() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 24) .replace(/-+$/g, '') const suffix = stoid ? String(stoid) : crypto.randomBytes(3).toString('hex') return `${slug || 'blut24'}-${suffix}` } const randomPassword = (len = 12): string => { const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789' let out = '' for (let i = 0; i < len; i++) out += charset[crypto.randomInt(0, charset.length)] return out } /** Splits "Dr. Anna Muster" into the ERP's ceoName field (used verbatim). */ const contactName = (reg: RegistrationRow) => String(reg.contact_name || '').trim() const post = async (url: string, body: any, timeoutMs = 45000): Promise => { const s = erpSettings() const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) try { return await $fetch(url, { method: 'POST', body, signal: controller.signal, headers: { 'Content-Type': 'application/json', // The token MUST travel as the `logship_it` cookie. The ERP resolves its caller // through getTokenHelper(), which reads that cookie and nothing else — it never // looks at an Authorization header. The one middleware that does translate a // Bearer token into that cookie (server/middleware/00.app-auth.ts) fires only on // `X-Logship-App: 1`, the bundled Capacitor app's marker, which this is not. // Sent as a Bearer header alone the token was silently ignored and onboarding // failed on authentication instead of creating the clinic. // // This is a server-to-server call from the Nitro route — the token is read from // private runtime config and never reaches a browser. Cookie: [ `logship_it=${s.token}`, ...(s.organizationId ? [`logship_organization_id=${s.organizationId}`] : []), ].join('; '), // Kept for request attribution in the ERP access log; carries no authority. 'X-Blut24-Service': '1', }, }) } finally { clearTimeout(timer) } } /** Body for POST /api/admin/merchant-onboarding/process. */ export const buildProcessBody = (reg: RegistrationRow) => { const s = erpSettings() // READ PATH: normalised, so a row written under the retired three-type flow still lands // in the clinic group. The type selects nothing but the partner GROUP (the coarse // persona) — whether the clinic supplies, receives or does both is set in the ERP itself // at first sign-in, via C_BPartner.IsVendor / IsCustomer, which may both be true. const isCourier = isCourierType(reg.type) const body: any = { companyName: reg.org_name, ceoName: contactName(reg), shortName: buildShortName(reg.org_name, reg.stoid), street: reg.street, zip: reg.plz, city: reg.city, companyEmail: reg.contact_email, phoneWired: reg.contact_phone, password: randomPassword(12), isFulfillmentCustomer: false, } if (s.countryId) body.countryId = Number(s.countryId) if (s.priceListId) body.priceListId = Number(s.priceListId) const groupId = isCourier ? s.groupCourier : s.groupClinic if (groupId) body.partnerGroupId = Number(groupId) return body } /** Body for POST /api/admin/merchant-onboarding/send-credentials. */ export const buildCredentialsBody = (reg: RegistrationRow, ctx: any, username: string) => ({ toEmail: reg.contact_email, username, userId: ctx?.userId, bpartnerId: ctx?.bpartnerId, name: contactName(reg) || reg.org_name, description: contactName(reg), resetPassword: true, }) const disabled = (message: string): ErpResult => ({ enabled: false, ok: false, status: 'disabled', message, credentialsSent: false, ctx: null, report: null, }) /** * Runs the two-step ERP onboarding. NEVER throws: any failure is reported back so the * confirmation page still succeeds and operations can retry manually. */ export const runErpOnboarding = async (reg: RegistrationRow): Promise => { const s = erpSettings() if (!s.url || !s.token) { const message = 'ERP-Onboarding übersprungen (ERP_ONBOARDING_URL / ERP_SERVICE_TOKEN nicht gesetzt).' console.info(`[blut24][erp] ${message} Registrierung #${reg.id} "${reg.org_name}" ` + `(${registrationTypeLabelDe(reg.type)}) wartet auf manuelle Anlage.`) return disabled(message) } const processBody = buildProcessBody(reg) let processRes: any = null try { processRes = await post(`${s.url}/api/admin/merchant-onboarding/process`, processBody) } catch (err: any) { const message = err?.data?.message || err?.message || String(err) console.error('[blut24][erp] merchant-onboarding/process fehlgeschlagen:', message) return { enabled: true, ok: false, status: 'error', message, credentialsSent: false, ctx: null, report: null } } if (Number(processRes?.status) !== 200) { const message = processRes?.message || 'ERP-Onboarding wurde abgebrochen.' console.error('[blut24][erp] merchant-onboarding/process meldet Fehler:', message) return { enabled: true, ok: false, status: 'error', message, credentialsSent: false, ctx: processRes?.ctx ?? null, report: processRes?.report ?? null, } } let credentialsSent = false let message = '' try { const credRes: any = await post( `${s.url}/api/admin/merchant-onboarding/send-credentials`, buildCredentialsBody(reg, processRes?.ctx, processBody.shortName), ) credentialsSent = Number(credRes?.status ?? 200) === 200 if (!credentialsSent) message = credRes?.message || 'Zugangsdaten konnten nicht versendet werden.' } catch (err: any) { message = err?.data?.message || err?.message || String(err) console.error('[blut24][erp] send-credentials fehlgeschlagen:', message) } return { enabled: true, ok: true, status: 'success', message, credentialsSent, ctx: processRes?.ctx ?? null, report: processRes?.report ?? null, } }