first version

This commit is contained in:
Gerhard Scheikl
2026-04-28 21:56:11 +02:00
parent 0f75dbaccb
commit 5b2aa5d62b
50 changed files with 5514 additions and 481 deletions
+360
View File
@@ -0,0 +1,360 @@
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";
interface SettingsFieldErrors {
vatId?: string;
iban?: string;
bic?: string;
smtpPort?: string;
paymentTermDays?: string;
invoiceSeed?: 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 },
});
return { settings };
};
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.";
}
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"),
kleinunternehmer: bool("kleinunternehmer"),
logoUrl: str("logoUrl"),
smtpHost: str("smtpHost"),
smtpPort: smtpPort ?? 587,
smtpSecure: bool("smtpSecure"),
smtpUser: str("smtpUser"),
smtpPassword: str("smtpPassword"),
smtpFromName: str("smtpFromName"),
smtpFromEmail: str("smtpFromEmail"),
smtpReplyTo: str("smtpReplyTo"),
};
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 } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const isSaving = nav.state === "submitting";
const errors = actionData?.errors ?? {};
return (
<s-page heading="Invoice settings">
<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>
{actionData?.ok && (
<s-banner tone="success">Settings saved.</s-banner>
)}
{actionData && !actionData.ok && (
<s-banner tone="critical">
Please fix the errors highlighted below.
</s-banner>
)}
<Form method="post">
<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 (optional)" name="footerNote" defaultValue={settings.footerNote} />
<Field label="Logo URL (PNG/JPG, served from Shopify Files or any HTTPS URL)" name="logoUrl" defaultValue={settings.logoUrl} />
</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-stack direction="inline" gap="base">
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
Save settings
</s-button>
</s-stack>
</Form>
</s-page>
);
}
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>
);
}