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
+52
View File
@@ -0,0 +1,52 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { generateInvoice } from "../services/invoice/generateInvoice.server";
/**
* Flow action endpoint: "Generate invoice for order".
*
* Expected payload (verified by `authenticate.flow`):
* {
* "shop_id": "...",
* "shopify_domain": "...",
* "properties": { "order_id": "gid://shopify/Order/..." },
* ...
* }
*
* Returns 200 on success (Flow treats any 2xx as success) and 4xx/5xx
* with a JSON body describing the failure.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { session, admin, payload } = await authenticate.flow(request);
const orderId = extractOrderId(payload);
if (!orderId) {
return Response.json(
{ ok: false, error: "Missing 'order_id' in Flow payload properties." },
{ status: 400 },
);
}
try {
const result = await generateInvoice({
shopDomain: session.shop,
admin,
orderId,
});
return { ok: true, ...result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("flow.generate-invoice failed:", err);
return Response.json({ ok: false, error: message }, { status: 422 });
}
};
function extractOrderId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const props = (payload as { properties?: Record<string, unknown> }).properties;
if (!props || typeof props !== "object") return null;
const raw = props["order_id"];
if (typeof raw === "string" && raw.length > 0) return raw;
if (typeof raw === "number") return String(raw);
return null;
}
@@ -0,0 +1,90 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { generateInvoice } from "../services/invoice/generateInvoice.server";
import { sendInvoiceEmail } from "../services/invoice/email.server";
/**
* Flow action endpoint: "Send invoice email to customer".
*
* Generates the invoice if missing, then sends it via the shop's configured
* SMTP. Marks the invoice as `sent`, locking it from in-place regeneration.
*
* Expected payload properties:
* - order_id (required)
* - recipient_email_override (optional)
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { session, admin, payload } = await authenticate.flow(request);
const orderId = extractOrderId(payload);
if (!orderId) {
return Response.json(
{ ok: false, error: "Missing 'order_id' in Flow payload properties." },
{ status: 400 },
);
}
const recipientOverride = extractProp(payload, "recipient_email_override");
try {
// Make sure an invoice exists.
const orderGid = orderId.startsWith("gid://")
? orderId
: `gid://shopify/Order/${orderId}`;
let invoice = await db.invoice.findFirst({
where: {
shopDomain: session.shop,
orderId: orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (!invoice) {
const generated = await generateInvoice({
shopDomain: session.shop,
admin,
orderId,
});
invoice = await db.invoice.findUnique({ where: { id: generated.invoiceId } });
}
if (!invoice) throw new Error("Failed to materialise an invoice for this order.");
const result = await sendInvoiceEmail({
shopDomain: session.shop,
invoiceId: invoice.id,
toAddress: recipientOverride || undefined,
});
if (!result.ok) {
return Response.json(
{ ok: false, error: result.errorMessage ?? "Email send failed." },
{ status: 422 },
);
}
return { ok: true, invoiceNumber: invoice.invoiceNumber, toAddress: result.toAddress };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("flow.send-invoice-email failed:", err);
return Response.json({ ok: false, error: message }, { status: 422 });
}
};
function extractOrderId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const props = (payload as { properties?: Record<string, unknown> }).properties;
if (!props || typeof props !== "object") return null;
const raw = props["order_id"];
if (typeof raw === "string" && raw.length > 0) return raw;
if (typeof raw === "number") return String(raw);
return null;
}
function extractProp(payload: unknown, key: string): string | null {
if (!payload || typeof payload !== "object") return null;
const props = (payload as { properties?: Record<string, unknown> }).properties;
if (!props || typeof props !== "object") return null;
const raw = props[key];
if (typeof raw === "string" && raw.length > 0) return raw;
return null;
}
+102
View File
@@ -0,0 +1,102 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { generateInvoice } from "../services/invoice/generateInvoice.server";
import { cancelAndReissue } from "../services/invoice/cancelAndReissue.server";
/**
* GET /api/orders/:orderId/invoice → returns latest invoice metadata + history
* POST /api/orders/:orderId/invoice → generates (or regenerates) the invoice
*
* `orderId` may be a numeric Shopify order id or a full GID; the generator
* normalises it.
*/
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const orderId = requireOrderId(params);
const orderGid = orderId.startsWith("gid://")
? orderId
: `gid://shopify/Order/${orderId}`;
const invoices = await db.invoice.findMany({
where: { shopDomain: session.shop, orderId: orderGid },
orderBy: [{ issuedAt: "desc" }],
});
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
return {
latest: latest ? serialise(latest) : null,
history: invoices.map(serialise),
};
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const orderId = requireOrderId(params);
const url = new URL(request.url);
let op = url.searchParams.get("action");
if (!op) {
// Also accept the action from the form body (used by the in-app fetcher).
const ct = request.headers.get("content-type") || "";
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
const form = await request.formData();
op = (form.get("action") as string | null) ?? null;
}
}
op = op ?? "generate";
try {
if (op === "cancel_reissue") {
const result = await cancelAndReissue({
shopDomain: session.shop,
admin,
orderId,
});
return { ok: true, op, ...result };
}
const result = await generateInvoice({
shopDomain: session.shop,
admin,
orderId,
});
return { ok: true, op: "generate", ...result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("invoice action failed:", err);
return Response.json({ ok: false, error: message }, { status: 400 });
}
};
function requireOrderId(params: { orderId?: string }): string {
const id = params.orderId;
if (!id) throw new Response("orderId is required", { status: 400 });
return id;
}
function serialise(invoice: {
id: string;
invoiceNumber: string;
version: number;
kind: string;
pdfUrl: string;
status: string;
sentAt: Date | null;
cancelledAt: Date | null;
issuedAt: Date;
}) {
return {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
version: invoice.version,
kind: invoice.kind,
pdfUrl: invoice.pdfUrl,
status: invoice.status,
sentAt: invoice.sentAt?.toISOString() ?? null,
cancelledAt: invoice.cancelledAt?.toISOString() ?? null,
issuedAt: invoice.issuedAt.toISOString(),
};
}
+68 -321
View File
@@ -1,345 +1,92 @@
import { useEffect } from "react";
import type {
ActionFunctionArgs,
HeadersFunction,
LoaderFunctionArgs,
} from "react-router";
import { useFetcher } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import type { LoaderFunctionArgs } from "react-router";
import { Link, useLoaderData } from "react-router";
import { authenticate } from "../shopify.server";
import { boundary } from "@shopify/shopify-app-react-router/server";
import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
const { session } = await authenticate.admin(request);
return null;
};
const [settings, recent] = await Promise.all([
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
db.invoice.findMany({
where: { shopDomain: session.shop },
orderBy: [{ issuedAt: "desc" }],
take: 10,
}),
]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const color = ["Red", "Orange", "Yellow", "Green"][
Math.floor(Math.random() * 4)
];
const response = await admin.graphql(
`#graphql
mutation populateProduct($product: ProductCreateInput!) {
productCreate(product: $product) {
product {
id
title
handle
status
variants(first: 10) {
edges {
node {
id
price
barcode
createdAt
}
}
}
demoInfo: metafield(namespace: "$app", key: "demo_info") {
jsonValue
}
}
}
}`,
{
variables: {
product: {
title: `${color} Snowboard`,
metafields: [
{
namespace: "$app",
key: "demo_info",
value: "Created by React Router Template",
},
],
},
},
},
const settingsConfigured = !!(
settings &&
settings.companyName &&
settings.addressLine1 &&
settings.iban
);
const responseJson = await response.json();
const product = responseJson.data!.productCreate!.product!;
const variantId = product.variants.edges[0]!.node!.id!;
const variantResponse = await admin.graphql(
`#graphql
mutation shopifyReactRouterTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
barcode
createdAt
}
}
}`,
{
variables: {
productId: product.id,
variants: [{ id: variantId, price: "100.00" }],
},
},
);
const variantResponseJson = await variantResponse.json();
const metaobjectResponse = await admin.graphql(
`#graphql
mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
metaobject {
id
handle
title: field(key: "title") {
jsonValue
}
description: field(key: "description") {
jsonValue
}
}
userErrors {
field
message
}
}
}`,
{
variables: {
handle: {
type: "$app:example",
handle: "demo-entry",
},
metaobject: {
fields: [
{ key: "title", value: "Demo Entry" },
{
key: "description",
value:
"This metaobject was created by the Shopify app template to demonstrate the metaobject API.",
},
],
},
},
},
);
const metaobjectResponseJson = await metaobjectResponse.json();
return {
product: responseJson!.data!.productCreate!.product,
variant:
variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,
metaobject:
metaobjectResponseJson!.data!.metaobjectUpsert!.metaobject,
settingsConfigured,
recent: recent.map((i) => ({
id: i.id,
number: i.invoiceNumber,
kind: i.kind,
orderName: i.orderName,
version: i.version,
sentAt: i.sentAt?.toISOString() ?? null,
cancelledAt: i.cancelledAt?.toISOString() ?? null,
issuedAt: i.issuedAt.toISOString(),
pdfUrl: i.pdfUrl,
})),
};
};
export default function Index() {
const fetcher = useFetcher<typeof action>();
const shopify = useAppBridge();
const isLoading =
["loading", "submitting"].includes(fetcher.state) &&
fetcher.formMethod === "POST";
useEffect(() => {
if (fetcher.data?.product?.id) {
shopify.toast.show("Product created");
}
}, [fetcher.data?.product?.id, shopify]);
const generateProduct = () => fetcher.submit({}, { method: "POST" });
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
return (
<s-page heading="Shopify app template">
<s-button slot="primary-action" onClick={generateProduct}>
Generate a product
</s-button>
<s-page heading="LinumIQ Invoice">
{!settingsConfigured && (
<s-banner tone="warning" heading="Configure your invoice settings">
Complete your company, bank and numbering details so generated
invoices are legally compliant.{" "}
<Link to="/app/settings">Open settings</Link>
</s-banner>
)}
<s-section heading="Congrats on creating a new Shopify app 🎉">
<s-section heading="What this app does">
<s-paragraph>
This embedded app template uses{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
>
App Bridge
</s-link>{" "}
interface examples like an{" "}
<s-link href="/app/additional">additional page in the app nav</s-link>
, as well as an{" "}
<s-link
href="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
>
Admin GraphQL
</s-link>{" "}
mutation demo, to provide a starting point for app development.
Generates Austrian-compliant PDF invoices for your Shopify orders.
Trigger from the order page (Generate invoice action), via Shopify
Flow, or in bulk from the Invoices page. PDFs are stored on
Shopify Files and linked to each order via metafields.
</s-paragraph>
</s-section>
<s-section heading="Get started with products">
<s-paragraph>
Generate a product with GraphQL and get the JSON output for that
product. Learn more about the{" "}
<s-link
href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
target="_blank"
>
productCreate
</s-link>{" "}
mutation in our API references. Includes a product{" "}
<s-link
href="https://shopify.dev/docs/apps/build/custom-data/metafields"
target="_blank"
>
metafield
</s-link>{" "}
and{" "}
<s-link
href="https://shopify.dev/docs/apps/build/custom-data/metaobjects"
target="_blank"
>
metaobject
</s-link>
.
</s-paragraph>
<s-stack direction="inline" gap="base">
<s-button
onClick={generateProduct}
{...(isLoading ? { loading: true } : {})}
>
Generate a product
</s-button>
{fetcher.data?.product && (
<s-button
onClick={() => {
shopify.intents.invoke?.("edit:shopify/Product", {
value: fetcher.data?.product?.id,
});
}}
target="_blank"
variant="tertiary"
>
Edit product
</s-button>
)}
</s-stack>
{fetcher.data?.product && (
<s-section heading="productCreate mutation">
<s-stack direction="block" gap="base">
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>{JSON.stringify(fetcher.data.product, null, 2)}</code>
</pre>
</s-box>
<s-heading>productVariantsBulkUpdate mutation</s-heading>
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>{JSON.stringify(fetcher.data.variant, null, 2)}</code>
</pre>
</s-box>
<s-heading>metaobjectUpsert mutation</s-heading>
<s-box
padding="base"
borderWidth="base"
borderRadius="base"
background="subdued"
>
<pre style={{ margin: 0 }}>
<code>
{JSON.stringify(fetcher.data.metaobject, null, 2)}
</code>
</pre>
</s-box>
</s-stack>
</s-section>
<s-section heading="Recent invoices">
{recent.length === 0 ? (
<s-paragraph>No invoices generated yet.</s-paragraph>
) : (
<s-unordered-list>
{recent.map((i) => (
<s-list-item key={i.id}>
{i.kind === "storno" ? "Storno " : ""}
{i.number} order {i.orderName} (v{i.version})
{i.cancelledAt
? " — cancelled"
: i.sentAt
? " — sent"
: ""}
{i.pdfUrl ? (
<>
{" "}
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
</>
) : null}
</s-list-item>
))}
</s-unordered-list>
)}
</s-section>
<s-section slot="aside" heading="App template specs">
<s-paragraph>
<s-text>Framework: </s-text>
<s-link href="https://reactrouter.com/" target="_blank">
React Router
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Interface: </s-text>
<s-link
href="https://shopify.dev/docs/api/app-home/using-polaris-components"
target="_blank"
>
Polaris web components
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>API: </s-text>
<s-link
href="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
>
GraphQL
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Custom data: </s-text>
<s-link
href="https://shopify.dev/docs/apps/build/custom-data"
target="_blank"
>
Metafields &amp; metaobjects
</s-link>
</s-paragraph>
<s-paragraph>
<s-text>Database: </s-text>
<s-link href="https://www.prisma.io/" target="_blank">
Prisma
</s-link>
</s-paragraph>
</s-section>
<s-section slot="aside" heading="Next steps">
<s-unordered-list>
<s-list-item>
Build an{" "}
<s-link
href="https://shopify.dev/docs/apps/getting-started/build-app-example"
target="_blank"
>
example app
</s-link>
</s-list-item>
<s-list-item>
Explore Shopify&apos;s API with{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
target="_blank"
>
GraphiQL
</s-link>
</s-list-item>
</s-unordered-list>
<Link to="/app/invoices">Open invoices page</Link>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};
-37
View File
@@ -1,37 +0,0 @@
export default function AdditionalPage() {
return (
<s-page heading="Additional page">
<s-section heading="Multiple pages">
<s-paragraph>
The app template comes with an additional page which demonstrates how
to create multiple pages within app navigation using{" "}
<s-link
href="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
>
App Bridge
</s-link>
.
</s-paragraph>
<s-paragraph>
To create your own page and have it show up in the app navigation, add
a page inside <code>app/routes</code>, and a link to it in the{" "}
<code>&lt;ui-nav-menu&gt;</code> component found in{" "}
<code>app/routes/app.jsx</code>.
</s-paragraph>
</s-section>
<s-section slot="aside" heading="Resources">
<s-unordered-list>
<s-list-item>
<s-link
href="https://shopify.dev/docs/apps/design-guidelines/navigation#app-nav"
target="_blank"
>
App nav best practices
</s-link>
</s-list-item>
</s-unordered-list>
</s-section>
</s-page>
);
}
+191
View File
@@ -0,0 +1,191 @@
import type { LoaderFunctionArgs } from "react-router";
import { Link, useLoaderData, useNavigation, useFetcher } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
interface RecentOrder {
id: string; // gid
name: string;
createdAt: string;
totalPrice: string;
currency: string;
hasInvoice: boolean;
invoiceNumber?: string;
invoiceSent?: boolean;
invoiceCancelled?: boolean;
pdfUrl?: string;
}
const RECENT_ORDERS_QUERY = `#graphql
query RecentOrders($first: Int!) {
orders(first: $first, sortKey: CREATED_AT, reverse: true) {
nodes {
id
name
createdAt
displayFinancialStatus
totalPriceSet { shopMoney { amount currencyCode } }
}
}
}
`;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const url = new URL(request.url);
const filter = url.searchParams.get("filter") ?? "all";
// Recent orders from Shopify (first 25).
let orders: RecentOrder[] = [];
try {
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
const json = (await res.json()) as {
data?: {
orders?: {
nodes?: Array<{
id: string;
name: string;
createdAt: string;
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
}>;
};
};
};
const nodes = json.data?.orders?.nodes ?? [];
const orderIds = nodes.map((n) => n.id);
// Look up which of these orders already have a non-cancelled invoice.
const invoices = await db.invoice.findMany({
where: {
shopDomain: session.shop,
orderId: { in: orderIds },
kind: "invoice",
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
const latestByOrder = new Map<string, (typeof invoices)[number]>();
for (const inv of invoices) {
if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv);
}
orders = nodes.map((n) => {
const inv = latestByOrder.get(n.id);
return {
id: n.id,
name: n.name,
createdAt: n.createdAt,
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
hasInvoice: !!inv && !inv.cancelledAt,
invoiceNumber: inv?.invoiceNumber,
invoiceSent: !!inv?.sentAt,
invoiceCancelled: !!inv?.cancelledAt,
pdfUrl: inv?.pdfUrl,
};
});
} catch (err) {
console.warn("Failed to load recent orders:", err);
}
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
return { orders, filter };
};
export default function InvoicesPage() {
const { orders, filter } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
return (
<s-page heading="Invoices">
<s-section heading="Recent orders">
<s-paragraph>
Generate or view the latest invoice for each order. For sent
invoices, use Cancel & reissue to issue a Stornorechnung followed
by a fresh invoice.
</s-paragraph>
<s-stack direction="inline" gap="base">
<Link to="?filter=all">All</Link>
<Link to="?filter=missing">Missing invoice</Link>
<Link to="?filter=with">Has invoice</Link>
</s-stack>
{isLoading ? (
<s-paragraph>Loading</s-paragraph>
) : orders.length === 0 ? (
<s-paragraph>No orders match the current filter.</s-paragraph>
) : (
<s-unordered-list>
{orders.map((o) => (
<OrderRow key={o.id} order={o} />
))}
</s-unordered-list>
)}
</s-section>
<s-section heading="Bulk generate">
<s-paragraph>
Use the buttons next to each order to generate one at a time. (A
true multi-select bulk run will be added once the admin block
extension is installed.)
</s-paragraph>
<s-paragraph>
Tip: filter by <em>Missing invoice</em> to see orders that still
need one.
</s-paragraph>
</s-section>
</s-page>
);
}
function OrderRow({ order }: { order: RecentOrder }) {
const numericId = order.id.replace(/^.*\//, "");
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const isBusy = fetcher.state !== "idle";
return (
<s-list-item>
<s-stack direction="inline" gap="base" align-items="center">
<strong>{order.name}</strong>
<span>{order.totalPrice} {order.currency}</span>
{order.hasInvoice ? (
<span>
{order.invoiceNumber}
{order.invoiceCancelled
? " (cancelled)"
: order.invoiceSent
? " (sent)"
: ""}
{order.pdfUrl ? (
<>
{" — "}
<a href={order.pdfUrl} target="_blank" rel="noreferrer">PDF</a>
</>
) : null}
</span>
) : (
<span> no invoice yet</span>
)}
<fetcher.Form method="post" action={`/api/orders/${numericId}/invoice`}>
<s-button type="submit" disabled={isBusy} variant="primary">
{order.hasInvoice
? order.invoiceSent
? "Cancel & reissue"
: "Regenerate"
: "Generate"}
</s-button>
{order.hasInvoice && order.invoiceSent && (
<input type="hidden" name="action" value="cancel_reissue" />
)}
</fetcher.Form>
{fetcher.data?.error && (
<span style={{ color: "red" }}>{fetcher.data.error}</span>
)}
</s-stack>
</s-list-item>
);
}
+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>
);
}
+2 -1
View File
@@ -19,7 +19,8 @@ export default function App() {
<AppProvider embedded apiKey={apiKey}>
<s-app-nav>
<s-link href="/app">Home</s-link>
<s-link href="/app/additional">Additional page</s-link>
<s-link href="/app/invoices">Invoices</s-link>
<s-link href="/app/settings">Settings</s-link>
</s-app-nav>
<Outlet />
</AppProvider>
+11
View File
@@ -0,0 +1,11 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
// We don't auto-generate invoices on order create. This handler just
// acknowledges the webhook so Shopify keeps it healthy and gives us a
// hook point for future work (e.g. cache invalidation).
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
return new Response();
};
+10
View File
@@ -0,0 +1,10 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
// Acknowledged but not yet acted on. Future: invalidate cached invoice
// snapshots when a relevant field on the order changes.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
return new Response();
};