diff --git a/app/routes/api.flow.generate-invoice.tsx b/app/routes/api.flow.generate-invoice.tsx new file mode 100644 index 0000000..0a16b29 --- /dev/null +++ b/app/routes/api.flow.generate-invoice.tsx @@ -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 }).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; +} diff --git a/app/routes/api.flow.send-invoice-email.tsx b/app/routes/api.flow.send-invoice-email.tsx new file mode 100644 index 0000000..c2904ab --- /dev/null +++ b/app/routes/api.flow.send-invoice-email.tsx @@ -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 }).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 }).properties; + if (!props || typeof props !== "object") return null; + const raw = props[key]; + if (typeof raw === "string" && raw.length > 0) return raw; + return null; +} diff --git a/app/routes/api.orders.$orderId.invoice.tsx b/app/routes/api.orders.$orderId.invoice.tsx new file mode 100644 index 0000000..94ed81d --- /dev/null +++ b/app/routes/api.orders.$orderId.invoice.tsx @@ -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(), + }; +} diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index 7fe26a1..7c29916 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -1,345 +1,92 @@ -import { useEffect } from "react"; -import type { - ActionFunctionArgs, - HeadersFunction, - LoaderFunctionArgs, -} from "react-router"; -import { useFetcher } from "react-router"; -import { useAppBridge } from "@shopify/app-bridge-react"; +import type { LoaderFunctionArgs } from "react-router"; +import { Link, useLoaderData } from "react-router"; + import { authenticate } from "../shopify.server"; -import { boundary } from "@shopify/shopify-app-react-router/server"; +import db from "../db.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - await authenticate.admin(request); + const { session } = await authenticate.admin(request); - return null; -}; + const [settings, recent] = await Promise.all([ + db.shopSettings.findUnique({ where: { shopDomain: session.shop } }), + db.invoice.findMany({ + where: { shopDomain: session.shop }, + orderBy: [{ issuedAt: "desc" }], + take: 10, + }), + ]); -export const action = async ({ request }: ActionFunctionArgs) => { - const { admin } = await authenticate.admin(request); - const color = ["Red", "Orange", "Yellow", "Green"][ - Math.floor(Math.random() * 4) - ]; - const response = await admin.graphql( - `#graphql - mutation populateProduct($product: ProductCreateInput!) { - productCreate(product: $product) { - product { - id - title - handle - status - variants(first: 10) { - edges { - node { - id - price - barcode - createdAt - } - } - } - demoInfo: metafield(namespace: "$app", key: "demo_info") { - jsonValue - } - } - } - }`, - { - variables: { - product: { - title: `${color} Snowboard`, - metafields: [ - { - namespace: "$app", - key: "demo_info", - value: "Created by React Router Template", - }, - ], - }, - }, - }, + const settingsConfigured = !!( + settings && + settings.companyName && + settings.addressLine1 && + settings.iban ); - const responseJson = await response.json(); - - const product = responseJson.data!.productCreate!.product!; - const variantId = product.variants.edges[0]!.node!.id!; - - const variantResponse = await admin.graphql( - `#graphql - mutation shopifyReactRouterTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { - productVariantsBulkUpdate(productId: $productId, variants: $variants) { - productVariants { - id - price - barcode - createdAt - } - } - }`, - { - variables: { - productId: product.id, - variants: [{ id: variantId, price: "100.00" }], - }, - }, - ); - - const variantResponseJson = await variantResponse.json(); - - const metaobjectResponse = await admin.graphql( - `#graphql - mutation shopifyReactRouterTemplateUpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) { - metaobjectUpsert(handle: $handle, metaobject: $metaobject) { - metaobject { - id - handle - title: field(key: "title") { - jsonValue - } - description: field(key: "description") { - jsonValue - } - } - userErrors { - field - message - } - } - }`, - { - variables: { - handle: { - type: "$app:example", - handle: "demo-entry", - }, - metaobject: { - fields: [ - { key: "title", value: "Demo Entry" }, - { - key: "description", - value: - "This metaobject was created by the Shopify app template to demonstrate the metaobject API.", - }, - ], - }, - }, - }, - ); - - const metaobjectResponseJson = await metaobjectResponse.json(); return { - product: responseJson!.data!.productCreate!.product, - variant: - variantResponseJson!.data!.productVariantsBulkUpdate!.productVariants, - metaobject: - metaobjectResponseJson!.data!.metaobjectUpsert!.metaobject, + settingsConfigured, + recent: recent.map((i) => ({ + id: i.id, + number: i.invoiceNumber, + kind: i.kind, + orderName: i.orderName, + version: i.version, + sentAt: i.sentAt?.toISOString() ?? null, + cancelledAt: i.cancelledAt?.toISOString() ?? null, + issuedAt: i.issuedAt.toISOString(), + pdfUrl: i.pdfUrl, + })), }; }; export default function Index() { - const fetcher = useFetcher(); - - const shopify = useAppBridge(); - const isLoading = - ["loading", "submitting"].includes(fetcher.state) && - fetcher.formMethod === "POST"; - - useEffect(() => { - if (fetcher.data?.product?.id) { - shopify.toast.show("Product created"); - } - }, [fetcher.data?.product?.id, shopify]); - - const generateProduct = () => fetcher.submit({}, { method: "POST" }); + const { settingsConfigured, recent } = useLoaderData(); return ( - - - Generate a product - + + {!settingsConfigured && ( + + Complete your company, bank and numbering details so generated + invoices are legally compliant.{" "} + Open settings + + )} - + - This embedded app template uses{" "} - - App Bridge - {" "} - interface examples like an{" "} - additional page in the app nav - , as well as an{" "} - - Admin GraphQL - {" "} - mutation demo, to provide a starting point for app development. + Generates Austrian-compliant PDF invoices for your Shopify orders. + Trigger from the order page (Generate invoice action), via Shopify + Flow, or in bulk from the Invoices page. PDFs are stored on + Shopify Files and linked to each order via metafields. - - - Generate a product with GraphQL and get the JSON output for that - product. Learn more about the{" "} - - productCreate - {" "} - mutation in our API references. Includes a product{" "} - - metafield - {" "} - and{" "} - - metaobject - - . - - - - Generate a product - - {fetcher.data?.product && ( - { - shopify.intents.invoke?.("edit:shopify/Product", { - value: fetcher.data?.product?.id, - }); - }} - target="_blank" - variant="tertiary" - > - Edit product - - )} - - {fetcher.data?.product && ( - - - -
-                  {JSON.stringify(fetcher.data.product, null, 2)}
-                
-
- productVariantsBulkUpdate mutation - -
-                  {JSON.stringify(fetcher.data.variant, null, 2)}
-                
-
- - metaobjectUpsert mutation - -
-                  
-                    {JSON.stringify(fetcher.data.metaobject, null, 2)}
-                  
-                
-
-
-
+ + {recent.length === 0 ? ( + No invoices generated yet. + ) : ( + + {recent.map((i) => ( + + {i.kind === "storno" ? "Storno " : ""} + {i.number} — order {i.orderName} (v{i.version}) + {i.cancelledAt + ? " — cancelled" + : i.sentAt + ? " — sent" + : ""} + {i.pdfUrl ? ( + <> + {" "} + [PDF] + + ) : null} + + ))} + )} - - - - - Framework: - - React Router - - - - Interface: - - Polaris web components - - - - API: - - GraphQL - - - - Custom data: - - Metafields & metaobjects - - - - Database: - - Prisma - - - - - - - - Build an{" "} - - example app - - - - Explore Shopify's API with{" "} - - GraphiQL - - - + Open invoices page
); } - -export const headers: HeadersFunction = (headersArgs) => { - return boundary.headers(headersArgs); -}; diff --git a/app/routes/app.additional.tsx b/app/routes/app.additional.tsx deleted file mode 100644 index e7c8c72..0000000 --- a/app/routes/app.additional.tsx +++ /dev/null @@ -1,37 +0,0 @@ -export default function AdditionalPage() { - return ( - - - - The app template comes with an additional page which demonstrates how - to create multiple pages within app navigation using{" "} - - App Bridge - - . - - - To create your own page and have it show up in the app navigation, add - a page inside app/routes, and a link to it in the{" "} - <ui-nav-menu> component found in{" "} - app/routes/app.jsx. - - - - - - - App nav best practices - - - - - - ); -} diff --git a/app/routes/app.invoices.tsx b/app/routes/app.invoices.tsx new file mode 100644 index 0000000..85ee1e1 --- /dev/null +++ b/app/routes/app.invoices.tsx @@ -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(); + 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(); + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle"; + + return ( + + + + Generate or view the latest invoice for each order. For sent + invoices, use Cancel & reissue to issue a Stornorechnung followed + by a fresh invoice. + + + + All + Missing invoice + Has invoice + + + {isLoading ? ( + Loading… + ) : orders.length === 0 ? ( + No orders match the current filter. + ) : ( + + {orders.map((o) => ( + + ))} + + )} + + + + 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.) + + + Tip: filter by Missing invoice to see orders that still + need one. + + + + ); +} + +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 ( + + + {order.name} + {order.totalPrice} {order.currency} + {order.hasInvoice ? ( + + ✓ {order.invoiceNumber} + {order.invoiceCancelled + ? " (cancelled)" + : order.invoiceSent + ? " (sent)" + : ""} + {order.pdfUrl ? ( + <> + {" — "} + PDF + + ) : null} + + ) : ( + — no invoice yet + )} + + + {order.hasInvoice + ? order.invoiceSent + ? "Cancel & reissue" + : "Regenerate" + : "Generate"} + + {order.hasInvoice && order.invoiceSent && ( + + )} + + {fetcher.data?.error && ( + {fetcher.data.error} + )} + + + ); +} diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx new file mode 100644 index 0000000..bdda619 --- /dev/null +++ b/app/routes/app.settings.tsx @@ -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(); + const actionData = useActionData(); + const nav = useNavigation(); + const isSaving = nav.state === "submitting"; + const errors = actionData?.errors ?? {}; + + return ( + + + 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. + + + {actionData?.ok && ( + Settings saved. + )} + {actionData && !actionData.ok && ( + + Please fix the errors highlighted below. + + )} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + shopify_order_number reuses the Shopify order + number (e.g. RE-1004). prefix_sequential + {" "}allocates a strictly gapless number from an internal counter + (legally safest under § 11 UStG). + + + + + + + + + + + + + + + + + + + + + + + + + + + Save settings + + +
+
+ ); +} + +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 + + ); + } + return ( + + ); +} + +interface ToggleProps { label: string; name: string; checked: boolean } +function Toggle({ label, name, checked }: ToggleProps) { + return ( + + ); +} + +interface SelectProps { + label: string; + name: string; + defaultValue: string; + options: { value: string; label: string }[]; +} +function Select({ label, name, defaultValue, options }: SelectProps) { + return ( + + {options.map((o) => ( + + {o.label} + + ))} + + ); +} diff --git a/app/routes/app.tsx b/app/routes/app.tsx index 338e74b..63e29f6 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -19,7 +19,8 @@ export default function App() { Home - Additional page + Invoices + Settings diff --git a/app/routes/webhooks.orders.create.tsx b/app/routes/webhooks.orders.create.tsx new file mode 100644 index 0000000..98bb4ee --- /dev/null +++ b/app/routes/webhooks.orders.create.tsx @@ -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(); +}; diff --git a/app/routes/webhooks.orders.updated.tsx b/app/routes/webhooks.orders.updated.tsx new file mode 100644 index 0000000..0039d3a --- /dev/null +++ b/app/routes/webhooks.orders.updated.tsx @@ -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(); +}; diff --git a/app/services/invoice/cancelAndReissue.server.tsx b/app/services/invoice/cancelAndReissue.server.tsx new file mode 100644 index 0000000..62ea5f2 --- /dev/null +++ b/app/services/invoice/cancelAndReissue.server.tsx @@ -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: }`, 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 { + 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( + , + )) 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 { + 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; +} diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts new file mode 100644 index 0000000..a61ae93 --- /dev/null +++ b/app/services/invoice/composeInvoice.ts @@ -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(); + 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, + 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()); +} diff --git a/app/services/invoice/email.server.ts b/app/services/invoice/email.server.ts new file mode 100644 index 0000000..914d875 --- /dev/null +++ b/app/services/invoice/email.server.ts @@ -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 { + 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 { + 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 = + `

Dear customer,

` + + `

Please find attached invoice ${escapeHtml(invoiceNumber)}.

` + + `

Kind regards,
${escapeHtml(company)}

`; + 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 = + `

Sehr geehrte Damen und Herren,

` + + `

anbei finden Sie die Rechnung ${escapeHtml(invoiceNumber)}.

` + + `

Mit freundlichen Grüßen,
${escapeHtml(company)}

`; + return { text, html }; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, + ); +} diff --git a/app/services/invoice/format.ts b/app/services/invoice/format.ts new file mode 100644 index 0000000..38e98de --- /dev/null +++ b/app/services/invoice/format.ts @@ -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(); +const QTY_FORMATTERS = new Map(); +const DATE_FORMATTERS = new Map(); + +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)}%`; +} diff --git a/app/services/invoice/generateInvoice.server.tsx b/app/services/invoice/generateInvoice.server.tsx new file mode 100644 index 0000000..2577d91 --- /dev/null +++ b/app/services/invoice/generateInvoice.server.tsx @@ -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 { + 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 { + // @react-pdf returns a Node Buffer in node environments. + return renderToBuffer() as Promise; +} + +/* ------------------------------------------------------------------ */ +/* 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 { + 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 { + 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 { + 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)}`); + } +} diff --git a/app/services/invoice/girocode.ts b/app/services/invoice/girocode.ts new file mode 100644 index 0000000..49347e5 --- /dev/null +++ b/app/services/invoice/girocode.ts @@ -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 { + const payload = buildGiroCodePayload(input); + // EPC requires error correction level M. + return QRCode.toDataURL(payload, { + errorCorrectionLevel: "M", + margin: 1, + width: 256, + }); +} diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts new file mode 100644 index 0000000..ac30cbe --- /dev/null +++ b/app/services/invoice/i18n.ts @@ -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; +} diff --git a/app/services/invoice/loadOrderForInvoice.server.ts b/app/services/invoice/loadOrderForInvoice.server.ts new file mode 100644 index 0000000..a563c9b --- /dev/null +++ b/app/services/invoice/loadOrderForInvoice.server.ts @@ -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 { + 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 }, + }; +} diff --git a/app/services/invoice/logoCache.server.ts b/app/services/invoice/logoCache.server.ts new file mode 100644 index 0000000..978f5d7 --- /dev/null +++ b/app/services/invoice/logoCache.server.ts @@ -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 { + 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"; +} diff --git a/app/services/invoice/numbering.server.ts b/app/services/invoice/numbering.server.ts new file mode 100644 index 0000000..8ccb0f9 --- /dev/null +++ b/app/services/invoice/numbering.server.ts @@ -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 { + 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}`; +} diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx new file mode 100644 index 0000000..4211e80 --- /dev/null +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -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 ( + + + {invoice.kind === "storno" && ( + + {t.stornoInvoice} + {invoice.cancelsNumber ? ` — ${t.stornoReference(invoice.cancelsNumber)}` : ""} + + )} + +
+ + + + {senderInline(invoice.issuer)} + + + + + + {t.invoiceNumber} + {invoice.number} + + + {t.invoiceDate} + {formatDate(invoice.invoiceDate, invoice.language)} + + + {t.deliveryDate} + {formatDate(invoice.deliveryDate, invoice.language)} + + {invoice.recipientVatId ? ( + + {t.customerVatId} + {invoice.recipientVatId} + + ) : null} + + + + + + {invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number} + + + {t.salutationGeneric} + {t.thankYouLine} + + + + {t.position} + {t.description} + {t.quantity} + {t.unitPrice} + {t.totalPrice} + + {invoice.lines.map((line) => ( + + ))} + + + + + {t.netTotal} + + {formatMoney(invoice.totals.net, cur, invoice.language)} + + + {invoice.totals.vatBreakdown.map((v) => ( + + {t.vatLine(formatTaxRate(v.ratePct, invoice.language))} + {formatMoney(v.tax, cur, invoice.language)} + + ))} + + {t.grossTotal} + + {formatMoney(invoice.totals.gross, cur, invoice.language)} + + + + + {invoice.notices.length > 0 && ( + + {invoice.notices.map((n) => ( + + {n.kind === "reverseCharge" && t.reverseChargeNotice} + {n.kind === "export" && t.exportNotice} + {n.kind === "kleinunternehmer" && t.kleinunternehmerNotice} + + ))} + + )} + + + {invoice.dueDate + ? t.paymentTerms( + Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)), + formatDate(invoice.dueDate, invoice.language), + ) + : t.paymentTermsImmediate} + + + {invoice.giroCodePngDataUrl && !invoice.paid && ( + + + + {t.giroCodeCaption} + {invoice.issuer.bankName} + {t.ibanLabel}: {invoice.issuer.iban} + {invoice.issuer.bic ? ( + {t.bicLabel}: {invoice.issuer.bic} + ) : null} + + {formatMoney(invoice.totals.gross, cur, invoice.language)} + + + + )} + + + {t.closing} + + {invoice.issuer.ownerName || invoice.issuer.companyName} + + + +