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(),
};
}
+65 -318
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-section heading="Congrats on creating a new Shopify app 🎉">
<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.
</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-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-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>
<s-section slot="aside" heading="App template specs">
<s-section heading="What this app does">
<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>
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 slot="aside" heading="Next steps">
<s-section heading="Recent invoices">
{recent.length === 0 ? (
<s-paragraph>No invoices generated yet.</s-paragraph>
) : (
<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>
{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>
)}
<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();
};
@@ -0,0 +1,201 @@
import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
import db from "../../db.server";
import { composeInvoice } from "./composeInvoice";
import {
generateInvoice,
sanitiseForFilename,
toOrderGid,
uploadPdfToShopifyFiles,
writeOrderMetafields,
type GeneratedInvoice,
} from "./generateInvoice.server";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { getLogoDataUrl } from "./logoCache.server";
import { InvoiceDocument } from "./pdf/InvoiceDocument";
export interface CancelAndReissueArgs {
shopDomain: string;
admin: AdminApiContext;
orderId: string;
}
export interface CancelAndReissueResult {
storno: {
invoiceId: string;
invoiceNumber: string;
pdfUrl: string;
};
newInvoice: GeneratedInvoice;
}
/**
* Cancels the latest sent invoice for an order by issuing a Stornorechnung
* (negative amounts, references the original number) and then issuing a
* brand-new invoice with a fresh number reflecting the corrected data.
*
* Both documents are uploaded to Shopify Files. The original invoice row is
* marked `cancelledAt = now()`, the storno is persisted as
* `Invoice { kind: 'storno', cancelsInvoiceId: <original.id> }`, and the
* order's metafields are updated to point at the new invoice (with the
* storno PDF URL written to a separate metafield).
*/
export async function cancelAndReissue(
args: CancelAndReissueArgs,
): Promise<CancelAndReissueResult> {
const { shopDomain, admin } = args;
const orderGid = toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
update: {},
create: { shopDomain },
});
const original = await db.invoice.findFirst({
where: {
shopDomain,
orderId: orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (!original) {
throw new Error("No active invoice found for this order to cancel.");
}
const order = await loadOrderForInvoice(admin, orderGid);
// Storno number: same as original with a "-S" suffix (so it is visually
// tied to the cancelled invoice and never collides with the new number).
const stornoNumber = `${original.invoiceNumber}-S`;
const stornoView = composeInvoice({
order,
settings,
invoiceNumber: stornoNumber,
storno: { cancelsNumber: original.invoiceNumber },
});
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
if (logoDataUrl) stornoView.issuer.logoDataUrl = logoDataUrl;
const stornoBuffer = (await renderToBuffer(
<InvoiceDocument invoice={stornoView} />,
)) as Buffer;
const stornoUpload = await uploadPdfToShopifyFiles(admin, {
bytes: stornoBuffer,
filename: `Stornorechnung-${sanitiseForFilename(stornoNumber)}.pdf`,
alt: `Stornorechnung ${stornoNumber}`,
});
const stornoRow = await db.$transaction(async (tx) => {
const created = await tx.invoice.create({
data: {
shopDomain,
orderId: orderGid,
orderName: order.name,
orderNumber: order.orderNumber,
invoiceNumber: stornoNumber,
language: stornoView.language,
kind: "storno",
version: 1,
cancelsInvoiceId: original.id,
pdfFileGid: stornoUpload.fileGid,
pdfUrl: stornoUpload.url,
totalsJson: JSON.stringify(stornoView.totals),
customerJson: JSON.stringify({
recipient: stornoView.recipient,
isB2B: stornoView.isB2B,
recipientVatId: stornoView.recipientVatId,
}),
status: "issued",
},
});
await tx.invoice.update({
where: { id: original.id },
data: { cancelledAt: new Date(), status: "cancelled" },
});
return created;
});
// Best-effort: link the storno PDF + previous number on the order.
try {
await writeStornoSidecarMetafields(admin, orderGid, {
stornoUrl: stornoUpload.url,
previousNumber: original.invoiceNumber,
});
} catch (err) {
console.warn("Storno sidecar metafield write failed:", err);
}
// Now issue a brand-new invoice (fresh number from the configured mode).
const newInvoice = await generateInvoice({ shopDomain, admin, orderId: orderGid });
// The metafields written by generateInvoice already point at the new
// invoice's PDF URL/number/version. The storno sidecar metafields above
// remain referencing the storno PDF and the previous number.
return {
storno: {
invoiceId: stornoRow.id,
invoiceNumber: stornoRow.invoiceNumber,
pdfUrl: stornoUpload.url,
},
newInvoice,
};
}
const STORNO_METAFIELDS_MUTATION = `#graphql
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id namespace key }
userErrors { field message }
}
}
`;
async function writeStornoSidecarMetafields(
admin: AdminApiContext,
orderGid: string,
data: { stornoUrl: string; previousNumber: string },
): Promise<void> {
const res = await admin.graphql(STORNO_METAFIELDS_MUTATION, {
variables: {
metafields: [
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "storno_pdf_url",
type: "url",
value: data.stornoUrl,
},
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "previous_number",
type: "single_line_text_field",
value: data.previousNumber,
},
],
},
});
const json = (await res.json()) as {
data?: {
metafieldsSet?: {
userErrors?: { field: string[]; message: string }[];
};
};
};
const errs = json.data?.metafieldsSet?.userErrors ?? [];
if (errs.length > 0) {
throw new Error(`metafieldsSet (storno) failed: ${JSON.stringify(errs)}`);
}
// suppress unused-import warnings when the orchestrator path doesn't use this:
void writeOrderMetafields;
}
+299
View File
@@ -0,0 +1,299 @@
import type { ShopSettings } from "@prisma/client";
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
import type {
InvoiceLine,
InvoiceNotice,
InvoiceTotals,
InvoiceViewModel,
IssuerData,
RecipientData,
VatBreakdownEntry,
} from "./types";
import { addDays } from "./format";
import { pickLanguage, type InvoiceLanguage } from "./i18n";
interface ComposeArgs {
order: RawOrderForInvoice;
settings: ShopSettings;
invoiceNumber: string;
/** Language override (e.g. for Storno copies). */
forceLanguage?: InvoiceLanguage;
/**
* When set, produces a Stornorechnung view model: line and total amounts
* are negated, `kind` is `"storno"`, and `cancelsNumber` references the
* original invoice number. Notices, GiroCode and payment-due date are
* suppressed (a storno is informational, not a request for payment).
*/
storno?: { cancelsNumber: string };
/** Optional override for invoice/delivery date (defaults to order date). */
issueDate?: Date;
}
export function composeInvoice({
order,
settings,
invoiceNumber,
forceLanguage,
storno,
issueDate,
}: ComposeArgs): InvoiceViewModel {
const language = forceLanguage
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
const issuer = mapIssuer(settings);
const recipient = mapRecipient(order);
const isB2B = !!order.purchasingEntity?.company;
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
let { lines, totals } = mapLinesAndTotals(order);
let notices = deriveNotices({ order, settings, isB2B });
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
const deliveryDate = invoiceDate;
const dueDate = !storno && settings.paymentTermDays > 0
? addDays(invoiceDate, settings.paymentTermDays)
: undefined;
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
if (storno) {
lines = lines.map((l) => ({
...l,
unitPriceNet: -l.unitPriceNet,
totalNet: -l.totalNet,
}));
totals = {
net: -totals.net,
vatBreakdown: totals.vatBreakdown.map((v) => ({
ratePct: v.ratePct,
net: -v.net,
tax: -v.tax,
})),
totalVat: -totals.totalVat,
gross: -totals.gross,
};
// Notices are still relevant (e.g. reverse-charge), but the storno is not
// a payment request — leave them in place for legal symmetry.
}
return {
language,
currency: order.currencyCode,
kind: storno ? "storno" : "invoice",
number: invoiceNumber,
cancelsNumber: storno?.cancelsNumber,
invoiceDate,
deliveryDate,
dueDate,
issuer,
recipient,
isB2B,
recipientVatId,
lines,
totals,
notices,
paid,
};
}
function mapIssuer(s: ShopSettings): IssuerData {
return {
companyName: s.companyName,
legalForm: s.legalForm,
ownerName: s.ownerName,
addressLine1: s.addressLine1,
addressLine2: s.addressLine2,
postalCode: s.postalCode,
city: s.city,
countryCode: s.countryCode,
phone: s.phone,
email: s.email,
website: s.website,
vatId: s.vatId,
taxNumber: s.taxNumber,
registrationNo: s.registrationNo,
registrationCourt: s.registrationCourt,
bankName: s.bankName,
iban: s.iban,
bic: s.bic,
footerNote: s.footerNote,
};
}
function mapRecipient(order: RawOrderForInvoice): RecipientData {
// Prefer billingAddress; fall back to shippingAddress; fall back to customer name only.
const a = order.billingAddress ?? order.shippingAddress ?? null;
const customerFullName = [order.customer?.firstName, order.customer?.lastName]
.filter(Boolean)
.join(" ")
.trim();
if (!a) {
return {
name: customerFullName,
company: order.purchasingEntity?.company?.name ?? "",
addressLine1: "",
addressLine2: "",
postalCode: "",
city: "",
countryCode: "",
};
}
return {
name: a.name ?? customerFullName,
company: a.company ?? order.purchasingEntity?.company?.name ?? "",
addressLine1: a.address1 ?? "",
addressLine2: a.address2 ?? "",
postalCode: a.zip ?? "",
city: a.city ?? "",
countryCode: a.countryCode ?? "",
};
}
function mapLinesAndTotals(order: RawOrderForInvoice): {
lines: InvoiceLine[];
totals: InvoiceTotals;
} {
const taxesIncluded = order.taxesIncluded;
const linesOut: InvoiceLine[] = [];
const vatMap = new Map<number, VatBreakdownEntry>();
let netSum = 0;
order.lineItems.forEach((li, idx) => {
const qty = li.quantity;
const grossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
// Total tax for this line summed across its tax lines.
const lineTax = li.taxLines.reduce(
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
0,
);
// If taxes are included in the unit price, subtract them to get net.
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
const unitNet = qty > 0 ? lineNet / qty : 0;
linesOut.push({
position: idx + 1,
title: li.title,
sku: li.sku ?? undefined,
quantity: qty,
unitPriceNet: round2(unitNet),
totalNet: round2(lineNet),
});
netSum += lineNet;
li.taxLines.forEach((t) => accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), lineNet));
void lineGross;
});
// Prefer order-level taxLines for the breakdown grouping if line-level is missing.
if (vatMap.size === 0 && order.taxLines.length > 0) {
order.taxLines.forEach((t) => {
const tax = parseFloat(t.priceSet.shopMoney.amount);
// We don't have per-rate net from the order level; approximate by inferring from rate.
const rate = normaliseRate(t);
const net = rate > 0 ? tax / (rate / 100) : 0;
const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 };
entry.net += net;
entry.tax += tax;
vatMap.set(rate, entry);
});
}
const vatBreakdown = Array.from(vatMap.values())
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
.filter((e) => e.tax > 0)
.sort((a, b) => a.ratePct - b.ratePct);
const totalVat = vatBreakdown.reduce((acc, e) => acc + e.tax, 0);
const grossFromOrder = order.totalPriceSet
? parseFloat(order.totalPriceSet.shopMoney.amount)
: netSum + totalVat;
return {
lines: linesOut,
totals: {
net: round2(netSum),
vatBreakdown,
totalVat: round2(totalVat),
gross: round2(grossFromOrder),
},
};
}
function accumulateVat(
vatMap: Map<number, VatBreakdownEntry>,
t: RawTaxLine,
taxAmount: number,
lineNet: number,
) {
if (taxAmount <= 0) return;
const rate = normaliseRate(t);
const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 };
entry.net += lineNet;
entry.tax += taxAmount;
vatMap.set(rate, entry);
}
function normaliseRate(t: RawTaxLine): number {
if (t.ratePercentage != null) return Number(t.ratePercentage);
if (t.rate != null) {
const r = Number(t.rate);
return r <= 1 ? r * 100 : r;
}
return 0;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function deriveNotices({
order,
settings,
isB2B,
}: {
order: RawOrderForInvoice;
settings: ShopSettings;
isB2B: boolean;
}): InvoiceNotice[] {
const notices: InvoiceNotice[] = [];
const totalTax = order.totalTaxSet
? parseFloat(order.totalTaxSet.shopMoney.amount)
: 0;
const recipientCountry =
order.billingAddress?.countryCode || order.shippingAddress?.countryCode || "";
const issuerCountry = settings.countryCode || "AT";
if (settings.kleinunternehmer) {
notices.push({ kind: "kleinunternehmer" });
return notices; // exclusive of the others
}
if (totalTax === 0) {
if (
isB2B &&
recipientCountry &&
recipientCountry !== issuerCountry &&
isEuCountry(recipientCountry)
) {
notices.push({ kind: "reverseCharge" });
} else if (recipientCountry && !isEuCountry(recipientCountry)) {
notices.push({ kind: "export" });
}
}
return notices;
}
const EU_COUNTRIES = new Set([
"AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE",
"IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK",
]);
function isEuCountry(code: string): boolean {
return EU_COUNTRIES.has(code.toUpperCase());
}
+205
View File
@@ -0,0 +1,205 @@
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import type { ShopSettings } from "@prisma/client";
import db from "../../db.server";
import { getStrings, pickLanguage } from "./i18n";
export interface SendInvoiceEmailArgs {
shopDomain: string;
invoiceId: string;
toAddress?: string;
/** Customer locale (e.g. "de-AT" or "en"); used to pick subject/body language. */
customerLocale?: string;
/**
* Override the underlying transport (test only). Production code should
* leave this undefined so SMTP creds from `ShopSettings` are used.
*/
transportOverride?: Transporter;
}
export interface SendInvoiceEmailResult {
ok: boolean;
toAddress: string;
messageId?: string;
errorMessage?: string;
}
/**
* Sends the invoice PDF as an email attachment to the customer using the
* shop's configured SMTP credentials. On success, marks the invoice
* `sentAt = now()` and `status = 'sent'`, which locks it from in-place
* regeneration (cancel-and-reissue is required to correct it after this).
*/
export async function sendInvoiceEmail(
args: SendInvoiceEmailArgs,
): Promise<SendInvoiceEmailResult> {
const settings = await db.shopSettings.findUnique({
where: { shopDomain: args.shopDomain },
});
if (!settings) {
return failLog(args, "ShopSettings missing for this shop.");
}
const invoice = await db.invoice.findUnique({ where: { id: args.invoiceId } });
if (!invoice) return failLog(args, `Invoice ${args.invoiceId} not found.`);
if (invoice.shopDomain !== args.shopDomain) {
return failLog(args, "Invoice does not belong to this shop.");
}
if (!invoice.pdfUrl) return failLog(args, "Invoice has no PDF URL.");
// Resolve recipient: explicit override > customer email captured on the invoice.
let to = args.toAddress?.trim();
if (!to) {
try {
const customer = JSON.parse(invoice.customerJson) as { customerEmail?: string };
to = customer.customerEmail?.trim();
} catch {
// ignore
}
}
if (!to) return failLog(args, "No recipient email available.", invoice.id);
// Build email content.
const language = pickLanguage(args.customerLocale ?? settings.defaultLanguage);
const t = getStrings(language);
const subject = `${t.invoice} ${invoice.invoiceNumber}` +
(settings.companyName ? `${settings.companyName}` : "");
const body = renderEmailBody({
settings,
invoiceNumber: invoice.invoiceNumber,
language,
});
// Download the PDF (Shopify Files URLs are public CDN URLs).
let pdfBytes: Uint8Array;
try {
const res = await fetch(invoice.pdfUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pdfBytes = new Uint8Array(await res.arrayBuffer());
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
}
const transporter = args.transportOverride ?? buildTransport(settings);
const fromName = settings.smtpFromName || settings.companyName || "Invoices";
const fromEmail = settings.smtpFromEmail || settings.smtpUser || settings.email;
if (!fromEmail) {
return failLog(args, "No SMTP From address configured.", invoice.id);
}
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
replyTo: settings.smtpReplyTo || undefined,
subject,
text: body.text,
html: body.html,
attachments: [
{
filename: `${invoice.kind === "storno" ? "Stornorechnung" : "Rechnung"}-${invoice.invoiceNumber}.pdf`,
content: Buffer.from(pdfBytes),
contentType: "application/pdf",
},
],
});
await db.$transaction(async (tx) => {
await tx.invoice.update({
where: { id: invoice.id },
data: { sentAt: new Date(), status: "sent" },
});
await tx.emailLog.create({
data: {
shopDomain: args.shopDomain,
invoiceId: invoice.id,
toAddress: to!,
subject,
status: "sent",
},
});
});
return { ok: true, toAddress: to, messageId: info.messageId };
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return failLog(args, `SMTP send failed: ${m}`, invoice.id, to);
}
}
function buildTransport(settings: ShopSettings): Transporter {
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpSecure,
auth: settings.smtpUser
? { user: settings.smtpUser, pass: settings.smtpPassword }
: undefined,
});
}
async function failLog(
args: SendInvoiceEmailArgs,
message: string,
invoiceId?: string,
to?: string,
): Promise<SendInvoiceEmailResult> {
if (invoiceId) {
try {
await db.emailLog.create({
data: {
shopDomain: args.shopDomain,
invoiceId,
toAddress: to ?? args.toAddress ?? "",
subject: "(failed)",
status: "failed",
error: message,
},
});
} catch {
// best-effort
}
}
return { ok: false, toAddress: to ?? args.toAddress ?? "", errorMessage: message };
}
function renderEmailBody({
settings,
invoiceNumber,
language,
}: {
settings: ShopSettings;
invoiceNumber: string;
language: "de" | "en";
}): { text: string; html: string } {
const company = settings.companyName || "your supplier";
if (language === "en") {
const text =
`Dear customer,\n\n` +
`Please find attached invoice ${invoiceNumber}.\n\n` +
`Kind regards,\n${company}`;
const html =
`<p>Dear customer,</p>` +
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Kind regards,<br/>${escapeHtml(company)}</p>`;
return { text, html };
}
const text =
`Sehr geehrte Damen und Herren,\n\n` +
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
`Mit freundlichen Grüßen,\n${company}`;
const html =
`<p>Sehr geehrte Damen und Herren,</p>` +
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Mit freundlichen Grüßen,<br/>${escapeHtml(company)}</p>`;
return { text, html };
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Per-locale formatters used in PDF rendering. We pin these to specific
* locales (de-AT for German invoices) so that the output is deterministic
* regardless of the runtime's default locale.
*/
const MONEY_FORMATTERS = new Map<string, Intl.NumberFormat>();
const QTY_FORMATTERS = new Map<string, Intl.NumberFormat>();
const DATE_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
function localeFor(language: string): string {
return language === "en" ? "en-GB" : "de-AT";
}
export function formatMoney(
amount: number | string,
currency: string,
language: string,
): string {
const num = typeof amount === "string" ? Number(amount) : amount;
const key = `${language}|${currency}`;
let f = MONEY_FORMATTERS.get(key);
if (!f) {
f = new Intl.NumberFormat(localeFor(language), {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
MONEY_FORMATTERS.set(key, f);
}
return `${f.format(Number.isFinite(num) ? num : 0)} ${currency}`;
}
export function formatQuantity(qty: number, unit: string, language: string): string {
let f = QTY_FORMATTERS.get(language);
if (!f) {
f = new Intl.NumberFormat(localeFor(language), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
QTY_FORMATTERS.set(language, f);
}
return `${f.format(qty)} ${unit}`;
}
export function formatDate(date: Date | string, language: string): string {
const d = typeof date === "string" ? new Date(date) : date;
let f = DATE_FORMATTERS.get(language);
if (!f) {
f = new Intl.DateTimeFormat(localeFor(language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
DATE_FORMATTERS.set(language, f);
}
return f.format(d);
}
/** Adds days to a date, returning a new Date. */
export function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
/**
* Formats a percentage. Tax rates from Shopify can be either 0.20 or 20 — we
* accept both shapes via heuristics.
*/
export function formatTaxRate(rate: number, language: string): string {
const pct = rate <= 1 ? rate * 100 : rate;
const f = new Intl.NumberFormat(localeFor(language), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
return `${f.format(pct)}%`;
}
@@ -0,0 +1,399 @@
import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
import db from "../../db.server";
import { composeInvoice } from "./composeInvoice";
import { buildGiroCodeDataUrl } from "./girocode";
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
import { getLogoDataUrl } from "./logoCache.server";
import { allocateInvoiceNumber } from "./numbering.server";
import { InvoiceDocument } from "./pdf/InvoiceDocument";
import type { InvoiceViewModel } from "./types";
export interface GenerateInvoiceArgs {
shopDomain: string;
admin: AdminApiContext;
/** Either a numeric Shopify order id or a full GID. */
orderId: string;
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
forceRegenerate?: boolean;
}
export interface GeneratedInvoice {
invoiceId: string;
invoiceNumber: string;
pdfUrl: string;
pdfFileGid: string;
version: number;
reused: boolean;
}
/**
* Top-level orchestrator. Loads order + settings, composes the view model,
* renders the PDF, uploads to Shopify Files and persists an Invoice row.
*
* Idempotency rules:
* - If a non-cancelled Invoice for the order exists and is unsent, it is
* regenerated in place (new file, same number, version++).
* - If the latest is `sent` and not cancelled, generation is refused (caller
* must use the cancel-and-reissue flow). Future phase.
*/
export async function generateInvoice(
args: GenerateInvoiceArgs,
): Promise<GeneratedInvoice> {
const { shopDomain, admin } = args;
const orderGid = toOrderGid(args.orderId);
const settings = await db.shopSettings.upsert({
where: { shopDomain },
update: {},
create: { shopDomain },
});
const order = await loadOrderForInvoice(admin, orderGid);
// Find latest existing invoice (excluding storno) for this order.
const latest = await db.invoice.findFirst({
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (latest && latest.sentAt && !args.forceRegenerate) {
throw new Error(
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
);
}
const invoiceNumber = latest
? latest.invoiceNumber
: await allocateInvoiceNumber(settings, order.orderNumber);
// Compose view model and render PDF.
const viewModel = composeInvoice({ order, settings, invoiceNumber });
// Logo (cached).
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl;
// GiroCode (only for unpaid + IBAN configured + enabled).
if (
settings.giroCodeEnabled &&
settings.iban &&
!viewModel.paid &&
viewModel.totals.gross > 0
) {
viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName || "Beneficiary",
iban: settings.iban,
bic: settings.bic,
amount: viewModel.totals.gross,
currency: viewModel.currency,
remittance: invoiceNumber,
});
}
const pdfBuffer = await renderInvoicePdf(viewModel);
const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
const upload = await uploadPdfToShopifyFiles(admin, {
bytes: pdfBuffer,
filename,
alt: `Invoice ${invoiceNumber}`,
});
const version = latest ? latest.version + 1 : 1;
const totalsJson = JSON.stringify(viewModel.totals);
const customerJson = JSON.stringify({
recipient: viewModel.recipient,
isB2B: viewModel.isB2B,
recipientVatId: viewModel.recipientVatId,
customerEmail: order.customer?.email,
});
// Persist (upsert by latest row when regenerating in place).
const invoice = latest
? await db.invoice.update({
where: { id: latest.id },
data: {
version,
pdfFileGid: upload.fileGid,
pdfUrl: upload.url,
totalsJson,
customerJson,
issuedAt: new Date(),
status: "issued",
lastError: "",
},
})
: await db.invoice.create({
data: {
shopDomain,
orderId: orderGid,
orderName: order.name,
orderNumber: order.orderNumber,
invoiceNumber,
language: viewModel.language,
kind: "invoice",
version: 1,
pdfFileGid: upload.fileGid,
pdfUrl: upload.url,
totalsJson,
customerJson,
status: "issued",
},
});
// Link the latest PDF on the order via metafields (best-effort; do not
// fail the whole operation if scopes are missing).
try {
await writeOrderMetafields(admin, orderGid, {
pdfUrl: upload.url,
number: invoiceNumber,
version: invoice.version,
});
} catch (err) {
console.warn("Order metafield write failed:", err);
}
return {
invoiceId: invoice.id,
invoiceNumber,
pdfUrl: upload.url,
pdfFileGid: upload.fileGid,
version: invoice.version,
reused: !!latest,
};
}
/** Convert legacy numeric ids to a full Shopify GID. */
export function toOrderGid(input: string): string {
if (input.startsWith("gid://")) return input;
return `gid://shopify/Order/${input}`;
}
function sanitiseForFilename(s: string): string {
return s.replace(/[^A-Za-z0-9._-]/g, "_");
}
export { sanitiseForFilename };
/** Renders the PDF as a Node Buffer. */
export async function renderInvoicePdf(view: InvoiceViewModel): Promise<Buffer> {
// @react-pdf returns a Node Buffer in node environments.
return renderToBuffer(<InvoiceDocument invoice={view} />) as Promise<Buffer>;
}
/* ------------------------------------------------------------------ */
/* Shopify Files upload */
/* ------------------------------------------------------------------ */
interface UploadResult {
fileGid: string;
url: string;
}
const STAGED_UPLOAD_MUTATION = `#graphql
mutation stagedUploads($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters { name value }
}
userErrors { field message }
}
}
`;
const FILE_CREATE_MUTATION = `#graphql
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
id
alt
createdAt
... on GenericFile {
url
}
}
userErrors { field message }
}
}
`;
const FILE_QUERY = `#graphql
query File($id: ID!) {
node(id: $id) {
... on GenericFile {
id
url
}
}
}
`;
interface StagedTarget {
url: string;
resourceUrl: string;
parameters: { name: string; value: string }[];
}
interface UploadInput {
bytes: Buffer;
filename: string;
alt: string;
}
export async function uploadPdfToShopifyFiles(
admin: AdminApiContext,
input: UploadInput,
): Promise<UploadResult> {
const stagedRes = await admin.graphql(STAGED_UPLOAD_MUTATION, {
variables: {
input: [
{
filename: input.filename,
mimeType: "application/pdf",
httpMethod: "POST",
resource: "FILE",
fileSize: input.bytes.length.toString(),
},
],
},
});
const stagedJson = (await stagedRes.json()) as {
data?: {
stagedUploadsCreate?: {
stagedTargets?: StagedTarget[];
userErrors?: { field: string[]; message: string }[];
};
};
};
const stagedErr = stagedJson.data?.stagedUploadsCreate?.userErrors ?? [];
if (stagedErr.length > 0) {
throw new Error(`stagedUploadsCreate failed: ${JSON.stringify(stagedErr)}`);
}
const target = stagedJson.data?.stagedUploadsCreate?.stagedTargets?.[0];
if (!target) throw new Error("stagedUploadsCreate returned no target");
// POST the bytes to the staged URL (multipart form with the parameters).
const form = new FormData();
for (const p of target.parameters) form.append(p.name, p.value);
form.append(
"file",
new Blob([new Uint8Array(input.bytes)], { type: "application/pdf" }),
input.filename,
);
const putRes = await fetch(target.url, { method: "POST", body: form });
if (!putRes.ok) {
const text = await putRes.text().catch(() => "");
throw new Error(`Staged upload POST failed (${putRes.status}): ${text}`);
}
// Register the file with Shopify.
const fileRes = await admin.graphql(FILE_CREATE_MUTATION, {
variables: {
files: [
{
alt: input.alt,
contentType: "FILE",
originalSource: target.resourceUrl,
},
],
},
});
const fileJson = (await fileRes.json()) as {
data?: {
fileCreate?: {
files?: { id: string; url?: string | null }[];
userErrors?: { field: string[]; message: string }[];
};
};
};
const fileErr = fileJson.data?.fileCreate?.userErrors ?? [];
if (fileErr.length > 0) {
throw new Error(`fileCreate failed: ${JSON.stringify(fileErr)}`);
}
const file = fileJson.data?.fileCreate?.files?.[0];
if (!file) throw new Error("fileCreate returned no file");
// The CDN url is populated asynchronously after Shopify ingests the file.
// Poll a few times for it to appear.
let url = file.url ?? "";
if (!url) {
for (let i = 0; i < 8 && !url; i++) {
await sleep(500);
const q = await admin.graphql(FILE_QUERY, { variables: { id: file.id } });
const qj = (await q.json()) as {
data?: { node?: { url?: string | null } | null };
};
url = qj.data?.node?.url ?? "";
}
}
return { fileGid: file.id, url };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/* ------------------------------------------------------------------ */
/* Order metafield linkage */
/* ------------------------------------------------------------------ */
const METAFIELDS_SET_MUTATION = `#graphql
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id namespace key }
userErrors { field message }
}
}
`;
export async function writeOrderMetafields(
admin: AdminApiContext,
orderGid: string,
data: { pdfUrl: string; number: string; version: number },
): Promise<void> {
const res = await admin.graphql(METAFIELDS_SET_MUTATION, {
variables: {
metafields: [
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "pdf_url",
type: "url",
value: data.pdfUrl,
},
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "number",
type: "single_line_text_field",
value: data.number,
},
{
ownerId: orderGid,
namespace: "linumiq_invoice",
key: "version",
type: "number_integer",
value: data.version.toString(),
},
],
},
});
const json = (await res.json()) as {
data?: {
metafieldsSet?: {
userErrors?: { field: string[]; message: string }[];
};
};
};
const errs = json.data?.metafieldsSet?.userErrors ?? [];
if (errs.length > 0) {
throw new Error(`metafieldsSet failed: ${JSON.stringify(errs)}`);
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* EPC (SEPA Credit Transfer) QR code payload, a.k.a. GiroCode.
* Specification: EPC069-12 v3.0.
*
* The payload is a fixed-order line-delimited block of fields that any SEPA
* banking app can scan to pre-fill a transfer.
*/
import QRCode from "qrcode";
export interface GiroCodeInput {
beneficiaryName: string;
iban: string;
bic?: string;
amount: number;
currency?: string;
/** Free-form remittance information (e.g. invoice number). Max 140 chars. */
remittance: string;
}
export function buildGiroCodePayload(input: GiroCodeInput): string {
const currency = input.currency || "EUR";
if (currency !== "EUR") {
// EPC069-12 requires EUR; keep going but warn (most invoices are EUR).
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
}
// Beneficiary name max 70 chars per spec.
const name = input.beneficiaryName.slice(0, 70);
const iban = input.iban.replace(/\s+/g, "").toUpperCase();
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
const amount = input.amount.toFixed(2);
const remittance = input.remittance.slice(0, 140);
// Field order is fixed; trailing fields can be empty.
// Service tag SCT = SEPA Credit Transfer.
const lines = [
"BCD",
"002", // version
"1", // character set 1 = UTF-8
"SCT", // SEPA Credit Transfer
bic, // BIC (optional in v002)
name,
iban,
`EUR${amount}`,
"", // purpose (optional)
"", // structured remittance
remittance, // unstructured remittance
];
return lines.join("\n");
}
export async function buildGiroCodeDataUrl(
input: GiroCodeInput,
): Promise<string> {
const payload = buildGiroCodePayload(input);
// EPC requires error correction level M.
return QRCode.toDataURL(payload, {
errorCorrectionLevel: "M",
margin: 1,
width: 256,
});
}
+158
View File
@@ -0,0 +1,158 @@
// Translatable strings for invoice rendering. Two languages: de (default), en.
export type InvoiceLanguage = "de" | "en";
export interface InvoiceStrings {
invoice: string;
stornoInvoice: string;
stornoReference: (originalNumber: string) => string;
invoiceNumber: string;
invoiceDate: string;
deliveryDate: string;
customerVatId: string;
position: string;
description: string;
quantity: string;
unitPrice: string;
totalPrice: string;
netTotal: string;
vatLine: (ratePct: string) => string;
grossTotal: string;
salutationGeneric: string;
thankYouLine: string;
closing: string;
paymentTerms: (days: number, dueDate: string) => string;
paymentTermsImmediate: string;
giroCodeCaption: string;
reverseChargeNotice: string;
exportNotice: string;
kleinunternehmerNotice: string;
pieceUnit: string;
page: (current: number, total: number) => string;
legalCourtLabel: string;
fnLabel: string;
vatIdLabel: string;
taxNumberLabel: string;
ownerLabel: string;
ibanLabel: string;
bicLabel: string;
bankLabel: string;
addressHeading: string;
contactHeading: string;
legalHeading: string;
bankHeading: string;
emailLabel: string;
webLabel: string;
phoneLabel: string;
paidStamp: string;
}
const de: InvoiceStrings = {
invoice: "Rechnung",
stornoInvoice: "Stornorechnung",
stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
invoiceNumber: "Rechnungs-Nr.",
invoiceDate: "Rechnungsdatum",
deliveryDate: "Lieferdatum",
customerVatId: "Ihre USt-Id.",
position: "Pos.",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
totalPrice: "Gesamtpreis",
netTotal: "Gesamtbetrag netto",
vatLine: (r) => `zzgl. Umsatzsteuer ${r}`,
grossTotal: "Gesamtbetrag brutto",
salutationGeneric: "Sehr geehrte Damen und Herren,",
thankYouLine:
"vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:",
closing: "Mit freundlichen Grüßen",
paymentTerms: (days, due) =>
`Bitte überweisen Sie den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung stehen wir Ihnen gerne zur Verfügung.`,
paymentTermsImmediate:
"Der Rechnungsbetrag ist sofort nach Erhalt zur Zahlung fällig.",
giroCodeCaption: "GiroCode",
reverseChargeNotice:
"Steuerschuldnerschaft des Leistungsempfängers gemäß Art. 196 MwStSystRL (Reverse Charge).",
exportNotice: "Steuerfreie Ausfuhrlieferung gemäß § 7 UStG.",
kleinunternehmerNotice:
"Gemäß § 6 Abs. 1 Z 27 UStG wird keine Umsatzsteuer ausgewiesen (Kleinunternehmer).",
pieceUnit: "Stk",
page: (c, t) => `${c}/${t}`,
legalCourtLabel: "Amtsgericht",
fnLabel: "FN",
vatIdLabel: "UID",
taxNumberLabel: "St.Nr.",
ownerLabel: "Inhaber",
ibanLabel: "IBAN",
bicLabel: "BIC",
bankLabel: "Bank",
addressHeading: "Adresse",
contactHeading: "Kontakt",
legalHeading: "Rechtliches",
bankHeading: "Bankverbindung",
emailLabel: "E-Mail",
webLabel: "Web",
phoneLabel: "Tel.",
paidStamp: "BEZAHLT",
};
const en: InvoiceStrings = {
invoice: "Invoice",
stornoInvoice: "Cancellation invoice",
stornoReference: (n) => `Cancels invoice no. ${n}`,
invoiceNumber: "Invoice no.",
invoiceDate: "Invoice date",
deliveryDate: "Delivery date",
customerVatId: "Your VAT ID",
position: "Pos.",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
totalPrice: "Total",
netTotal: "Net total",
vatLine: (r) => `plus VAT ${r}`,
grossTotal: "Gross total",
salutationGeneric: "Dear Sir or Madam,",
thankYouLine:
"Thank you for your order. We hereby invoice you for the following:",
closing: "Kind regards",
paymentTerms: (days, due) =>
`Please transfer the invoice amount within ${days} days, no later than ${due}, to the bank account shown below.`,
paymentTermsImmediate: "The invoice amount is due immediately upon receipt.",
giroCodeCaption: "GiroCode",
reverseChargeNotice:
"Reverse charge: VAT to be accounted for by the recipient pursuant to Art. 196 of Council Directive 2006/112/EC.",
exportNotice: "Tax-exempt export delivery pursuant to § 7 UStG.",
kleinunternehmerNotice:
"VAT is not charged pursuant to § 6 (1) 27 UStG (small-business exemption).",
pieceUnit: "pcs",
page: (c, t) => `${c}/${t}`,
legalCourtLabel: "Commercial court",
fnLabel: "FN",
vatIdLabel: "VAT ID",
taxNumberLabel: "Tax no.",
ownerLabel: "Owner",
ibanLabel: "IBAN",
bicLabel: "BIC",
bankLabel: "Bank",
addressHeading: "Address",
contactHeading: "Contact",
legalHeading: "Legal",
bankHeading: "Bank details",
emailLabel: "E-mail",
webLabel: "Web",
phoneLabel: "Tel.",
paidStamp: "PAID",
};
export function pickLanguage(input: string | null | undefined): InvoiceLanguage {
if (!input) return "de";
const v = input.toLowerCase();
if (v.startsWith("en")) return "en";
return "de";
}
export function getStrings(language: InvoiceLanguage): InvoiceStrings {
return language === "en" ? en : de;
}
@@ -0,0 +1,225 @@
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
/**
* Raw shape of the data we need from the Shopify Admin GraphQL API to
* compose an invoice. Kept narrow so the composer is testable with fixtures.
*/
export interface RawOrderForInvoice {
id: string;
name: string;
orderNumber: number;
createdAt: string;
processedAt: string | null;
currencyCode: string;
displayFinancialStatus: string | null;
customer: {
firstName: string | null;
lastName: string | null;
email: string | null;
locale: string | null;
} | null;
billingAddress: RawAddress | null;
shippingAddress: RawAddress | null;
lineItems: RawLineItem[];
taxLines: RawTaxLine[];
taxesIncluded: boolean;
subtotalSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
purchasingEntity: {
company?: {
name: string;
vatId: string | null;
address: RawAddress | null;
} | null;
} | null;
}
export interface RawAddress {
name: string | null;
company: string | null;
address1: string | null;
address2: string | null;
zip: string | null;
city: string | null;
province: string | null;
countryCode: string | null;
}
export interface RawMoney {
amount: string;
currencyCode: string;
}
export interface RawLineItem {
title: string;
sku: string | null;
quantity: number;
originalUnitPriceSet: { shopMoney: RawMoney };
taxLines: RawTaxLine[];
}
export interface RawTaxLine {
title: string | null;
rate: number | null;
ratePercentage: number | null;
priceSet: { shopMoney: RawMoney };
}
const QUERY = `#graphql
query OrderForInvoice($id: ID!) {
order(id: $id) {
id
name
number
createdAt
processedAt
currencyCode
displayFinancialStatus
taxesIncluded
customer {
firstName
lastName
email
locale
}
billingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
shippingAddress {
name
company
address1
address2
zip
city
province
countryCode: countryCodeV2
}
subtotalPriceSet { shopMoney { amount currencyCode } }
totalTaxSet { shopMoney { amount currencyCode } }
totalPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
lineItems(first: 250) {
edges {
node {
title
sku
quantity
originalUnitPriceSet { shopMoney { amount currencyCode } }
taxLines {
title
rate
ratePercentage
priceSet { shopMoney { amount currencyCode } }
}
}
}
}
purchasingEntity {
... on PurchasingCompany {
company {
name
}
location {
taxRegistrationId
billingAddress {
address1
address2
zip
city
countryCode
}
}
}
}
}
}
`;
interface RawAdminResponse {
data?: {
order?: {
id: string;
name: string;
number: number;
createdAt: string;
processedAt: string | null;
currencyCode: string;
displayFinancialStatus: string | null;
taxesIncluded: boolean;
customer: {
firstName: string | null;
lastName: string | null;
email: string | null;
locale: string | null;
} | null;
billingAddress: RawAddress | null;
shippingAddress: RawAddress | null;
subtotalPriceSet: { shopMoney: RawMoney } | null;
totalTaxSet: { shopMoney: RawMoney } | null;
totalPriceSet: { shopMoney: RawMoney } | null;
taxLines: RawTaxLine[];
lineItems: { edges: { node: RawLineItem }[] };
purchasingEntity: {
company?: { name: string } | null;
location?: {
taxRegistrationId: string | null;
billingAddress: RawAddress | null;
} | null;
} | null;
} | null;
};
}
export async function loadOrderForInvoice(
admin: AdminApiContext,
orderGid: string,
): Promise<RawOrderForInvoice> {
const response = await admin.graphql(QUERY, { variables: { id: orderGid } });
const json = (await response.json()) as RawAdminResponse;
const order = json.data?.order;
if (!order) {
throw new Error(`Order ${orderGid} not found.`);
}
const purchasingCompany = order.purchasingEntity?.company
? {
name: order.purchasingEntity.company.name,
vatId: order.purchasingEntity.location?.taxRegistrationId ?? null,
address: order.purchasingEntity.location?.billingAddress ?? null,
}
: null;
return {
id: order.id,
name: order.name,
orderNumber: order.number,
createdAt: order.createdAt,
processedAt: order.processedAt,
currencyCode: order.currencyCode,
displayFinancialStatus: order.displayFinancialStatus,
taxesIncluded: order.taxesIncluded,
customer: order.customer,
billingAddress: order.billingAddress,
shippingAddress: order.shippingAddress,
subtotalSet: order.subtotalPriceSet,
totalTaxSet: order.totalTaxSet,
totalPriceSet: order.totalPriceSet,
taxLines: order.taxLines || [],
lineItems: (order.lineItems?.edges || []).map((e) => e.node),
purchasingEntity: { company: purchasingCompany },
};
}
+67
View File
@@ -0,0 +1,67 @@
import db from "../../db.server";
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB cap
const STALE_AFTER_MS = 24 * 60 * 60 * 1000; // re-fetch once a day at most
/**
* Returns a `data:` URL for the shop's logo bytes, fetching from the
* configured URL on first use (or when stale) and persisting to the
* `LogoCache` table for subsequent renders.
*/
export async function getLogoDataUrl(
shopDomain: string,
logoUrl: string,
): Promise<string | undefined> {
if (!logoUrl) return undefined;
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
const isFresh =
cached &&
cached.sourceUrl === logoUrl &&
Date.now() - cached.fetchedAt.getTime() < STALE_AFTER_MS;
if (isFresh && cached) {
return toDataUrl(cached.bytes, cached.contentType);
}
let response: Response;
try {
response = await fetch(logoUrl);
} catch (err) {
console.warn(`Logo fetch failed for ${shopDomain}:`, err);
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
}
if (!response.ok) {
console.warn(`Logo fetch HTTP ${response.status} for ${shopDomain}`);
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
}
const arrayBuf = await response.arrayBuffer();
if (arrayBuf.byteLength > MAX_BYTES) {
console.warn(`Logo too large (${arrayBuf.byteLength} bytes) — skipping cache.`);
return undefined;
}
const bytes = Buffer.from(arrayBuf);
const contentType = response.headers.get("content-type") || guessContentType(logoUrl);
const etag = response.headers.get("etag") || "";
await db.logoCache.upsert({
where: { shopDomain },
create: { shopDomain, sourceUrl: logoUrl, bytes, contentType, etag },
update: { sourceUrl: logoUrl, bytes, contentType, etag, fetchedAt: new Date() },
});
return toDataUrl(bytes, contentType);
}
function toDataUrl(bytes: Buffer | Uint8Array, contentType: string): string {
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
return `data:${contentType};base64,${buf.toString("base64")}`;
}
function guessContentType(url: string): string {
const lower = url.toLowerCase();
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".webp")) return "image/webp";
return "image/png";
}
+38
View File
@@ -0,0 +1,38 @@
import db from "../../db.server";
import type { ShopSettings } from "@prisma/client";
/**
* Allocates an invoice number for the given order, using the shop's
* configured numbering mode. For `prefix_sequential`, allocation is atomic
* across concurrent requests via Prisma's interactive transaction (with the
* counter row acting as the lock).
*/
export async function allocateInvoiceNumber(
settings: ShopSettings,
orderNumber: number,
): Promise<string> {
const prefix = settings.invoicePrefix || "";
if (settings.numberingMode === "prefix_sequential") {
const next = await db.$transaction(async (tx) => {
const counter = await tx.invoiceCounter.upsert({
where: { shopDomain: settings.shopDomain },
create: {
shopDomain: settings.shopDomain,
lastValue: settings.invoiceSeed,
},
update: {},
});
const newValue = counter.lastValue + 1;
await tx.invoiceCounter.update({
where: { shopDomain: settings.shopDomain },
data: { lastValue: newValue },
});
return newValue;
});
return `${prefix}${next}`;
}
// Default: reuse the Shopify order number with the configured prefix.
return `${prefix}${orderNumber}`;
}
@@ -0,0 +1,467 @@
/* eslint-disable react/no-unknown-property */
import {
Document,
Image,
Page,
StyleSheet,
Text,
View,
} from "@react-pdf/renderer";
import React from "react";
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
import { getStrings } from "../i18n";
import type { InvoiceLanguage } from "../i18n";
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
// Brand blue chosen to roughly match the reference invoice. This is not
// pixel-perfect; merchants can tweak via a future setting if needed.
const BRAND_BLUE = "#1E8FCD";
const TEXT_DARK = "#1F2933";
const TEXT_MUTED = "#6B7280";
const TABLE_BORDER = "#E5E7EB";
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 110, // leaves room for fixed footer
paddingHorizontal: 40,
fontSize: 9,
fontFamily: "Helvetica",
color: TEXT_DARK,
lineHeight: 1.4,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 24,
},
logo: {
maxHeight: 50,
maxWidth: 180,
objectFit: "contain",
},
senderLine: {
fontSize: 7,
color: TEXT_MUTED,
marginBottom: 4,
textDecoration: "underline",
},
recipientBlock: {
width: "55%",
},
recipientName: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
},
metaBlock: {
width: "40%",
},
metaTable: {
flexDirection: "column",
},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 2,
},
metaLabel: {
color: TEXT_MUTED,
},
metaValue: {
fontFamily: "Helvetica-Bold",
},
invoiceNumberBig: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
fontSize: 14,
},
title: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
fontSize: 18,
marginTop: 20,
marginBottom: 12,
},
paragraph: {
marginBottom: 6,
},
table: {
marginTop: 10,
borderTopWidth: 0,
},
tableHeader: {
flexDirection: "row",
backgroundColor: BRAND_BLUE,
color: "#FFFFFF",
fontFamily: "Helvetica-Bold",
paddingVertical: 6,
paddingHorizontal: 4,
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: TABLE_BORDER,
paddingVertical: 6,
paddingHorizontal: 4,
},
colPos: { width: "8%" },
colDescription: { width: "44%" },
colQty: { width: "16%", textAlign: "right" },
colUnit: { width: "16%", textAlign: "right" },
colTotal: { width: "16%", textAlign: "right" },
itemTitle: {
fontFamily: "Helvetica-Bold",
},
itemSku: {
color: TEXT_MUTED,
fontSize: 7,
marginTop: 1,
},
totalsBlock: {
marginTop: 10,
alignSelf: "flex-end",
width: "50%",
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalLabel: {
color: TEXT_DARK,
},
totalLabelBlue: {
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
},
totalValue: {
textAlign: "right",
},
totalValueBoldBlue: {
textAlign: "right",
color: BRAND_BLUE,
fontFamily: "Helvetica-Bold",
},
noticeBlock: {
marginTop: 10,
padding: 6,
backgroundColor: "#F3F4F6",
fontSize: 8,
},
giroBlock: {
marginTop: 20,
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
},
giroImage: {
width: 90,
height: 90,
},
giroCaption: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 9,
marginBottom: 4,
},
giroDetails: {
fontSize: 8,
color: TEXT_DARK,
lineHeight: 1.4,
},
closing: {
marginTop: 24,
},
footer: {
position: "absolute",
bottom: 30,
left: 40,
right: 40,
borderTopWidth: 0.5,
borderTopColor: BRAND_BLUE,
paddingTop: 6,
flexDirection: "row",
justifyContent: "space-between",
fontSize: 7,
color: TEXT_DARK,
},
footerCol: { width: "23%" },
footerHeading: {
fontFamily: "Helvetica-Bold",
color: BRAND_BLUE,
fontSize: 7,
marginBottom: 3,
},
pageIndicator: {
position: "absolute",
bottom: 12,
right: 40,
fontSize: 7,
color: TEXT_MUTED,
},
stornoBanner: {
backgroundColor: "#B91C1C",
color: "#FFFFFF",
fontFamily: "Helvetica-Bold",
padding: 6,
marginBottom: 10,
fontSize: 11,
textAlign: "center",
},
});
interface DocProps {
invoice: InvoiceViewModel;
}
export function InvoiceDocument({ invoice }: DocProps) {
const t = getStrings(invoice.language);
const cur = invoice.currency;
return (
<Document
title={`${t.invoice} ${invoice.number}`}
author={invoice.issuer.companyName}
creator="LinumIQ Invoice"
>
<Page size="A4" style={styles.page}>
{invoice.kind === "storno" && (
<Text style={styles.stornoBanner}>
{t.stornoInvoice}
{invoice.cancelsNumber ? `${t.stornoReference(invoice.cancelsNumber)}` : ""}
</Text>
)}
<Header issuer={invoice.issuer} />
<View style={styles.headerRow}>
<View style={styles.recipientBlock}>
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
<Recipient recipient={invoice.recipient} />
</View>
<View style={styles.metaBlock}>
<View style={styles.metaTable}>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.invoiceNumber}</Text>
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.invoiceDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
</View>
{invoice.recipientVatId ? (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
<Text style={styles.metaValue}>{invoice.recipientVatId}</Text>
</View>
) : null}
</View>
</View>
</View>
<Text style={styles.title}>
{invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number}
</Text>
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.colPos}>{t.position}</Text>
<Text style={styles.colDescription}>{t.description}</Text>
<Text style={styles.colQty}>{t.quantity}</Text>
<Text style={styles.colUnit}>{t.unitPrice}</Text>
<Text style={styles.colTotal}>{t.totalPrice}</Text>
</View>
{invoice.lines.map((line) => (
<LineRow key={line.position} line={line} language={invoice.language} currency={cur} />
))}
</View>
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabelBlue}>{t.netTotal}</Text>
<Text style={[styles.totalValue, { color: BRAND_BLUE, fontFamily: "Helvetica-Bold" }]}>
{formatMoney(invoice.totals.net, cur, invoice.language)}
</Text>
</View>
{invoice.totals.vatBreakdown.map((v) => (
<View key={`vat-${v.ratePct}`} style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.vatLine(formatTaxRate(v.ratePct, invoice.language))}</Text>
<Text style={styles.totalValue}>{formatMoney(v.tax, cur, invoice.language)}</Text>
</View>
))}
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
<Text style={styles.totalLabelBlue}>{t.grossTotal}</Text>
<Text style={styles.totalValueBoldBlue}>
{formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
</View>
</View>
{invoice.notices.length > 0 && (
<View style={styles.noticeBlock}>
{invoice.notices.map((n) => (
<Text key={n.kind}>
{n.kind === "reverseCharge" && t.reverseChargeNotice}
{n.kind === "export" && t.exportNotice}
{n.kind === "kleinunternehmer" && t.kleinunternehmerNotice}
</Text>
))}
</View>
)}
<Text style={[styles.paragraph, { marginTop: 16 }]}>
{invoice.dueDate
? t.paymentTerms(
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
formatDate(invoice.dueDate, invoice.language),
)
: t.paymentTermsImmediate}
</Text>
{invoice.giroCodePngDataUrl && !invoice.paid && (
<View style={styles.giroBlock}>
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
<View>
<Text style={styles.giroCaption}>{t.giroCodeCaption}</Text>
<Text style={styles.giroDetails}>{invoice.issuer.bankName}</Text>
<Text style={styles.giroDetails}>{t.ibanLabel}: {invoice.issuer.iban}</Text>
{invoice.issuer.bic ? (
<Text style={styles.giroDetails}>{t.bicLabel}: {invoice.issuer.bic}</Text>
) : null}
<Text style={styles.giroDetails}>
{formatMoney(invoice.totals.gross, cur, invoice.language)}
</Text>
</View>
</View>
)}
<View style={styles.closing}>
<Text>{t.closing}</Text>
<Text style={{ fontFamily: "Helvetica-Bold", marginTop: 4 }}>
{invoice.issuer.ownerName || invoice.issuer.companyName}
</Text>
</View>
<Footer issuer={invoice.issuer} language={invoice.language} />
<Text
style={styles.pageIndicator}
render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`}
fixed
/>
</Page>
</Document>
);
}
function senderInline(issuer: IssuerData): string {
return [
[issuer.companyName, issuer.legalForm].filter(Boolean).join(" "),
issuer.addressLine1,
[issuer.postalCode, issuer.city].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(" - ");
}
function Header({ issuer }: { issuer: IssuerData }) {
return (
<View style={styles.headerRow}>
<View>{/* spacer; logo is right-aligned */}</View>
{issuer.logoDataUrl ? <Image src={issuer.logoDataUrl} style={styles.logo} /> : <View />}
</View>
);
}
function Recipient({ recipient }: { recipient: RecipientData }) {
const lines: string[] = [];
if (recipient.company) lines.push(recipient.company);
if (recipient.name && recipient.name !== recipient.company) lines.push(recipient.name);
if (recipient.addressLine1) lines.push(recipient.addressLine1);
if (recipient.addressLine2) lines.push(recipient.addressLine2);
const cityLine = [recipient.postalCode, recipient.city].filter(Boolean).join(" ");
if (cityLine) lines.push(cityLine);
if (recipient.countryCode) lines.push(recipient.countryCode);
return (
<View>
{lines.map((l, i) => (
<Text key={i} style={i === 0 ? styles.recipientName : undefined}>
{l}
</Text>
))}
</View>
);
}
function LineRow({
line,
language,
currency,
}: {
line: InvoiceLine;
language: InvoiceLanguage;
currency: string;
}) {
const t = getStrings(language);
return (
<View style={styles.tableRow}>
<Text style={styles.colPos}>{line.position}</Text>
<View style={styles.colDescription}>
<Text style={styles.itemTitle}>{line.title}</Text>
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
</View>
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
</View>
);
}
function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLanguage }) {
const t = getStrings(language);
return (
<View style={styles.footer} fixed>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.addressHeading}</Text>
<Text>{[issuer.companyName, issuer.legalForm].filter(Boolean).join(" ")}</Text>
{issuer.addressLine1 ? <Text>{issuer.addressLine1}</Text> : null}
{issuer.addressLine2 ? <Text>{issuer.addressLine2}</Text> : null}
<Text>{[issuer.postalCode, issuer.city].filter(Boolean).join(" ")}</Text>
{issuer.countryCode ? <Text>{issuer.countryCode}</Text> : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.contactHeading}</Text>
{issuer.phone ? <Text>{t.phoneLabel}: {issuer.phone}</Text> : null}
{issuer.email ? <Text>{t.emailLabel}: {issuer.email}</Text> : null}
{issuer.website ? <Text>{t.webLabel}: {issuer.website}</Text> : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
{issuer.registrationCourt ? (
<Text>{t.legalCourtLabel}: {issuer.registrationCourt}</Text>
) : null}
{issuer.registrationNo ? <Text>{t.fnLabel}: {issuer.registrationNo}</Text> : null}
{issuer.vatId ? <Text>{t.vatIdLabel}: {issuer.vatId}</Text> : null}
{issuer.taxNumber ? <Text>{t.taxNumberLabel}: {issuer.taxNumber}</Text> : null}
{issuer.ownerName ? <Text>{t.ownerLabel}: {issuer.ownerName}</Text> : null}
</View>
<View style={styles.footerCol}>
<Text style={styles.footerHeading}>{t.bankHeading}</Text>
{issuer.bankName ? <Text>{t.bankLabel}: {issuer.bankName}</Text> : null}
{issuer.iban ? <Text>{t.ibanLabel}: {issuer.iban}</Text> : null}
{issuer.bic ? <Text>{t.bicLabel}: {issuer.bic}</Text> : null}
{issuer.footerNote ? <Text>{issuer.footerNote}</Text> : null}
</View>
</View>
);
}
+110
View File
@@ -0,0 +1,110 @@
import type { InvoiceLanguage } from "./i18n";
/**
* The view model passed into the PDF renderer. Decouples the PDF layer from
* Shopify's GraphQL response shape so the renderer can be unit-tested with
* fixtures.
*/
export interface InvoiceViewModel {
language: InvoiceLanguage;
currency: string;
// Identity
kind: "invoice" | "storno";
number: string;
/** Only set for storno: the original invoice number being cancelled. */
cancelsNumber?: string;
invoiceDate: Date;
deliveryDate: Date;
dueDate?: Date;
// Issuer
issuer: IssuerData;
// Recipient
recipient: RecipientData;
isB2B: boolean;
recipientVatId?: string;
// Lines
lines: InvoiceLine[];
totals: InvoiceTotals;
// Notices appended below the totals (legal text, picked from i18n keys).
notices: InvoiceNotice[];
// Optional GiroCode (rendered when issuer.iban is set, invoice is unpaid,
// and giroCodeEnabled is true upstream).
giroCodePngDataUrl?: string;
// Status flags
paid: boolean;
}
export interface IssuerData {
companyName: string;
legalForm: string;
ownerName: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
countryCode: string;
phone: string;
email: string;
website: string;
vatId: string;
taxNumber: string;
registrationNo: string;
registrationCourt: string;
bankName: string;
iban: string;
bic: string;
/** Optional pre-fetched logo bytes as a data URL. */
logoDataUrl?: string;
footerNote: string;
}
export interface RecipientData {
name: string;
company: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
countryCode: string;
}
export interface InvoiceLine {
position: number;
title: string;
/** Raw quantity (e.g. 6). */
quantity: number;
/** Net unit price (excluding tax). */
unitPriceNet: number;
/** Net total = quantity * unitPriceNet. */
totalNet: number;
/** Optional SKU for display under the title. */
sku?: string;
}
export type NoticeKind = "reverseCharge" | "export" | "kleinunternehmer";
export interface InvoiceNotice { kind: NoticeKind }
export interface InvoiceTotals {
net: number;
/** Empty when no VAT applies (Kleinunternehmer / export / reverse-charge). */
vatBreakdown: VatBreakdownEntry[];
totalVat: number;
gross: number;
}
export interface VatBreakdownEntry {
/** Stored as percent (e.g. 20 for 20%). */
ratePct: number;
/** Net amount taxed at this rate. */
net: number;
/** Tax amount for this rate. */
tax: number;
}
+61
View File
@@ -0,0 +1,61 @@
// IBAN validation helpers (BBAN length per country + mod-97 checksum).
// Deliberately self-contained — avoids pulling in an extra dependency.
const IBAN_LENGTHS: Record<string, number> = {
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22,
BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28,
EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23,
GL: 18, GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26,
IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20,
LV: 21, LY: 25, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30,
NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22,
SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28, TL: 23,
TN: 24, TR: 26, UA: 29, VA: 22, VG: 24, XK: 20,
};
/** Strips spaces and uppercases. Returns "" if input is null-ish. */
export function normaliseIban(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, "").toUpperCase();
}
/** Formats an IBAN in the canonical 4-char-grouped form. */
export function formatIban(value: string): string {
const n = normaliseIban(value);
return n.replace(/(.{4})/g, "$1 ").trim();
}
/** Validates IBAN: country length + mod-97 checksum. */
export function isValidIban(value: string): boolean {
const iban = normaliseIban(value);
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) return false;
const country = iban.slice(0, 2);
const expectedLength = IBAN_LENGTHS[country];
if (!expectedLength || iban.length !== expectedLength) return false;
// Move first 4 chars to the end, convert letters to digits (A=10..Z=35).
const rearranged = iban.slice(4) + iban.slice(0, 4);
let numeric = "";
for (const ch of rearranged) {
if (ch >= "0" && ch <= "9") numeric += ch;
else numeric += (ch.charCodeAt(0) - 55).toString(); // 'A' (65) -> 10
}
// mod 97 over a long numeric string — chunked to fit safely in JS numbers.
let remainder = 0;
for (let i = 0; i < numeric.length; i += 7) {
const block = remainder.toString() + numeric.substring(i, i + 7);
remainder = parseInt(block, 10) % 97;
}
return remainder === 1;
}
/** True for BIC formats: 8 or 11 alphanumeric uppercase characters. */
export function isValidBic(value: string): boolean {
const v = (value ?? "").replace(/\s+/g, "").toUpperCase();
return /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(v);
}
/** Austrian UID: ATU followed by 8 digits (case-insensitive). */
export function isValidAtVatId(value: string): boolean {
const v = (value ?? "").replace(/\s+/g, "").toUpperCase();
return /^ATU\d{8}$/.test(v);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
# Generate invoice (Flow action)
Triggers `POST /api/flow/generate-invoice` on the embedded app.
Input: `order_id` (commerce object reference, type `order`).
The `runtime_url` placeholder above is replaced by the Shopify CLI with the
deployed app URL on `shopify app deploy`. For local dev with `shopify app dev`
the CLI overrides it with the current tunnel URL automatically.
@@ -0,0 +1,18 @@
api_version = "2026-01"
uid = "c5ddcfc1-772d-c55a-bbd6-8502c4a56f15a7dba32e"
[[extensions]]
name = "Generate invoice"
type = "flow_action"
handle = "flow-generate-invoice"
description = "Generates an Austria-compliant PDF invoice for the given order and uploads it to Shopify Files."
runtime_url = "https://example.com/api/flow/generate-invoice"
[extensions.settings]
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
required = true
@@ -0,0 +1,8 @@
# Send invoice email (Flow action)
Triggers `POST /api/flow/send-invoice-email` on the embedded app.
Inputs:
- `order_id` (commerce object reference, type `order`, required)
- `recipient_email_override` (single line text, optional)
If no invoice exists yet for the order, the endpoint generates one first.
@@ -0,0 +1,25 @@
api_version = "2026-01"
uid = "755ff6f6-8f68-10df-5f23-2244a5eed905c7b67bc4"
[[extensions]]
name = "Send invoice email"
type = "flow_action"
handle = "flow-send-invoice-email"
description = "Sends the generated PDF invoice via email to the order's customer (or an override address)."
runtime_url = "https://example.com/api/flow/send-invoice-email"
[extensions.settings]
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "order_id"
name = "Order ID"
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
required = true
[[extensions.settings.fields]]
type = "single_line_text_field"
key = "recipient_email_override"
name = "Recipient email (optional)"
description = "If set, sends to this address instead of the customer's email on the order."
required = false
@@ -0,0 +1,5 @@
{
"admin.order-details.action.render": {
"main": "dist/invoice-action.js"
}
}
@@ -0,0 +1,10 @@
{
"name": "invoice-order-action",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/ActionExtension.tsx' {
const shopify: import('@shopify/ui-extensions/admin.order-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,11 @@
api_version = "2026-01"
[[extensions]]
name = "Generate invoice"
handle = "invoice-action"
type = "ui_extension"
uid = "linumiq-invoice-order-action"
[[extensions.targeting]]
target = "admin.order-details.action.render"
module = "./src/ActionExtension.tsx"
@@ -0,0 +1,55 @@
import { render } from "preact";
import { useState } from "preact/hooks";
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const { close, data } = (globalThis as any).shopify;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const orderGid: string | undefined = data?.selected?.[0]?.id;
const orderId = orderGid ? orderGid.split("/").pop() : undefined;
async function trigger(action: "generate" | "cancel_reissue") {
if (!orderId) {
setError("No order selected");
return;
}
setBusy(true);
setError(null);
try {
const body = new URLSearchParams({ action });
const res = await fetch(`/api/orders/${orderId}/invoice`, {
method: "POST",
body,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
}
close();
} catch (e: any) {
setError(e?.message ?? "Failed");
} finally {
setBusy(false);
}
}
return (
<s-admin-action heading="Generate invoice" loading={busy}>
<s-stack gap="200">
<s-text>Create or regenerate the PDF invoice for this order. The file will be uploaded to Shopify Files and linked via order metafields.</s-text>
{error ? <s-banner tone="critical">{error}</s-banner> : null}
</s-stack>
<s-button slot="primary-action" onClick={() => trigger("generate")} disabled={busy}>
Generate / regenerate
</s-button>
<s-button slot="secondary-actions" tone="critical" onClick={() => trigger("cancel_reissue")} disabled={busy}>
Cancel &amp; reissue
</s-button>
</s-admin-action>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
@@ -0,0 +1,5 @@
{
"admin.order-details.block.render": {
"main": "dist/invoice-order-block.js"
}
}
@@ -0,0 +1,10 @@
{
"name": "invoice-order-block",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@shopify/ui-extensions": "^2026.1.0",
"preact": "^10.22.0",
"typescript": "^5.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/BlockExtension.tsx' {
const shopify: import('@shopify/ui-extensions/admin.order-details.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
@@ -0,0 +1,11 @@
api_version = "2026-01"
[[extensions]]
name = "Invoice details"
handle = "invoice-order-block"
type = "ui_extension"
uid = "linumiq-invoice-order-block"
[[extensions.targeting]]
target = "admin.order-details.block.render"
module = "./src/BlockExtension.tsx"
@@ -0,0 +1,79 @@
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
interface InvoiceRow {
id: string;
invoiceNumber: string;
version: number;
kind: string;
issuedAt: string;
status: string;
pdfUrl: string;
cancelledAt: string | null;
sentAt: string | null;
}
interface Payload {
latest: InvoiceRow | null;
history: InvoiceRow[];
}
export default async () => {
render(<Extension />, document.body);
};
function Extension() {
const { data } = (globalThis as any).shopify;
const orderGid: string | undefined = data?.selected?.[0]?.id;
const orderId = orderGid ? orderGid.split("/").pop() : undefined;
const [payload, setPayload] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!orderId) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/orders/${orderId}/invoice`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: Payload = await res.json();
if (!cancelled) setPayload(json);
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [orderId]);
return (
<s-admin-block heading="Invoice">
{loading ? (
<s-text>Loading</s-text>
) : error ? (
<s-banner tone="critical">{error}</s-banner>
) : !payload?.latest ? (
<s-text>No invoice yet for this order.</s-text>
) : (
<s-stack gap="200">
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text>
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text>
<s-badge tone={payload.latest.sentAt ? "success" : "info"}>
{payload.latest.sentAt ? "Sent" : "Not sent"}
</s-badge>
{payload.latest.pdfUrl ? (
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link>
) : null}
{payload.history.length > 1 ? (
<s-text tone="subdued">{payload.history.length} versions in history</s-text>
) : null}
</s-stack>
)}
</s-admin-block>
);
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
+1323 -93
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"@prisma/client": "^6.16.3",
"@react-pdf/renderer": "^4.5.1",
"@react-router/dev": "^7.12.0",
"@react-router/fs-routes": "^7.12.0",
"@react-router/node": "^7.12.0",
@@ -32,8 +33,11 @@
"@shopify/app-bridge-react": "^4.2.4",
"@shopify/shopify-app-react-router": "^1.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
"@types/nodemailer": "^8.0.0",
"isbot": "^5.1.31",
"nodemailer": "^8.0.7",
"prisma": "^6.16.3",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.12.0",
@@ -44,6 +48,7 @@
"@shopify/polaris-types": "^1.0.1",
"@types/eslint": "^9.6.1",
"@types/node": "^22.18.8",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.25",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^6.21.0",
@@ -56,6 +61,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"graphql-config": "^5.1.1",
"prettier": "^3.6.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^6.3.6"
},
@@ -66,7 +72,8 @@
"@shopify/plugin-cloudflare"
],
"overrides": {
"p-map": "^4.0.0"
"p-map": "^4.0.0",
"@shopify/shopify-api": "13.0.0"
},
"author": "gerhard"
}
@@ -0,0 +1,117 @@
-- CreateTable
CREATE TABLE "ShopSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"companyName" TEXT NOT NULL DEFAULT '',
"legalForm" TEXT NOT NULL DEFAULT '',
"ownerName" TEXT NOT NULL DEFAULT '',
"addressLine1" TEXT NOT NULL DEFAULT '',
"addressLine2" TEXT NOT NULL DEFAULT '',
"postalCode" TEXT NOT NULL DEFAULT '',
"city" TEXT NOT NULL DEFAULT '',
"countryCode" TEXT NOT NULL DEFAULT 'AT',
"phone" TEXT NOT NULL DEFAULT '',
"email" TEXT NOT NULL DEFAULT '',
"website" TEXT NOT NULL DEFAULT '',
"vatId" TEXT NOT NULL DEFAULT '',
"taxNumber" TEXT NOT NULL DEFAULT '',
"registrationNo" TEXT NOT NULL DEFAULT '',
"registrationCourt" TEXT NOT NULL DEFAULT '',
"bankName" TEXT NOT NULL DEFAULT '',
"iban" TEXT NOT NULL DEFAULT '',
"bic" TEXT NOT NULL DEFAULT '',
"giroCodeEnabled" BOOLEAN NOT NULL DEFAULT true,
"numberingMode" TEXT NOT NULL DEFAULT 'shopify_order_number',
"invoicePrefix" TEXT NOT NULL DEFAULT 'RE-',
"invoiceSeed" INTEGER NOT NULL DEFAULT 1000,
"defaultLanguage" TEXT NOT NULL DEFAULT 'de',
"paymentTermDays" INTEGER NOT NULL DEFAULT 14,
"footerNote" TEXT NOT NULL DEFAULT '',
"kleinunternehmer" BOOLEAN NOT NULL DEFAULT false,
"logoUrl" TEXT NOT NULL DEFAULT '',
"smtpHost" TEXT NOT NULL DEFAULT '',
"smtpPort" INTEGER NOT NULL DEFAULT 587,
"smtpSecure" BOOLEAN NOT NULL DEFAULT false,
"smtpUser" TEXT NOT NULL DEFAULT '',
"smtpPassword" TEXT NOT NULL DEFAULT '',
"smtpFromName" TEXT NOT NULL DEFAULT '',
"smtpFromEmail" TEXT NOT NULL DEFAULT '',
"smtpReplyTo" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"orderName" TEXT NOT NULL,
"orderNumber" INTEGER NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"language" TEXT NOT NULL DEFAULT 'de',
"kind" TEXT NOT NULL DEFAULT 'invoice',
"version" INTEGER NOT NULL DEFAULT 1,
"cancelsInvoiceId" TEXT,
"pdfFileGid" TEXT NOT NULL DEFAULT '',
"pdfUrl" TEXT NOT NULL DEFAULT '',
"totalsJson" TEXT NOT NULL DEFAULT '{}',
"customerJson" TEXT NOT NULL DEFAULT '{}',
"issuedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" DATETIME,
"cancelledAt" DATETIME,
"status" TEXT NOT NULL DEFAULT 'issued',
"lastError" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Invoice_shopDomain_fkey" FOREIGN KEY ("shopDomain") REFERENCES "ShopSettings" ("shopDomain") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "InvoiceCounter" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"lastValue" INTEGER NOT NULL DEFAULT 0,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "EmailLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"toAddress" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"status" TEXT NOT NULL,
"error" TEXT NOT NULL DEFAULT '',
"sentAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "LogoCache" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopDomain" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"bytes" BLOB NOT NULL,
"contentType" TEXT NOT NULL DEFAULT 'image/png',
"etag" TEXT NOT NULL DEFAULT '',
"fetchedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ShopSettings_shopDomain_key" ON "ShopSettings"("shopDomain");
-- CreateIndex
CREATE INDEX "Invoice_shopDomain_orderId_idx" ON "Invoice"("shopDomain", "orderId");
-- CreateIndex
CREATE INDEX "Invoice_shopDomain_invoiceNumber_idx" ON "Invoice"("shopDomain", "invoiceNumber");
-- CreateIndex
CREATE UNIQUE INDEX "InvoiceCounter_shopDomain_key" ON "InvoiceCounter"("shopDomain");
-- CreateIndex
CREATE INDEX "EmailLog_shopDomain_invoiceId_idx" ON "EmailLog"("shopDomain", "invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "LogoCache_shopDomain_key" ON "LogoCache"("shopDomain");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+145
View File
@@ -32,3 +32,148 @@ model Session {
refreshToken String?
refreshTokenExpires DateTime?
}
// Per-shop issuer/configuration data. One row per installed shop.
model ShopSettings {
id String @id @default(cuid())
shopDomain String @unique
// Issuer / company data
companyName String @default("")
legalForm String @default("")
ownerName String @default("")
addressLine1 String @default("")
addressLine2 String @default("")
postalCode String @default("")
city String @default("")
countryCode String @default("AT")
phone String @default("")
email String @default("")
website String @default("")
// Legal identifiers (Austria)
vatId String @default("") // UID, e.g. ATU12345678
taxNumber String @default("") // Steuernummer
registrationNo String @default("") // FN (Firmenbuchnummer)
registrationCourt String @default("") // Firmenbuchgericht
// Bank
bankName String @default("")
iban String @default("")
bic String @default("")
giroCodeEnabled Boolean @default(true)
// Invoice numbering
// shopify_order_number | prefix_sequential
numberingMode String @default("shopify_order_number")
invoicePrefix String @default("RE-")
// Used only for prefix_sequential (the next number to issue minus 1)
invoiceSeed Int @default(1000)
// Defaults
defaultLanguage String @default("de")
paymentTermDays Int @default(14)
footerNote String @default("")
// Kleinunternehmer (§ 6 Abs. 1 Z 27 UStG)
kleinunternehmer Boolean @default(false)
// Logo (URL on Shopify Files or any reachable URL)
logoUrl String @default("")
// SMTP for email (used in later phase)
smtpHost String @default("")
smtpPort Int @default(587)
smtpSecure Boolean @default(false)
smtpUser String @default("")
smtpPassword String @default("")
smtpFromName String @default("")
smtpFromEmail String @default("")
smtpReplyTo String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoices Invoice[]
}
// Generated invoice record. One row per invoice document (versions and stornos
// each create new rows; the latest is linked on the order via metafield).
model Invoice {
id String @id @default(cuid())
shopDomain String
settings ShopSettings @relation(fields: [shopDomain], references: [shopDomain])
// Shopify order references
orderId String // gid://shopify/Order/...
orderName String // e.g. "#1004"
orderNumber Int // numeric order_number
// Invoice identity
invoiceNumber String // e.g. "RE-1004"
language String @default("de")
// invoice | storno
kind String @default("invoice")
// Increments per regeneration of the same invoiceNumber (always 1 for storno)
version Int @default(1)
// For storno rows: points to the cancelled Invoice.id
cancelsInvoiceId String?
// PDF storage
pdfFileGid String @default("") // gid://shopify/GenericFile/...
pdfUrl String @default("")
// Snapshots (JSON strings on sqlite)
totalsJson String @default("{}")
customerJson String @default("{}")
// Lifecycle
issuedAt DateTime @default(now())
sentAt DateTime?
cancelledAt DateTime?
status String @default("issued") // issued | sent | cancelled | failed
lastError String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shopDomain, orderId])
@@index([shopDomain, invoiceNumber])
}
// Per-shop atomic counter for prefix_sequential numbering mode.
model InvoiceCounter {
id String @id @default(cuid())
shopDomain String @unique
// Last issued numeric value; allocate new = lastValue + 1 atomically.
lastValue Int @default(0)
updatedAt DateTime @updatedAt
}
// Email delivery log (used by later Flow send action; declared now so the
// generator can record manual sends as well).
model EmailLog {
id String @id @default(cuid())
shopDomain String
invoiceId String
toAddress String
subject String
status String // queued | sent | failed
error String @default("")
sentAt DateTime @default(now())
@@index([shopDomain, invoiceId])
}
// Per-shop logo bytes cache. Avoids fetching the logo from Shopify Files on
// every PDF render.
model LogoCache {
id String @id @default(cuid())
shopDomain String @unique
sourceUrl String
bytes Bytes
contentType String @default("image/png")
etag String @default("")
fetchedAt DateTime @default(now())
}
+350
View File
@@ -0,0 +1,350 @@
/**
* Smoke test — renders an invoice PDF for a synthetic order matching the
* style of `data/rechnung.png` (1× Bluetooth Tracker @ 5,99 EUR × 6, B2B
* customer "Schmidhofer Dienstleistungen" with UID ATU57680511) and writes
* it to `data/sample-rechnung.pdf` for visual review.
*
* Also runs assertions on:
* - VAT calculations
* - GiroCode payload shape
* - IBAN validation
* - Numbering allocation in both modes
* - Notice derivation for B2B EU reverse-charge / export / Kleinunternehmer
*
* Run with: npx tsx scripts/render-sample.ts
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { composeInvoice } from "../app/services/invoice/composeInvoice";
import { buildGiroCodeDataUrl, buildGiroCodePayload } from "../app/services/invoice/girocode";
import {
isValidAtVatId,
isValidBic,
isValidIban,
normaliseIban,
} from "../app/services/invoice/validation";
import { renderInvoicePdf } from "../app/services/invoice/generateInvoice.server";
import type { RawOrderForInvoice } from "../app/services/invoice/loadOrderForInvoice.server";
// ------------------------------------------------------------------
// Lightweight assertion helper
// ------------------------------------------------------------------
let failed = 0;
function assert(name: string, cond: boolean, detail?: string) {
if (cond) {
console.log(`${name}`);
} else {
failed++;
console.error(`${name}${detail ? `${detail}` : ""}`);
}
}
function assertEq<T>(name: string, actual: T, expected: T) {
assert(name, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function assertNear(name: string, actual: number, expected: number, eps = 0.01) {
assert(name, Math.abs(actual - expected) <= eps, `expected ~${expected}, got ${actual}`);
}
// ------------------------------------------------------------------
// Synthetic settings (mirrors the reference invoice)
// ------------------------------------------------------------------
const settings = {
id: "test",
shopDomain: "linumiq.myshopify.com",
companyName: "LinumIQ",
legalForm: "e.U.",
ownerName: "Gerhard Berger",
addressLine1: "Anton-Kleinoscheg-Straße 64c/4",
addressLine2: "",
postalCode: "8051",
city: "Graz",
countryCode: "AT",
phone: "+43 660 1234567",
email: "office@linumiq.com",
website: "www.linumiq.com",
vatId: "ATU12345678",
taxNumber: "12 345/6789",
registrationNo: "FN 123456a",
registrationCourt: "Landesgericht Graz",
bankName: "Raiffeisen Steiermark",
iban: "AT611904300234573201",
bic: "RZSTAT2G",
giroCodeEnabled: true,
numberingMode: "shopify_order_number",
invoicePrefix: "RE-",
invoiceSeed: 1000,
defaultLanguage: "de",
paymentTermDays: 14,
footerNote: "",
kleinunternehmer: false,
logoUrl: "",
smtpHost: "",
smtpPort: 587,
smtpSecure: false,
smtpUser: "",
smtpPassword: "",
smtpFromName: "",
smtpFromEmail: "",
smtpReplyTo: "",
createdAt: new Date(),
updatedAt: new Date(),
} as const;
// ------------------------------------------------------------------
// Synthetic AT B2B order matching the reference image
// ------------------------------------------------------------------
function buildAtB2BOrder(): RawOrderForInvoice {
// 6 × 5,99 EUR net = 35,94 EUR net; 20% VAT = 7,19 EUR; gross = 43,13 EUR
const qty = 6;
const unitNet = 5.99;
const lineNet = qty * unitNet; // 35.94
const lineTax = +(lineNet * 0.2).toFixed(2); // 7.19
const lineGross = +(lineNet + lineTax).toFixed(2); // 43.13
return {
id: "gid://shopify/Order/9000000001",
name: "#1004",
orderNumber: 1004,
createdAt: "2026-04-15T10:00:00Z",
processedAt: "2026-04-15T10:00:00Z",
currencyCode: "EUR",
displayFinancialStatus: "PENDING",
taxesIncluded: false,
customer: {
firstName: "Lukas",
lastName: "Schmidhofer",
email: "lukas@schmidhofer.example",
locale: "de-AT",
},
billingAddress: {
name: "Lukas Schmidhofer",
company: "Schmidhofer Dienstleistungen",
address1: "Hauptstraße 12",
address2: null,
zip: "8010",
city: "Graz",
province: null,
countryCode: "AT",
},
shippingAddress: null,
lineItems: [
{
title: "Bluetooth Tracker",
sku: "BT-TRK-001",
quantity: qty,
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
},
],
},
],
taxLines: [
{
title: "USt 20%",
rate: 0.2,
ratePercentage: 20,
priceSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
},
],
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } },
purchasingEntity: {
company: {
name: "Schmidhofer Dienstleistungen",
vatId: "ATU57680511",
address: null,
},
},
};
}
// ------------------------------------------------------------------
// Variants for VAT-scenario assertions
// ------------------------------------------------------------------
function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.billingAddress!.countryCode = "DE";
o.billingAddress!.company = "Müller GmbH";
o.billingAddress!.zip = "80331";
o.billingAddress!.city = "München";
o.purchasingEntity!.company!.vatId = "DE123456789";
o.lineItems[0].taxLines = [];
o.taxLines = [];
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
return o;
}
function buildExportOrder(): RawOrderForInvoice {
const o = buildAtB2BOrder();
o.purchasingEntity = null;
o.billingAddress!.countryCode = "US";
o.billingAddress!.company = "";
o.billingAddress!.name = "Jane Doe";
o.billingAddress!.zip = "10001";
o.billingAddress!.city = "New York";
o.lineItems[0].taxLines = [];
o.taxLines = [];
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
o.customer!.locale = "en";
return o;
}
// ------------------------------------------------------------------
// Run assertions
// ------------------------------------------------------------------
async function main() {
console.log("• Validation helpers");
assert("isValidIban accepts a known-good AT IBAN", isValidIban("AT611904300234573201"));
assert("isValidIban rejects garbage", !isValidIban("AT00 0000 0000 0000 0001"));
assert("normaliseIban strips spaces and uppercases", normaliseIban("at61 1904 3002 3457 3201") === "AT611904300234573201");
assert("isValidBic accepts 8-char", isValidBic("RZSTAT2G"));
assert("isValidBic accepts 11-char", isValidBic("RZSTAT2G123"));
assert("isValidBic rejects too-short", !isValidBic("RZ"));
assert("isValidAtVatId accepts ATU + 8 digits", isValidAtVatId("ATU12345678"));
assert("isValidAtVatId rejects non-AT", !isValidAtVatId("DE123456789"));
console.log("• GiroCode payload");
const payload = buildGiroCodePayload({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: 43.13,
remittance: "RE-1004",
});
const lines = payload.split("\n");
assertEq("BCD service tag", lines[0], "BCD");
assertEq("version 002", lines[1], "002");
assertEq("charset UTF-8", lines[2], "1");
assertEq("SCT", lines[3], "SCT");
assertEq("BIC", lines[4], "RZSTAT2G");
assertEq("Beneficiary name", lines[5], "LinumIQ");
assertEq("IBAN", lines[6], "AT611904300234573201");
assertEq("Amount", lines[7], "EUR43.13");
assertEq("Remittance", lines[10], "RE-1004");
assert("Payload is < 331 bytes per EPC069-12", Buffer.byteLength(payload, "utf8") < 331,
`actual ${Buffer.byteLength(payload, "utf8")}`);
console.log("• AT B2B compose (matches reference invoice)");
const order = buildAtB2BOrder();
const vm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1004" });
assertEq("language picked from de-AT", vm.language, "de");
assertEq("currency", vm.currency, "EUR");
assert("isB2B detected", vm.isB2B);
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
assertEq("line count", vm.lines.length, 1);
const ln = vm.lines[0];
assertEq("line title", ln.title, "Bluetooth Tracker");
assertEq("line qty", ln.quantity, 6);
assertNear("line unit net", ln.unitPriceNet, 5.99);
assertNear("line total net", ln.totalNet, 35.94);
assertNear("net total", vm.totals.net, 35.94);
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
assertNear("gross", vm.totals.gross, 43.13);
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
assert("due date 14 days after invoice date", !!vm.dueDate
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
console.log("• EU B2B reverse-charge notice");
const euOrder = buildEuB2BReverseChargeOrder();
const euVm = composeInvoice({ order: euOrder, settings: settings as never, invoiceNumber: "RE-1005" });
assert("isB2B", euVm.isB2B);
assertEq("zero VAT", euVm.totals.totalVat, 0);
assertEq("reverseCharge notice present", euVm.notices.find((n) => n.kind === "reverseCharge")?.kind, "reverseCharge");
console.log("• Third-country export notice");
const exportOrder = buildExportOrder();
const exVm = composeInvoice({ order: exportOrder, settings: settings as never, invoiceNumber: "RE-1006" });
assertEq("zero VAT", exVm.totals.totalVat, 0);
assertEq("language fallback to en", exVm.language, "en");
assertEq("export notice present", exVm.notices.find((n) => n.kind === "export")?.kind, "export");
console.log("• Kleinunternehmer notice");
const k = composeInvoice({
order: buildAtB2BOrder(),
settings: { ...(settings as object), kleinunternehmer: true } as never,
invoiceNumber: "RE-1007",
});
assertEq("kleinunternehmer notice present", k.notices.find((n) => n.kind === "kleinunternehmer")?.kind, "kleinunternehmer");
console.log("• Render PDF for AT B2B order");
// Attach logo (loaded from disk so the smoke output mirrors a real merchant setup).
const logoPath = resolve(__dirname, "..", "data", "linumiq-logo.png");
const logoBytes = readFileSync(logoPath);
vm.issuer.logoDataUrl = `data:image/png;base64,${logoBytes.toString("base64")}`;
// Attach GiroCode (rendered manually here since the orchestrator does it).
vm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
beneficiaryName: settings.companyName,
iban: settings.iban,
bic: settings.bic,
amount: vm.totals.gross,
remittance: vm.number,
});
const buffer = await renderInvoicePdf(vm);
const out = resolve(__dirname, "..", "data", "sample-rechnung.pdf");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, buffer);
const start = buffer.subarray(0, 5).toString("ascii");
assertEq("PDF magic header %PDF-", start, "%PDF-");
assert("PDF size > 5 KB", buffer.length > 5_000, `actual ${buffer.length}`);
console.log(` → wrote ${out} (${buffer.length} bytes)`);
// ----------------------------------------------------------------
// Storno (cancellation invoice) composition + render
// ----------------------------------------------------------------
console.log("• Storno composition (cancels RE-1004)");
const storno = composeInvoice({
order,
settings: settings as never,
invoiceNumber: "RE-1004-S",
storno: { cancelsNumber: "RE-1004" },
});
assertEq("kind = storno", storno.kind, "storno");
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
assert("dueDate suppressed for storno", storno.dueDate == null);
assertEq("line count preserved", storno.lines.length, 1);
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
assertNear("totals.net negated", storno.totals.net, -35.94);
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
assertNear("totals.gross negated", storno.totals.gross, -43.13);
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19);
console.log("• Render storno PDF");
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
// Storno deliberately omits GiroCode (negative amount).
const stornoBuf = await renderInvoicePdf(storno);
const stornoOut = resolve(__dirname, "..", "data", "sample-storno.pdf");
writeFileSync(stornoOut, stornoBuf);
assertEq("storno PDF magic", stornoBuf.subarray(0, 5).toString("ascii"), "%PDF-");
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
if (failed > 0) {
console.error(`\n${failed} assertion(s) FAILED`);
process.exit(1);
}
console.log("\nAll smoke checks passed.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+13 -26
View File
@@ -6,32 +6,11 @@ embedded = true
name = "linumiq-invoice"
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "write_products,write_metaobjects,write_metaobject_definitions"
[product.metafields.app.demo_info]
type = "single_line_text_field"
name = "Demo Source Info"
description = "Tracks products created by the Shopify app template for development"
[product.metafields.app.demo_info.access]
admin = "merchant_read_write"
[metaobjects.app.example]
name = "Example"
description = "An example metaobject definition created by this template"
[metaobjects.app.example.access]
admin = "merchant_read_write"
[metaobjects.app.example.fields.title]
name = "Title"
type = "single_line_text_field"
required = true
[metaobjects.app.example.fields.description]
name = "Description"
type = "multi_line_text_field"
# Read orders + customers + companies (B2B) for invoice data.
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
# write_orders required to write the order metafield linking the latest PDF.
# read_all_orders allows access to orders older than 60 days for backfill.
scopes = "read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files"
[webhooks]
api_version = "2026-07"
@@ -44,6 +23,14 @@ api_version = "2026-07"
uri = "/webhooks/app/scopes_update"
topics = [ "app/scopes_update" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = [ "orders/create" ]
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = [ "orders/updated" ]
[auth]
redirect_urls = [ "https://example.com/api/auth" ]
+2 -1
View File
@@ -1,5 +1,6 @@
{
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
"exclude": ["node_modules", "build", "extensions"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"strict": true,
@@ -16,7 +17,7 @@
"moduleResolution": "Bundler",
"target": "ES2022",
"baseUrl": ".",
"types": ["@react-router/node", "vite/client", "@shopify/polaris-types"],
"types": ["@react-router/node", "vite/client", "@shopify/polaris-types", "@shopify/app-bridge-types"],
"rootDirs": [".", "./.react-router/types"]
}
}