first version
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { generateInvoice } from "../services/invoice/generateInvoice.server";
|
||||
import { cancelAndReissue } from "../services/invoice/cancelAndReissue.server";
|
||||
|
||||
/**
|
||||
* GET /api/orders/:orderId/invoice → returns latest invoice metadata + history
|
||||
* POST /api/orders/:orderId/invoice → generates (or regenerates) the invoice
|
||||
*
|
||||
* `orderId` may be a numeric Shopify order id or a full GID; the generator
|
||||
* normalises it.
|
||||
*/
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const orderId = requireOrderId(params);
|
||||
const orderGid = orderId.startsWith("gid://")
|
||||
? orderId
|
||||
: `gid://shopify/Order/${orderId}`;
|
||||
|
||||
const invoices = await db.invoice.findMany({
|
||||
where: { shopDomain: session.shop, orderId: orderGid },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
});
|
||||
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
|
||||
|
||||
return {
|
||||
latest: latest ? serialise(latest) : null,
|
||||
history: invoices.map(serialise),
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
const orderId = requireOrderId(params);
|
||||
const url = new URL(request.url);
|
||||
let op = url.searchParams.get("action");
|
||||
if (!op) {
|
||||
// Also accept the action from the form body (used by the in-app fetcher).
|
||||
const ct = request.headers.get("content-type") || "";
|
||||
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData();
|
||||
op = (form.get("action") as string | null) ?? null;
|
||||
}
|
||||
}
|
||||
op = op ?? "generate";
|
||||
|
||||
try {
|
||||
if (op === "cancel_reissue") {
|
||||
const result = await cancelAndReissue({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
return { ok: true, op, ...result };
|
||||
}
|
||||
|
||||
const result = await generateInvoice({
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
});
|
||||
return { ok: true, op: "generate", ...result };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("invoice action failed:", err);
|
||||
return Response.json({ ok: false, error: message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
function requireOrderId(params: { orderId?: string }): string {
|
||||
const id = params.orderId;
|
||||
if (!id) throw new Response("orderId is required", { status: 400 });
|
||||
return id;
|
||||
}
|
||||
|
||||
function serialise(invoice: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
version: number;
|
||||
kind: string;
|
||||
pdfUrl: string;
|
||||
status: string;
|
||||
sentAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
issuedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
version: invoice.version,
|
||||
kind: invoice.kind,
|
||||
pdfUrl: invoice.pdfUrl,
|
||||
status: invoice.status,
|
||||
sentAt: invoice.sentAt?.toISOString() ?? null,
|
||||
cancelledAt: invoice.cancelledAt?.toISOString() ?? null,
|
||||
issuedAt: invoice.issuedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
+68
-321
@@ -1,345 +1,92 @@
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
HeadersFunction,
|
||||
LoaderFunctionArgs,
|
||||
} from "react-router";
|
||||
import { useFetcher } from "react-router";
|
||||
import { useAppBridge } from "@shopify/app-bridge-react";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import db from "../db.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await authenticate.admin(request);
|
||||
const { session } = await authenticate.admin(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
const [settings, recent] = await Promise.all([
|
||||
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
|
||||
db.invoice.findMany({
|
||||
where: { shopDomain: session.shop },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { admin } = await authenticate.admin(request);
|
||||
const color = ["Red", "Orange", "Yellow", "Green"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
const response = await admin.graphql(
|
||||
`#graphql
|
||||
mutation populateProduct($product: ProductCreateInput!) {
|
||||
productCreate(product: $product) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
status
|
||||
variants(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
price
|
||||
barcode
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
demoInfo: metafield(namespace: "$app", key: "demo_info") {
|
||||
jsonValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
variables: {
|
||||
product: {
|
||||
title: `${color} Snowboard`,
|
||||
metafields: [
|
||||
{
|
||||
namespace: "$app",
|
||||
key: "demo_info",
|
||||
value: "Created by React Router Template",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
const settingsConfigured = !!(
|
||||
settings &&
|
||||
settings.companyName &&
|
||||
settings.addressLine1 &&
|
||||
settings.iban
|
||||
);
|
||||
const responseJson = await response.json();
|
||||
|
||||
const product = responseJson.data!.productCreate!.product!;
|
||||
const variantId = product.variants.edges[0]!.node!.id!;
|
||||
|
||||
const variantResponse = await admin.graphql(
|
||||
`#graphql
|
||||
mutation shopifyReactRouterTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||
productVariants {
|
||||
id
|
||||
price
|
||||
barcode
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
variables: {
|
||||
productId: product.id,
|
||||
variants: [{ id: variantId, price: "100.00" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const variantResponseJson = await variantResponse.json();
|
||||
|
||||
const metaobjectResponse = await admin.graphql(
|
||||
`#graphql
|
||||
mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
|
||||
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
|
||||
metaobject {
|
||||
id
|
||||
handle
|
||||
title: field(key: "title") {
|
||||
jsonValue
|
||||
}
|
||||
description: field(key: "description") {
|
||||
jsonValue
|
||||
}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
variables: {
|
||||
handle: {
|
||||
type: "$app:example",
|
||||
handle: "demo-entry",
|
||||
},
|
||||
metaobject: {
|
||||
fields: [
|
||||
{ key: "title", value: "Demo Entry" },
|
||||
{
|
||||
key: "description",
|
||||
value:
|
||||
"This metaobject was created by the Shopify app template to demonstrate the metaobject API.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metaobjectResponseJson = await metaobjectResponse.json();
|
||||
|
||||
return {
|
||||
product: responseJson!.data!.productCreate!.product,
|
||||
variant:
|
||||
variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants,
|
||||
metaobject:
|
||||
metaobjectResponseJson!.data!.metaobjectUpsert!.metaobject,
|
||||
settingsConfigured,
|
||||
recent: recent.map((i) => ({
|
||||
id: i.id,
|
||||
number: i.invoiceNumber,
|
||||
kind: i.kind,
|
||||
orderName: i.orderName,
|
||||
version: i.version,
|
||||
sentAt: i.sentAt?.toISOString() ?? null,
|
||||
cancelledAt: i.cancelledAt?.toISOString() ?? null,
|
||||
issuedAt: i.issuedAt.toISOString(),
|
||||
pdfUrl: i.pdfUrl,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
|
||||
const shopify = useAppBridge();
|
||||
const isLoading =
|
||||
["loading", "submitting"].includes(fetcher.state) &&
|
||||
fetcher.formMethod === "POST";
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.product?.id) {
|
||||
shopify.toast.show("Product created");
|
||||
}
|
||||
}, [fetcher.data?.product?.id, shopify]);
|
||||
|
||||
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading="Shopify app template">
|
||||
<s-button slot="primary-action" onClick={generateProduct}>
|
||||
Generate a product
|
||||
</s-button>
|
||||
<s-page heading="LinumIQ Invoice">
|
||||
{!settingsConfigured && (
|
||||
<s-banner tone="warning" heading="Configure your invoice settings">
|
||||
Complete your company, bank and numbering details so generated
|
||||
invoices are legally compliant.{" "}
|
||||
<Link to="/app/settings">Open settings</Link>
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<s-section heading="Congrats on creating a new Shopify app 🎉">
|
||||
<s-section heading="What this app does">
|
||||
<s-paragraph>
|
||||
This embedded app template uses{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/tools/app-bridge"
|
||||
target="_blank"
|
||||
>
|
||||
App Bridge
|
||||
</s-link>{" "}
|
||||
interface examples like an{" "}
|
||||
<s-link href="/app/additional">additional page in the app nav</s-link>
|
||||
, as well as an{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql"
|
||||
target="_blank"
|
||||
>
|
||||
Admin GraphQL
|
||||
</s-link>{" "}
|
||||
mutation demo, to provide a starting point for app development.
|
||||
Generates Austrian-compliant PDF invoices for your Shopify orders.
|
||||
Trigger from the order page (Generate invoice action), via Shopify
|
||||
Flow, or in bulk from the Invoices page. PDFs are stored on
|
||||
Shopify Files and linked to each order via metafields.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
<s-section heading="Get started with products">
|
||||
<s-paragraph>
|
||||
Generate a product with GraphQL and get the JSON output for that
|
||||
product. Learn more about the{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
|
||||
target="_blank"
|
||||
>
|
||||
productCreate
|
||||
</s-link>{" "}
|
||||
mutation in our API references. Includes a product{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data/metafields"
|
||||
target="_blank"
|
||||
>
|
||||
metafield
|
||||
</s-link>{" "}
|
||||
and{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data/metaobjects"
|
||||
target="_blank"
|
||||
>
|
||||
metaobject
|
||||
</s-link>
|
||||
.
|
||||
</s-paragraph>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-button
|
||||
onClick={generateProduct}
|
||||
{...(isLoading ? { loading: true } : {})}
|
||||
>
|
||||
Generate a product
|
||||
</s-button>
|
||||
{fetcher.data?.product && (
|
||||
<s-button
|
||||
onClick={() => {
|
||||
shopify.intents.invoke?.("edit:shopify/Product", {
|
||||
value: fetcher.data?.product?.id,
|
||||
});
|
||||
}}
|
||||
target="_blank"
|
||||
variant="tertiary"
|
||||
>
|
||||
Edit product
|
||||
</s-button>
|
||||
)}
|
||||
</s-stack>
|
||||
{fetcher.data?.product && (
|
||||
<s-section heading="productCreate mutation">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{JSON.stringify(fetcher.data.product, null, 2)}</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
|
||||
<s-heading>productVariantsBulkUpdate mutation</s-heading>
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{JSON.stringify(fetcher.data.variant, null, 2)}</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
|
||||
<s-heading>metaobjectUpsert mutation</s-heading>
|
||||
<s-box
|
||||
padding="base"
|
||||
borderWidth="base"
|
||||
borderRadius="base"
|
||||
background="subdued"
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>
|
||||
{JSON.stringify(fetcher.data.metaobject, null, 2)}
|
||||
</code>
|
||||
</pre>
|
||||
</s-box>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
<s-section heading="Recent invoices">
|
||||
{recent.length === 0 ? (
|
||||
<s-paragraph>No invoices generated yet.</s-paragraph>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{recent.map((i) => (
|
||||
<s-list-item key={i.id}>
|
||||
{i.kind === "storno" ? "Storno " : ""}
|
||||
{i.number} — order {i.orderName} (v{i.version})
|
||||
{i.cancelledAt
|
||||
? " — cancelled"
|
||||
: i.sentAt
|
||||
? " — sent"
|
||||
: ""}
|
||||
{i.pdfUrl ? (
|
||||
<>
|
||||
{" "}
|
||||
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
|
||||
</>
|
||||
) : null}
|
||||
</s-list-item>
|
||||
))}
|
||||
</s-unordered-list>
|
||||
)}
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="App template specs">
|
||||
<s-paragraph>
|
||||
<s-text>Framework: </s-text>
|
||||
<s-link href="https://reactrouter.com/" target="_blank">
|
||||
React Router
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Interface: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/app-home/using-polaris-components"
|
||||
target="_blank"
|
||||
>
|
||||
Polaris web components
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>API: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/api/admin-graphql"
|
||||
target="_blank"
|
||||
>
|
||||
GraphQL
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Custom data: </s-text>
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/build/custom-data"
|
||||
target="_blank"
|
||||
>
|
||||
Metafields & metaobjects
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
<s-text>Database: </s-text>
|
||||
<s-link href="https://www.prisma.io/" target="_blank">
|
||||
Prisma
|
||||
</s-link>
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="Next steps">
|
||||
<s-unordered-list>
|
||||
<s-list-item>
|
||||
Build an{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/getting-started/build-app-example"
|
||||
target="_blank"
|
||||
>
|
||||
example app
|
||||
</s-link>
|
||||
</s-list-item>
|
||||
<s-list-item>
|
||||
Explore Shopify's API with{" "}
|
||||
<s-link
|
||||
href="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
|
||||
target="_blank"
|
||||
>
|
||||
GraphiQL
|
||||
</s-link>
|
||||
</s-list-item>
|
||||
</s-unordered-list>
|
||||
<Link to="/app/invoices">Open invoices page</Link>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => {
|
||||
return boundary.headers(headersArgs);
|
||||
};
|
||||
|
||||
@@ -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><ui-nav-menu></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user