import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import { authenticate } from "../shopify.server"; import db from "../db.server"; import { isValidAtVatId, isValidBic, isValidIban, normaliseIban, } from "../services/invoice/validation"; import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants"; import { deleteStoredLogo, storeUploadedLogo, } from "../services/invoice/logoCache.server"; import { RichTextEditor } from "../components/RichTextEditor"; import { DEFAULT_EMAIL_BODY_DE, DEFAULT_EMAIL_BODY_EN, DEFAULT_EMAIL_SUBJECT_DE, DEFAULT_EMAIL_SUBJECT_EN, } from "../services/invoice/emailTemplates"; interface SettingsFieldErrors { vatId?: string; iban?: string; bic?: string; smtpPort?: string; paymentTermDays?: string; invoiceSeed?: string; logo?: string; } /** * Sentinel value used in the SMTP password input. The real password is * never sent to the client; if the form posts back this exact value the * action treats it as "unchanged" and keeps whatever is already in the DB. */ const SMTP_PASSWORD_SENTINEL = "__unchanged__"; export const loader = async ({ request }: LoaderFunctionArgs) => { const { session } = await authenticate.admin(request); const settings = await db.shopSettings.upsert({ where: { shopDomain: session.shop }, update: {}, create: { shopDomain: session.shop }, }); let logoPreviewDataUrl: string | null = null; if (settings.logoUrl === STORED_LOGO_SENTINEL) { const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } }); if (cached) { logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`; } } else if (settings.logoUrl) { // External HTTPS URL — fine to display directly in the editor. logoPreviewDataUrl = settings.logoUrl; } // Never expose the SMTP password to the browser. We replace it with a // sentinel and the form action interprets that as "keep existing value". const safeSettings = { ...settings, smtpPassword: settings.smtpPassword ? SMTP_PASSWORD_SENTINEL : "", }; return { settings: safeSettings, logoPreviewDataUrl, smtpPasswordSentinel: SMTP_PASSWORD_SENTINEL }; }; export const action = async ({ request }: ActionFunctionArgs) => { const { session } = await authenticate.admin(request); const form = await request.formData(); const errors: SettingsFieldErrors = {}; const str = (k: string, fallback = "") => (form.get(k) ?? fallback).toString().trim(); const bool = (k: string) => form.get(k) === "on"; const intOrNull = (k: string): number | null => { const raw = form.get(k); if (raw === null || raw === "") return null; const n = parseInt(raw.toString(), 10); return Number.isFinite(n) ? n : null; }; const vatId = str("vatId").toUpperCase(); if (vatId && !isValidAtVatId(vatId)) { errors.vatId = "Expected format: ATU followed by 8 digits (e.g. ATU12345678)."; } const iban = normaliseIban(str("iban")); if (iban && !isValidIban(iban)) { errors.iban = "Invalid IBAN (failed checksum or unknown country length)."; } const bic = str("bic").toUpperCase(); if (bic && !isValidBic(bic)) { errors.bic = "Invalid BIC (8 or 11 alphanumeric characters)."; } const smtpPort = intOrNull("smtpPort"); if (smtpPort !== null && (smtpPort < 1 || smtpPort > 65535)) { errors.smtpPort = "Port must be between 1 and 65535."; } const paymentTermDays = intOrNull("paymentTermDays"); if (paymentTermDays !== null && (paymentTermDays < 0 || paymentTermDays > 365)) { errors.paymentTermDays = "Must be between 0 and 365."; } const invoiceSeed = intOrNull("invoiceSeed"); if (invoiceSeed !== null && invoiceSeed < 0) { errors.invoiceSeed = "Must be a non-negative number."; } // --- Logo handling -------------------------------------------------------- // The settings page allows three actions on the logo: // 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`. // 2. Remove the current logo (`removeLogo=on`). // 3. Provide an external URL via the `logoUrl` field. // If a file is uploaded it wins over a manually-entered URL. // Look up the existing logoUrl so we don't accidentally clear it when // the user just edited unrelated fields (the visible URL field is hidden // for stored uploads, so it submits empty in that case). const existing = await db.shopSettings.findUnique({ where: { shopDomain: session.shop }, select: { logoUrl: true }, }); const submittedLogoUrl = str("logoUrl"); const removeLogo = bool("removeLogo"); const logoFile = form.get("logoFile"); const hasUpload = logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0; let resolvedLogoUrl = submittedLogoUrl || existing?.logoUrl || ""; if (removeLogo && !hasUpload) { await deleteStoredLogo(session.shop); resolvedLogoUrl = ""; } else if (hasUpload) { const file = logoFile as File; const buf = Buffer.from(await file.arrayBuffer()); const stored = await storeUploadedLogo(session.shop, buf, file.type); if (!stored.ok) { errors.logo = stored.error ?? "Failed to store uploaded logo."; } else { resolvedLogoUrl = STORED_LOGO_SENTINEL; } } if (Object.keys(errors).length > 0) { return { ok: false, errors, savedAt: null as string | null }; } // Resolve SMTP password: the loader sends a sentinel instead of the real // value. If the form posts that sentinel back unchanged, keep whatever is // already in the DB; otherwise persist the new value (including the empty // string, which means "clear the password"). const submittedSmtpPassword = str("smtpPassword"); let nextSmtpPassword: string; if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) { const current = await db.shopSettings.findUnique({ where: { shopDomain: session.shop }, select: { smtpPassword: true }, }); nextSmtpPassword = current?.smtpPassword ?? ""; } else { nextSmtpPassword = submittedSmtpPassword; } const data = { companyName: str("companyName"), legalForm: str("legalForm"), ownerName: str("ownerName"), addressLine1: str("addressLine1"), addressLine2: str("addressLine2"), postalCode: str("postalCode"), city: str("city"), countryCode: str("countryCode", "AT").toUpperCase(), phone: str("phone"), email: str("email"), website: str("website"), vatId, taxNumber: str("taxNumber"), registrationNo: str("registrationNo"), registrationCourt: str("registrationCourt"), bankName: str("bankName"), iban, bic, giroCodeEnabled: bool("giroCodeEnabled"), numberingMode: str("numberingMode", "shopify_order_number"), invoicePrefix: str("invoicePrefix"), invoiceSeed: invoiceSeed ?? 1000, defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de", paymentTermDays: paymentTermDays ?? 14, footerNote: str("footerNote"), footerNoteEn: str("footerNoteEn"), kleinunternehmer: bool("kleinunternehmer"), logoUrl: resolvedLogoUrl, smtpHost: str("smtpHost"), smtpPort: smtpPort ?? 587, smtpSecure: bool("smtpSecure"), smtpUser: str("smtpUser"), smtpPassword: nextSmtpPassword, smtpFromName: str("smtpFromName"), smtpFromEmail: str("smtpFromEmail"), smtpReplyTo: str("smtpReplyTo"), emailSubjectDe: str("emailSubjectDe"), emailBodyHtmlDe: str("emailBodyHtmlDe"), emailSubjectEn: str("emailSubjectEn"), emailBodyHtmlEn: str("emailBodyHtmlEn"), autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"), autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"), }; await db.shopSettings.upsert({ where: { shopDomain: session.shop }, update: data, create: { shopDomain: session.shop, ...data }, }); return { ok: true, errors: {}, savedAt: new Date().toISOString() }; }; export default function SettingsRoute() { const { settings, logoPreviewDataUrl } = useLoaderData(); const actionData = useActionData(); const nav = useNavigation(); const isSaving = nav.state === "submitting"; const errors = actionData?.errors ?? {}; const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL; // Show the URL field only when not using a stored upload — keeps the UI // simpler and avoids the sentinel value leaking into the input. const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl; return ( Configure issuer details, bank, numbering, language and SMTP. These values are written into every PDF invoice this app generates and must match your legal records.
shopify_order_number reuses the Shopify order number (e.g. RE-1004). prefix_sequential {" "}allocates a strictly gapless number from an internal counter (legally safest under § 11 UStG). The logo is rendered in the top-right corner of every invoice PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or provide a publicly reachable HTTPS URL — uploads take precedence. {errors.logo && {errors.logo}} {logoPreviewDataUrl && ( Current logo: Current invoice logo )} Upload a new logo: These templates are used when sending the invoice PDF by email. Leave a field empty to fall back to the built-in default. These trigger directly from Shopify order webhooks — no Shopify Flow required (Flow is gated to Plus stores for custom apps). When an automation fires, the invoice is generated (if it doesn't already exist) and emailed to the customer using the SMTP and email-template settings above. "Wire-transfer" is detected via Shopify's OrderTransaction.manualPaymentGateway flag, so any merchant-defined manual payment method (Überweisung, Cash on Delivery, Money Order, …) qualifies. {actionData?.ok && ( Your changes are now live and will be used for the next invoice. )} {actionData && !actionData.ok && ( Some fields below need attention before settings can be saved. )} {isSaving ? Saving… : null} Save settings
); } const EMAIL_VARS = [ { token: "{{invoiceNumber}}" }, { token: "{{customerName}}" }, { token: "{{customerFirstName}}" }, { token: "{{orderName}}" }, { token: "{{totalGross}}" }, { token: "{{dueDate}}" }, { token: "{{companyName}}" }, { token: "{{ownerName}}" }, { token: "{{shopEmail}}" }, { token: "{{shopWebsite}}" }, ]; interface FieldProps { label: string; name: string; defaultValue?: string; type?: string; error?: string; helpText?: string; } function Field({ label, name, defaultValue = "", type = "text", error, helpText }: FieldProps) { // Polaris web-components don't expose a generic html `type` prop; we use // distinct tags only when needed (e.g. password). The form action parses // numeric strings server-side. if (type === "password") { return ( // s-password-field renders an obscured input ); } return ( ); } interface ToggleProps { label: string; name: string; checked: boolean } function Toggle({ label, name, checked }: ToggleProps) { return ( ); } interface SelectProps { label: string; name: string; defaultValue: string; options: { value: string; label: string }[]; } function Select({ label, name, defaultValue, options }: SelectProps) { return ( {options.map((o) => ( {o.label} ))} ); }