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