Files
linumiq-invoice/app/routes/app.settings.tsx
T
Gerhard Scheikl 0800d1160b feat(automations): auto-email invoice on wire-transfer placed and on fulfillment
- New ShopSettings fields: autoEmailOnWireTransferPlaced,
  autoEmailOnFulfilledNonWireTransfer, wireTransferGatewayNames.
- New Automations section in settings with two toggles + gateway list.
- orders/create webhook now fires automation 1 (wire-transfer placed).
- New orders/fulfilled webhook fires automation 2 (non-wire-transfer fulfilled).
- Shared helper services/invoice/automations.server.ts handles classification
  and idempotent generate+send (skips if already sent).
- Webhook subscription for orders/fulfilled added to all 3 app tomls.

This is the non-Plus fallback for Shopify Flow, whose custom-app actions
are gated to Plus stores only.
2026-05-09 20:21:41 +02:00

549 lines
21 KiB
TypeScript

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;
}
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;
}
return { settings, logoPreviewDataUrl };
};
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 };
}
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: str("smtpPassword"),
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"),
wireTransferGatewayNames: str("wireTransferGatewayNames"),
};
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<typeof loader>();
const actionData = useActionData<typeof action>();
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 (
<s-page heading="Invoice settings">
<s-section>
<s-paragraph>
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.
</s-paragraph>
</s-section>
<Form method="post" encType="multipart/form-data">
<s-section heading="Company">
<s-stack direction="block" gap="base">
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
<Field label="Legal form (e.g. e.U., GmbH)" name="legalForm" defaultValue={settings.legalForm} />
<Field label="Owner / managing director" name="ownerName" defaultValue={settings.ownerName} />
<Field label="Address line 1" name="addressLine1" defaultValue={settings.addressLine1} />
<Field label="Address line 2" name="addressLine2" defaultValue={settings.addressLine2} />
<s-stack direction="inline" gap="base">
<Field label="Postal code" name="postalCode" defaultValue={settings.postalCode} />
<Field label="City" name="city" defaultValue={settings.city} />
<Field label="Country (ISO)" name="countryCode" defaultValue={settings.countryCode} />
</s-stack>
<s-stack direction="inline" gap="base">
<Field label="Phone" name="phone" defaultValue={settings.phone} />
<Field label="E-mail" name="email" defaultValue={settings.email} />
<Field label="Website" name="website" defaultValue={settings.website} />
</s-stack>
</s-stack>
</s-section>
<s-section heading="Legal identifiers">
<s-stack direction="block" gap="base">
<Field
label="VAT ID (UID)"
name="vatId"
defaultValue={settings.vatId}
error={errors.vatId}
helpText="Austrian format: ATU followed by 8 digits."
/>
<Field label="Tax number (Steuernummer)" name="taxNumber" defaultValue={settings.taxNumber} />
<Field label="Commercial register no. (FN)" name="registrationNo" defaultValue={settings.registrationNo} />
<Field label="Commercial register court" name="registrationCourt" defaultValue={settings.registrationCourt} />
<Toggle
label="I am a Kleinunternehmer (no VAT charged, § 6 Abs. 1 Z 27 UStG)"
name="kleinunternehmer"
checked={settings.kleinunternehmer}
/>
</s-stack>
</s-section>
<s-section heading="Bank">
<s-stack direction="block" gap="base">
<Field label="Bank name" name="bankName" defaultValue={settings.bankName} />
<Field
label="IBAN"
name="iban"
defaultValue={settings.iban}
error={errors.iban}
/>
<Field
label="BIC"
name="bic"
defaultValue={settings.bic}
error={errors.bic}
/>
<Toggle
label="Render GiroCode (EPC QR) on unpaid invoices"
name="giroCodeEnabled"
checked={settings.giroCodeEnabled}
/>
</s-stack>
</s-section>
<s-section heading="Invoice numbering">
<s-stack direction="block" gap="base">
<s-paragraph>
<strong>shopify_order_number</strong> reuses the Shopify order
number (e.g. <code>RE-1004</code>). <strong>prefix_sequential</strong>
{" "}allocates a strictly gapless number from an internal counter
(legally safest under § 11 UStG).
</s-paragraph>
<Select
label="Numbering mode"
name="numberingMode"
defaultValue={settings.numberingMode}
options={[
{ value: "shopify_order_number", label: "Use Shopify order number" },
{ value: "prefix_sequential", label: "Sequential (gapless, app-managed)" },
]}
/>
<Field label="Invoice number prefix" name="invoicePrefix" defaultValue={settings.invoicePrefix} />
<Field
label="Sequential start (last issued; next = this + 1)"
name="invoiceSeed"
type="number"
defaultValue={String(settings.invoiceSeed)}
error={errors.invoiceSeed}
/>
<Field
label="Payment term (days)"
name="paymentTermDays"
type="number"
defaultValue={String(settings.paymentTermDays)}
error={errors.paymentTermDays}
/>
<Select
label="Default language"
name="defaultLanguage"
defaultValue={settings.defaultLanguage}
options={[
{ value: "de", label: "German (de)" },
{ value: "en", label: "English (en)" },
]}
/>
<Field label="Footer note (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
</s-stack>
</s-section>
<s-section heading="Logo">
<s-stack direction="block" gap="base">
<s-paragraph>
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.
</s-paragraph>
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
{logoPreviewDataUrl && (
<s-stack direction="block" gap="small">
<s-text>Current logo:</s-text>
<img
src={logoPreviewDataUrl}
alt="Current invoice logo"
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
/>
<s-checkbox name="removeLogo" label="Remove current logo on save" />
</s-stack>
)}
<s-text>Upload a new logo:</s-text>
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
<Field
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
name="logoUrl"
defaultValue={visibleLogoUrl}
/>
</s-stack>
</s-section>
<s-section heading="SMTP (used by the email Flow action)">
<s-stack direction="block" gap="base">
<s-stack direction="inline" gap="base">
<Field label="Host" name="smtpHost" defaultValue={settings.smtpHost} />
<Field
label="Port"
name="smtpPort"
type="number"
defaultValue={String(settings.smtpPort)}
error={errors.smtpPort}
/>
</s-stack>
<Toggle
label="Use TLS (SMTPS) — typically required for port 465"
name="smtpSecure"
checked={settings.smtpSecure}
/>
<Field label="Username" name="smtpUser" defaultValue={settings.smtpUser} />
<Field label="Password" name="smtpPassword" type="password" defaultValue={settings.smtpPassword} />
<s-stack direction="inline" gap="base">
<Field label="From name" name="smtpFromName" defaultValue={settings.smtpFromName} />
<Field label="From e-mail" name="smtpFromEmail" defaultValue={settings.smtpFromEmail} />
<Field label="Reply-to" name="smtpReplyTo" defaultValue={settings.smtpReplyTo} />
</s-stack>
</s-stack>
</s-section>
<s-section heading="Email templates">
<s-stack direction="block" gap="base">
<s-paragraph>
These templates are used when sending the invoice PDF by email.
Leave a field empty to fall back to the built-in default.
</s-paragraph>
<Field
label="Subject (German)"
name="emailSubjectDe"
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlDe"
label="Body (German)"
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
<Field
label="Subject (English)"
name="emailSubjectEn"
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
helpText="Variables like {{invoiceNumber}} are substituted at send time."
/>
<RichTextEditor
name="emailBodyHtmlEn"
label="Body (English)"
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
</s-stack>
</s-section>
<s-section heading="Automations">
<s-stack direction="block" gap="base">
<s-paragraph>
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. A copy is recorded in the email
log; failures are logged server-side.
</s-paragraph>
<Toggle
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
name="autoEmailOnWireTransferPlaced"
checked={settings.autoEmailOnWireTransferPlaced}
/>
<Toggle
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
name="autoEmailOnFulfilledNonWireTransfer"
checked={settings.autoEmailOnFulfilledNonWireTransfer}
/>
<Field
label="Wire-transfer payment gateway names (comma-separated, case-insensitive substring match)"
name="wireTransferGatewayNames"
defaultValue={settings.wireTransferGatewayNames}
helpText='Used to classify which orders count as "wire transfer". Leave empty to use the default: manual, Überweisung, Wire Transfer, Bank Transfer, Vorkasse, Bank Deposit.'
/>
</s-stack>
</s-section>
<s-section>
<s-stack direction="block" gap="base">
{actionData?.ok && (
<s-banner tone="success" heading="Settings saved">
Your changes are now live and will be used for the next invoice.
</s-banner>
)}
{actionData && !actionData.ok && (
<s-banner tone="critical" heading="Please fix the highlighted errors">
Some fields below need attention before settings can be saved.
</s-banner>
)}
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
{isSaving ? <s-text tone="neutral">Saving…</s-text> : null}
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
</s-stack>
</s-stack>
</s-section>
</Form>
</s-page>
);
}
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
<s-password-field
label={label}
name={name}
value={defaultValue}
{...(error ? { error } : {})}
{...(helpText ? { details: helpText } : {})}
/>
);
}
return (
<s-text-field
label={label}
name={name}
value={defaultValue}
{...(error ? { error } : {})}
{...(helpText ? { details: helpText } : {})}
/>
);
}
interface ToggleProps { label: string; name: string; checked: boolean }
function Toggle({ label, name, checked }: ToggleProps) {
return (
<s-checkbox
name={name}
label={label}
{...(checked ? { checked: true } : {})}
/>
);
}
interface SelectProps {
label: string;
name: string;
defaultValue: string;
options: { value: string; label: string }[];
}
function Select({ label, name, defaultValue, options }: SelectProps) {
return (
<s-select label={label} name={name} value={defaultValue}>
{options.map((o) => (
<s-option key={o.value} value={o.value}>
{o.label}
</s-option>
))}
</s-select>
);
}