= 2) { $first = $value[0]; $last = $value[strlen($value) - 1]; if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { $value = substr($value, 1, -1); } } $result[$key] = $value; } return $result; } function expand_env_values(array $env): array { $expanded = $env; $pattern = '/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/'; foreach ($expanded as $key => $value) { $expanded[$key] = preg_replace_callback( $pattern, static function (array $matches) use (&$expanded): string { $lookup = $matches[1]; return (string) ($expanded[$lookup] ?? getenv($lookup) ?: ''); }, (string) $value ) ?? (string) $value; } return $expanded; } function env_value(string $key, array $localEnv, string $default = ''): string { $runtime = getenv($key); if ($runtime !== false && $runtime !== '') { return $runtime; } if (isset($localEnv[$key]) && $localEnv[$key] !== '') { return (string) $localEnv[$key]; } return $default; } function parse_number(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_int($value) || is_float($value)) { return (float) $value; } if (!is_string($value)) { return null; } $normalized = str_replace(["\u{00A0}", ' '], '', trim($value)); $normalized = str_replace(',', '.', $normalized); if (!is_numeric($normalized)) { return null; } return (float) $normalized; } function lookup_method_id(PDO $pdo, string $table, ?string $code): ?int { if ($code === null || $code === '') { return null; } $stmt = $pdo->prepare("SELECT id FROM public.{$table} WHERE code = :code LIMIT 1"); $stmt->execute([':code' => $code]); $id = $stmt->fetchColumn(); return $id === false ? null : (int) $id; } function map_payment_code(string $input): ?string { $v = strtolower(trim($input)); if ($v === '') { return null; } if (str_contains($v, 'twint')) { return 'twint'; } if (str_contains($v, 'bank') || str_contains($v, 'vorauskasse') || str_contains($v, 'ueberweisung')) { return 'bank_transfer'; } if (str_contains($v, 'kredit') || str_contains($v, 'debit') || str_contains($v, 'card')) { return 'card'; } return null; } function map_shipping_code(string $input): ?string { $v = strtolower(trim($input)); if ($v === '') { return null; } if (str_contains($v, 'abholung') || str_contains($v, 'pickup')) { return 'pickup'; } if (str_contains($v, 'post') || str_contains($v, 'versand')) { return 'post_standard'; } return null; } function connect_database(array $localEnv): PDO { $databaseUrl = env_value('DATABASE_URL', $localEnv); if ($databaseUrl !== '') { $parts = parse_url($databaseUrl); if ($parts !== false && ($parts['scheme'] ?? '') === 'postgresql') { $host = (string) ($parts['host'] ?? ''); $port = (string) ($parts['port'] ?? '5432'); $dbName = ltrim((string) ($parts['path'] ?? ''), '/'); $user = (string) ($parts['user'] ?? ''); $pass = (string) ($parts['pass'] ?? ''); if ($host !== '' && $dbName !== '' && $user !== '') { $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; return new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); } } } $host = env_value('DB_HOST', $localEnv); $port = env_value('DB_PORT', $localEnv, '5432'); $dbName = env_value('DB_NAME', $localEnv); $user = env_value('DB_USER', $localEnv); $pass = env_value('DB_PASSWORD', $localEnv); if ($host === '' || $dbName === '' || $user === '') { throw new RuntimeException('Missing DB configuration'); } $dsn = "pgsql:host={$host};port={$port};dbname={$dbName}"; return new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); } function ensure_required_tables_exist(PDO $pdo): void { $required = [ 'party', 'address', 'sales_order', 'sales_order_line', 'payment_method', 'shipping_method', ]; $stmt = $pdo->query( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" ); $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); $existing = array_map('strval', $rows ?: []); $missing = array_values(array_diff($required, $existing)); if ($missing !== []) { throw new RuntimeException( 'DB schema not initialized. Missing tables: ' . implode(', ', $missing) ); } } function derive_label_webhook_url(array $localEnv): string { $explicit = env_value('N8N_LABEL_WEBHOOK_URL', $localEnv); if ($explicit !== '') { return $explicit; } $legacy = env_value('N8N_OUTBOUND_URL_ADRESSE', $localEnv); if ($legacy !== '' && str_contains(strtolower($legacy), 'adressetikette')) { return $legacy; } $base = env_value('N8N_BASE_URL', $localEnv); if ($base === '') { return ''; } $root = preg_replace('#/api/v1/?$#', '', rtrim($base, '/')); if (!is_string($root) || $root === '') { return ''; } return $root . '/webhook/naurua_erp_adressetikette'; } function post_json(string $url, array $payload, array $headers = [], int $timeoutSeconds = 15): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($body === false) { return ['ok' => false, 'status' => 0, 'body' => '', 'error' => 'Could not encode payload']; } $headerLines = ['Content-Type: application/json']; foreach ($headers as $name => $value) { if ($name === '' || $value === '') { continue; } $headerLines[] = $name . ': ' . $value; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headerLines), 'content' => $body, 'timeout' => $timeoutSeconds, 'ignore_errors' => true, ], ]); $responseBody = @file_get_contents($url, false, $context); $responseHeaders = $http_response_header ?? []; $status = 0; if (isset($responseHeaders[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $responseHeaders[0], $m) === 1) { $status = (int) $m[1]; } if ($responseBody === false) { $responseBody = ''; } return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => substr($responseBody, 0, 500), 'error' => ($status === 0) ? 'Request failed or timed out' : '', ]; } function trigger_shipping_label_flow(array $order, array $localEnv): array { $url = derive_label_webhook_url($localEnv); if ($url === '') { return [ 'enabled' => false, 'ok' => false, 'message' => 'Label webhook URL not configured', ]; } $payload = [ 'BestellungNr' => (string) ($order['BestellungNr'] ?? ''), 'Vorname_LfAdr' => (string) ($order['Vorname_LfAdr'] ?? $order['Vorname'] ?? ''), 'Nachname_LfAdr' => (string) ($order['Nachname_LfAdr'] ?? $order['Nachname'] ?? ''), 'Strasse_LfAdr' => (string) ($order['Strasse_LfAdr'] ?? $order['Strasse'] ?? ''), 'Hausnummer_LfAdr' => (string) ($order['Hausnummer_LfAdr'] ?? $order['Hausnummer'] ?? ''), 'PLZ_LfAdr' => (string) ($order['PLZ_LfAdr'] ?? $order['PLZ'] ?? ''), 'Stadt_LfAdr' => (string) ($order['Stadt_LfAdr'] ?? $order['Stadt'] ?? ''), 'Land_LfAdr' => (string) ($order['Land_LfAdr'] ?? $order['Land'] ?? ''), // Also include flat keys to be compatible with both mapping and direct template usage. 'Vorname' => (string) ($order['Vorname_LfAdr'] ?? $order['Vorname'] ?? ''), 'Nachname' => (string) ($order['Nachname_LfAdr'] ?? $order['Nachname'] ?? ''), 'Strasse' => (string) ($order['Strasse_LfAdr'] ?? $order['Strasse'] ?? ''), 'Hausnummer' => (string) ($order['Hausnummer_LfAdr'] ?? $order['Hausnummer'] ?? ''), 'PLZ' => (string) ($order['PLZ_LfAdr'] ?? $order['PLZ'] ?? ''), 'Stadt' => (string) ($order['Stadt_LfAdr'] ?? $order['Stadt'] ?? ''), 'Land' => (string) ($order['Land_LfAdr'] ?? $order['Land'] ?? ''), ]; $headers = []; $secret = env_value('N8N_WEBHOOK_SECRET', $localEnv); if ($secret !== '') { $headers['X-Webhook-Secret'] = $secret; $headers['X-N8N-Secret'] = $secret; $headers['X-API-Key'] = $secret; $headers['Authorization'] = 'Bearer ' . $secret; } $result = post_json($url, $payload, $headers, 20); return [ 'enabled' => true, 'ok' => $result['ok'], 'status' => $result['status'], 'url' => $url, 'message' => $result['ok'] ? 'Label flow triggered' : ($result['error'] !== '' ? $result['error'] : 'Label flow returned non-2xx'), 'responseBody' => $result['body'], ]; } function find_or_create_party(PDO $pdo, array $data): int { $email = trim((string) ($data['EmailKunde'] ?? '')); $firstName = trim((string) ($data['Vorname_RgAdr'] ?? '')); $lastName = trim((string) ($data['Nachname_RgAdr'] ?? '')); $name = trim($firstName . ' ' . $lastName); if ($name === '') { $name = 'Online-Shop Kunde'; } if ($email !== '') { $findStmt = $pdo->prepare('SELECT id FROM public.party WHERE lower(email) = lower(:email) ORDER BY id ASC LIMIT 1'); $findStmt->execute([':email' => $email]); $existing = $findStmt->fetchColumn(); if ($existing !== false) { $partyId = (int) $existing; $updateStmt = $pdo->prepare('UPDATE public.party SET name = :name, updated_at = NOW() WHERE id = :id'); $updateStmt->execute([':id' => $partyId, ':name' => $name]); return $partyId; } } $insertStmt = $pdo->prepare( 'INSERT INTO public.party (type, name, email, status, created_at, updated_at) VALUES (\'customer\', :name, :email, \'active\', NOW(), NOW()) RETURNING id' ); $insertStmt->execute([ ':name' => $name, ':email' => $email !== '' ? $email : null, ]); $id = $insertStmt->fetchColumn(); if ($id === false) { throw new RuntimeException('Could not create party'); } return (int) $id; } function upsert_addresses(PDO $pdo, int $partyId, array $data): void { $delete = $pdo->prepare('DELETE FROM public.address WHERE party_id = :party_id AND type IN (\'billing\', \'shipping\')'); $delete->execute([':party_id' => $partyId]); $insert = $pdo->prepare( 'INSERT INTO public.address ( party_id, type, first_name, last_name, street, house_number, zip, city, state_code, country_name, raw_payload, created_at, updated_at ) VALUES ( :party_id, :type, :first_name, :last_name, :street, :house_number, :zip, :city, :state_code, :country_name, :raw_payload::jsonb, NOW(), NOW() )' ); $insert->execute([ ':party_id' => $partyId, ':type' => 'billing', ':first_name' => trim((string) ($data['Vorname_RgAdr'] ?? '')), ':last_name' => trim((string) ($data['Nachname_RgAdr'] ?? '')), ':street' => trim((string) ($data['Strasse_RgAdr'] ?? '')), ':house_number' => trim((string) ($data['Hausnummer_RgAdr'] ?? '')), ':zip' => trim((string) ($data['PLZ_RgAdr'] ?? '')), ':city' => trim((string) ($data['Stadt_RgAdr'] ?? '')), ':state_code' => trim((string) ($data['Bundesland_RgAdr'] ?? '')), ':country_name' => trim((string) ($data['Land_RgAdr'] ?? '')), ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); $insert->execute([ ':party_id' => $partyId, ':type' => 'shipping', ':first_name' => trim((string) ($data['Vorname_LfAdr'] ?? '')), ':last_name' => trim((string) ($data['Nachname_LfAdr'] ?? '')), ':street' => trim((string) ($data['Strasse_LfAdr'] ?? '')), ':house_number' => trim((string) ($data['Hausnummer_LfAdr'] ?? '')), ':zip' => trim((string) ($data['PLZ_LfAdr'] ?? '')), ':city' => trim((string) ($data['Stadt_LfAdr'] ?? '')), ':state_code' => trim((string) ($data['Bundesland_LfAdr'] ?? '')), ':country_name' => trim((string) ($data['Land_LfAdr'] ?? '')), ':raw_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { json_response(405, ['ok' => false, 'error' => 'Method Not Allowed']); } $env = parse_env_file(__DIR__ . '/.env'); $env = array_merge($env, parse_env_file(dirname(__DIR__) . '/.env')); $env = expand_env_values($env); $expectedSecret = env_value('N8N_WEBHOOK_SECRET', $env); $providedSecret = (string) ($_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? ''); if ($expectedSecret === '') { json_response(500, ['ok' => false, 'error' => 'N8N_WEBHOOK_SECRET not configured']); } if ($providedSecret === '' || !hash_equals($expectedSecret, $providedSecret)) { json_response(401, ['ok' => false, 'error' => 'Unauthorized']); } $rawPayload = file_get_contents('php://input'); if ($rawPayload === false || trim($rawPayload) === '') { json_response(400, ['ok' => false, 'error' => 'Empty payload']); } try { $data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { json_response(400, ['ok' => false, 'error' => 'Invalid JSON payload']); } if (!is_array($data)) { json_response(400, ['ok' => false, 'error' => 'JSON object expected']); } // n8n can send either a JSON object or a single-item array with the order object. if (array_is_list($data)) { if (!isset($data[0]) || !is_array($data[0])) { json_response(400, ['ok' => false, 'error' => 'Array payload must contain one order object']); } $data = $data[0]; } $externalRef = trim((string) ($data['BestellungNr'] ?? '')); if ($externalRef === '') { json_response(422, ['ok' => false, 'error' => 'BestellungNr is required']); } $lineItems = $data['lineItems'] ?? []; if (!is_array($lineItems)) { $lineItems = []; } try { $pdo = connect_database($env); ensure_required_tables_exist($pdo); $pdo->beginTransaction(); $partyId = find_or_create_party($pdo, $data); upsert_addresses($pdo, $partyId, $data); $paymentMethodId = lookup_method_id($pdo, 'payment_method', map_payment_code((string) ($data['Zahlungsmethode'] ?? ''))); $shippingMethodId = lookup_method_id($pdo, 'shipping_method', map_shipping_code((string) ($data['Liefermethode'] ?? ''))); $orderStmt = $pdo->prepare( 'INSERT INTO public.sales_order ( external_ref, party_id, order_source, order_status, payment_status, payment_method_id, shipping_method_id, amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, webhook_payload, imported_at, created_at, updated_at ) VALUES ( :external_ref, :party_id, \'wix\', \'imported\', \'paid\', :payment_method_id, :shipping_method_id, :amount_net, :amount_shipping, :amount_tax, :amount_discount, :total_amount, \'CHF\', :webhook_payload::jsonb, NOW(), NOW(), NOW() ) ON CONFLICT (external_ref) DO UPDATE SET party_id = EXCLUDED.party_id, order_source = EXCLUDED.order_source, order_status = EXCLUDED.order_status, payment_status = EXCLUDED.payment_status, payment_method_id = EXCLUDED.payment_method_id, shipping_method_id = EXCLUDED.shipping_method_id, amount_net = EXCLUDED.amount_net, amount_shipping = EXCLUDED.amount_shipping, amount_tax = EXCLUDED.amount_tax, amount_discount = EXCLUDED.amount_discount, total_amount = EXCLUDED.total_amount, currency = EXCLUDED.currency, webhook_payload = EXCLUDED.webhook_payload, imported_at = NOW(), updated_at = NOW() RETURNING id' ); $orderStmt->execute([ ':external_ref' => $externalRef, ':party_id' => $partyId, ':payment_method_id' => $paymentMethodId, ':shipping_method_id' => $shippingMethodId, ':amount_net' => parse_number($data['Netto'] ?? null), ':amount_shipping' => parse_number($data['Versandkosten'] ?? null), ':amount_tax' => parse_number($data['Mehrwertsteuer'] ?? null), ':amount_discount' => parse_number($data['Rabatt'] ?? null), ':total_amount' => parse_number($data['Gesamtsumme'] ?? null), ':webhook_payload' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); $orderId = $orderStmt->fetchColumn(); if ($orderId === false) { throw new RuntimeException('Could not upsert order'); } $orderId = (int) $orderId; $deleteLines = $pdo->prepare('DELETE FROM public.sales_order_line WHERE sales_order_id = :sales_order_id'); $deleteLines->execute([':sales_order_id' => $orderId]); $lineInsert = $pdo->prepare( 'INSERT INTO public.sales_order_line ( sales_order_id, line_no, sellable_item_id, raw_external_article_number, raw_external_title, qty, unit_price, line_total, created_at, updated_at ) VALUES ( :sales_order_id, :line_no, NULL, :article_number, :title, :qty, :unit_price, :line_total, NOW(), NOW() )' ); $insertedLines = 0; foreach ($lineItems as $index => $lineItem) { if (!is_array($lineItem)) { continue; } $qty = parse_number($lineItem['artikelanzahl'] ?? null); if ($qty === null || $qty <= 0) { continue; } $unitPrice = parse_number($lineItem['preisEinheit'] ?? null); $lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null; $lineInsert->execute([ ':sales_order_id' => $orderId, ':line_no' => $index + 1, ':article_number' => trim((string) ($lineItem['artikelnummer'] ?? '')), ':title' => trim((string) ($lineItem['titel'] ?? '')), ':qty' => $qty, ':unit_price' => $unitPrice, ':line_total' => $lineTotal, ]); $insertedLines++; } $pdo->commit(); $labelTrigger = trigger_shipping_label_flow($data, $env); json_response(200, [ 'ok' => true, 'orderId' => $orderId, 'externalRef' => $externalRef, 'lineItemsImported' => $insertedLines, 'labelTrigger' => $labelTrigger, ]); } catch (Throwable $e) { if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) { $pdo->rollBack(); } json_response(500, [ 'ok' => false, 'error' => 'Order import failed', 'detail' => $e->getMessage(), ]); }