many updates :-)
This commit is contained in:
+159
-33
@@ -7,15 +7,25 @@ import db from "../db.server";
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
|
||||
const [settings, recent] = await Promise.all([
|
||||
const [settings, recent, counts] = await Promise.all([
|
||||
db.shopSettings.findUnique({ where: { shopDomain: session.shop } }),
|
||||
db.invoice.findMany({
|
||||
where: { shopDomain: session.shop },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
take: 10,
|
||||
take: 8,
|
||||
}),
|
||||
db.invoice.groupBy({
|
||||
by: ["status"],
|
||||
where: { shopDomain: session.shop },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = counts.reduce((acc, row) => acc + row._count._all, 0);
|
||||
const issuedCount = counts.find((c) => c.status === "issued")?._count._all ?? 0;
|
||||
const sentCount = counts.find((c) => c.status === "sent")?._count._all ?? 0;
|
||||
const cancelledCount = counts.find((c) => c.status === "cancelled")?._count._all ?? 0;
|
||||
|
||||
const settingsConfigured = !!(
|
||||
settings &&
|
||||
settings.companyName &&
|
||||
@@ -25,12 +35,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
settingsConfigured,
|
||||
metrics: { total, issuedCount, sentCount, cancelledCount },
|
||||
recent: recent.map((i) => ({
|
||||
id: i.id,
|
||||
number: i.invoiceNumber,
|
||||
kind: i.kind,
|
||||
orderName: i.orderName,
|
||||
version: i.version,
|
||||
status: i.status,
|
||||
sentAt: i.sentAt?.toISOString() ?? null,
|
||||
cancelledAt: i.cancelledAt?.toISOString() ?? null,
|
||||
issuedAt: i.issuedAt.toISOString(),
|
||||
@@ -39,54 +51,168 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return dateFmt.format(new Date(iso));
|
||||
}
|
||||
|
||||
interface RecentInvoice {
|
||||
id: string;
|
||||
number: string;
|
||||
kind: string;
|
||||
orderName: string;
|
||||
version: number;
|
||||
status: string;
|
||||
sentAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
issuedAt: string;
|
||||
pdfUrl: string;
|
||||
}
|
||||
|
||||
function statusBadge(invoice: RecentInvoice): {
|
||||
tone: "success" | "info" | "critical" | "warning";
|
||||
label: string;
|
||||
} {
|
||||
if (invoice.cancelledAt) return { tone: "critical", label: "Cancelled" };
|
||||
if (invoice.kind === "storno") return { tone: "warning", label: "Storno" };
|
||||
if (invoice.sentAt) return { tone: "success", label: "Sent" };
|
||||
return { tone: "info", label: "Issued" };
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const { settingsConfigured, recent } = useLoaderData<typeof loader>();
|
||||
const { settingsConfigured, metrics, recent } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<s-page heading="LinumIQ Invoice">
|
||||
{!settingsConfigured && (
|
||||
<s-banner tone="warning" heading="Configure your invoice settings">
|
||||
<s-paragraph>
|
||||
Complete your company, bank and numbering details so generated
|
||||
invoices are legally compliant.{" "}
|
||||
<Link to="/app/settings">Open settings</Link>
|
||||
invoices are legally compliant.
|
||||
</s-paragraph>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-link href="/app/settings">Open settings →</s-link>
|
||||
</s-stack>
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<s-section heading="What this app does">
|
||||
<s-paragraph>
|
||||
Generates Austrian-compliant PDF invoices for your Shopify orders.
|
||||
Trigger from the order page (Generate invoice action), via Shopify
|
||||
Flow, or in bulk from the Invoices page. PDFs are stored on
|
||||
Shopify Files and linked to each order via metafields.
|
||||
</s-paragraph>
|
||||
<s-section heading="Overview">
|
||||
<s-grid gridTemplateColumns="repeat(4, minmax(0, 1fr))" gap="base">
|
||||
<Metric label="Total invoices" value={metrics.total} />
|
||||
<Metric label="Issued" value={metrics.issuedCount} tone="info" />
|
||||
<Metric label="Sent" value={metrics.sentCount} tone="success" />
|
||||
<Metric label="Cancelled" value={metrics.cancelledCount} tone="critical" />
|
||||
</s-grid>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Recent invoices">
|
||||
<s-section
|
||||
heading="Recent invoices"
|
||||
padding="none"
|
||||
accessibilityLabel="Recent invoices table"
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<s-paragraph>No invoices generated yet.</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="block" gap="base" alignItems="center">
|
||||
<s-text type="strong">No invoices yet</s-text>
|
||||
<s-paragraph tone="neutral">
|
||||
Generate your first invoice from the Invoices page or directly
|
||||
from a Shopify order.
|
||||
</s-paragraph>
|
||||
<s-link href="/app/invoices">Open invoices →</s-link>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{recent.map((i) => (
|
||||
<s-list-item key={i.id}>
|
||||
{i.kind === "storno" ? "Storno " : ""}
|
||||
{i.number} — order {i.orderName} (v{i.version})
|
||||
{i.cancelledAt
|
||||
? " — cancelled"
|
||||
: i.sentAt
|
||||
? " — sent"
|
||||
: ""}
|
||||
{i.pdfUrl ? (
|
||||
<>
|
||||
{" "}
|
||||
[<a href={i.pdfUrl} target="_blank" rel="noreferrer">PDF</a>]
|
||||
</>
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Invoice</s-table-header>
|
||||
<s-table-header>Order</s-table-header>
|
||||
<s-table-header>Issued</s-table-header>
|
||||
<s-table-header listSlot="secondary">Status</s-table-header>
|
||||
<s-table-header listSlot="labeled">PDF</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{recent.map((invoice) => {
|
||||
const badge = statusBadge(invoice);
|
||||
return (
|
||||
<s-table-row key={invoice.id}>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-text type="strong">{invoice.number}</s-text>
|
||||
{invoice.version > 1 ? (
|
||||
<s-text tone="neutral">v{invoice.version}</s-text>
|
||||
) : null}
|
||||
</s-list-item>
|
||||
))}
|
||||
</s-unordered-list>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{invoice.orderName}</s-table-cell>
|
||||
<s-table-cell>{formatDate(invoice.issuedAt)}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-badge tone={badge.tone}>{badge.label}</s-badge>
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack direction="inline" gap="small" justifyContent="end">
|
||||
{invoice.pdfUrl ? (
|
||||
<s-link href={invoice.pdfUrl} target="_blank">
|
||||
Open
|
||||
</s-link>
|
||||
) : (
|
||||
<s-text tone="neutral">—</s-text>
|
||||
)}
|
||||
<Link to="/app/invoices">Open invoices page</Link>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
);
|
||||
})}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
)}
|
||||
<s-box padding="base">
|
||||
<s-stack direction="inline" justifyContent="end">
|
||||
<Link to="/app/invoices">View all invoices →</Link>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="How it works">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
LinumIQ Invoice generates Austrian-compliant PDF invoices for your
|
||||
Shopify orders. PDFs are stored on Shopify Files and linked to
|
||||
each order via metafields.
|
||||
</s-paragraph>
|
||||
<s-paragraph tone="neutral">
|
||||
Trigger generation from the order page (Generate invoice action),
|
||||
via Shopify Flow, or in bulk from the Invoices page.
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "info" | "success" | "critical";
|
||||
}) {
|
||||
const valueTone = tone ?? "neutral";
|
||||
return (
|
||||
<s-box padding="base" background="subdued" border="base" borderRadius="base">
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-text tone="neutral">{label}</s-text>
|
||||
<s-heading>
|
||||
<s-text tone={valueTone}>{value}</s-text>
|
||||
</s-heading>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
);
|
||||
}
|
||||
|
||||
+188
-70
@@ -6,12 +6,15 @@ import db from "../db.server";
|
||||
|
||||
interface RecentOrder {
|
||||
id: string; // gid
|
||||
numericId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPrice: string;
|
||||
currency: string;
|
||||
customerName: string;
|
||||
hasInvoice: boolean;
|
||||
invoiceNumber?: string;
|
||||
invoiceVersion?: number;
|
||||
invoiceSent?: boolean;
|
||||
invoiceCancelled?: boolean;
|
||||
pdfUrl?: string;
|
||||
@@ -26,21 +29,26 @@ const RECENT_ORDERS_QUERY = `#graphql
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
customer { firstName lastName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Filter = "all" | "missing" | "with";
|
||||
|
||||
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";
|
||||
const filterParam = (url.searchParams.get("filter") ?? "all") as Filter;
|
||||
const filter: Filter = ["all", "missing", "with"].includes(filterParam)
|
||||
? filterParam
|
||||
: "all";
|
||||
|
||||
// Recent orders from Shopify (first 25).
|
||||
let orders: RecentOrder[] = [];
|
||||
try {
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 25 } });
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
orders?: {
|
||||
@@ -49,6 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
|
||||
customer?: { firstName: string | null; lastName: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -56,7 +65,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
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,
|
||||
@@ -72,14 +80,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
orders = nodes.map((n) => {
|
||||
const inv = latestByOrder.get(n.id);
|
||||
const customer = n.customer
|
||||
? [n.customer.firstName, n.customer.lastName].filter(Boolean).join(" ").trim()
|
||||
: "";
|
||||
return {
|
||||
id: n.id,
|
||||
numericId: n.id.replace(/^.*\//, ""),
|
||||
name: n.name,
|
||||
createdAt: n.createdAt,
|
||||
totalPrice: n.totalPriceSet?.shopMoney.amount ?? "",
|
||||
currency: n.totalPriceSet?.shopMoney.currencyCode ?? "EUR",
|
||||
customerName: customer || "Guest",
|
||||
hasInvoice: !!inv && !inv.cancelledAt,
|
||||
invoiceNumber: inv?.invoiceNumber,
|
||||
invoiceVersion: inv?.version,
|
||||
invoiceSent: !!inv?.sentAt,
|
||||
invoiceCancelled: !!inv?.cancelledAt,
|
||||
pdfUrl: inv?.pdfUrl,
|
||||
@@ -89,103 +103,207 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
console.warn("Failed to load recent orders:", err);
|
||||
}
|
||||
|
||||
const allCount = orders.length;
|
||||
const withCount = orders.filter((o) => o.hasInvoice).length;
|
||||
const missingCount = allCount - withCount;
|
||||
|
||||
if (filter === "missing") orders = orders.filter((o) => !o.hasInvoice);
|
||||
if (filter === "with") orders = orders.filter((o) => o.hasInvoice);
|
||||
|
||||
return { orders, filter };
|
||||
return {
|
||||
orders,
|
||||
filter,
|
||||
counts: { all: allCount, with: withCount, missing: missingCount },
|
||||
};
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const moneyFmt = new Intl.NumberFormat("de-AT", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
function formatMoney(amount: string, currency: string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return `${amount} ${currency}`;
|
||||
return `${moneyFmt.format(n)} ${currency}`;
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const { orders, filter } = useLoaderData<typeof loader>();
|
||||
const { orders, filter, counts } = useLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
return (
|
||||
<s-page heading="Invoices">
|
||||
<s-section heading="Recent orders">
|
||||
<s-section heading="Recent orders" padding="none">
|
||||
<s-box padding="base">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
Generate or view the latest invoice for each order. For sent
|
||||
invoices, use Cancel & reissue to issue a Stornorechnung followed
|
||||
by a fresh invoice.
|
||||
Generate the invoice for an order, regenerate an unsent draft,
|
||||
or cancel-and-reissue a sent one. Newest orders appear first.
|
||||
</s-paragraph>
|
||||
|
||||
<s-stack direction="inline" gap="base">
|
||||
<Link to="?filter=all">All</Link>
|
||||
<Link to="?filter=missing">Missing invoice</Link>
|
||||
<Link to="?filter=with">Has invoice</Link>
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<FilterChip to="?filter=all" active={filter === "all"} count={counts.all}>
|
||||
All
|
||||
</FilterChip>
|
||||
<FilterChip
|
||||
to="?filter=missing"
|
||||
active={filter === "missing"}
|
||||
count={counts.missing}
|
||||
>
|
||||
Missing invoice
|
||||
</FilterChip>
|
||||
<FilterChip to="?filter=with" active={filter === "with"} count={counts.with}>
|
||||
Has invoice
|
||||
</FilterChip>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
|
||||
{isLoading ? (
|
||||
<s-paragraph>Loading…</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-spinner size="small" accessibilityLabel="Loading orders" />
|
||||
<s-text tone="neutral">Loading…</s-text>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : orders.length === 0 ? (
|
||||
<s-paragraph>No orders match the current filter.</s-paragraph>
|
||||
<s-box padding="base">
|
||||
<s-stack direction="block" gap="base" alignItems="center">
|
||||
<s-text type="strong">No orders match this filter</s-text>
|
||||
<s-paragraph tone="neutral">
|
||||
Try a different filter or wait for new orders.
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : (
|
||||
<s-unordered-list>
|
||||
{orders.map((o) => (
|
||||
<OrderRow key={o.id} order={o} />
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Order</s-table-header>
|
||||
<s-table-header>Customer</s-table-header>
|
||||
<s-table-header>Date</s-table-header>
|
||||
<s-table-header format="numeric">Total</s-table-header>
|
||||
<s-table-header listSlot="secondary">Invoice</s-table-header>
|
||||
<s-table-header listSlot="labeled">Actions</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{orders.map((order) => (
|
||||
<OrderRow key={order.id} order={order} />
|
||||
))}
|
||||
</s-unordered-list>
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
)}
|
||||
</s-section>
|
||||
<s-section heading="Bulk generate">
|
||||
|
||||
<s-section heading="About this page">
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-paragraph>
|
||||
Use the buttons next to each order to generate one at a time. (A
|
||||
true multi-select bulk run will be added once the admin block
|
||||
extension is installed.)
|
||||
Buttons trigger the same generation pipeline used by the order
|
||||
page action and Shopify Flow. PDFs are uploaded to Shopify Files
|
||||
and linked back to the order via metafields.
|
||||
</s-paragraph>
|
||||
<s-paragraph>
|
||||
Tip: filter by <em>Missing invoice</em> to see orders that still
|
||||
<s-paragraph tone="neutral">
|
||||
Tip: filter by <em>Missing invoice</em> to find orders that still
|
||||
need one.
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderRow({ order }: { order: RecentOrder }) {
|
||||
const numericId = order.id.replace(/^.*\//, "");
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
|
||||
function FilterChip({
|
||||
to,
|
||||
active,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
active: boolean;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<s-list-item>
|
||||
<s-stack direction="inline" gap="base" align-items="center">
|
||||
<strong>{order.name}</strong>
|
||||
<span>{order.totalPrice} {order.currency}</span>
|
||||
{order.hasInvoice ? (
|
||||
<span>
|
||||
✓ {order.invoiceNumber}
|
||||
{order.invoiceCancelled
|
||||
? " (cancelled)"
|
||||
: order.invoiceSent
|
||||
? " (sent)"
|
||||
: ""}
|
||||
{order.pdfUrl ? (
|
||||
<>
|
||||
{" — "}
|
||||
<a href={order.pdfUrl} target="_blank" rel="noreferrer">PDF</a>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>— no invoice yet</span>
|
||||
)}
|
||||
<fetcher.Form method="post" action={`/api/orders/${numericId}/invoice`}>
|
||||
<s-button type="submit" disabled={isBusy} variant="primary">
|
||||
{order.hasInvoice
|
||||
? order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate"
|
||||
: "Generate"}
|
||||
</s-button>
|
||||
{order.hasInvoice && order.invoiceSent && (
|
||||
<input type="hidden" name="action" value="cancel_reissue" />
|
||||
)}
|
||||
</fetcher.Form>
|
||||
{fetcher.data?.error && (
|
||||
<span style={{ color: "red" }}>{fetcher.data.error}</span>
|
||||
)}
|
||||
</s-stack>
|
||||
</s-list-item>
|
||||
<Link to={to} style={{ textDecoration: "none" }}>
|
||||
<s-clickable-chip color={active ? "strong" : "base"} accessibilityLabel={String(children)}>
|
||||
{children} ({count})
|
||||
</s-clickable-chip>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderRow({ order }: { order: RecentOrder }) {
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
const isCancelReissue = order.hasInvoice && order.invoiceSent;
|
||||
const buttonLabel = !order.hasInvoice
|
||||
? "Generate"
|
||||
: order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate";
|
||||
|
||||
return (
|
||||
<s-table-row>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-text type="strong">{order.name}</s-text>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{order.customerName}</s-table-cell>
|
||||
<s-table-cell>{dateFmt.format(new Date(order.createdAt))}</s-table-cell>
|
||||
<s-table-cell>{formatMoney(order.totalPrice, order.currency)}</s-table-cell>
|
||||
<s-table-cell>
|
||||
{order.hasInvoice ? (
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-text type="strong">{order.invoiceNumber}</s-text>
|
||||
{order.invoiceCancelled ? (
|
||||
<s-badge tone="critical">Cancelled</s-badge>
|
||||
) : order.invoiceSent ? (
|
||||
<s-badge tone="success">Sent</s-badge>
|
||||
) : (
|
||||
<s-badge tone="info">Issued</s-badge>
|
||||
)}
|
||||
{order.invoiceVersion && order.invoiceVersion > 1 ? (
|
||||
<s-text tone="neutral">v{order.invoiceVersion}</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
{fetcher.data?.error ? (
|
||||
<s-text tone="critical">{fetcher.data.error}</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-text tone="neutral">—</s-text>
|
||||
)}
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack direction="inline" gap="small" justifyContent="end" alignItems="center">
|
||||
{order.pdfUrl ? (
|
||||
<s-link href={order.pdfUrl} target="_blank">
|
||||
PDF
|
||||
</s-link>
|
||||
) : null}
|
||||
<fetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
|
||||
{isCancelReissue ? (
|
||||
<input type="hidden" name="action" value="cancel_reissue" />
|
||||
) : null}
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
variant={order.hasInvoice ? "secondary" : "primary"}
|
||||
tone={isCancelReissue ? "critical" : "auto"}
|
||||
>
|
||||
{isBusy ? "Working…" : buttonLabel}
|
||||
</s-button>
|
||||
</fetcher.Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
);
|
||||
}
|
||||
|
||||
+90
-10
@@ -8,6 +8,11 @@ import {
|
||||
isValidIban,
|
||||
normaliseIban,
|
||||
} from "../services/invoice/validation";
|
||||
import {
|
||||
STORED_LOGO_SENTINEL,
|
||||
deleteStoredLogo,
|
||||
storeUploadedLogo,
|
||||
} from "../services/invoice/logoCache.server";
|
||||
|
||||
interface SettingsFieldErrors {
|
||||
vatId?: string;
|
||||
@@ -16,6 +21,7 @@ interface SettingsFieldErrors {
|
||||
smtpPort?: string;
|
||||
paymentTermDays?: string;
|
||||
invoiceSeed?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -25,7 +31,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
update: {},
|
||||
create: { shopDomain: session.shop },
|
||||
});
|
||||
return { settings };
|
||||
let logoPreviewDataUrl: string | null = null;
|
||||
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
|
||||
const cached = await db.logoCache.findUnique({ where: { shopDomain: session.shop } });
|
||||
if (cached) {
|
||||
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
return { settings, logoPreviewDataUrl };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -72,6 +85,32 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
errors.invoiceSeed = "Must be a non-negative number.";
|
||||
}
|
||||
|
||||
// --- Logo handling --------------------------------------------------------
|
||||
// The settings page allows three actions on the logo:
|
||||
// 1. Upload a new file (multipart `logoFile`) — stored in `LogoCache`.
|
||||
// 2. Remove the current logo (`removeLogo=on`).
|
||||
// 3. Provide an external URL via the `logoUrl` field.
|
||||
// If a file is uploaded it wins over a manually-entered URL.
|
||||
let resolvedLogoUrl = str("logoUrl");
|
||||
const removeLogo = bool("removeLogo");
|
||||
const logoFile = form.get("logoFile");
|
||||
const hasUpload =
|
||||
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
|
||||
|
||||
if (removeLogo && !hasUpload) {
|
||||
await deleteStoredLogo(session.shop);
|
||||
resolvedLogoUrl = "";
|
||||
} else if (hasUpload) {
|
||||
const file = logoFile as File;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const stored = await storeUploadedLogo(session.shop, buf, file.type);
|
||||
if (!stored.ok) {
|
||||
errors.logo = stored.error ?? "Failed to store uploaded logo.";
|
||||
} else {
|
||||
resolvedLogoUrl = STORED_LOGO_SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors, savedAt: null as string | null };
|
||||
}
|
||||
@@ -102,8 +141,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
defaultLanguage: str("defaultLanguage", "de") === "en" ? "en" : "de",
|
||||
paymentTermDays: paymentTermDays ?? 14,
|
||||
footerNote: str("footerNote"),
|
||||
footerNoteEn: str("footerNoteEn"),
|
||||
kleinunternehmer: bool("kleinunternehmer"),
|
||||
logoUrl: str("logoUrl"),
|
||||
logoUrl: resolvedLogoUrl,
|
||||
smtpHost: str("smtpHost"),
|
||||
smtpPort: smtpPort ?? 587,
|
||||
smtpSecure: bool("smtpSecure"),
|
||||
@@ -124,30 +164,38 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function SettingsRoute() {
|
||||
const { settings } = useLoaderData<typeof loader>();
|
||||
const { settings, logoPreviewDataUrl } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const nav = useNavigation();
|
||||
const isSaving = nav.state === "submitting";
|
||||
const errors = actionData?.errors ?? {};
|
||||
const hasStoredLogo = settings.logoUrl === STORED_LOGO_SENTINEL;
|
||||
// Show the URL field only when not using a stored upload — keeps the UI
|
||||
// simpler and avoids the sentinel value leaking into the input.
|
||||
const visibleLogoUrl = hasStoredLogo ? "" : settings.logoUrl;
|
||||
|
||||
return (
|
||||
<s-page heading="Invoice settings">
|
||||
<s-section>
|
||||
<s-paragraph>
|
||||
Configure issuer details, bank, numbering, language and SMTP. These
|
||||
values are written into every PDF invoice this app generates and must
|
||||
match your legal records.
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
|
||||
{actionData?.ok && (
|
||||
<s-banner tone="success">Settings saved.</s-banner>
|
||||
<s-banner tone="success" heading="Settings saved">
|
||||
Your changes are now live and will be used for the next invoice.
|
||||
</s-banner>
|
||||
)}
|
||||
{actionData && !actionData.ok && (
|
||||
<s-banner tone="critical">
|
||||
Please fix the errors highlighted below.
|
||||
<s-banner tone="critical" heading="Please fix the highlighted errors">
|
||||
Some fields below need attention before settings can be saved.
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<Form method="post">
|
||||
<Form method="post" encType="multipart/form-data">
|
||||
<s-section heading="Company">
|
||||
<s-stack direction="block" gap="base">
|
||||
<Field label="Company name" name="companyName" defaultValue={settings.companyName} />
|
||||
@@ -252,8 +300,37 @@ export default function SettingsRoute() {
|
||||
{ value: "en", label: "English (en)" },
|
||||
]}
|
||||
/>
|
||||
<Field label="Footer note (optional)" name="footerNote" defaultValue={settings.footerNote} />
|
||||
<Field label="Logo URL (PNG/JPG, served from Shopify Files or any HTTPS URL)" name="logoUrl" defaultValue={settings.logoUrl} />
|
||||
<Field label="Footer note (German)" name="footerNote" defaultValue={settings.footerNote} helpText="Shown at the bottom of every German PDF invoice (e.g. „Vielen Dank für Ihren Auftrag.“)." />
|
||||
<Field label="Footer note (English)" name="footerNoteEn" defaultValue={settings.footerNoteEn} helpText="Shown on English PDF invoices. Falls back to the German note when empty." />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Logo">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
The logo is rendered in the top-right corner of every invoice
|
||||
PDF. Upload an image (PNG, JPEG, WebP or GIF, max 5 MB) or
|
||||
provide a publicly reachable HTTPS URL — uploads take precedence.
|
||||
</s-paragraph>
|
||||
{errors.logo && <s-banner tone="critical">{errors.logo}</s-banner>}
|
||||
{logoPreviewDataUrl && (
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-text>Current logo:</s-text>
|
||||
<img
|
||||
src={logoPreviewDataUrl}
|
||||
alt="Current invoice logo"
|
||||
style={{ maxWidth: "240px", maxHeight: "120px", border: "1px solid #ddd", padding: "8px", background: "#fff" }}
|
||||
/>
|
||||
<s-checkbox name="removeLogo" label="Remove current logo on save" />
|
||||
</s-stack>
|
||||
)}
|
||||
<s-text>Upload a new logo:</s-text>
|
||||
<input type="file" name="logoFile" accept="image/png,image/jpeg,image/webp,image/gif" />
|
||||
<Field
|
||||
label="Or external logo URL (PNG/JPG/WebP, served from Shopify Files or any HTTPS URL)"
|
||||
name="logoUrl"
|
||||
defaultValue={visibleLogoUrl}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
@@ -284,11 +361,14 @@ export default function SettingsRoute() {
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-section>
|
||||
<s-stack direction="inline" gap="base" justifyContent="end" alignItems="center">
|
||||
{isSaving ? <s-text tone="neutral">Saving…</s-text> : null}
|
||||
<s-button type="submit" variant="primary" {...(isSaving ? { loading: true } : {})}>
|
||||
Save settings
|
||||
</s-button>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</Form>
|
||||
</s-page>
|
||||
);
|
||||
|
||||
@@ -118,6 +118,7 @@ function mapIssuer(s: ShopSettings): IssuerData {
|
||||
iban: s.iban,
|
||||
bic: s.bic,
|
||||
footerNote: s.footerNote,
|
||||
footerNoteEn: s.footerNoteEn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +182,7 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
quantity: qty,
|
||||
unitPriceNet: round2(unitNet),
|
||||
totalNet: round2(lineNet),
|
||||
imageUrl: li.imageUrl ?? undefined,
|
||||
});
|
||||
|
||||
netSum += lineNet;
|
||||
|
||||
@@ -175,26 +175,25 @@ function renderEmailBody({
|
||||
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}`;
|
||||
`Thank you for your purchase.`;
|
||||
const html =
|
||||
`<p>Dear customer,</p>` +
|
||||
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
||||
`<p>Kind regards,<br/>${escapeHtml(company)}</p>`;
|
||||
`<p>Thank you for your purchase.</p>`;
|
||||
return { text, html };
|
||||
}
|
||||
const text =
|
||||
`Sehr geehrte Damen und Herren,\n\n` +
|
||||
`Hallo,\n\n` +
|
||||
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
|
||||
`Mit freundlichen Grüßen,\n${company}`;
|
||||
`Danke für deinen Einkauf.`;
|
||||
const html =
|
||||
`<p>Sehr geehrte Damen und Herren,</p>` +
|
||||
`<p>Hallo,</p>` +
|
||||
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
||||
`<p>Mit freundlichen Grüßen,<br/>${escapeHtml(company)}</p>`;
|
||||
`<p>Danke für deinen Einkauf.</p>`;
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { composeInvoice } from "./composeInvoice";
|
||||
import { buildGiroCodeDataUrl } from "./girocode";
|
||||
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
|
||||
import { getLogoDataUrl } from "./logoCache.server";
|
||||
import { attachLineItemImages } from "./productImageCache.server";
|
||||
import { allocateInvoiceNumber } from "./numbering.server";
|
||||
import { InvoiceDocument } from "./pdf/InvoiceDocument";
|
||||
import type { InvoiceViewModel } from "./types";
|
||||
@@ -76,6 +77,9 @@ export async function generateInvoice(
|
||||
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
|
||||
if (logoDataUrl) viewModel.issuer.logoDataUrl = logoDataUrl;
|
||||
|
||||
// Product images for each line (best-effort, parallel, in-process cache).
|
||||
await attachLineItemImages(viewModel.lines);
|
||||
|
||||
// GiroCode (only for unpaid + IBAN configured + enabled).
|
||||
if (
|
||||
settings.giroCodeEnabled &&
|
||||
|
||||
@@ -66,7 +66,7 @@ const de: InvoiceStrings = {
|
||||
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",
|
||||
closing: "Danke für deinen Einkauf",
|
||||
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:
|
||||
@@ -116,7 +116,7 @@ const en: InvoiceStrings = {
|
||||
salutationGeneric: "Dear Sir or Madam,",
|
||||
thankYouLine:
|
||||
"Thank you for your order. We hereby invoice you for the following:",
|
||||
closing: "Kind regards",
|
||||
closing: "Thank you for your purchase.",
|
||||
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.",
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface RawLineItem {
|
||||
quantity: number;
|
||||
originalUnitPriceSet: { shopMoney: RawMoney };
|
||||
taxLines: RawTaxLine[];
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RawTaxLine {
|
||||
@@ -119,6 +120,7 @@ const QUERY = `#graphql
|
||||
sku
|
||||
quantity
|
||||
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
||||
image { url altText }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
@@ -219,7 +221,17 @@ export async function loadOrderForInvoice(
|
||||
totalTaxSet: order.totalTaxSet,
|
||||
totalPriceSet: order.totalPriceSet,
|
||||
taxLines: order.taxLines || [],
|
||||
lineItems: (order.lineItems?.edges || []).map((e) => e.node),
|
||||
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
||||
return {
|
||||
title: node.title,
|
||||
sku: node.sku,
|
||||
quantity: node.quantity,
|
||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||
taxLines: node.taxLines,
|
||||
imageUrl: node.image?.url ?? null,
|
||||
};
|
||||
}),
|
||||
purchasingEntity: { company: purchasingCompany },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ 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
|
||||
|
||||
/**
|
||||
* Sentinel value stored in `ShopSettings.logoUrl` when the logo was uploaded
|
||||
* directly through the settings UI (rather than fetched from a remote URL).
|
||||
* The actual bytes live in `LogoCache` for that shop.
|
||||
*/
|
||||
export const STORED_LOGO_SENTINEL = "stored://shop-logo";
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -15,6 +22,12 @@ export async function getLogoDataUrl(
|
||||
if (!logoUrl) return undefined;
|
||||
|
||||
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
|
||||
|
||||
// Locally uploaded logo: bytes live in LogoCache, no HTTP fetch.
|
||||
if (logoUrl === STORED_LOGO_SENTINEL) {
|
||||
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
||||
}
|
||||
|
||||
const isFresh =
|
||||
cached &&
|
||||
cached.sourceUrl === logoUrl &&
|
||||
@@ -65,3 +78,46 @@ function guessContentType(url: string): string {
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
const ALLOWED_LOGO_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
||||
|
||||
export interface StoreUploadedLogoResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
contentType?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists an uploaded logo file directly into `LogoCache`. Caller is
|
||||
* responsible for setting `ShopSettings.logoUrl = STORED_LOGO_SENTINEL`.
|
||||
*/
|
||||
export async function storeUploadedLogo(
|
||||
shopDomain: string,
|
||||
bytes: Buffer,
|
||||
contentType: string,
|
||||
): Promise<StoreUploadedLogoResult> {
|
||||
const ct = (contentType || "").toLowerCase();
|
||||
if (!ALLOWED_LOGO_MIME.has(ct)) {
|
||||
return { ok: false, error: `Unsupported image type "${contentType || "unknown"}". Use PNG, JPEG, WebP or GIF.` };
|
||||
}
|
||||
if (bytes.byteLength === 0) {
|
||||
return { ok: false, error: "Uploaded file is empty." };
|
||||
}
|
||||
if (bytes.byteLength > MAX_BYTES) {
|
||||
return { ok: false, error: `File too large (${(bytes.byteLength / 1024 / 1024).toFixed(2)} MB). Max is ${MAX_BYTES / 1024 / 1024} MB.` };
|
||||
}
|
||||
|
||||
const bytesU8 = new Uint8Array(bytes);
|
||||
await db.logoCache.upsert({
|
||||
where: { shopDomain },
|
||||
create: { shopDomain, sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "" },
|
||||
update: { sourceUrl: STORED_LOGO_SENTINEL, bytes: bytesU8, contentType: ct, etag: "", fetchedAt: new Date() },
|
||||
});
|
||||
|
||||
return { ok: true, contentType: ct, byteLength: bytes.byteLength };
|
||||
}
|
||||
|
||||
export async function deleteStoredLogo(shopDomain: string): Promise<void> {
|
||||
await db.logoCache.deleteMany({ where: { shopDomain } });
|
||||
}
|
||||
|
||||
@@ -111,6 +111,26 @@ const styles = StyleSheet.create({
|
||||
colQty: { width: "16%", textAlign: "right" },
|
||||
colUnit: { width: "16%", textAlign: "right" },
|
||||
colTotal: { width: "16%", textAlign: "right" },
|
||||
descriptionCell: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 6,
|
||||
},
|
||||
productIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
objectFit: "contain",
|
||||
borderWidth: 0.5,
|
||||
borderColor: TABLE_BORDER,
|
||||
borderRadius: 2,
|
||||
},
|
||||
productIconPlaceholder: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
},
|
||||
itemTitle: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
@@ -123,6 +143,9 @@ const styles = StyleSheet.create({
|
||||
marginTop: 10,
|
||||
alignSelf: "flex-end",
|
||||
width: "50%",
|
||||
// Match the table rows' horizontal padding so the right-aligned amounts
|
||||
// line up perfectly with the "Total" column above.
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
@@ -346,9 +369,6 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
|
||||
<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} />
|
||||
@@ -416,10 +436,17 @@ function LineRow({
|
||||
return (
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.colPos}>{line.position}</Text>
|
||||
<View style={styles.colDescription}>
|
||||
<View style={[styles.colDescription, styles.descriptionCell]}>
|
||||
{line.imageDataUrl ? (
|
||||
<Image src={line.imageDataUrl} style={styles.productIcon} />
|
||||
) : (
|
||||
<View style={styles.productIconPlaceholder} />
|
||||
)}
|
||||
<View style={styles.descriptionText}>
|
||||
<Text style={styles.itemTitle}>{line.title}</Text>
|
||||
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
||||
</View>
|
||||
</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>
|
||||
@@ -460,8 +487,20 @@ function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLan
|
||||
{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}
|
||||
{pickFooterNote(issuer, language) ? <Text>{pickFooterNote(issuer, language)}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the footer note for the rendered language. English falls back to the
|
||||
* German `footerNote` when `footerNoteEn` is empty (so existing single-language
|
||||
* setups keep working). German always uses `footerNote`.
|
||||
*/
|
||||
function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, language: InvoiceLanguage): string {
|
||||
if (language === "en") {
|
||||
return issuer.footerNoteEn?.trim() || issuer.footerNote || "";
|
||||
}
|
||||
return issuer.footerNote || "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Fetches product images for invoice line items and returns them as
|
||||
* `data:` URLs ready to embed in the PDF.
|
||||
*
|
||||
* Uses a simple in-process LRU-ish Map keyed by URL. Images are typically
|
||||
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
||||
* hammering the network when regenerating an invoice multiple times.
|
||||
*/
|
||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
||||
const CACHE_MAX_ENTRIES = 200;
|
||||
|
||||
const cache = new Map<string, string>(); // url -> data URL
|
||||
|
||||
function rememberInCache(url: string, dataUrl: string) {
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) {
|
||||
// Drop the oldest entry (Map preserves insertion order).
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
cache.set(url, dataUrl);
|
||||
}
|
||||
|
||||
function guessContentType(url: string, headerCt: string | null): string {
|
||||
if (headerCt && headerCt.startsWith("image/")) return headerCt;
|
||||
const lower = url.toLowerCase();
|
||||
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg";
|
||||
if (lower.includes(".webp")) return "image/webp";
|
||||
if (lower.includes(".gif")) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
export async function fetchProductImageDataUrl(url: string): Promise<string | undefined> {
|
||||
if (!url) return undefined;
|
||||
const hit = cache.get(url);
|
||||
if (hit) return hit;
|
||||
|
||||
// Request a small Shopify CDN variant when possible to keep the PDF lean.
|
||||
// Shopify image URLs accept a `width=` query param; fall back to the original URL.
|
||||
const requestUrl = url.includes("cdn.shopify.com") && !/[?&](width|height|crop)=/.test(url)
|
||||
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
||||
: url;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(requestUrl);
|
||||
} catch (err) {
|
||||
console.warn(`Product image fetch failed for ${url}:`, err);
|
||||
return undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.warn(`Product image HTTP ${res.status} for ${url}`);
|
||||
return undefined;
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES) return undefined;
|
||||
|
||||
const contentType = guessContentType(url, res.headers.get("content-type"));
|
||||
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
|
||||
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
|
||||
|
||||
const b64 = Buffer.from(buf).toString("base64");
|
||||
const dataUrl = `data:${contentType};base64,${b64}`;
|
||||
rememberInCache(url, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
|
||||
* Failures are swallowed (the row simply renders without an icon).
|
||||
*/
|
||||
export async function attachLineItemImages(
|
||||
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
lines.map(async (line) => {
|
||||
if (!line.imageUrl) return;
|
||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
|
||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export interface IssuerData {
|
||||
/** Optional pre-fetched logo bytes as a data URL. */
|
||||
logoDataUrl?: string;
|
||||
footerNote: string;
|
||||
footerNoteEn: string;
|
||||
}
|
||||
|
||||
export interface RecipientData {
|
||||
@@ -87,6 +88,10 @@ export interface InvoiceLine {
|
||||
totalNet: number;
|
||||
/** Optional SKU for display under the title. */
|
||||
sku?: string;
|
||||
/** Source URL of the product/variant image (Shopify CDN). */
|
||||
imageUrl?: string;
|
||||
/** `data:` URL with base64-encoded image bytes — attached by the generator. */
|
||||
imageDataUrl?: string;
|
||||
}
|
||||
|
||||
export type NoticeKind = "reverseCharge" | "export" | "kleinunternehmer";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_ShopSettings" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"shopDomain" TEXT NOT NULL,
|
||||
"companyName" TEXT NOT NULL DEFAULT '',
|
||||
"legalForm" TEXT NOT NULL DEFAULT '',
|
||||
"ownerName" TEXT NOT NULL DEFAULT '',
|
||||
"addressLine1" TEXT NOT NULL DEFAULT '',
|
||||
"addressLine2" TEXT NOT NULL DEFAULT '',
|
||||
"postalCode" TEXT NOT NULL DEFAULT '',
|
||||
"city" TEXT NOT NULL DEFAULT '',
|
||||
"countryCode" TEXT NOT NULL DEFAULT 'AT',
|
||||
"phone" TEXT NOT NULL DEFAULT '',
|
||||
"email" TEXT NOT NULL DEFAULT '',
|
||||
"website" TEXT NOT NULL DEFAULT '',
|
||||
"vatId" TEXT NOT NULL DEFAULT '',
|
||||
"taxNumber" TEXT NOT NULL DEFAULT '',
|
||||
"registrationNo" TEXT NOT NULL DEFAULT '',
|
||||
"registrationCourt" TEXT NOT NULL DEFAULT '',
|
||||
"bankName" TEXT NOT NULL DEFAULT '',
|
||||
"iban" TEXT NOT NULL DEFAULT '',
|
||||
"bic" TEXT NOT NULL DEFAULT '',
|
||||
"giroCodeEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"numberingMode" TEXT NOT NULL DEFAULT 'shopify_order_number',
|
||||
"invoicePrefix" TEXT NOT NULL DEFAULT 'RE-',
|
||||
"invoiceSeed" INTEGER NOT NULL DEFAULT 1000,
|
||||
"defaultLanguage" TEXT NOT NULL DEFAULT 'de',
|
||||
"paymentTermDays" INTEGER NOT NULL DEFAULT 14,
|
||||
"footerNote" TEXT NOT NULL DEFAULT '',
|
||||
"footerNoteEn" TEXT NOT NULL DEFAULT '',
|
||||
"kleinunternehmer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"logoUrl" TEXT NOT NULL DEFAULT '',
|
||||
"smtpHost" TEXT NOT NULL DEFAULT '',
|
||||
"smtpPort" INTEGER NOT NULL DEFAULT 587,
|
||||
"smtpSecure" BOOLEAN NOT NULL DEFAULT false,
|
||||
"smtpUser" TEXT NOT NULL DEFAULT '',
|
||||
"smtpPassword" TEXT NOT NULL DEFAULT '',
|
||||
"smtpFromName" TEXT NOT NULL DEFAULT '',
|
||||
"smtpFromEmail" TEXT NOT NULL DEFAULT '',
|
||||
"smtpReplyTo" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_ShopSettings" ("addressLine1", "addressLine2", "bankName", "bic", "city", "companyName", "countryCode", "createdAt", "defaultLanguage", "email", "footerNote", "giroCodeEnabled", "iban", "id", "invoicePrefix", "invoiceSeed", "kleinunternehmer", "legalForm", "logoUrl", "numberingMode", "ownerName", "paymentTermDays", "phone", "postalCode", "registrationCourt", "registrationNo", "shopDomain", "smtpFromEmail", "smtpFromName", "smtpHost", "smtpPassword", "smtpPort", "smtpReplyTo", "smtpSecure", "smtpUser", "taxNumber", "updatedAt", "vatId", "website") SELECT "addressLine1", "addressLine2", "bankName", "bic", "city", "companyName", "countryCode", "createdAt", "defaultLanguage", "email", "footerNote", "giroCodeEnabled", "iban", "id", "invoicePrefix", "invoiceSeed", "kleinunternehmer", "legalForm", "logoUrl", "numberingMode", "ownerName", "paymentTermDays", "phone", "postalCode", "registrationCourt", "registrationNo", "shopDomain", "smtpFromEmail", "smtpFromName", "smtpHost", "smtpPassword", "smtpPort", "smtpReplyTo", "smtpSecure", "smtpUser", "taxNumber", "updatedAt", "vatId", "website" FROM "ShopSettings";
|
||||
DROP TABLE "ShopSettings";
|
||||
ALTER TABLE "new_ShopSettings" RENAME TO "ShopSettings";
|
||||
CREATE UNIQUE INDEX "ShopSettings_shopDomain_key" ON "ShopSettings"("shopDomain");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -73,7 +73,10 @@ model ShopSettings {
|
||||
// Defaults
|
||||
defaultLanguage String @default("de")
|
||||
paymentTermDays Int @default(14)
|
||||
// German footer note (also used as fallback for unspecified languages).
|
||||
footerNote String @default("")
|
||||
// English footer note (falls back to `footerNote` when empty).
|
||||
footerNoteEn String @default("")
|
||||
// Kleinunternehmer (§ 6 Abs. 1 Z 27 UStG)
|
||||
kleinunternehmer Boolean @default(false)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Regenerates a single existing invoice using the offline session token,
|
||||
* downloads the resulting PDF and pretty-prints the closing/footer text so
|
||||
* we can verify the new wording end-to-end against the live dev store.
|
||||
*
|
||||
* Usage: npx tsx scripts/regenerate-invoice.ts [invoiceNumber]
|
||||
* default invoiceNumber = RE-1001
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import db from "../app/db.server";
|
||||
import { unauthenticated } from "../app/shopify.server";
|
||||
import { generateInvoice } from "../app/services/invoice/generateInvoice.server";
|
||||
|
||||
async function main() {
|
||||
const wanted = process.argv[2] ?? "RE-1001";
|
||||
|
||||
const invoice = await db.invoice.findFirst({
|
||||
where: { invoiceNumber: wanted, kind: "invoice", cancelledAt: null },
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
if (!invoice) throw new Error(`No invoice ${wanted} found in DB`);
|
||||
|
||||
console.log(`Found ${invoice.invoiceNumber} (status=${invoice.status}, sentAt=${invoice.sentAt ?? "—"}) for order ${invoice.orderId}`);
|
||||
|
||||
const { admin } = await unauthenticated.admin(invoice.shopDomain);
|
||||
|
||||
const result = await generateInvoice({
|
||||
shopDomain: invoice.shopDomain,
|
||||
admin,
|
||||
orderId: invoice.orderId,
|
||||
forceRegenerate: true,
|
||||
});
|
||||
|
||||
console.log(`Regenerated → version ${result.version}, pdfUrl=${result.pdfUrl}`);
|
||||
|
||||
const out = resolve(process.cwd(), "data", `regen-${result.invoiceNumber}.pdf`);
|
||||
const res = await fetch(result.pdfUrl);
|
||||
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(out, buf);
|
||||
console.log(`Saved ${out} (${buf.length} bytes)`);
|
||||
|
||||
const text = execFileSync("pdftotext", ["-layout", "-enc", "UTF-8", out, "-"], { encoding: "utf8" });
|
||||
|
||||
const okDe = text.includes("Danke für deinen Einkauf");
|
||||
const okEn = text.includes("Thank you for your purchase.");
|
||||
const oldDe = text.includes("Mit freundlichen Grüßen");
|
||||
const oldEn = /\bKind regards\b/.test(text);
|
||||
|
||||
console.log(`\n--- closing text checks ---`);
|
||||
console.log(` contains 'Danke für deinen Einkauf' : ${okDe}`);
|
||||
console.log(` contains 'Thank you for your purchase.': ${okEn}`);
|
||||
console.log(` contains old 'Mit freundlichen Grüßen' : ${oldDe}`);
|
||||
console.log(` contains old 'Kind regards' : ${oldEn}`);
|
||||
|
||||
const langOk = invoice.language === "en" ? okEn : okDe;
|
||||
const cleanOld = invoice.language === "en" ? !oldEn : !oldDe;
|
||||
if (!langOk || !cleanOld) {
|
||||
console.error(`FAIL: closing line did not match expected wording for language=${invoice.language}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`PASS: regenerated ${invoice.invoiceNumber} (lang=${invoice.language}) shows the new closing.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => db.$disconnect());
|
||||
@@ -17,6 +17,8 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -51,6 +53,22 @@ function assertNear(name: string, actual: number, expected: number, eps = 0.01)
|
||||
assert(name, Math.abs(actual - expected) <= eps, `expected ~${expected}, got ${actual}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text from a PDF buffer using the system `pdftotext` (poppler).
|
||||
* The smoke script runs in a dev environment where poppler is available;
|
||||
* if it ever isn't, the assertion failures will surface a clear ENOENT.
|
||||
*/
|
||||
async function pdfToText(pdf: Buffer): Promise<string> {
|
||||
const inPath = resolve(tmpdir(), `linumiq-smoke-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
|
||||
writeFileSync(inPath, pdf);
|
||||
try {
|
||||
const out = execFileSync("pdftotext", ["-layout", "-enc", "UTF-8", inPath, "-"], { encoding: "utf8" });
|
||||
return out;
|
||||
} finally {
|
||||
try { require("node:fs").unlinkSync(inPath); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Synthetic settings (mirrors the reference invoice)
|
||||
// ------------------------------------------------------------------
|
||||
@@ -81,7 +99,8 @@ const settings = {
|
||||
invoiceSeed: 1000,
|
||||
defaultLanguage: "de",
|
||||
paymentTermDays: 14,
|
||||
footerNote: "",
|
||||
footerNote: "Vielen Dank für Ihren Auftrag.",
|
||||
footerNoteEn: "Thank you for your business.",
|
||||
kleinunternehmer: false,
|
||||
logoUrl: "",
|
||||
smtpHost: "",
|
||||
@@ -139,6 +158,7 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
sku: "BT-TRK-001",
|
||||
quantity: qty,
|
||||
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
|
||||
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
|
||||
taxLines: [
|
||||
{
|
||||
title: "USt 20%",
|
||||
@@ -285,7 +305,13 @@ async function main() {
|
||||
// Attach logo (loaded from disk so the smoke output mirrors a real merchant setup).
|
||||
const logoPath = resolve(__dirname, "..", "data", "linumiq-logo.png");
|
||||
const logoBytes = readFileSync(logoPath);
|
||||
vm.issuer.logoDataUrl = `data:image/png;base64,${logoBytes.toString("base64")}`;
|
||||
const logoDataUrl = `data:image/png;base64,${logoBytes.toString("base64")}`;
|
||||
vm.issuer.logoDataUrl = logoDataUrl;
|
||||
// Stand in for a real product image (the orchestrator fetches it from
|
||||
// Shopify CDN; here we re-use the logo bytes so the table cell exercises
|
||||
// the icon rendering path).
|
||||
assertEq("composer propagates imageUrl", vm.lines[0].imageUrl, "file://product-image");
|
||||
vm.lines[0].imageDataUrl = logoDataUrl;
|
||||
// Attach GiroCode (rendered manually here since the orchestrator does it).
|
||||
vm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
|
||||
beneficiaryName: settings.companyName,
|
||||
@@ -337,6 +363,58 @@ async function main() {
|
||||
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
|
||||
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Footer note translation
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Footer note (per-language)");
|
||||
const deVm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1010" });
|
||||
assertEq("DE footerNote propagated", deVm.issuer.footerNote, "Vielen Dank für Ihren Auftrag.");
|
||||
assertEq("EN footerNote propagated", deVm.issuer.footerNoteEn, "Thank you for your business.");
|
||||
const enVm = composeInvoice({ order, settings: settings as never, invoiceNumber: "RE-1011", forceLanguage: "en" });
|
||||
assertEq("forceLanguage=en", enVm.language, "en");
|
||||
|
||||
// Render both and verify the rendered text differs by language.
|
||||
const dePdf = await renderInvoicePdf(deVm);
|
||||
const enPdf = await renderInvoicePdf(enVm);
|
||||
const deText = await pdfToText(dePdf);
|
||||
const enText = await pdfToText(enPdf);
|
||||
assert("DE PDF contains German footer", deText.includes("Vielen Dank für Ihren Auftrag."), `text snippet: ${deText.slice(0, 200)}…`);
|
||||
assert("DE PDF does NOT contain English footer", !deText.includes("Thank you for your business."));
|
||||
assert("EN PDF contains English footer", enText.includes("Thank you for your business."), `text snippet: ${enText.slice(0, 200)}…`);
|
||||
assert("EN PDF does NOT contain German footer", !enText.includes("Vielen Dank für Ihren Auftrag."));
|
||||
|
||||
// Closing line: generic thank-you, no owner-name signature directly under it.
|
||||
// (The legal "Inhaber: <name>" block in the page footer is unrelated and stays.)
|
||||
assert("DE PDF closing reads 'Danke für deinen Einkauf'", deText.includes("Danke für deinen Einkauf"));
|
||||
assert("DE PDF no longer contains 'Mit freundlichen Grüßen'", !deText.includes("Mit freundlichen Grüßen"));
|
||||
assert(
|
||||
"DE PDF: owner name is NOT a signature under the closing",
|
||||
!/Danke für deinen Einkauf[\s\S]{0,40}Gerhard Berger/.test(deText),
|
||||
);
|
||||
assert("EN PDF closing reads 'Thank you for your purchase.'", enText.includes("Thank you for your purchase."));
|
||||
assert("EN PDF no longer contains 'Kind regards'", !enText.includes("Kind regards"));
|
||||
assert(
|
||||
"EN PDF: owner name is NOT a signature under the closing",
|
||||
!/Thank you for your purchase\.[\s\S]{0,40}Gerhard Berger/.test(enText),
|
||||
);
|
||||
|
||||
// Fallback: when footerNoteEn is empty, English uses the German note.
|
||||
console.log("• Footer note fallback (en → de when EN empty)");
|
||||
const settingsNoEn = { ...(settings as object), footerNoteEn: "" } as never;
|
||||
const fbVm = composeInvoice({ order, settings: settingsNoEn, invoiceNumber: "RE-1012", forceLanguage: "en" });
|
||||
const fbPdf = await renderInvoicePdf(fbVm);
|
||||
const fbText = await pdfToText(fbPdf);
|
||||
assert("EN PDF falls back to DE footer when EN empty", fbText.includes("Vielen Dank für Ihren Auftrag."));
|
||||
|
||||
// Empty German footer + empty English footer renders nothing (no crash).
|
||||
console.log("• Footer note empty (no crash, nothing rendered)");
|
||||
const settingsBlank = { ...(settings as object), footerNote: "", footerNoteEn: "" } as never;
|
||||
const blankVm = composeInvoice({ order, settings: settingsBlank, invoiceNumber: "RE-1013" });
|
||||
const blankPdf = await renderInvoicePdf(blankVm);
|
||||
const blankText = await pdfToText(blankPdf);
|
||||
assert("blank-footer DE PDF produced", blankPdf.length > 4_000);
|
||||
assert("blank-footer DE PDF has no German footer text", !blankText.includes("Vielen Dank für Ihren Auftrag."));
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} assertion(s) FAILED`);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user