first version

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