Compare commits
64 Commits
bdec06bc67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2837731815 | |||
| 15c62627be | |||
| 01b4734477 | |||
| d7d437a871 | |||
| c5b6bfc20d | |||
| 91c1a74c1b | |||
| 40ee895719 | |||
| 9c732618e1 | |||
| fe54f6e64a | |||
| d5bdc41e0a | |||
| 09769153be | |||
| c24d567ae4 | |||
| 2a4a7fd983 | |||
| a2b3c14022 | |||
| 720f508ec3 | |||
| 8a40bcbee6 | |||
| 4e522f41df | |||
| f16ef4e103 | |||
| d742e75419 | |||
| 415a9dd462 | |||
| 8780b4a68a | |||
| 55a0dd03f2 | |||
| dde53319e5 | |||
| 274ccfbc01 | |||
| 3a77bed716 | |||
| c45648832a | |||
| ca769c49a4 | |||
| 5ac2e09f8c | |||
| 53ce591f55 | |||
| f6c5d108ad | |||
| 3fb8600402 | |||
| f59c981ff4 | |||
| cc7cedfedb | |||
| 8bc86ef985 | |||
| 35dea965f6 | |||
| 884070cddc | |||
| 93aec2f368 | |||
| 0800d1160b | |||
| a99dbc51c5 | |||
| 5061dbb3d5 | |||
| 6ded8ec1b9 | |||
| 6224597497 | |||
| 1ec4faaac5 | |||
| ecd2b00985 | |||
| 8cceb8af66 | |||
| 9bfce39db2 | |||
| 85a56cac59 | |||
| d454843856 | |||
| b5d41046a0 | |||
| cc159f9b6b | |||
| 227c00b3a0 | |||
| f97d6dc9d2 | |||
| 26e4af97bc | |||
| 67204d79ac | |||
| 573dfbfd50 | |||
| 04933fcac6 | |||
| 537dfd34cb | |||
| 64ac54d3c3 | |||
| 093db30b6c | |||
| 02a93b502b | |||
| 64dbdcbc6f | |||
| bbb2cdc94a | |||
| e865bc5985 | |||
| 9557a3b335 |
@@ -8,9 +8,24 @@ node_modules
|
||||
!.env.production.example
|
||||
prisma/dev.sqlite
|
||||
prisma/dev.sqlite-journal
|
||||
# Any local SQLite DB / journal / WAL must never enter the image.
|
||||
prisma/*.sqlite*
|
||||
prisma/dev.sqlite*
|
||||
data/
|
||||
.shopify
|
||||
.git
|
||||
.github
|
||||
*.log
|
||||
extensions/*/dist
|
||||
|
||||
# Dev-only tooling / docs — not needed at build or runtime.
|
||||
# NOTE: prisma/schema.prisma and prisma/migrations are intentionally NOT
|
||||
# excluded (required by `prisma generate` and `prisma migrate deploy`).
|
||||
tests/
|
||||
scripts/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.vscode/
|
||||
*.md
|
||||
**/*.md
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ database.sqlite
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
!deploy/.env.dev.example
|
||||
!deploy/.env.prod.example
|
||||
|
||||
|
||||
|
||||
@@ -31,4 +34,5 @@ database.sqlite
|
||||
.shopify.lock
|
||||
|
||||
# Hide files auto-generated by react router
|
||||
.react-router/log.txt
|
||||
.react-router/
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shopify-dev-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"setopt": true,
|
||||
"npx shopify": true,
|
||||
"npx tsx": true
|
||||
}
|
||||
}
|
||||
+73
-5
@@ -1,18 +1,86 @@
|
||||
FROM node:20-alpine
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base image pin
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinned to Node 24 (Active LTS, supported until ~April 2028) so rebuilds are
|
||||
# reproducible and satisfy the package.json `engines` constraint
|
||||
# (">=20.19 <22 || >=22.12"). Node 20 is EOL (~April 2026) and its frozen
|
||||
# `20.19-alpine` snapshot accumulates unpatched CVEs, so we track the
|
||||
# actively-patched 24.x line instead.
|
||||
# A digest pin is PREFERRED for full immutability, e.g.:
|
||||
# FROM node:24-alpine@sha256:<real-digest>
|
||||
# Add the real sha256 (from `docker buildx imagetools inspect node:24-alpine`)
|
||||
# when you have network access. We do NOT invent a fake digest here.
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 1 — builder: install ALL deps, generate Prisma client, build the app
|
||||
# ===========================================================================
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
# openssl is required by Prisma's engines.
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /app
|
||||
|
||||
# Install the full dependency tree (incl. devDependencies needed by the
|
||||
# Vite / React Router build toolchain). NODE_ENV is intentionally left unset
|
||||
# here so `npm ci` does not prune devDependencies.
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the source and produce the production build + Prisma client.
|
||||
COPY . .
|
||||
RUN npx prisma generate \
|
||||
&& npm run build
|
||||
|
||||
# ===========================================================================
|
||||
# Stage 2 — runtime: pruned prod deps + only the artifacts needed to run
|
||||
# ===========================================================================
|
||||
FROM node:24-alpine AS runtime
|
||||
|
||||
# openssl for Prisma engines at runtime (migrate deploy / query engine).
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Keep npm + Prisma incidental writes off the (dev) read-only root filesystem:
|
||||
# both are redirected to the tmpfs-backed /tmp mount.
|
||||
ENV NPM_CONFIG_CACHE=/tmp/.npm
|
||||
ENV CHECKPOINT_DISABLE=1
|
||||
|
||||
# Install ONLY production dependencies.
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY . .
|
||||
# Prisma schema + migrations are needed for `prisma generate` (below) and for
|
||||
# `prisma migrate deploy` on container start.
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
# Bake the generated Prisma client into the image so the container start
|
||||
# command only has to run migrations (no generate at runtime → compatible with
|
||||
# a read-only root filesystem).
|
||||
RUN npx prisma generate
|
||||
|
||||
RUN npm run build
|
||||
# Application artifacts required at runtime.
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/server.js ./server.js
|
||||
|
||||
# Run as the unprivileged, pre-existing `node` user (uid/gid 1000). Ensure the
|
||||
# app tree is owned by it. NOTE: the SQLite DB is written to the /data bind
|
||||
# mount — the HOST directory mounted at /data MUST be chown'd to uid 1000
|
||||
# (see deploy/README.md), otherwise migrations/writes will fail as non-root.
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Container-level health probe hitting the unauthenticated /healthz route.
|
||||
# busybox wget ships with node:alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
|
||||
|
||||
# Functionally equivalent to today's start: runs DB migrations then the server.
|
||||
# (`docker-start` no longer needs `prisma generate` — the client is baked above.)
|
||||
CMD ["npm", "run", "docker-start"]
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: "Default", value: null as string | null },
|
||||
{ name: "LinumIQ blue", value: "#0883DA" },
|
||||
{ name: "Black", value: "#000000" },
|
||||
{ name: "Grey", value: "#6d7175" },
|
||||
{ name: "Red", value: "#d72c0d" },
|
||||
{ name: "Green", value: "#1a7e3a" },
|
||||
];
|
||||
|
||||
interface RichTextEditorProps {
|
||||
/** Hidden form field name; the rendered HTML is mirrored into it. */
|
||||
name: string;
|
||||
/** Visible label rendered above the editor. */
|
||||
label: string;
|
||||
/** Initial HTML loaded into the editor. */
|
||||
defaultValue?: string;
|
||||
/** Optional helper text shown below the editor. */
|
||||
helpText?: string;
|
||||
/** Insertable variable tokens shown as quick-insert buttons. */
|
||||
variables?: { token: string; label?: string }[];
|
||||
/** Min editor height in px. */
|
||||
minHeight?: number;
|
||||
/**
|
||||
* If set, occurrences of `cid:invoice-logo` (in src attributes) are
|
||||
* displayed using this data/HTTPS URL inside the editor, but mirrored
|
||||
* back to `cid:invoice-logo` in the submitted hidden field. This keeps
|
||||
* the stored template portable while showing the real logo while editing.
|
||||
*/
|
||||
logoDataUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TipTap-based WYSIWYG editor that mirrors its HTML content into a hidden
|
||||
* input so the parent <Form> can submit it like any other field.
|
||||
*
|
||||
* Variables like {{invoiceNumber}} are inserted as plain text — the email
|
||||
* renderer is responsible for substituting them at send time.
|
||||
*/
|
||||
export function RichTextEditor({
|
||||
name,
|
||||
label,
|
||||
defaultValue = "",
|
||||
helpText,
|
||||
variables = [],
|
||||
minHeight = 200,
|
||||
logoDataUrl = null,
|
||||
}: RichTextEditorProps) {
|
||||
// TipTap calls into the DOM on init; defer mounting until after hydration
|
||||
// so SSR markup matches the initial client render.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
// Mirror editor HTML into local state so the hidden <input> always
|
||||
// reflects the latest content. Without this, React doesn't re-render
|
||||
// when TipTap's content changes and the form submits stale HTML.
|
||||
const initialHtml = swapCidToLogo(defaultValue || "<p></p>", logoDataUrl);
|
||||
const [html, setHtml] = useState(initialHtml);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Drop heading levels we don't need to keep the toolbar focused.
|
||||
heading: { levels: [2, 3] },
|
||||
}),
|
||||
Link.configure({ openOnClick: false }),
|
||||
// Extend Image so the inline `style` attribute (e.g. max-height) is
|
||||
// preserved on parse — TipTap's default Image only keeps src/alt/title.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
default: "max-height:48px;",
|
||||
parseHTML: (el) => (el as HTMLElement).getAttribute("style"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.style ? { style: attrs.style as string } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false, allowBase64: true }),
|
||||
TextStyle,
|
||||
Color,
|
||||
],
|
||||
content: swapCidToLogo(defaultValue || "<p></p>", logoDataUrl),
|
||||
onUpdate: ({ editor }) => {
|
||||
setHtml(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "wysiwyg-editor",
|
||||
style: `min-height:${minHeight}px;padding:8px 12px;border:1px solid #c9cccf;border-top:0;border-radius:0 0 6px 6px;background:#fff;outline:none;`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Keep editor disposed cleanly on unmount.
|
||||
useEffect(() => () => editor?.destroy(), [editor]);
|
||||
|
||||
const submittedHtml = swapLogoToCid(html, logoDataUrl);
|
||||
|
||||
if (!mounted) {
|
||||
// Server / pre-hydration fallback: a textarea so the value is still
|
||||
// submittable if JS fails or before TipTap mounts.
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<textarea
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
style={{ width: "100%", minHeight, padding: 8, border: "1px solid #c9cccf", borderRadius: 6 }}
|
||||
/>
|
||||
{helpText ? (
|
||||
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
|
||||
{label}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
role="toolbar"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: "6px 6px 0 0",
|
||||
background: "#f6f6f7",
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
active={editor?.isActive("bold") ?? false}
|
||||
label="B"
|
||||
title="Bold"
|
||||
bold
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
active={editor?.isActive("italic") ?? false}
|
||||
label="I"
|
||||
title="Italic"
|
||||
italic
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
active={editor?.isActive("strike") ?? false}
|
||||
label="S"
|
||||
title="Strikethrough"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().setParagraph().run()}
|
||||
active={editor?.isActive("paragraph") ?? false}
|
||||
label="¶"
|
||||
title="Paragraph"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor?.isActive("heading", { level: 2 }) ?? false}
|
||||
label="H2"
|
||||
title="Heading 2"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor?.isActive("heading", { level: 3 }) ?? false}
|
||||
label="H3"
|
||||
title="Heading 3"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
active={editor?.isActive("bulletList") ?? false}
|
||||
label="• List"
|
||||
title="Bullet list"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
active={editor?.isActive("orderedList") ?? false}
|
||||
label="1. List"
|
||||
title="Numbered list"
|
||||
/>
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
const url = window.prompt("URL");
|
||||
if (url) editor?.chain().focus().setLink({ href: url }).run();
|
||||
else editor?.chain().focus().unsetLink().run();
|
||||
}}
|
||||
active={editor?.isActive("link") ?? false}
|
||||
label="Link"
|
||||
title="Insert/remove link"
|
||||
/>
|
||||
<Sep />
|
||||
<ColorMenu editor={editor} />
|
||||
<Sep />
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
active={false}
|
||||
label="↺"
|
||||
title="Undo"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
active={false}
|
||||
label="↻"
|
||||
title="Redo"
|
||||
/>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
<input type="hidden" name={name} value={submittedHtml} />
|
||||
{variables.length > 0 ? (
|
||||
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, color: "#6d7175", marginRight: 4 }}>Insert variable:</span>
|
||||
{variables.map((v) => (
|
||||
<button
|
||||
key={v.token}
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().insertContent(v.token).run()}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: 12,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={`Inserts ${v.token}`}
|
||||
>
|
||||
{v.label ?? v.token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{helpText ? (
|
||||
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
label,
|
||||
title,
|
||||
bold,
|
||||
italic,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
label: string;
|
||||
title: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
background: active ? "#e3e5e7" : "#fff",
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: 4,
|
||||
fontWeight: bold ? 700 : 500,
|
||||
fontStyle: italic ? "italic" : "normal",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
minWidth: 28,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <span aria-hidden style={{ width: 1, background: "#c9cccf", margin: "2px 4px" }} />;
|
||||
}
|
||||
|
||||
function ColorMenu({ editor }: { editor: ReturnType<typeof useEditor> | null }) {
|
||||
if (!editor) return null;
|
||||
return (
|
||||
<span style={{ display: "inline-flex", gap: 2, alignItems: "center" }} title="Text colour">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
c.value
|
||||
? editor.chain().focus().setColor(c.value).run()
|
||||
: editor.chain().focus().unsetColor().run()
|
||||
}
|
||||
title={c.name}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: "1px solid #c9cccf",
|
||||
borderRadius: 4,
|
||||
background: c.value ?? "#fff",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{c.value === null ? (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `src="cid:invoice-logo"` with the supplied URL so the editor
|
||||
* can display the actual logo. Done as a string replace because TipTap
|
||||
* doesn't render `cid:` URLs.
|
||||
*/
|
||||
function swapCidToLogo(html: string, logoUrl: string | null): string {
|
||||
if (!logoUrl) return html;
|
||||
const escaped = logoUrl.replace(/"/g, """);
|
||||
return html
|
||||
.replace(/src="cid:invoice-logo"/g, `src="${escaped}"`)
|
||||
.replace(/src='cid:invoice-logo'/g, `src='${escaped}'`);
|
||||
}
|
||||
|
||||
/** Inverse of swapCidToLogo — ensures the cid token is what we store. */
|
||||
function swapLogoToCid(html: string, logoUrl: string | null): string {
|
||||
if (!logoUrl) return html;
|
||||
const escaped = logoUrl.replace(/"/g, """);
|
||||
return html
|
||||
.replace(new RegExp(`src="${escapeRegExp(escaped)}"`, "g"), 'src="cid:invoice-logo"')
|
||||
.replace(new RegExp(`src='${escapeRegExp(escaped)}'`, "g"), "src='cid:invoice-logo'");
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
+19
-34
@@ -1,56 +1,41 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect, Form, useLoaderData } from "react-router";
|
||||
|
||||
import { login } from "../../shopify.server";
|
||||
import { redirect, useLoaderData } from "react-router";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const allowedShop = process.env.ALLOWED_SHOP?.trim();
|
||||
const shop = url.searchParams.get("shop");
|
||||
|
||||
if (url.searchParams.get("shop")) {
|
||||
// If a shop param is present and it's the allow-listed merchant, send them
|
||||
// straight into the embedded app. Any other shop is rejected so this URL
|
||||
// can't be used to install the app on arbitrary stores.
|
||||
if (shop) {
|
||||
if (!allowedShop || shop.toLowerCase() === allowedShop.toLowerCase()) {
|
||||
throw redirect(`/app?${url.searchParams.toString()}`);
|
||||
}
|
||||
throw new Response("This app is private and not available for installation.", { status: 403 });
|
||||
}
|
||||
|
||||
return { showForm: Boolean(login) };
|
||||
return { allowedShop: allowedShop ?? null };
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { showForm } = useLoaderData<typeof loader>();
|
||||
const { allowedShop } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className={styles.index}>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.heading}>A short heading about [your app]</h1>
|
||||
<h1 className={styles.heading}>LinumIQ Invoice</h1>
|
||||
<p className={styles.text}>
|
||||
A tagline about [your app] that describes your value proposition.
|
||||
Private Shopify app for issuing GoBD-compliant PDF invoices.
|
||||
</p>
|
||||
<p className={styles.text}>
|
||||
{allowedShop
|
||||
? `This installation is reserved for ${allowedShop}. Open the app from the Shopify admin.`
|
||||
: "Open the app from the Shopify admin."}
|
||||
</p>
|
||||
{showForm && (
|
||||
<Form className={styles.form} method="post" action="/auth/login">
|
||||
<label className={styles.label}>
|
||||
<span>Shop domain</span>
|
||||
<input className={styles.input} type="text" name="shop" />
|
||||
<span>e.g: my-shop-domain.myshopify.com</span>
|
||||
</label>
|
||||
<button className={styles.button} type="submit">
|
||||
Log in
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Product feature</strong>. Some detail about your feature and
|
||||
its benefit to your customer.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,15 +15,17 @@ import { sendInvoiceEmail } from "../services/invoice/email.server";
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { session, cors } = await authenticate.admin(request);
|
||||
const orderId = requireOrderId(params);
|
||||
const url = new URL(request.url);
|
||||
const kind = (url.searchParams.get("kind") === "offer" ? "offer" : "invoice") as "invoice" | "offer";
|
||||
const orderGid = orderId.startsWith("gid://")
|
||||
? orderId
|
||||
: `gid://shopify/Order/${orderId}`;
|
||||
: `gid://shopify/${kind === "offer" ? "DraftOrder" : "Order"}/${orderId}`;
|
||||
|
||||
const invoices = await db.invoice.findMany({
|
||||
where: { shopDomain: session.shop, orderId: orderGid },
|
||||
orderBy: [{ issuedAt: "desc" }],
|
||||
});
|
||||
const latest = invoices.find((i) => i.kind === "invoice" && !i.cancelledAt);
|
||||
const latest = invoices.find((i) => i.kind === kind && !i.cancelledAt);
|
||||
|
||||
return cors(
|
||||
Response.json({
|
||||
@@ -41,15 +43,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const orderId = requireOrderId(params);
|
||||
const url = new URL(request.url);
|
||||
let op = url.searchParams.get("action");
|
||||
if (!op) {
|
||||
// Also accept the action from the form body (used by the in-app fetcher).
|
||||
let kindParam = url.searchParams.get("kind");
|
||||
if (!op || !kindParam) {
|
||||
// Also accept the action / kind from the form body (used by the in-app fetcher).
|
||||
const ct = request.headers.get("content-type") || "";
|
||||
if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
|
||||
const form = await request.formData();
|
||||
op = (form.get("action") as string | null) ?? null;
|
||||
op = op ?? ((form.get("action") as string | null) ?? null);
|
||||
kindParam = kindParam ?? ((form.get("kind") as string | null) ?? null);
|
||||
}
|
||||
}
|
||||
op = op ?? "generate";
|
||||
const kind: "invoice" | "offer" = kindParam === "offer" ? "offer" : "invoice";
|
||||
|
||||
try {
|
||||
if (op === "cancel_reissue") {
|
||||
@@ -109,8 +114,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
shopDomain: session.shop,
|
||||
admin,
|
||||
orderId,
|
||||
kind,
|
||||
});
|
||||
return cors(Response.json({ ok: true, op: "generate", ...result }));
|
||||
return cors(Response.json({ ok: true, op: kind === "offer" ? "generate_offer" : "generate", ...result }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("invoice action failed:", err);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { unauthenticated } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { buildGiroCodePngBuffer } from "../services/invoice/girocode";
|
||||
import { verifyGiroCodeUrl } from "../services/invoice/signedUrl";
|
||||
import { resolveOrderRemittance } from "../services/invoice/remittance.server";
|
||||
|
||||
/**
|
||||
* Public PNG endpoint that returns the GiroCode QR image bytes for an order.
|
||||
* Auth: short-lived HMAC-signed URL (issued by /api/public/payment-info).
|
||||
*
|
||||
* Required query params: shop, orderId, exp, sig.
|
||||
*/
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const verified = verifyGiroCodeUrl(url.searchParams);
|
||||
if (!verified.ok) {
|
||||
return new Response(`unauthorized: ${verified.reason ?? "invalid"}`, { status: 401 });
|
||||
}
|
||||
const { shop, orderId } = verified;
|
||||
if (!shop || !orderId) {
|
||||
return new Response("bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||
if (!settings?.iban) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Recompute payload server-side from order + settings (don't trust client).
|
||||
const numericId = orderId.replace(/[^0-9]/g, "");
|
||||
const orderGid = `gid://shopify/Order/${numericId}`;
|
||||
|
||||
const { admin } = await unauthenticated.admin(shop);
|
||||
const res = await admin.graphql(
|
||||
`#graphql
|
||||
query GiroCodeOrderInfo($id: ID!) {
|
||||
order(id: $id) {
|
||||
name
|
||||
number
|
||||
currencyCode
|
||||
totalPriceSet { shopMoney { amount } }
|
||||
totalOutstandingSet { shopMoney { amount } }
|
||||
}
|
||||
}`,
|
||||
{ variables: { id: orderGid } },
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
order?: {
|
||||
name?: string;
|
||||
number?: number | null;
|
||||
currencyCode?: string;
|
||||
totalPriceSet?: { shopMoney: { amount: string } };
|
||||
totalOutstandingSet?: { shopMoney: { amount: string } };
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
const o = json.data?.order;
|
||||
if (!o) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
const total = parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0");
|
||||
const outstanding = parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0");
|
||||
const amount = outstanding > 0 ? outstanding : total;
|
||||
// Use the canonical invoice number printed on the PDF — keeping the QR
|
||||
// and the customer-facing thank-you/account page in lockstep so the
|
||||
// bank treats both as one and the same payment.
|
||||
const remittance = await resolveOrderRemittance({
|
||||
shopDomain: shop,
|
||||
orderGid,
|
||||
orderNumber: typeof o.number === "number" ? o.number : null,
|
||||
settings,
|
||||
});
|
||||
|
||||
const png = await buildGiroCodePngBuffer({
|
||||
beneficiaryName: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
amount,
|
||||
currency: o.currencyCode ?? "EUR",
|
||||
remittance,
|
||||
});
|
||||
|
||||
const body = new Uint8Array(png);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "private, max-age=300",
|
||||
// No CORS header: the PNG is rendered via an <s-image> tag in the
|
||||
// checkout/customer-account extensions (see extensions/*/src/*.tsx),
|
||||
// i.e. a plain image load, which is not subject to CORS. Dropping the
|
||||
// previous `Access-Control-Allow-Origin: *` removes the ability for any
|
||||
// origin to fetch() these bytes cross-origin while keeping the
|
||||
// legitimate <img>-style loads working.
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate, unauthenticated } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { formatMoney, formatDate, addDays } from "../services/invoice/format";
|
||||
import { getStrings, pickLanguage } from "../services/invoice/i18n";
|
||||
import { signGiroCodeUrl } from "../services/invoice/signedUrl";
|
||||
import { resolveOrderRemittance } from "../services/invoice/remittance.server";
|
||||
|
||||
/**
|
||||
* Public endpoint consumed by the checkout / thank-you UI extension AND by
|
||||
* the customer-account order page extension to fetch payment instructions
|
||||
* (GiroCode + bank details) for an order.
|
||||
*
|
||||
* Auth: validated Shopify session token. The handler tries
|
||||
* `authenticate.public.customerAccount` first and falls back to
|
||||
* `authenticate.public.checkout` so a single endpoint serves both surfaces.
|
||||
* The shop domain is derived from `sessionToken.dest`; the order id is read
|
||||
* from the `?orderId=` query parameter (numeric or GID, both accepted).
|
||||
*
|
||||
* Returns:
|
||||
* { showPaymentInstructions: boolean, payload?: { ... } }
|
||||
*
|
||||
* `payload` is only populated when:
|
||||
* - the order has at least one transaction processed by a manual payment
|
||||
* gateway (Shopify's `manualPaymentGateway` flag), and
|
||||
* - the shop has an IBAN configured.
|
||||
*/
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
type AuthSource = "customerAccount" | "checkout";
|
||||
type SessionTokenLike = { dest?: string; sub?: string };
|
||||
type CorsFn = (res: Response) => Response;
|
||||
let sessionToken: SessionTokenLike | null = null;
|
||||
let cors: CorsFn = (r) => r;
|
||||
let authSource: AuthSource | null = null;
|
||||
try {
|
||||
const auth = await authenticate.public.customerAccount(request);
|
||||
sessionToken = auth.sessionToken as SessionTokenLike;
|
||||
cors = auth.cors as CorsFn;
|
||||
authSource = "customerAccount";
|
||||
} catch {
|
||||
try {
|
||||
const auth = await authenticate.public.checkout(request);
|
||||
sessionToken = auth.sessionToken as SessionTokenLike;
|
||||
cors = auth.cors as CorsFn;
|
||||
authSource = "checkout";
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const shop = (sessionToken?.dest ?? "").toString().replace(/^https?:\/\//, "");
|
||||
if (!shop) {
|
||||
return cors(Response.json({ showPaymentInstructions: false, error: "no-shop" }, { status: 400 }));
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const orderIdRaw = url.searchParams.get("orderId");
|
||||
if (!orderIdRaw) {
|
||||
return cors(Response.json({ showPaymentInstructions: false, error: "no-order-id" }, { status: 400 }));
|
||||
}
|
||||
// The thank-you page exposes the order id as an `OrderIdentity` GID
|
||||
// (e.g. `gid://shopify/OrderIdentity/123`). For the Admin API we need an
|
||||
// `Order` GID. The numeric id is the same — just rewrite the type segment.
|
||||
const numericId = orderIdRaw.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
||||
if (!numericId) {
|
||||
return cors(Response.json({ showPaymentInstructions: false, error: "bad-order-id" }, { status: 400 }));
|
||||
}
|
||||
const orderGid = `gid://shopify/Order/${numericId}`;
|
||||
|
||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||
if (!settings?.iban || !settings.giroCodeEnabled) {
|
||||
// No bank details / GiroCode disabled — nothing to render.
|
||||
return cors(Response.json({ showPaymentInstructions: false, reason: "no-iban-or-disabled" }));
|
||||
}
|
||||
|
||||
let orderInfo: OrderInfo | null = null;
|
||||
try {
|
||||
const { admin } = await unauthenticated.admin(shop);
|
||||
// Brief retry: the Order may not be queryable for a moment after creation.
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
orderInfo = await fetchOrderInfo(admin, orderGid);
|
||||
if (orderInfo) break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
}
|
||||
if (!orderInfo && lastErr) throw lastErr;
|
||||
} catch (err) {
|
||||
// Log the upstream detail server-side only — never echo internal error
|
||||
// messages (which may contain Admin API internals / order data) to the
|
||||
// public client.
|
||||
console.error(`payment-info: failed to load order ${orderGid} for ${shop}:`, err);
|
||||
return cors(
|
||||
Response.json(
|
||||
{ showPaymentInstructions: false, error: "order-load-failed" },
|
||||
{ status: 502 },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!orderInfo || !orderInfo.isManual) {
|
||||
return cors(
|
||||
Response.json({
|
||||
showPaymentInstructions: false,
|
||||
reason: "not-manual-payment",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Ownership check ----
|
||||
// Without this, any authenticated buyer of the shop could enumerate
|
||||
// arbitrary orderIds and harvest the shop's bank details / amounts.
|
||||
//
|
||||
// Token claims available (see @shopify/shopify-api `JwtPayload`): only the
|
||||
// standard JWT fields — iss, dest, aud, sub, exp, nbf, iat, jti, sid. There
|
||||
// is NO order- or checkout-scoped claim in either the checkout or the
|
||||
// customer-account session token, so we cannot bind the token to the
|
||||
// requested orderId directly. We therefore bind by customer identity where
|
||||
// possible and fall back to a tightened recency window for true guests.
|
||||
//
|
||||
// - customerAccount tokens always carry a customer GID in `sub`. We require
|
||||
// that the order's customer matches (strong binding — preferred path).
|
||||
// - Checkout (thank-you page) tokens for logged-in buyers also carry the
|
||||
// customer GID in `sub`; we bind to it identically.
|
||||
// - For guest checkouts (no customer on the order, checkout source only) we
|
||||
// have nothing in the token to bind against. We accept only when the order
|
||||
// was placed within a SHORT window — the thank-you page is rendered
|
||||
// immediately after checkout, so a few minutes is ample for the legitimate
|
||||
// flow. Residual risk: within this small window an attacker holding ANY
|
||||
// valid checkout token for this shop could enumerate the numeric ids of
|
||||
// very-recently-placed guest orders and read their amount/reference. This
|
||||
// is mitigated (not eliminated) by (a) the short window, (b) per-IP rate
|
||||
// limiting on /api/public/* (see server.js), and (c) numeric order ids
|
||||
// being unguessable in the short window. The customer-account path remains
|
||||
// the preferred, fully-bound surface.
|
||||
const tokenSub = (sessionToken?.sub ?? "").toString();
|
||||
const tokenCustomerNumeric = tokenSub.replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
||||
const orderCustomerNumeric = (orderInfo.customerId ?? "").replace(/^gid:\/\/shopify\/[^/]+\//, "").replace(/[^0-9]/g, "");
|
||||
|
||||
let ownershipOk = false;
|
||||
if (orderCustomerNumeric && tokenCustomerNumeric) {
|
||||
ownershipOk = orderCustomerNumeric === tokenCustomerNumeric;
|
||||
} else if (authSource === "checkout" && !orderCustomerNumeric) {
|
||||
// Guest checkout: no customer to bind against. Accept only if the order is
|
||||
// very fresh (the buyer has just completed checkout for it). Kept short to
|
||||
// shrink the enumeration window — see residual-risk note above.
|
||||
const placedAtMs = orderInfo.processedAtMs ?? 0;
|
||||
const RECENT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||
ownershipOk = placedAtMs > 0 && Date.now() - placedAtMs <= RECENT_WINDOW_MS;
|
||||
}
|
||||
|
||||
if (!ownershipOk) {
|
||||
// Minimal correlation log: never log raw customer identifiers (PII). Hash
|
||||
// the token subject (sha256, truncated) so repeated abuse from the same
|
||||
// principal is still correlatable without storing the GID itself.
|
||||
const subHash = tokenSub
|
||||
? crypto.createHash("sha256").update(tokenSub).digest("hex").slice(0, 12)
|
||||
: "-";
|
||||
console.warn(
|
||||
`payment-info: ownership check failed for shop=${shop} order=${orderGid} ` +
|
||||
`authSource=${authSource} subHash=${subHash}`,
|
||||
);
|
||||
return cors(
|
||||
Response.json(
|
||||
{ showPaymentInstructions: false, error: "forbidden" },
|
||||
{ status: 403 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const language = pickLanguage(orderInfo.customerLocale ?? settings.defaultLanguage);
|
||||
const t = getStrings(language);
|
||||
|
||||
// Outstanding amount: prefer totalOutstanding (set by Shopify for unpaid),
|
||||
// fall back to totalPrice when zero.
|
||||
const amount = orderInfo.outstandingAmount > 0 ? orderInfo.outstandingAmount : orderInfo.totalAmount;
|
||||
// Always use the canonical invoice number (e.g. "RE-1034") as the
|
||||
// remittance reference — NEVER the bare Shopify order name ("#1034"),
|
||||
// because:
|
||||
// (a) the customer sees this on the thank-you page and pastes it into
|
||||
// their banking app; if it doesn't match what's printed on the PDF
|
||||
// (which uses the invoice number), the bank treats them as two
|
||||
// different payments, and
|
||||
// (b) several banks reject "#" in the reference field.
|
||||
const remittance = await resolveOrderRemittance({
|
||||
shopDomain: shop,
|
||||
orderGid,
|
||||
orderNumber: orderInfo.orderNumber,
|
||||
settings,
|
||||
});
|
||||
|
||||
const giroCodeUrl = (() => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
|
||||
const reqUrl = new URL(request.url);
|
||||
// Behind a reverse proxy that terminates TLS the inbound URL is http.
|
||||
// Trust X-Forwarded-Proto, otherwise force https for any non-localhost host.
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const isLocal = reqUrl.hostname === "localhost" || reqUrl.hostname === "127.0.0.1";
|
||||
const proto = forwardedProto ?? (isLocal ? reqUrl.protocol.replace(":", "") : "https");
|
||||
const origin = `${proto}://${reqUrl.host}`;
|
||||
const qs = signGiroCodeUrl({ shop, orderId: numericId, exp });
|
||||
return `${origin}/api/public/girocode.png?${qs}`;
|
||||
})();
|
||||
|
||||
const dueDate = settings.paymentTermDays > 0
|
||||
? addDays(new Date(), settings.paymentTermDays)
|
||||
: null;
|
||||
|
||||
return cors(
|
||||
Response.json({
|
||||
showPaymentInstructions: true,
|
||||
payload: {
|
||||
language,
|
||||
heading: t.giroCodeCaption,
|
||||
giroCodeUrl,
|
||||
recipient: [settings.companyName, settings.legalForm].filter(Boolean).join(" "),
|
||||
bankName: settings.bankName,
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
amountFormatted: formatMoney(amount, orderInfo.currency, language),
|
||||
reference: remittance,
|
||||
dueDateFormatted: dueDate ? formatDate(dueDate, language) : null,
|
||||
instructions: dueDate
|
||||
? t.paymentTerms(settings.paymentTermDays, formatDate(dueDate, language))
|
||||
: t.paymentTermsImmediate,
|
||||
labels: {
|
||||
recipient: t.recipientLabel,
|
||||
bank: t.bankLabel,
|
||||
iban: t.ibanLabel,
|
||||
bic: t.bicLabel,
|
||||
amount: t.amountLabel,
|
||||
reference: t.referenceLabel,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
interface OrderInfo {
|
||||
isManual: boolean;
|
||||
totalAmount: number;
|
||||
outstandingAmount: number;
|
||||
currency: string;
|
||||
orderName: string;
|
||||
orderNumber: number | null;
|
||||
customerLocale?: string;
|
||||
customerId?: string;
|
||||
processedAtMs?: number;
|
||||
txCount: number;
|
||||
manualFlags: Array<{ status?: string; manual?: boolean }>;
|
||||
}
|
||||
|
||||
async function fetchOrderInfo(
|
||||
admin: { graphql: (q: string, opts?: { variables?: Record<string, unknown> }) => Promise<Response> },
|
||||
orderGid: string,
|
||||
): Promise<OrderInfo | null> {
|
||||
const res = await admin.graphql(
|
||||
`#graphql
|
||||
query OrderPaymentInfo($id: ID!) {
|
||||
order(id: $id) {
|
||||
name
|
||||
number
|
||||
currencyCode
|
||||
customerLocale
|
||||
processedAt
|
||||
createdAt
|
||||
customer { id }
|
||||
totalPriceSet { shopMoney { amount } }
|
||||
totalOutstandingSet { shopMoney { amount } }
|
||||
transactions(first: 20) {
|
||||
status
|
||||
manualPaymentGateway
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ variables: { id: orderGid } },
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
order?: {
|
||||
name?: string;
|
||||
number?: number | null;
|
||||
currencyCode?: string;
|
||||
customerLocale?: string | null;
|
||||
processedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
customer?: { id?: string } | null;
|
||||
totalPriceSet?: { shopMoney: { amount: string } };
|
||||
totalOutstandingSet?: { shopMoney: { amount: string } };
|
||||
transactions?: Array<{ status?: string; manualPaymentGateway?: boolean }>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
const o = json.data?.order;
|
||||
if (!o) return null;
|
||||
const txs = o.transactions ?? [];
|
||||
const isManual = txs.some(
|
||||
(t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR",
|
||||
);
|
||||
return {
|
||||
isManual,
|
||||
totalAmount: parseFloat(o.totalPriceSet?.shopMoney.amount ?? "0"),
|
||||
outstandingAmount: parseFloat(o.totalOutstandingSet?.shopMoney.amount ?? "0"),
|
||||
currency: o.currencyCode ?? "EUR",
|
||||
orderName: o.name ?? "",
|
||||
orderNumber: typeof o.number === "number" ? o.number : null,
|
||||
customerLocale: o.customerLocale ?? undefined,
|
||||
customerId: o.customer?.id ?? undefined,
|
||||
processedAtMs: (() => {
|
||||
const raw = o.processedAt ?? o.createdAt ?? null;
|
||||
if (!raw) return undefined;
|
||||
const t = Date.parse(raw);
|
||||
return Number.isFinite(t) ? t : undefined;
|
||||
})(),
|
||||
txCount: txs.length,
|
||||
manualFlags: txs.map((t) => ({ status: t.status, manual: t.manualPaymentGateway })),
|
||||
};
|
||||
}
|
||||
@@ -113,11 +113,9 @@ export default function Index() {
|
||||
|
||||
<s-section
|
||||
heading="Recent invoices"
|
||||
padding="none"
|
||||
accessibilityLabel="Recent invoices table"
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<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">
|
||||
@@ -126,7 +124,6 @@ export default function Index() {
|
||||
</s-paragraph>
|
||||
<s-link href="/app/invoices">Open invoices →</s-link>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
@@ -171,11 +168,9 @@ export default function Index() {
|
||||
</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">
|
||||
|
||||
+210
-15
@@ -3,6 +3,7 @@ import { Link, useLoaderData, useNavigation, useFetcher } from "react-router";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { buildRepresentativeInvoiceMap } from "../services/invoice/representativeInvoice";
|
||||
|
||||
interface RecentOrder {
|
||||
id: string; // gid
|
||||
@@ -20,6 +21,20 @@ interface RecentOrder {
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
interface DraftOrderRow {
|
||||
id: string; // gid
|
||||
numericId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPrice: string;
|
||||
currency: string;
|
||||
customerName: string;
|
||||
hasOffer: boolean;
|
||||
offerNumber?: string;
|
||||
offerVersion?: number;
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
const RECENT_ORDERS_QUERY = `#graphql
|
||||
query RecentOrders($first: Int!) {
|
||||
orders(first: $first, sortKey: CREATED_AT, reverse: true) {
|
||||
@@ -35,6 +50,20 @@ const RECENT_ORDERS_QUERY = `#graphql
|
||||
}
|
||||
`;
|
||||
|
||||
const RECENT_DRAFTS_QUERY = `#graphql
|
||||
query RecentDrafts($first: Int!) {
|
||||
draftOrders(first: $first, sortKey: UPDATED_AT, reverse: true, query: "status:open") {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
customer { firstName lastName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Filter = "all" | "missing" | "with";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -47,6 +76,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
: "all";
|
||||
|
||||
let orders: RecentOrder[] = [];
|
||||
let drafts: DraftOrderRow[] = [];
|
||||
try {
|
||||
const res = await admin.graphql(RECENT_ORDERS_QUERY, { variables: { first: 50 } });
|
||||
const json = (await res.json()) as {
|
||||
@@ -73,10 +103,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
},
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
const latestByOrder = new Map<string, (typeof invoices)[number]>();
|
||||
for (const inv of invoices) {
|
||||
if (!latestByOrder.has(inv.orderId)) latestByOrder.set(inv.orderId, inv);
|
||||
}
|
||||
// Pick the representative invoice per order. `invoices` is sorted by
|
||||
// version desc, but a cancelled invoice can carry a HIGHER version than
|
||||
// the current active one (cancel-and-reissue bumps versions), so a naive
|
||||
// "first row wins" would surface a stale cancelled invoice and hide the
|
||||
// live one. Prefer the latest non-cancelled invoice; only fall back to a
|
||||
// cancelled row when no active invoice exists.
|
||||
const latestByOrder = buildRepresentativeInvoiceMap(invoices);
|
||||
|
||||
orders = nodes.map((n) => {
|
||||
const inv = latestByOrder.get(n.id);
|
||||
@@ -103,6 +136,63 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
console.warn("Failed to load recent orders:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await admin.graphql(RECENT_DRAFTS_QUERY, { variables: { first: 50 } });
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
draftOrders?: {
|
||||
nodes?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
totalPriceSet?: { shopMoney: { amount: string; currencyCode: string } };
|
||||
customer?: { firstName: string | null; lastName: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
if (json.errors?.length) {
|
||||
console.warn(
|
||||
"draftOrders query returned errors:",
|
||||
json.errors.map((e) => e.message).join("; "),
|
||||
);
|
||||
}
|
||||
const nodes = json.data?.draftOrders?.nodes ?? [];
|
||||
const draftIds = nodes.map((n) => n.id);
|
||||
|
||||
const offers = await db.invoice.findMany({
|
||||
where: { shopDomain: session.shop, orderId: { in: draftIds }, kind: "offer" },
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
const latestByDraft = new Map<string, (typeof offers)[number]>();
|
||||
for (const off of offers) {
|
||||
if (!latestByDraft.has(off.orderId)) latestByDraft.set(off.orderId, off);
|
||||
}
|
||||
|
||||
drafts = nodes.map((n) => {
|
||||
const off = latestByDraft.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",
|
||||
hasOffer: !!off && !off.cancelledAt,
|
||||
offerNumber: off?.invoiceNumber,
|
||||
offerVersion: off?.version,
|
||||
pdfUrl: off?.pdfUrl,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to load draft orders:", err);
|
||||
}
|
||||
|
||||
const allCount = orders.length;
|
||||
const withCount = orders.filter((o) => o.hasInvoice).length;
|
||||
const missingCount = allCount - withCount;
|
||||
@@ -112,6 +202,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
orders,
|
||||
drafts,
|
||||
filter,
|
||||
counts: { all: allCount, with: withCount, missing: missingCount },
|
||||
};
|
||||
@@ -134,14 +225,13 @@ function formatMoney(amount: string, currency: string): string {
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const { orders, filter, counts } = useLoaderData<typeof loader>();
|
||||
const { orders, drafts, filter, counts } = useLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
return (
|
||||
<s-page heading="Invoices">
|
||||
<s-section heading="Recent orders" padding="none">
|
||||
<s-box padding="base">
|
||||
<s-section heading="Recent orders">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
Generate the invoice for an order, regenerate an unsent draft,
|
||||
@@ -164,24 +254,19 @@ export default function InvoicesPage() {
|
||||
</FilterChip>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
|
||||
{isLoading ? (
|
||||
<s-box padding="base">
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-spinner size="small" accessibilityLabel="Loading orders" />
|
||||
<s-stack direction="inline" gap="base" alignItems="center">
|
||||
<s-spinner size="base" accessibilityLabel="Loading orders" />
|
||||
<s-text tone="neutral">Loading…</s-text>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : orders.length === 0 ? (
|
||||
<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-table>
|
||||
<s-table-header-row>
|
||||
@@ -201,6 +286,40 @@ export default function InvoicesPage() {
|
||||
)}
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Draft orders (offers)">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
Generate a PDF offer (Angebot) for any open draft order. The
|
||||
offer's number is the draft order name (e.g. <em>D1</em>).
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<s-stack direction="block" gap="base" alignItems="center">
|
||||
<s-text type="strong">No open draft orders</s-text>
|
||||
<s-paragraph tone="neutral">
|
||||
Create a draft order in Shopify and refresh this page.
|
||||
</s-paragraph>
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Draft</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">Offer</s-table-header>
|
||||
<s-table-header listSlot="labeled">Actions</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{drafts.map((d) => (
|
||||
<DraftRow key={d.id} draft={d} />
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
)}
|
||||
</s-section>
|
||||
|
||||
<s-section heading="About this page">
|
||||
<s-stack direction="block" gap="small">
|
||||
<s-paragraph>
|
||||
@@ -240,19 +359,24 @@ function FilterChip({
|
||||
|
||||
function OrderRow({ order }: { order: RecentOrder }) {
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const sendFetcher = useFetcher<{ ok: boolean; error?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
const isSending = sendFetcher.state !== "idle";
|
||||
const isCancelReissue = order.hasInvoice && order.invoiceSent;
|
||||
const buttonLabel = !order.hasInvoice
|
||||
? "Generate"
|
||||
: order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate";
|
||||
const sendLabel = order.invoiceSent ? "Re-send" : "Send";
|
||||
|
||||
return (
|
||||
<s-table-row>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-link href={`shopify://admin/orders/${order.numericId}`}>
|
||||
<s-text type="strong">{order.name}</s-text>
|
||||
</s-link>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{order.customerName}</s-table-cell>
|
||||
@@ -277,6 +401,9 @@ function OrderRow({ order }: { order: RecentOrder }) {
|
||||
{fetcher.data?.error ? (
|
||||
<s-text tone="critical">{fetcher.data.error}</s-text>
|
||||
) : null}
|
||||
{sendFetcher.data?.error ? (
|
||||
<s-text tone="critical">{sendFetcher.data.error}</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-text tone="neutral">—</s-text>
|
||||
@@ -295,13 +422,81 @@ function OrderRow({ order }: { order: RecentOrder }) {
|
||||
) : null}
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || isSending}
|
||||
variant={order.hasInvoice ? "secondary" : "primary"}
|
||||
tone={isCancelReissue ? "critical" : "auto"}
|
||||
>
|
||||
{isBusy ? "Working…" : buttonLabel}
|
||||
</s-button>
|
||||
</fetcher.Form>
|
||||
<sendFetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
|
||||
<input type="hidden" name="action" value="send" />
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy || isSending}
|
||||
variant={order.hasInvoice && !order.invoiceSent ? "primary" : "secondary"}
|
||||
>
|
||||
{isSending ? "Sending…" : sendLabel}
|
||||
</s-button>
|
||||
</sendFetcher.Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftRow({ draft }: { draft: DraftOrderRow }) {
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
const buttonLabel = draft.hasOffer ? "Regenerate offer" : "Generate offer";
|
||||
|
||||
return (
|
||||
<s-table-row>
|
||||
<s-table-cell>
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-link href={`shopify://admin/draft_orders/${draft.numericId}`}>
|
||||
<s-text type="strong">{draft.name}</s-text>
|
||||
</s-link>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{draft.customerName}</s-table-cell>
|
||||
<s-table-cell>{dateFmt.format(new Date(draft.createdAt))}</s-table-cell>
|
||||
<s-table-cell>{formatMoney(draft.totalPrice, draft.currency)}</s-table-cell>
|
||||
<s-table-cell>
|
||||
{draft.hasOffer ? (
|
||||
<s-stack direction="block" gap="none">
|
||||
<s-stack direction="inline" gap="small" alignItems="center">
|
||||
<s-text type="strong">{draft.offerNumber}</s-text>
|
||||
<s-badge tone="info">Issued</s-badge>
|
||||
{draft.offerVersion && draft.offerVersion > 1 ? (
|
||||
<s-text tone="neutral">v{draft.offerVersion}</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">
|
||||
{draft.pdfUrl ? (
|
||||
<s-link href={draft.pdfUrl} target="_blank">
|
||||
PDF
|
||||
</s-link>
|
||||
) : null}
|
||||
<fetcher.Form method="post" action={`/api/orders/${draft.numericId}/invoice`}>
|
||||
<input type="hidden" name="kind" value="offer" />
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
variant={draft.hasOffer ? "secondary" : "primary"}
|
||||
>
|
||||
{isBusy ? "Working…" : buttonLabel}
|
||||
</s-button>
|
||||
</fetcher.Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
|
||||
+159
-14
@@ -9,10 +9,19 @@ import {
|
||||
normaliseIban,
|
||||
} from "../services/invoice/validation";
|
||||
import { STORED_LOGO_SENTINEL } from "../services/invoice/logoCache.constants";
|
||||
import { validateMerchantHttpsUrl } from "../services/invoice/safeFetch.server";
|
||||
import {
|
||||
deleteStoredLogo,
|
||||
storeUploadedLogo,
|
||||
} from "../services/invoice/logoCache.server";
|
||||
import { encryptField } from "../services/crypto/fieldCrypto.server";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import {
|
||||
DEFAULT_EMAIL_BODY_DE,
|
||||
DEFAULT_EMAIL_BODY_EN,
|
||||
DEFAULT_EMAIL_SUBJECT_DE,
|
||||
DEFAULT_EMAIL_SUBJECT_EN,
|
||||
} from "../services/invoice/emailTemplates";
|
||||
|
||||
interface SettingsFieldErrors {
|
||||
vatId?: string;
|
||||
@@ -24,6 +33,13 @@ interface SettingsFieldErrors {
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel value used in the SMTP password input. The real password is
|
||||
* never sent to the client; if the form posts back this exact value the
|
||||
* action treats it as "unchanged" and keeps whatever is already in the DB.
|
||||
*/
|
||||
const SMTP_PASSWORD_SENTINEL = "__unchanged__";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { session } = await authenticate.admin(request);
|
||||
const settings = await db.shopSettings.upsert({
|
||||
@@ -37,8 +53,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
if (cached) {
|
||||
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
||||
}
|
||||
} else if (settings.logoUrl) {
|
||||
// External HTTPS URL — fine to display directly in the editor.
|
||||
logoPreviewDataUrl = settings.logoUrl;
|
||||
}
|
||||
return { settings, logoPreviewDataUrl };
|
||||
// Never expose the SMTP password to the browser. We replace it with a
|
||||
// sentinel and the form action interprets that as "keep existing value".
|
||||
const safeSettings = {
|
||||
...settings,
|
||||
smtpPassword: settings.smtpPassword ? SMTP_PASSWORD_SENTINEL : "",
|
||||
};
|
||||
return { settings: safeSettings, logoPreviewDataUrl, smtpPasswordSentinel: SMTP_PASSWORD_SENTINEL };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -91,12 +116,28 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// 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");
|
||||
// Look up the existing logoUrl so we don't accidentally clear it when
|
||||
// the user just edited unrelated fields (the visible URL field is hidden
|
||||
// for stored uploads, so it submits empty in that case).
|
||||
const existing = await db.shopSettings.findUnique({
|
||||
where: { shopDomain: session.shop },
|
||||
select: { logoUrl: true },
|
||||
});
|
||||
const submittedLogoUrl = str("logoUrl");
|
||||
// Validate any merchant-supplied external logo URL at the trust boundary:
|
||||
// require a syntactically valid https URL whose host is a domain name, not
|
||||
// an IP literal (SSRF defence-in-depth; safeFetch is the runtime backstop).
|
||||
if (submittedLogoUrl && submittedLogoUrl !== STORED_LOGO_SENTINEL) {
|
||||
const urlError = validateMerchantHttpsUrl(submittedLogoUrl);
|
||||
if (urlError) errors.logo = urlError;
|
||||
}
|
||||
const removeLogo = bool("removeLogo");
|
||||
const logoFile = form.get("logoFile");
|
||||
const hasUpload =
|
||||
logoFile && typeof logoFile === "object" && "size" in logoFile && (logoFile as File).size > 0;
|
||||
|
||||
let resolvedLogoUrl = submittedLogoUrl || existing?.logoUrl || "";
|
||||
|
||||
if (removeLogo && !hasUpload) {
|
||||
await deleteStoredLogo(session.shop);
|
||||
resolvedLogoUrl = "";
|
||||
@@ -115,6 +156,26 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
return { ok: false, errors, savedAt: null as string | null };
|
||||
}
|
||||
|
||||
// Resolve SMTP password: the loader sends a sentinel instead of the real
|
||||
// value. If the form posts that sentinel back unchanged, keep whatever is
|
||||
// already in the DB; otherwise persist the new value (including the empty
|
||||
// string, which means "clear the password").
|
||||
const submittedSmtpPassword = str("smtpPassword");
|
||||
let nextSmtpPassword: string;
|
||||
if (submittedSmtpPassword === SMTP_PASSWORD_SENTINEL) {
|
||||
// Unchanged: keep the stored value as-is (already encrypted at rest).
|
||||
const current = await db.shopSettings.findUnique({
|
||||
where: { shopDomain: session.shop },
|
||||
select: { smtpPassword: true },
|
||||
});
|
||||
nextSmtpPassword = current?.smtpPassword ?? "";
|
||||
} else {
|
||||
// New password (including "" to clear). Encrypt non-empty values at rest.
|
||||
nextSmtpPassword = submittedSmtpPassword
|
||||
? encryptField(submittedSmtpPassword)
|
||||
: "";
|
||||
}
|
||||
|
||||
const data = {
|
||||
companyName: str("companyName"),
|
||||
legalForm: str("legalForm"),
|
||||
@@ -148,10 +209,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
smtpPort: smtpPort ?? 587,
|
||||
smtpSecure: bool("smtpSecure"),
|
||||
smtpUser: str("smtpUser"),
|
||||
smtpPassword: str("smtpPassword"),
|
||||
smtpPassword: nextSmtpPassword,
|
||||
smtpFromName: str("smtpFromName"),
|
||||
smtpFromEmail: str("smtpFromEmail"),
|
||||
smtpReplyTo: str("smtpReplyTo"),
|
||||
emailSubjectDe: str("emailSubjectDe"),
|
||||
emailBodyHtmlDe: str("emailBodyHtmlDe"),
|
||||
emailSubjectEn: str("emailSubjectEn"),
|
||||
emailBodyHtmlEn: str("emailBodyHtmlEn"),
|
||||
autoEmailOnWireTransferPlaced: bool("autoEmailOnWireTransferPlaced"),
|
||||
autoEmailOnFulfilledNonWireTransfer: bool("autoEmailOnFulfilledNonWireTransfer"),
|
||||
};
|
||||
|
||||
await db.shopSettings.upsert({
|
||||
@@ -184,17 +251,6 @@ export default function SettingsRoute() {
|
||||
</s-paragraph>
|
||||
</s-section>
|
||||
|
||||
{actionData?.ok && (
|
||||
<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" heading="Please fix the highlighted errors">
|
||||
Some fields below need attention before settings can be saved.
|
||||
</s-banner>
|
||||
)}
|
||||
|
||||
<Form method="post" encType="multipart/form-data">
|
||||
<s-section heading="Company">
|
||||
<s-stack direction="block" gap="base">
|
||||
@@ -361,19 +417,108 @@ export default function SettingsRoute() {
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Email templates">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
These templates are used when sending the invoice PDF by email.
|
||||
Leave a field empty to fall back to the built-in default.
|
||||
</s-paragraph>
|
||||
|
||||
<Field
|
||||
label="Subject (German)"
|
||||
name="emailSubjectDe"
|
||||
defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
|
||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
||||
/>
|
||||
<RichTextEditor
|
||||
name="emailBodyHtmlDe"
|
||||
label="Body (German)"
|
||||
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
||||
variables={EMAIL_VARS}
|
||||
minHeight={220}
|
||||
logoDataUrl={logoPreviewDataUrl}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Subject (English)"
|
||||
name="emailSubjectEn"
|
||||
defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
|
||||
helpText="Variables like {{invoiceNumber}} are substituted at send time."
|
||||
/>
|
||||
<RichTextEditor
|
||||
name="emailBodyHtmlEn"
|
||||
label="Body (English)"
|
||||
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
||||
variables={EMAIL_VARS}
|
||||
minHeight={220}
|
||||
logoDataUrl={logoPreviewDataUrl}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Automations">
|
||||
<s-stack direction="block" gap="base">
|
||||
<s-paragraph>
|
||||
These trigger directly from Shopify order webhooks — no Shopify
|
||||
Flow required (Flow is gated to Plus stores for custom apps).
|
||||
When an automation fires, the invoice is generated (if it doesn't
|
||||
already exist) and emailed to the customer using the SMTP and
|
||||
email-template settings above. "Wire-transfer" is detected via
|
||||
Shopify's <code>OrderTransaction.manualPaymentGateway</code> flag,
|
||||
so any merchant-defined manual payment method (Überweisung, Cash
|
||||
on Delivery, Money Order, …) qualifies.
|
||||
</s-paragraph>
|
||||
<Toggle
|
||||
label='Auto-email the invoice when a wire-transfer order is placed (so the customer gets the bank details + GiroCode immediately).'
|
||||
name="autoEmailOnWireTransferPlaced"
|
||||
checked={settings.autoEmailOnWireTransferPlaced}
|
||||
/>
|
||||
<Toggle
|
||||
label='Auto-email the invoice when an order is fulfilled and is NOT a wire-transfer order (e.g. the customer paid by card and we send the invoice with the shipment).'
|
||||
name="autoEmailOnFulfilledNonWireTransfer"
|
||||
checked={settings.autoEmailOnFulfilledNonWireTransfer}
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section>
|
||||
<s-stack direction="block" gap="base">
|
||||
{actionData?.ok && (
|
||||
<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" heading="Please fix the highlighted errors">
|
||||
Some fields below need attention before settings can be saved.
|
||||
</s-banner>
|
||||
)}
|
||||
<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-stack>
|
||||
</s-section>
|
||||
</Form>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_VARS = [
|
||||
{ token: "{{invoiceNumber}}" },
|
||||
{ token: "{{customerName}}" },
|
||||
{ token: "{{customerFirstName}}" },
|
||||
{ token: "{{orderName}}" },
|
||||
{ token: "{{totalGross}}" },
|
||||
{ token: "{{dueDate}}" },
|
||||
{ token: "{{companyName}}" },
|
||||
{ token: "{{ownerName}}" },
|
||||
{ token: "{{shopEmail}}" },
|
||||
{ token: "{{shopWebsite}}" },
|
||||
];
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
|
||||
@@ -6,13 +6,46 @@ import { Form, useActionData, useLoaderData } from "react-router";
|
||||
import { login } from "../../shopify.server";
|
||||
import { loginErrorMessage } from "./error.server";
|
||||
|
||||
function enforceAllowedShop(request: Request) {
|
||||
const allowedShop = process.env.ALLOWED_SHOP?.trim();
|
||||
if (!allowedShop) return;
|
||||
const url = new URL(request.url);
|
||||
const fromQuery = url.searchParams.get("shop");
|
||||
let fromBody: string | null = null;
|
||||
// Action requests submit the shop in the form body; we re-read it here.
|
||||
// (request.formData() can only be consumed once, so we clone.)
|
||||
if (request.method === "POST") {
|
||||
// We can't await the clone here without making this async; instead the
|
||||
// caller awaits the actual action and we re-validate the redirect target
|
||||
// via the query string check above. The action wrapper below also runs
|
||||
// a body check before delegating to `login()`.
|
||||
}
|
||||
if (fromQuery && fromQuery.toLowerCase() !== allowedShop.toLowerCase() && !fromBody) {
|
||||
throw new Response("This app is private.", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceAllowedShopFromBody(request: Request) {
|
||||
const allowedShop = process.env.ALLOWED_SHOP?.trim();
|
||||
if (!allowedShop) return request;
|
||||
const cloned = request.clone();
|
||||
const form = await cloned.formData();
|
||||
const shop = (form.get("shop") ?? "").toString().trim().toLowerCase();
|
||||
if (shop && shop !== allowedShop.toLowerCase()) {
|
||||
throw new Response("This app is private.", { status: 403 });
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
enforceAllowedShop(request);
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return { errors };
|
||||
return { errors, allowedShop: process.env.ALLOWED_SHOP ?? null };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
await enforceAllowedShopFromBody(request);
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return {
|
||||
@@ -23,7 +56,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
export default function Auth() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const [shop, setShop] = useState("");
|
||||
const [shop, setShop] = useState(loaderData.allowedShop ?? "");
|
||||
const { errors } = actionData || loaderData;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Lightweight unauthenticated health check used by Docker/Caddy.
|
||||
// Returns 200 with a tiny JSON body. Do NOT touch the database here:
|
||||
// the goal is only to confirm the Node process is serving HTTP.
|
||||
export const loader = () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
@@ -1,11 +1,74 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import {
|
||||
generateAndEmailInvoice,
|
||||
isManualPaymentOrder,
|
||||
} from "../services/invoice/automations.server";
|
||||
import { reserveWebhook } from "../services/webhooks/dedupe.server";
|
||||
import { runWebhookInBackground } from "../services/webhooks/background.server";
|
||||
|
||||
// We don't auto-generate invoices on order create. This handler just
|
||||
// acknowledges the webhook so Shopify keeps it healthy and gives us a
|
||||
// hook point for future work (e.g. cache invalidation).
|
||||
/**
|
||||
* orders/create — Automation 1: when a wire-transfer (manual-payment-gateway)
|
||||
* order is placed, immediately generate and email the invoice (which includes
|
||||
* the bank details + GiroCode) so the customer can pay. Other orders are
|
||||
* ignored here; they're handled by orders/fulfilled (Automation 2).
|
||||
*/
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic } = await authenticate.webhook(request);
|
||||
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
|
||||
// Reserve this delivery (status="processing"). `null` => already
|
||||
// done/in-flight, so short-circuit. The reservation is committed only after
|
||||
// the background work succeeds, and released on failure so Shopify's retry
|
||||
// re-runs it (prevents the silent invoice loss we'd get if we recorded the
|
||||
// id as processed before the slow PDF/email work).
|
||||
const reservation = await reserveWebhook(request, shop, topic);
|
||||
if (!reservation) return new Response();
|
||||
|
||||
if (!session || !admin) {
|
||||
// App uninstalled before the webhook drained — nothing to do.
|
||||
await reservation.commit();
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const orderId = payload?.id;
|
||||
if (orderId == null) {
|
||||
await reservation.commit();
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const customerLocale =
|
||||
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
||||
|
||||
// Respond 200 immediately and run the (slow) PDF + email work in the
|
||||
// background — keeps us well under Shopify's ~5s ack timeout. The queue
|
||||
// commits the reservation on success and releases it on failure.
|
||||
runWebhookInBackground(
|
||||
`${topic} order=${orderId} shop=${shop}`,
|
||||
async () => {
|
||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||
if (!settings?.autoEmailOnWireTransferPlaced) return;
|
||||
|
||||
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
|
||||
if (!(await isManualPaymentOrder(admin, orderGid))) return;
|
||||
|
||||
const result = await generateAndEmailInvoice({
|
||||
shopDomain: shop,
|
||||
admin,
|
||||
orderId,
|
||||
customerLocale,
|
||||
});
|
||||
if (!result.ok) {
|
||||
// Throw so the reservation is released and Shopify retries — don't
|
||||
// swallow the failure (which would leave the invoice unsent forever).
|
||||
throw new Error(
|
||||
`auto-email (wire-transfer placed) failed for order ${orderId} on ${shop}: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
reservation,
|
||||
);
|
||||
|
||||
return new Response();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import {
|
||||
generateAndEmailInvoice,
|
||||
isManualPaymentOrder,
|
||||
} from "../services/invoice/automations.server";
|
||||
import { reserveWebhook } from "../services/webhooks/dedupe.server";
|
||||
import { runWebhookInBackground } from "../services/webhooks/background.server";
|
||||
|
||||
/**
|
||||
* orders/fulfilled — Automation 2: when an order is fulfilled and is NOT a
|
||||
* wire-transfer (manual-payment-gateway) order, automatically email the
|
||||
* invoice to the customer. Manual-gateway orders are intentionally skipped
|
||||
* because Automation 1 already emailed them at order-create time.
|
||||
*/
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic, payload, session, admin } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
|
||||
// Reserve/commit dedupe — see webhooks/dedupe.server.ts. `null` => already
|
||||
// done/in-flight; commit on success / release on failure happen in the
|
||||
// background queue so a failed send is retried by Shopify, not dropped.
|
||||
const reservation = await reserveWebhook(request, shop, topic);
|
||||
if (!reservation) return new Response();
|
||||
|
||||
if (!session || !admin) {
|
||||
// App was uninstalled before the webhook drained — nothing to do.
|
||||
await reservation.commit();
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const orderId = payload?.id;
|
||||
if (orderId == null) {
|
||||
await reservation.commit();
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const customerLocale =
|
||||
typeof payload?.customer_locale === "string" ? payload.customer_locale : undefined;
|
||||
|
||||
// Respond fast; do the heavy lifting after the response (see notes in
|
||||
// webhooks.orders.create.tsx for the rationale).
|
||||
runWebhookInBackground(
|
||||
`${topic} order=${orderId} shop=${shop}`,
|
||||
async () => {
|
||||
const settings = await db.shopSettings.findUnique({ where: { shopDomain: shop } });
|
||||
if (!settings?.autoEmailOnFulfilledNonWireTransfer) return;
|
||||
|
||||
const orderGid = `gid://shopify/Order/${String(orderId).replace(/^.*\//, "")}`;
|
||||
if (await isManualPaymentOrder(admin, orderGid)) {
|
||||
// Manual / wire-transfer order — handled by Automation 1, skip here.
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await generateAndEmailInvoice({
|
||||
shopDomain: shop,
|
||||
admin,
|
||||
orderId,
|
||||
customerLocale,
|
||||
});
|
||||
if (!result.ok) {
|
||||
// Throw so the reservation is released and Shopify retries.
|
||||
throw new Error(
|
||||
`auto-email (fulfilled) failed for order ${orderId} on ${shop}: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
reservation,
|
||||
);
|
||||
|
||||
return new Response();
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { reserveWebhook } from "../services/webhooks/dedupe.server";
|
||||
|
||||
// Acknowledged but not yet acted on. Future: invalidate cached invoice
|
||||
// snapshots when a relevant field on the order changes.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
// Idempotency against Shopify retries — see webhooks/dedupe.server.ts.
|
||||
const reservation = await reserveWebhook(request, shop, topic);
|
||||
if (!reservation) return new Response();
|
||||
// No side-effect work yet, so the delivery is immediately complete.
|
||||
await reservation.commit();
|
||||
return new Response();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Small env-access helpers that fail closed.
|
||||
*
|
||||
* `requireEnv` throws a clear error (without ever printing the secret value)
|
||||
* when a required environment variable is missing or empty. `optionalEnv`
|
||||
* returns the trimmed value or undefined.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the value of `name` from `process.env`, throwing if it is unset or
|
||||
* empty (after trimming). The secret value itself is never included in the
|
||||
* error message.
|
||||
*/
|
||||
export function requireEnv(name: string): string {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
throw new Error(
|
||||
`Missing required environment variable "${name}". ` +
|
||||
`Set it before starting the app (see deploy/.env.dev.example).`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of `name` from `process.env`, or `undefined` if it is
|
||||
* unset or empty (after trimming).
|
||||
*/
|
||||
export function optionalEnv(name: string): string | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") return undefined;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Field-level encryption at rest using AES-256-GCM.
|
||||
*
|
||||
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
|
||||
*
|
||||
* `decryptField` is backward-compatible: values that do not carry the
|
||||
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
|
||||
* so an existing (dev) database keeps working without a data migration.
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { requireEnv } from "../config/env.server";
|
||||
|
||||
const PREFIX = "enc:v1:";
|
||||
const IV_BYTES = 12;
|
||||
const KEY_BYTES = 32;
|
||||
|
||||
let cachedKey: Buffer | null = null;
|
||||
|
||||
/**
|
||||
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
|
||||
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
|
||||
*/
|
||||
function getKey(): Buffer {
|
||||
if (cachedKey) return cachedKey;
|
||||
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
|
||||
}
|
||||
if (key.length !== KEY_BYTES) {
|
||||
throw new Error(
|
||||
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
|
||||
);
|
||||
}
|
||||
cachedKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
|
||||
export function encryptField(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return (
|
||||
PREFIX +
|
||||
iv.toString("base64") +
|
||||
":" +
|
||||
tag.toString("base64") +
|
||||
":" +
|
||||
ciphertext.toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
|
||||
* assumed to be legacy plaintext and returned unchanged.
|
||||
*/
|
||||
export function decryptField(value: string): string {
|
||||
if (!value.startsWith(PREFIX)) return value;
|
||||
const parts = value.slice(PREFIX.length).split(":");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
|
||||
}
|
||||
const [ivB64, tagB64, dataB64] = parts;
|
||||
const key = getKey();
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const tag = Buffer.from(tagB64, "base64");
|
||||
const ciphertext = Buffer.from(dataB64, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
import db from "../../db.server";
|
||||
import { generateInvoice } from "./generateInvoice.server";
|
||||
import { sendInvoiceEmail } from "./email.server";
|
||||
|
||||
/**
|
||||
* Returns true when the order has at least one transaction processed by a
|
||||
* Shopify "manual" payment gateway (wire transfer, cash on delivery,
|
||||
* money order, custom manual methods, …). This uses
|
||||
* `OrderTransaction.manualPaymentGateway`, the only first-class flag
|
||||
* Shopify exposes for distinguishing manual gateways from automated ones.
|
||||
*
|
||||
* Falls back to `false` on any GraphQL error so we don't block fulfilment
|
||||
* automations on transient API issues.
|
||||
*/
|
||||
export async function isManualPaymentOrder(
|
||||
admin: AdminApiContext,
|
||||
orderGid: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await admin.graphql(
|
||||
`#graphql
|
||||
query OrderManualPaymentCheck($id: ID!) {
|
||||
order(id: $id) {
|
||||
transactions(first: 20) {
|
||||
kind
|
||||
status
|
||||
manualPaymentGateway
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ variables: { id: orderGid } },
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
order?: {
|
||||
transactions?: Array<{
|
||||
kind?: string;
|
||||
status?: string;
|
||||
manualPaymentGateway?: boolean;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
const txs = json.data?.order?.transactions ?? [];
|
||||
// Any non-failed transaction processed by a manual gateway counts.
|
||||
return txs.some(
|
||||
(t) => t.manualPaymentGateway === true && t.status !== "FAILURE" && t.status !== "ERROR",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`isManualPaymentOrder query failed for ${orderGid}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutoEmailArgs {
|
||||
shopDomain: string;
|
||||
admin: AdminApiContext;
|
||||
/** Numeric Shopify order id (from REST webhook payload `id`). */
|
||||
orderId: string | number;
|
||||
/** Customer locale forwarded to the email service for subject/body language. */
|
||||
customerLocale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: if an unsent invoice already exists for the order, it is reused
|
||||
* and emailed. If a sent invoice already exists, sending is skipped (we never
|
||||
* spam the customer with the same invoice twice from automations).
|
||||
*/
|
||||
export async function generateAndEmailInvoice(args: AutoEmailArgs): Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
invoiceNumber?: string;
|
||||
}> {
|
||||
const orderNumeric = String(args.orderId).replace(/^.*\//, "");
|
||||
const orderGid = `gid://shopify/Order/${orderNumeric}`;
|
||||
|
||||
const existing = await db.invoice.findFirst({
|
||||
where: {
|
||||
shopDomain: args.shopDomain,
|
||||
orderId: orderGid,
|
||||
kind: "invoice",
|
||||
cancelledAt: null,
|
||||
},
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
if (existing && existing.sentAt) {
|
||||
return { ok: true, reason: "already-sent", invoiceNumber: existing.invoiceNumber };
|
||||
}
|
||||
|
||||
let invoiceId = existing?.id;
|
||||
let invoiceNumber = existing?.invoiceNumber;
|
||||
if (!invoiceId) {
|
||||
const generated = await generateInvoice({
|
||||
shopDomain: args.shopDomain,
|
||||
admin: args.admin,
|
||||
orderId: orderNumeric,
|
||||
});
|
||||
invoiceId = generated.invoiceId;
|
||||
invoiceNumber = generated.invoiceNumber;
|
||||
}
|
||||
|
||||
const result = await sendInvoiceEmail({
|
||||
shopDomain: args.shopDomain,
|
||||
invoiceId,
|
||||
customerLocale: args.customerLocale,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, reason: result.errorMessage ?? "email-failed", invoiceNumber };
|
||||
}
|
||||
return { ok: true, invoiceNumber };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ShopSettings } from "@prisma/client";
|
||||
|
||||
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
|
||||
import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server";
|
||||
import type {
|
||||
InvoiceLine,
|
||||
InvoiceNotice,
|
||||
@@ -8,10 +8,11 @@ import type {
|
||||
InvoiceViewModel,
|
||||
IssuerData,
|
||||
RecipientData,
|
||||
TrackingInfo,
|
||||
VatBreakdownEntry,
|
||||
} from "./types";
|
||||
import { addDays } from "./format";
|
||||
import { pickLanguage, type InvoiceLanguage } from "./i18n";
|
||||
import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n";
|
||||
|
||||
interface ComposeArgs {
|
||||
order: RawOrderForInvoice;
|
||||
@@ -28,6 +29,14 @@ interface ComposeArgs {
|
||||
storno?: { cancelsNumber: string };
|
||||
/** Optional override for invoice/delivery date (defaults to order date). */
|
||||
issueDate?: Date;
|
||||
/**
|
||||
* When true, render as an Angebot/Offer instead of an invoice:
|
||||
* - `kind = "offer"`
|
||||
* - no payment-due date (the dueDate field is repurposed by the renderer
|
||||
* as the offer's validity expiry).
|
||||
* - GiroCode and payment-terms text are suppressed.
|
||||
*/
|
||||
offer?: boolean;
|
||||
}
|
||||
|
||||
export function composeInvoice({
|
||||
@@ -37,6 +46,7 @@ export function composeInvoice({
|
||||
forceLanguage,
|
||||
storno,
|
||||
issueDate,
|
||||
offer,
|
||||
}: ComposeArgs): InvoiceViewModel {
|
||||
const language = forceLanguage
|
||||
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
|
||||
@@ -46,21 +56,78 @@ export function composeInvoice({
|
||||
const isB2B = !!order.purchasingEntity?.company;
|
||||
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
|
||||
|
||||
let { lines, totals } = mapLinesAndTotals(order);
|
||||
const strings = getStrings(language);
|
||||
let { lines, totals } = mapLinesAndTotals(order, {
|
||||
shippingItemPrefix: strings.shippingItemPrefix,
|
||||
});
|
||||
let notices = deriveNotices({ order, settings, isB2B });
|
||||
|
||||
const pickupInfo = detectPickup(order);
|
||||
const isPickup = pickupInfo != null;
|
||||
const separateShippingAddress = isPickup ? undefined : mapSeparateShippingAddress(order);
|
||||
// For shipping orders we surface the carrier label (e.g. "Standardversand").
|
||||
// For pickup orders the meta row uses a different label entirely
|
||||
// ("Abholort: <location>") — see the renderer.
|
||||
const shippingMethod = isPickup
|
||||
? undefined
|
||||
: order.shippingLine?.title?.trim() || undefined;
|
||||
const tracking = mapTracking(order);
|
||||
|
||||
const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt);
|
||||
const deliveryDate = invoiceDate;
|
||||
const dueDate = !storno && settings.paymentTermDays > 0
|
||||
// §11 UStG: deliveryDate is the date goods/services were rendered. Prefer
|
||||
// the latest fulfillment timestamp; fall back to invoice date when the
|
||||
// order is unfulfilled (e.g. immediate-render services or digital orders).
|
||||
const deliveryDate = pickDeliveryDate(order, invoiceDate);
|
||||
// For offers we treat `dueDate` as the offer's validity expiry (default 30
|
||||
// days from issue). The PDF renderer renders a different label.
|
||||
const dueDate = offer
|
||||
? addDays(invoiceDate, 30)
|
||||
: !storno && settings.paymentTermDays > 0
|
||||
? addDays(invoiceDate, settings.paymentTermDays)
|
||||
: undefined;
|
||||
|
||||
const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID";
|
||||
// Refunded gross amount, mirrored from Shopify's `totalRefundedSet`.
|
||||
// Storno/offer documents don't carry a refund row — a storno *is*
|
||||
// already the cancellation document, and offers have no payments yet.
|
||||
const refundedAmount = storno || offer
|
||||
? 0
|
||||
: Math.max(0, parseFloat(order.totalRefundedSet?.shopMoney.amount ?? "0") || 0);
|
||||
let paymentStatus = derivePaymentStatus(order.displayFinancialStatus);
|
||||
// Reclassification: Shopify flips `displayFinancialStatus` to
|
||||
// PARTIALLY_REFUNDED as soon as *any* refund is posted against a
|
||||
// paid order, even when the customer only got back a small fraction.
|
||||
// For our purposes such an order is still "paid" — the merchant kept
|
||||
// the difference — and showing "Erstattet" / "Refunded" in the
|
||||
// status row would falsely imply the customer got everything back.
|
||||
// Only when the refund equals (or, defensively, exceeds) the gross
|
||||
// do we keep the "refunded" status.
|
||||
if (
|
||||
paymentStatus === "refunded" &&
|
||||
refundedAmount > 0 &&
|
||||
refundedAmount < totals.gross
|
||||
) {
|
||||
paymentStatus = "paid";
|
||||
}
|
||||
// A document only requires payment when it's a regular invoice (not a
|
||||
// storno or an offer) AND money is still actually owed. Refunded and
|
||||
// paid orders both have a 0 outstanding balance — the difference is
|
||||
// just whether the money was kept (`paid`) or returned (`refunded`).
|
||||
const requiresPayment =
|
||||
!storno &&
|
||||
!offer &&
|
||||
paymentStatus !== "paid" &&
|
||||
paymentStatus !== "refunded" &&
|
||||
paymentStatus !== "voided";
|
||||
const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter(
|
||||
(n) => typeof n === "string" && n.trim().length > 0,
|
||||
);
|
||||
|
||||
if (storno) {
|
||||
lines = lines.map((l) => ({
|
||||
...l,
|
||||
unitPriceNet: -l.unitPriceNet,
|
||||
originalUnitPriceNet:
|
||||
l.originalUnitPriceNet != null ? -l.originalUnitPriceNet : undefined,
|
||||
totalNet: -l.totalNet,
|
||||
}));
|
||||
totals = {
|
||||
@@ -80,7 +147,7 @@ export function composeInvoice({
|
||||
return {
|
||||
language,
|
||||
currency: order.currencyCode,
|
||||
kind: storno ? "storno" : "invoice",
|
||||
kind: storno ? "storno" : offer ? "offer" : "invoice",
|
||||
number: invoiceNumber,
|
||||
cancelsNumber: storno?.cancelsNumber,
|
||||
invoiceDate,
|
||||
@@ -93,7 +160,17 @@ export function composeInvoice({
|
||||
lines,
|
||||
totals,
|
||||
notices,
|
||||
paid,
|
||||
paymentStatus,
|
||||
requiresPayment,
|
||||
refundedAmount,
|
||||
paymentGatewayNames,
|
||||
orderName: order.name,
|
||||
separateShippingAddress,
|
||||
shippingMethod,
|
||||
tracking,
|
||||
discountCodes: order.discountCodes ?? [],
|
||||
isPickup,
|
||||
pickupLocationName: pickupInfo?.locationName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,7 +230,10 @@ function mapRecipient(order: RawOrderForInvoice): RecipientData {
|
||||
};
|
||||
}
|
||||
|
||||
function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
function mapLinesAndTotals(
|
||||
order: RawOrderForInvoice,
|
||||
opts: { shippingItemPrefix: string },
|
||||
): {
|
||||
lines: InvoiceLine[];
|
||||
totals: InvoiceTotals;
|
||||
} {
|
||||
@@ -164,7 +244,13 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
|
||||
order.lineItems.forEach((li, idx) => {
|
||||
const qty = li.quantity;
|
||||
const grossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount);
|
||||
// Prefer the post-discount unit price when Shopify provides one (it
|
||||
// reflects both line-level and cart-level discount allocations). Fall
|
||||
// back to original price when no discount applied.
|
||||
const grossOrNetUnit = parseFloat(
|
||||
(li.discountedUnitPriceSet ?? li.originalUnitPriceSet).shopMoney.amount,
|
||||
);
|
||||
const originalGrossOrNetUnit = 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),
|
||||
@@ -174,6 +260,17 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
|
||||
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
|
||||
const unitNet = qty > 0 ? lineNet / qty : 0;
|
||||
// For the strikethrough original, compute net the same way the line is
|
||||
// computed: when taxesIncluded, derive an equivalent net per unit using
|
||||
// the line's effective tax rate; when not, the original IS the net.
|
||||
const effectiveRate = qty > 0 && grossOrNetUnit > 0
|
||||
? lineTax / (grossOrNetUnit * qty)
|
||||
: 0;
|
||||
const originalUnitNet = taxesIncluded
|
||||
? originalGrossOrNetUnit / (1 + effectiveRate)
|
||||
: originalGrossOrNetUnit;
|
||||
const hasDiscount =
|
||||
Math.round(originalGrossOrNetUnit * 100) !== Math.round(grossOrNetUnit * 100);
|
||||
|
||||
linesOut.push({
|
||||
position: idx + 1,
|
||||
@@ -181,6 +278,7 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
sku: li.sku ?? undefined,
|
||||
quantity: qty,
|
||||
unitPriceNet: round2(unitNet),
|
||||
originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined,
|
||||
totalNet: round2(lineNet),
|
||||
imageUrl: li.imageUrl ?? undefined,
|
||||
});
|
||||
@@ -205,6 +303,18 @@ function mapLinesAndTotals(order: RawOrderForInvoice): {
|
||||
});
|
||||
}
|
||||
|
||||
// Append the shipping line as a synthetic invoice row when the order has
|
||||
// a shipping cost > 0. This makes shipping appear in the items table
|
||||
// (visible to the customer) and folds its tax into the VAT breakdown.
|
||||
const shippingLineNet = appendShippingLine(
|
||||
order.shippingLine,
|
||||
taxesIncluded,
|
||||
linesOut,
|
||||
vatMap,
|
||||
opts.shippingItemPrefix,
|
||||
);
|
||||
netSum += shippingLineNet;
|
||||
|
||||
const vatBreakdown = Array.from(vatMap.values())
|
||||
.map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) }))
|
||||
.filter((e) => e.tax > 0)
|
||||
@@ -299,3 +409,172 @@ const EU_COUNTRIES = new Set([
|
||||
function isEuCountry(code: string): boolean {
|
||||
return EU_COUNTRIES.has(code.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a synthetic line item for the order's shipping cost. Returns the net
|
||||
* amount added (used to keep the running net subtotal in sync). Returns 0
|
||||
* when there's no shipping line or the shipping price is zero (e.g. free
|
||||
* shipping or digital orders).
|
||||
*/
|
||||
function appendShippingLine(
|
||||
shippingLine: RawShippingLine | null,
|
||||
taxesIncluded: boolean,
|
||||
linesOut: InvoiceLine[],
|
||||
vatMap: Map<number, VatBreakdownEntry>,
|
||||
prefix: string,
|
||||
): number {
|
||||
if (!shippingLine) return 0;
|
||||
const priceSet = shippingLine.discountedPriceSet ?? shippingLine.originalPriceSet;
|
||||
const grossOrNet = priceSet ? parseFloat(priceSet.shopMoney.amount) : 0;
|
||||
if (!Number.isFinite(grossOrNet) || grossOrNet === 0) return 0;
|
||||
|
||||
const tax = shippingLine.taxLines.reduce(
|
||||
(acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount),
|
||||
0,
|
||||
);
|
||||
const net = taxesIncluded ? grossOrNet - tax : grossOrNet;
|
||||
|
||||
const title = shippingLine.title?.trim()
|
||||
? `${prefix}: ${shippingLine.title.trim()}`
|
||||
: prefix;
|
||||
|
||||
linesOut.push({
|
||||
position: linesOut.length + 1,
|
||||
title,
|
||||
quantity: 1,
|
||||
unitPriceNet: round2(net),
|
||||
totalNet: round2(net),
|
||||
});
|
||||
|
||||
shippingLine.taxLines.forEach((t) =>
|
||||
accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), net),
|
||||
);
|
||||
|
||||
return net;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shipping address as a recipient block when it differs in any
|
||||
* meaningful way from the billing address. Returns undefined when both are
|
||||
* the same (so the renderer doesn't show a redundant block) or when there
|
||||
* is no shipping address at all.
|
||||
*/
|
||||
function mapSeparateShippingAddress(
|
||||
order: RawOrderForInvoice,
|
||||
): RecipientData | undefined {
|
||||
const ship = order.shippingAddress;
|
||||
const bill = order.billingAddress;
|
||||
if (!ship) return undefined;
|
||||
// No billing address → just use the existing recipient block, no need to
|
||||
// duplicate.
|
||||
if (!bill) return undefined;
|
||||
|
||||
const sameAddress =
|
||||
(ship.name ?? "") === (bill.name ?? "") &&
|
||||
(ship.company ?? "") === (bill.company ?? "") &&
|
||||
(ship.address1 ?? "") === (bill.address1 ?? "") &&
|
||||
(ship.address2 ?? "") === (bill.address2 ?? "") &&
|
||||
(ship.zip ?? "") === (bill.zip ?? "") &&
|
||||
(ship.city ?? "") === (bill.city ?? "") &&
|
||||
(ship.countryCode ?? "") === (bill.countryCode ?? "");
|
||||
if (sameAddress) return undefined;
|
||||
|
||||
const customerFullName = [order.customer?.firstName, order.customer?.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
name: ship.name ?? customerFullName,
|
||||
company: ship.company ?? "",
|
||||
addressLine1: ship.address1 ?? "",
|
||||
addressLine2: ship.address2 ?? "",
|
||||
postalCode: ship.zip ?? "",
|
||||
city: ship.city ?? "",
|
||||
countryCode: ship.countryCode ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tracking info from all fulfillments. Skips entries without a
|
||||
* tracking number. Deduplicates on `number`.
|
||||
*/
|
||||
function mapTracking(order: RawOrderForInvoice): TrackingInfo[] {
|
||||
const out: TrackingInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const f of order.fulfillments ?? []) {
|
||||
for (const t of f.trackingInfo ?? []) {
|
||||
const number = (t.number ?? "").trim();
|
||||
if (!number || seen.has(number)) continue;
|
||||
seen.add(number);
|
||||
out.push({
|
||||
number,
|
||||
url: t.url?.trim() || undefined,
|
||||
company: t.company?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the order is a "local pickup" order using three signals
|
||||
* (any one is enough). All rely only on the `read_orders` scope.
|
||||
*
|
||||
* 1. **No shipping address** despite `requiresShipping == true`. Shopify
|
||||
* never lets a regular ship-to-customer order check out without one,
|
||||
* so this combination is a textbook pickup. This is the *only* signal
|
||||
* for the built-in "Shop location" rate, which leaves
|
||||
* `deliveryCategory` null and the title/code as the bare location
|
||||
* name (e.g. "Shop location" / "Lager Graz").
|
||||
* 2. `shippingLine.deliveryCategory` contains "pickup"/"local_pickup".
|
||||
* Set by some Local Pickup integrations.
|
||||
* 3. Regex on `shippingLine.{source,code,title,carrierIdentifier}` for
|
||||
* custom rates titled "Abholung"/"Pickup".
|
||||
*
|
||||
* (We deliberately do NOT query `Order.fulfillmentOrders.deliveryMethod`:
|
||||
* that field requires the `read_merchant_managed_fulfillment_orders` scope,
|
||||
* which would force every install to re-grant permissions.)
|
||||
*
|
||||
* Location name is taken from `shippingLine.title` — for the Shopify
|
||||
* Local Pickup app and the built-in "Shop location" rate, the title IS
|
||||
* the chosen location name.
|
||||
*
|
||||
* Returns the pickup descriptor or `null` when the order is a normal
|
||||
* shipping order. Callers should not render the pickup-location address
|
||||
* as a separate "delivery address".
|
||||
*/
|
||||
function detectPickup(
|
||||
order: RawOrderForInvoice,
|
||||
): { locationName: string | null } | null {
|
||||
const sl = order.shippingLine;
|
||||
// Strongest signal: shipping is required but there's no shipping address.
|
||||
// Shopify rejects checkout otherwise, so this is conclusive.
|
||||
const noShipAddrButRequired =
|
||||
order.requiresShipping && order.shippingAddress == null;
|
||||
// Secondary: explicit pickup category from Local Pickup apps.
|
||||
const dc = (sl?.deliveryCategory ?? "").toLowerCase();
|
||||
const isPickupCategory = dc.includes("pickup") || dc.includes("pick_up") || dc.includes("pick-up");
|
||||
// Tertiary: regex on title/code/source/carrier — covers merchants who
|
||||
// model pickup as a custom shipping rate.
|
||||
const haystack = [sl?.source, sl?.code, sl?.title, sl?.carrierIdentifier]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const isPickupString = /pick[\s-]?up|abholung|abhol\b/.test(haystack);
|
||||
if (!noShipAddrButRequired && !isPickupCategory && !isPickupString) return null;
|
||||
return { locationName: sl?.title?.trim() || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the delivery date for §11 UStG: the latest fulfillment timestamp
|
||||
* when the order is fulfilled, otherwise the invoice date itself (best
|
||||
* approximation for unfulfilled / digital orders).
|
||||
*/
|
||||
function pickDeliveryDate(order: RawOrderForInvoice, invoiceDate: Date): Date {
|
||||
const stamps = (order.fulfillments ?? [])
|
||||
.map((f) => (f.createdAt ? new Date(f.createdAt).getTime() : NaN))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (stamps.length === 0) return invoiceDate;
|
||||
return new Date(Math.max(...stamps));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ import type { ShopSettings } from "@prisma/client";
|
||||
|
||||
import db from "../../db.server";
|
||||
import { getStrings, pickLanguage } from "./i18n";
|
||||
import {
|
||||
DEFAULT_EMAIL_BODY_DE,
|
||||
DEFAULT_EMAIL_BODY_EN,
|
||||
DEFAULT_EMAIL_SUBJECT_DE,
|
||||
DEFAULT_EMAIL_SUBJECT_EN,
|
||||
} from "./emailTemplates";
|
||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||
import { decryptField } from "../crypto/fieldCrypto.server";
|
||||
import { optionalEnv } from "../config/env.server";
|
||||
|
||||
export interface SendInvoiceEmailArgs {
|
||||
shopDomain: string;
|
||||
@@ -60,23 +70,48 @@ export async function sendInvoiceEmail(
|
||||
}
|
||||
if (!to) return failLog(args, "No recipient email available.", invoice.id);
|
||||
|
||||
// Build email content.
|
||||
const language = pickLanguage(args.customerLocale ?? settings.defaultLanguage);
|
||||
// Build email content. Always use the language the invoice PDF was
|
||||
// rendered in, so the email matches the attachment. Caller can still
|
||||
// override via `customerLocale` if they really want a different language.
|
||||
const language = pickLanguage(args.customerLocale ?? invoice.language ?? settings.defaultLanguage);
|
||||
const t = getStrings(language);
|
||||
const subject = `${t.invoice} ${invoice.invoiceNumber}` +
|
||||
(settings.companyName ? ` — ${settings.companyName}` : "");
|
||||
const body = renderEmailBody({
|
||||
const customer = parseCustomer(invoice.customerJson);
|
||||
const totals = parseTotals(invoice.totalsJson);
|
||||
const vars = buildTemplateVars({
|
||||
invoice,
|
||||
settings,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
language,
|
||||
customerName: customer.customerName ?? "",
|
||||
customerFirstName: (customer.customerName ?? "").split(/\s+/)[0] ?? "",
|
||||
totalGross: totals.totalGross ?? "",
|
||||
});
|
||||
|
||||
const customSubject =
|
||||
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
|
||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||
const subject = renderTemplate(customSubject, vars, { html: false });
|
||||
|
||||
const customBodyHtml =
|
||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
||||
(language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
|
||||
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
|
||||
|
||||
// If the rendered body references the inline logo, attach it.
|
||||
const inlineLogo = body.html.includes("cid:invoice-logo")
|
||||
? await loadInlineLogo(args.shopDomain, settings)
|
||||
: null;
|
||||
|
||||
// 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());
|
||||
const res = await safeFetch(invoice.pdfUrl, {
|
||||
maxBytes: 25 * 1024 * 1024, // 25 MB — generous; emails impose their own limit later
|
||||
accept: "application/pdf",
|
||||
// Invoice PDFs always live on Shopify's Files CDN — anything else is
|
||||
// suspicious and should be rejected.
|
||||
allowedHosts: SHOPIFY_CDN_HOSTS,
|
||||
});
|
||||
if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`);
|
||||
pdfBytes = res.bytes;
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
|
||||
@@ -91,9 +126,13 @@ export async function sendInvoiceEmail(
|
||||
}
|
||||
|
||||
try {
|
||||
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
|
||||
// to a comma-separated address list to opt in.
|
||||
const bcc = optionalEnv("INVOICE_BCC");
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to,
|
||||
...(bcc ? { bcc } : {}),
|
||||
replyTo: settings.smtpReplyTo || undefined,
|
||||
subject,
|
||||
text: body.text,
|
||||
@@ -104,6 +143,16 @@ export async function sendInvoiceEmail(
|
||||
content: Buffer.from(pdfBytes),
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
...(inlineLogo
|
||||
? [
|
||||
{
|
||||
filename: "logo",
|
||||
content: Buffer.from(inlineLogo.bytes),
|
||||
contentType: inlineLogo.contentType,
|
||||
cid: "invoice-logo",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -136,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpSecure,
|
||||
auth: settings.smtpUser
|
||||
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
||||
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -202,3 +251,162 @@ function escapeHtml(s: string): string {
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Template variables ----------------------------------------------------
|
||||
|
||||
interface TemplateVars {
|
||||
invoiceNumber: string;
|
||||
customerName: string;
|
||||
customerFirstName: string;
|
||||
orderName: string;
|
||||
totalGross: string;
|
||||
dueDate: string;
|
||||
companyName: string;
|
||||
ownerName: string;
|
||||
shopEmail: string;
|
||||
shopWebsite: string;
|
||||
}
|
||||
|
||||
function buildTemplateVars(args: {
|
||||
invoice: { invoiceNumber: string; orderName: string };
|
||||
settings: ShopSettings;
|
||||
customerName: string;
|
||||
customerFirstName: string;
|
||||
totalGross: string;
|
||||
}): TemplateVars {
|
||||
const dueMs = Number((args.invoice as unknown as { dueDate?: string | Date }).dueDate ?? 0);
|
||||
return {
|
||||
invoiceNumber: args.invoice.invoiceNumber,
|
||||
orderName: args.invoice.orderName,
|
||||
customerName: args.customerName,
|
||||
customerFirstName: args.customerFirstName,
|
||||
totalGross: args.totalGross,
|
||||
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
|
||||
companyName: args.settings.companyName,
|
||||
ownerName: args.settings.ownerName,
|
||||
shopEmail: args.settings.email,
|
||||
shopWebsite: args.settings.website,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
||||
* in place so the user notices typos instead of silent blanks.
|
||||
*
|
||||
* For HTML output (the default), every interpolated value is HTML-escaped to
|
||||
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
|
||||
* email body. URL-valued tokens that land inside `href` attributes are scheme-
|
||||
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
|
||||
* look like a bare email address (rendered after a `mailto:` prefix); anything
|
||||
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
|
||||
*
|
||||
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
|
||||
* the raw value is substituted without HTML entity encoding.
|
||||
*/
|
||||
const URL_TOKENS = new Set(["shopWebsite"]);
|
||||
const EMAIL_TOKENS = new Set(["shopEmail"]);
|
||||
|
||||
function safeHttpsUrl(value: string): string {
|
||||
const v = value.trim();
|
||||
try {
|
||||
return new URL(v).protocol === "https:" ? v : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeEmailAddress(value: string): string {
|
||||
const v = value.trim();
|
||||
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
|
||||
}
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
vars: TemplateVars,
|
||||
opts: { html?: boolean } = {},
|
||||
): string {
|
||||
const html = opts.html !== false; // HTML-escape by default
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
||||
const raw = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
if (raw === undefined) return full;
|
||||
if (!html) return raw;
|
||||
let value = raw;
|
||||
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
|
||||
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
|
||||
return escapeHtml(value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Strips HTML tags to produce a plain-text fallback for the multipart email. */
|
||||
function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>(?=\s|$)/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderHtmlBody(html: string): { text: string; html: string } {
|
||||
return { html, text: htmlToText(html) };
|
||||
}
|
||||
|
||||
interface InvoiceCustomerSnapshot {
|
||||
customerEmail?: string;
|
||||
customerName?: string;
|
||||
}
|
||||
function parseCustomer(json: string): InvoiceCustomerSnapshot {
|
||||
try {
|
||||
return JSON.parse(json) as InvoiceCustomerSnapshot;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface InvoiceTotalsSnapshot {
|
||||
totalGross?: string;
|
||||
}
|
||||
function parseTotals(json: string): InvoiceTotalsSnapshot {
|
||||
try {
|
||||
return JSON.parse(json) as InvoiceTotalsSnapshot;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the shop logo bytes for inline embedding. Returns null if no
|
||||
* logo is configured or the lookup fails — the email is still sent, just
|
||||
* without the inline image (the alt text remains visible).
|
||||
*/
|
||||
async function loadInlineLogo(
|
||||
shopDomain: string,
|
||||
settings: ShopSettings,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | null> {
|
||||
if (!settings.logoUrl) return null;
|
||||
try {
|
||||
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
|
||||
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
|
||||
if (!cached) return null;
|
||||
return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType };
|
||||
}
|
||||
const res = await safeFetch(settings.logoUrl, {
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
accept: "image/*",
|
||||
});
|
||||
if (res.status < 200 || res.status >= 300) return null;
|
||||
const ct = res.contentType ?? "image/png";
|
||||
return { bytes: res.bytes, contentType: ct };
|
||||
} catch (err) {
|
||||
if (err instanceof SafeFetchError) {
|
||||
console.warn(`Inline logo fetch refused (${err.code}): ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Default invoice email templates per language. Used when the user hasn't
|
||||
* customised them in settings. Variables ({{invoiceNumber}}, etc.) are
|
||||
* substituted by `renderTemplate` at send time.
|
||||
*
|
||||
* The shop logo is rendered as an inline attachment with content-id
|
||||
* `invoice-logo`; the email sender attaches the logo bytes automatically
|
||||
* when the template (or any custom template) references that cid.
|
||||
*/
|
||||
|
||||
const DE_HTML = `\
|
||||
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
|
||||
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Danke für deinen Einkauf!</span></h3>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||
Die Rechnung befindet sich im Anhang.
|
||||
</p>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||
Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden:
|
||||
<strong>{{invoiceNumber}}</strong><br>
|
||||
Besten Dank!
|
||||
</p>
|
||||
<p style="margin-top:24px;">
|
||||
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
||||
</p>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#0883DA;">
|
||||
✉ <a href="mailto:{{shopEmail}}" style="color:#0883DA;">Kontakt</a><br>
|
||||
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
||||
</p>`;
|
||||
|
||||
const EN_HTML = `\
|
||||
<h2 style="margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">{{companyName}}</span></h2>
|
||||
<h3 style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;"><span style="color:#0883DA">Thank you for your purchase!</span></h3>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||
Please find the invoice attached.
|
||||
</p>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
|
||||
When paying by bank transfer, please use the invoice number as the reference:
|
||||
<strong>{{invoiceNumber}}</strong><br>
|
||||
Thanks a lot!
|
||||
</p>
|
||||
<p style="margin-top:24px;">
|
||||
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
|
||||
</p>
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#0883DA;">
|
||||
✉ <a href="mailto:{{shopEmail}}" style="color:#0883DA;">Contact</a><br>
|
||||
🌐 <a href="{{shopWebsite}}" style="color:#0883DA;">{{shopWebsite}}</a>
|
||||
</p>`;
|
||||
|
||||
export const DEFAULT_EMAIL_SUBJECT_DE = "Rechnung {{invoiceNumber}} – {{companyName}}";
|
||||
export const DEFAULT_EMAIL_SUBJECT_EN = "Invoice {{invoiceNumber}} – {{companyName}}";
|
||||
export const DEFAULT_EMAIL_BODY_DE = DE_HTML;
|
||||
export const DEFAULT_EMAIL_BODY_EN = EN_HTML;
|
||||
@@ -6,6 +6,7 @@ import db from "../../db.server";
|
||||
import { composeInvoice } from "./composeInvoice";
|
||||
import { buildGiroCodeDataUrl } from "./girocode";
|
||||
import { loadOrderForInvoice } from "./loadOrderForInvoice.server";
|
||||
import { loadDraftOrderForOffer } from "./loadDraftOrderForOffer.server";
|
||||
import { getLogoDataUrl } from "./logoCache.server";
|
||||
import { attachLineItemImages } from "./productImageCache.server";
|
||||
import { allocateInvoiceNumber } from "./numbering.server";
|
||||
@@ -19,6 +20,15 @@ export interface GenerateInvoiceArgs {
|
||||
orderId: string;
|
||||
/** When true, bypass the "sent invoice is locked" rule and regenerate in place. */
|
||||
forceRegenerate?: boolean;
|
||||
/**
|
||||
* Document kind. Default "invoice". When "offer":
|
||||
* - `orderId` is interpreted as a DraftOrder id (numeric or GID).
|
||||
* - The number is the draft order's name (e.g. "D1") rather than an
|
||||
* allocated invoice number.
|
||||
* - GiroCode is suppressed and the dueDate is repurposed as the offer's
|
||||
* validity expiry.
|
||||
*/
|
||||
kind?: "invoice" | "offer";
|
||||
}
|
||||
|
||||
export interface GeneratedInvoice {
|
||||
@@ -44,7 +54,8 @@ export async function generateInvoice(
|
||||
args: GenerateInvoiceArgs,
|
||||
): Promise<GeneratedInvoice> {
|
||||
const { shopDomain, admin } = args;
|
||||
const orderGid = toOrderGid(args.orderId);
|
||||
const kind = args.kind ?? "invoice";
|
||||
const orderGid = kind === "offer" ? toDraftOrderGid(args.orderId) : toOrderGid(args.orderId);
|
||||
|
||||
const settings = await db.shopSettings.upsert({
|
||||
where: { shopDomain },
|
||||
@@ -52,15 +63,17 @@ export async function generateInvoice(
|
||||
create: { shopDomain },
|
||||
});
|
||||
|
||||
const order = await loadOrderForInvoice(admin, orderGid);
|
||||
const order = kind === "offer"
|
||||
? await loadDraftOrderForOffer(admin, orderGid)
|
||||
: await loadOrderForInvoice(admin, orderGid);
|
||||
|
||||
// Find latest existing invoice (excluding storno) for this order.
|
||||
// Find latest existing document of this kind for this (draft) order.
|
||||
const latest = await db.invoice.findFirst({
|
||||
where: { shopDomain, orderId: orderGid, kind: "invoice", cancelledAt: null },
|
||||
where: { shopDomain, orderId: orderGid, kind, cancelledAt: null },
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
if (latest && latest.sentAt && !args.forceRegenerate) {
|
||||
if (kind === "invoice" && latest && latest.sentAt && !args.forceRegenerate) {
|
||||
throw new Error(
|
||||
`Invoice ${latest.invoiceNumber} has already been sent. Use cancel-and-reissue to correct it.`,
|
||||
);
|
||||
@@ -68,10 +81,12 @@ export async function generateInvoice(
|
||||
|
||||
const invoiceNumber = latest
|
||||
? latest.invoiceNumber
|
||||
: kind === "offer"
|
||||
? order.name // e.g. "D1" — Shopify's draft order name is the offer number.
|
||||
: await allocateInvoiceNumber(settings, order.orderNumber);
|
||||
|
||||
// Compose view model and render PDF.
|
||||
const viewModel = composeInvoice({ order, settings, invoiceNumber });
|
||||
const viewModel = composeInvoice({ order, settings, invoiceNumber, offer: kind === "offer" });
|
||||
|
||||
// Logo (cached).
|
||||
const logoDataUrl = await getLogoDataUrl(shopDomain, settings.logoUrl);
|
||||
@@ -80,15 +95,21 @@ export async function generateInvoice(
|
||||
// Product images for each line (best-effort, parallel, in-process cache).
|
||||
await attachLineItemImages(viewModel.lines);
|
||||
|
||||
// GiroCode (only for unpaid + IBAN configured + enabled).
|
||||
// GiroCode (only when the invoice is actually outstanding + IBAN
|
||||
// configured + enabled). `requiresPayment` already encodes "regular
|
||||
// invoice AND money still owed" — so paid, refunded, storno and
|
||||
// offer documents all skip QR generation and the PDF stays tidy.
|
||||
if (
|
||||
kind === "invoice" &&
|
||||
settings.giroCodeEnabled &&
|
||||
settings.iban &&
|
||||
!viewModel.paid &&
|
||||
viewModel.requiresPayment &&
|
||||
viewModel.totals.gross > 0
|
||||
) {
|
||||
viewModel.giroCodePngDataUrl = await buildGiroCodeDataUrl({
|
||||
beneficiaryName: settings.companyName || "Beneficiary",
|
||||
beneficiaryName:
|
||||
[settings.companyName, settings.legalForm].filter(Boolean).join(" ") ||
|
||||
"Beneficiary",
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
amount: viewModel.totals.gross,
|
||||
@@ -99,12 +120,13 @@ export async function generateInvoice(
|
||||
|
||||
const pdfBuffer = await renderInvoicePdf(viewModel);
|
||||
|
||||
const filename = `Rechnung-${sanitiseForFilename(invoiceNumber)}.pdf`;
|
||||
const filenamePrefix = kind === "offer" ? "Angebot" : "Rechnung";
|
||||
const filename = `${filenamePrefix}-${sanitiseForFilename(invoiceNumber)}.pdf`;
|
||||
|
||||
const upload = await uploadPdfToShopifyFiles(admin, {
|
||||
bytes: pdfBuffer,
|
||||
filename,
|
||||
alt: `Invoice ${invoiceNumber}`,
|
||||
alt: kind === "offer" ? `Offer ${invoiceNumber}` : `Invoice ${invoiceNumber}`,
|
||||
});
|
||||
|
||||
const version = latest ? latest.version + 1 : 1;
|
||||
@@ -139,7 +161,7 @@ export async function generateInvoice(
|
||||
orderNumber: order.orderNumber,
|
||||
invoiceNumber,
|
||||
language: viewModel.language,
|
||||
kind: "invoice",
|
||||
kind,
|
||||
version: 1,
|
||||
pdfFileGid: upload.fileGid,
|
||||
pdfUrl: upload.url,
|
||||
@@ -150,7 +172,9 @@ export async function generateInvoice(
|
||||
});
|
||||
|
||||
// Link the latest PDF on the order via metafields (best-effort; do not
|
||||
// fail the whole operation if scopes are missing).
|
||||
// fail the whole operation if scopes are missing). Skip for offers since
|
||||
// draft orders don't accept the same metafields.
|
||||
if (kind === "invoice") {
|
||||
try {
|
||||
await writeOrderMetafields(admin, orderGid, {
|
||||
pdfUrl: upload.url,
|
||||
@@ -160,6 +184,7 @@ export async function generateInvoice(
|
||||
} catch (err) {
|
||||
console.warn("Order metafield write failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
invoiceId: invoice.id,
|
||||
@@ -177,8 +202,14 @@ export function toOrderGid(input: string): string {
|
||||
return `gid://shopify/Order/${input}`;
|
||||
}
|
||||
|
||||
/** Same idea for DraftOrder ids. */
|
||||
export function toDraftOrderGid(input: string): string {
|
||||
if (input.startsWith("gid://")) return input;
|
||||
return `gid://shopify/DraftOrder/${input}`;
|
||||
}
|
||||
|
||||
function sanitiseForFilename(s: string): string {
|
||||
return s.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
return s.replace(/[^A-Za-z0-9._-]/g, "");
|
||||
}
|
||||
|
||||
export { sanitiseForFilename };
|
||||
|
||||
@@ -18,6 +18,15 @@ export interface GiroCodeInput {
|
||||
remittance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces CR/LF in a free-text EPC field with a single space and collapses
|
||||
* runs of whitespace, so the line-delimited payload can't be tampered with by
|
||||
* smuggling newlines into user-supplied text (beneficiary name / remittance).
|
||||
*/
|
||||
function sanitizeEpcField(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function buildGiroCodePayload(input: GiroCodeInput): string {
|
||||
const currency = input.currency || "EUR";
|
||||
if (currency !== "EUR") {
|
||||
@@ -25,12 +34,14 @@ export function buildGiroCodePayload(input: GiroCodeInput): string {
|
||||
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
|
||||
}
|
||||
|
||||
// Beneficiary name max 70 chars per spec.
|
||||
const name = input.beneficiaryName.slice(0, 70);
|
||||
// Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
|
||||
// newlines can't forge/add EPC fields (the payload is line-delimited).
|
||||
const name = sanitizeEpcField(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);
|
||||
// Unstructured remittance max 140 chars; strip CR/LF for the same reason.
|
||||
const remittance = sanitizeEpcField(input.remittance).slice(0, 140);
|
||||
|
||||
// Field order is fixed; trailing fields can be empty.
|
||||
// Service tag SCT = SEPA Credit Transfer.
|
||||
@@ -61,3 +72,15 @@ export async function buildGiroCodeDataUrl(
|
||||
width: 256,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildGiroCodePngBuffer(
|
||||
input: GiroCodeInput,
|
||||
): Promise<Buffer> {
|
||||
const payload = buildGiroCodePayload(input);
|
||||
return QRCode.toBuffer(payload, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 256,
|
||||
type: "png",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ export type InvoiceLanguage = "de" | "en";
|
||||
export interface InvoiceStrings {
|
||||
invoice: string;
|
||||
stornoInvoice: string;
|
||||
offer: string;
|
||||
offerNumber: string;
|
||||
offerDate: string;
|
||||
offerValidUntil: (until: string) => string;
|
||||
stornoReference: (originalNumber: string) => string;
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
@@ -37,6 +41,24 @@ export interface InvoiceStrings {
|
||||
ibanLabel: string;
|
||||
bicLabel: string;
|
||||
bankLabel: string;
|
||||
recipientLabel: string;
|
||||
amountLabel: string;
|
||||
referenceLabel: string;
|
||||
/** Label for the refund row that appears below `grossTotal` when the
|
||||
* order has been (partially or fully) refunded. Mirrors what Shopify
|
||||
* shows on the order page ("Zurückerstattet" / "Refunded"). */
|
||||
refundedLabel: string;
|
||||
/** Label for the final outstanding balance row (`grossTotal -
|
||||
* refundedAmount`) shown when there has been a refund. "Offener
|
||||
* Betrag" / "Outstanding amount". */
|
||||
outstandingLabel: string;
|
||||
/** Label used in place of `outstandingLabel` when the order has been
|
||||
* refunded but nothing is actually owed any more (i.e. the customer
|
||||
* paid in full and got back only part — or all — of the gross via
|
||||
* refunds). "Endbetrag" / "Total". The distinction matters for
|
||||
* PARTIALLY_REFUNDED orders, where calling the kept portion
|
||||
* "outstanding" would falsely suggest the customer still owes it. */
|
||||
finalAmountLabel: string;
|
||||
addressHeading: string;
|
||||
contactHeading: string;
|
||||
legalHeading: string;
|
||||
@@ -44,17 +66,106 @@ export interface InvoiceStrings {
|
||||
emailLabel: string;
|
||||
webLabel: string;
|
||||
phoneLabel: string;
|
||||
paidStamp: string;
|
||||
paymentMethodLabel: string;
|
||||
paymentStatusLabel: string;
|
||||
paymentStatusPaid: string;
|
||||
paymentStatusUnpaid: string;
|
||||
paymentStatusPartial: string;
|
||||
paymentStatusRefunded: string;
|
||||
paymentStatusVoided: string;
|
||||
orderNumberLabel: string;
|
||||
shippingAddressHeading: string;
|
||||
shippingMethodLabel: string;
|
||||
trackingLabel: string;
|
||||
shippingItemPrefix: string;
|
||||
discountCodeLabel: string;
|
||||
pickupLabel: string;
|
||||
/** Used as the meta-row label when the order is a local pickup. The row
|
||||
* value is then the pickup location name (e.g. "Lager Graz"). */
|
||||
pickupLocationLabel: string;
|
||||
/** Localized labels for Shopify's built-in payment-gateway names. The
|
||||
* Admin GraphQL API only ever returns the *English* template name
|
||||
* (e.g. "Bank Deposit") in `Order.paymentGatewayNames`, even when the
|
||||
* storefront / order-confirmation page renders the localized variant
|
||||
* ("Banküberweisung"). We mirror Shopify's checkout copy here so the
|
||||
* printed PDF matches what the customer saw at checkout. Lookup is
|
||||
* case-insensitive on the normalized key (lowercased, separators
|
||||
* collapsed). Unknown gateways fall back to a title-cased rendering
|
||||
* of the raw name. */
|
||||
paymentGatewayLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Status displayed for the order's payment, derived from Shopify's
|
||||
* `displayFinancialStatus`. */
|
||||
export type PaymentStatus =
|
||||
| "paid"
|
||||
| "unpaid"
|
||||
| "partial"
|
||||
| "refunded"
|
||||
| "voided";
|
||||
|
||||
export function paymentStatusLabel(
|
||||
status: PaymentStatus,
|
||||
strings: InvoiceStrings,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return strings.paymentStatusPaid;
|
||||
case "partial":
|
||||
return strings.paymentStatusPartial;
|
||||
case "refunded":
|
||||
return strings.paymentStatusRefunded;
|
||||
case "voided":
|
||||
return strings.paymentStatusVoided;
|
||||
default:
|
||||
return strings.paymentStatusUnpaid;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps Shopify's `displayFinancialStatus` to our condensed enum.
|
||||
*
|
||||
* - PAID → paid
|
||||
* - PARTIALLY_PAID → partial
|
||||
* - REFUNDED / PARTIALLY_REFUNDED → refunded
|
||||
* (composeInvoice further reclassifies PARTIALLY_REFUNDED with a
|
||||
* refund < gross back to "paid".)
|
||||
* - VOIDED → voided
|
||||
* (authorisation cancelled before capture; no money was ever
|
||||
* received and none is owed — distinct from "unpaid".)
|
||||
* - PENDING / AUTHORIZED / EXPIRED / unknown → unpaid
|
||||
*
|
||||
* Unknown values log a warning so we notice when Shopify adds a new
|
||||
* enum member. */
|
||||
export function derivePaymentStatus(
|
||||
displayFinancialStatus: string | null | undefined,
|
||||
): PaymentStatus {
|
||||
const v = (displayFinancialStatus || "").toUpperCase();
|
||||
if (v === "PAID") return "paid";
|
||||
if (v === "PARTIALLY_PAID") return "partial";
|
||||
if (v === "REFUNDED" || v === "PARTIALLY_REFUNDED") return "refunded";
|
||||
if (v === "VOIDED") return "voided";
|
||||
if (v && v !== "PENDING" && v !== "AUTHORIZED" && v !== "EXPIRED") {
|
||||
console.warn(
|
||||
`[invoice] derivePaymentStatus: unknown displayFinancialStatus ${JSON.stringify(
|
||||
displayFinancialStatus,
|
||||
)} — falling back to "unpaid".`,
|
||||
);
|
||||
}
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
const de: InvoiceStrings = {
|
||||
invoice: "Rechnung",
|
||||
stornoInvoice: "Stornorechnung",
|
||||
offer: "Angebot",
|
||||
offerNumber: "Angebots-Nr.",
|
||||
offerDate: "Angebotsdatum",
|
||||
offerValidUntil: (d) => `Dieses Angebot ist gültig bis ${d}.`,
|
||||
stornoReference: (n) => `Storno zu Rechnung Nr. ${n}`,
|
||||
invoiceNumber: "Rechnungs-Nr.",
|
||||
invoiceDate: "Rechnungsdatum",
|
||||
deliveryDate: "Lieferdatum",
|
||||
customerVatId: "Ihre USt-Id.",
|
||||
customerVatId: "Deine USt-Id.",
|
||||
position: "Pos.",
|
||||
description: "Beschreibung",
|
||||
quantity: "Menge",
|
||||
@@ -63,12 +174,12 @@ const de: InvoiceStrings = {
|
||||
netTotal: "Gesamtbetrag netto",
|
||||
vatLine: (r) => `zzgl. Umsatzsteuer ${r}`,
|
||||
grossTotal: "Gesamtbetrag brutto",
|
||||
salutationGeneric: "Sehr geehrte Damen und Herren,",
|
||||
salutationGeneric: "Hallo,",
|
||||
thankYouLine:
|
||||
"vielen Dank für Ihren Auftrag. Wir erlauben uns, Ihnen folgende Leistungen in Rechnung zu stellen:",
|
||||
"Vielen Dank für deine Bestellung. Wir berechnen dir folgende Leistungen:",
|
||||
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.`,
|
||||
`Bitte überweise den Rechnungsbetrag innerhalb von ${days} Tagen, spätestens bis zum ${due}, auf das unten angegebene Konto. Bei Fragen zur Rechnung sind wir gerne für dich da.`,
|
||||
paymentTermsImmediate:
|
||||
"Der Rechnungsbetrag ist sofort nach Erhalt zur Zahlung fällig.",
|
||||
giroCodeCaption: "GiroCode",
|
||||
@@ -87,6 +198,12 @@ const de: InvoiceStrings = {
|
||||
ibanLabel: "IBAN",
|
||||
bicLabel: "BIC",
|
||||
bankLabel: "Bank",
|
||||
recipientLabel: "Empfänger",
|
||||
amountLabel: "Betrag",
|
||||
referenceLabel: "Referenz",
|
||||
refundedLabel: "Zurückerstattet",
|
||||
outstandingLabel: "Offener Betrag",
|
||||
finalAmountLabel: "Endbetrag",
|
||||
addressHeading: "Adresse",
|
||||
contactHeading: "Kontakt",
|
||||
legalHeading: "Rechtliches",
|
||||
@@ -94,12 +211,47 @@ const de: InvoiceStrings = {
|
||||
emailLabel: "E-Mail",
|
||||
webLabel: "Web",
|
||||
phoneLabel: "Tel.",
|
||||
paidStamp: "BEZAHLT",
|
||||
paymentMethodLabel: "Zahlart",
|
||||
paymentStatusLabel: "Zahlstatus",
|
||||
paymentStatusPaid: "Bezahlt",
|
||||
paymentStatusUnpaid: "Offen",
|
||||
paymentStatusPartial: "Teilweise bezahlt",
|
||||
paymentStatusRefunded: "Erstattet",
|
||||
paymentStatusVoided: "Annulliert",
|
||||
orderNumberLabel: "Bestellnummer",
|
||||
shippingAddressHeading: "Lieferadresse",
|
||||
shippingMethodLabel: "Versandart",
|
||||
trackingLabel: "Sendungsnummer",
|
||||
shippingItemPrefix: "Versand",
|
||||
discountCodeLabel: "Rabattcode",
|
||||
pickupLabel: "Abholung",
|
||||
pickupLocationLabel: "Abholort",
|
||||
paymentGatewayLabels: {
|
||||
// Built-in Shopify manual payment methods (template names).
|
||||
"bank deposit": "Banküberweisung",
|
||||
"bank transfer": "Banküberweisung",
|
||||
"money order": "Postanweisung",
|
||||
"cash on delivery": "Nachnahme",
|
||||
"cash on delivery (cod)": "Nachnahme",
|
||||
// Generic / technical gateways.
|
||||
manual: "Manuelle Zahlung",
|
||||
bogus: "Bogus (Test)",
|
||||
"shopify payments": "Shopify Payments",
|
||||
paypal: "PayPal",
|
||||
"paypal express checkout": "PayPal",
|
||||
klarna: "Klarna",
|
||||
sofort: "Sofort",
|
||||
giropay: "Giropay",
|
||||
},
|
||||
};
|
||||
|
||||
const en: InvoiceStrings = {
|
||||
invoice: "Invoice",
|
||||
stornoInvoice: "Cancellation invoice",
|
||||
offer: "Offer",
|
||||
offerNumber: "Offer no.",
|
||||
offerDate: "Offer date",
|
||||
offerValidUntil: (d) => `This offer is valid until ${d}.`,
|
||||
stornoReference: (n) => `Cancels invoice no. ${n}`,
|
||||
invoiceNumber: "Invoice no.",
|
||||
invoiceDate: "Invoice date",
|
||||
@@ -136,6 +288,12 @@ const en: InvoiceStrings = {
|
||||
ibanLabel: "IBAN",
|
||||
bicLabel: "BIC",
|
||||
bankLabel: "Bank",
|
||||
recipientLabel: "Recipient",
|
||||
amountLabel: "Amount",
|
||||
referenceLabel: "Reference",
|
||||
refundedLabel: "Refunded",
|
||||
outstandingLabel: "Outstanding amount",
|
||||
finalAmountLabel: "Total",
|
||||
addressHeading: "Address",
|
||||
contactHeading: "Contact",
|
||||
legalHeading: "Legal",
|
||||
@@ -143,14 +301,51 @@ const en: InvoiceStrings = {
|
||||
emailLabel: "E-mail",
|
||||
webLabel: "Web",
|
||||
phoneLabel: "Tel.",
|
||||
paidStamp: "PAID",
|
||||
paymentMethodLabel: "Payment method",
|
||||
paymentStatusLabel: "Payment status",
|
||||
paymentStatusPaid: "Paid",
|
||||
paymentStatusUnpaid: "Outstanding",
|
||||
paymentStatusPartial: "Partially paid",
|
||||
paymentStatusRefunded: "Refunded",
|
||||
paymentStatusVoided: "Voided",
|
||||
orderNumberLabel: "Order no.",
|
||||
shippingAddressHeading: "Shipping address",
|
||||
shippingMethodLabel: "Shipping method",
|
||||
trackingLabel: "Tracking no.",
|
||||
shippingItemPrefix: "Shipping",
|
||||
discountCodeLabel: "Discount code",
|
||||
pickupLabel: "Pick-up",
|
||||
pickupLocationLabel: "Pick-up location",
|
||||
paymentGatewayLabels: {
|
||||
"bank deposit": "Bank deposit",
|
||||
"bank transfer": "Bank transfer",
|
||||
"money order": "Money order",
|
||||
"cash on delivery": "Cash on delivery",
|
||||
"cash on delivery (cod)": "Cash on delivery (COD)",
|
||||
manual: "Manual",
|
||||
bogus: "Bogus (Test)",
|
||||
"shopify payments": "Shopify Payments",
|
||||
paypal: "PayPal",
|
||||
"paypal express checkout": "PayPal",
|
||||
klarna: "Klarna",
|
||||
sofort: "Sofort",
|
||||
giropay: "Giropay",
|
||||
},
|
||||
};
|
||||
|
||||
// Locale → invoice language. We only render in German (`de`) when the
|
||||
// caller is explicitly German-speaking (de, de-AT, de-DE, de_CH, …).
|
||||
// Everything else (it, fr, es, en, …) falls back to English so that
|
||||
// non-German-speaking customers don't receive a German invoice. Callers
|
||||
// that have a per-shop default fall back to it via
|
||||
// `pickLanguage(customerLocale ?? settings.defaultLanguage)`, which is why
|
||||
// `null`/`undefined` still maps to `de` (the legacy default for the
|
||||
// Austrian shops this app was built for).
|
||||
export function pickLanguage(input: string | null | undefined): InvoiceLanguage {
|
||||
if (!input) return "de";
|
||||
const v = input.toLowerCase();
|
||||
if (v.startsWith("en")) return "en";
|
||||
return "de";
|
||||
if (v.startsWith("de")) return "de";
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function getStrings(language: InvoiceLanguage): InvoiceStrings {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { AdminApiContext } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
import type {
|
||||
RawAddress,
|
||||
RawLineItem,
|
||||
RawMoney,
|
||||
RawOrderForInvoice,
|
||||
RawTaxLine,
|
||||
} from "./loadOrderForInvoice.server";
|
||||
|
||||
/**
|
||||
* Loads a Shopify DraftOrder and adapts it to the same `RawOrderForInvoice`
|
||||
* shape used for completed orders, so the rest of the pipeline (composer,
|
||||
* PDF, etc.) doesn't need to know whether it's rendering an invoice or an
|
||||
* offer.
|
||||
*
|
||||
* Drafts have no `processedAt` (we use createdAt) and no
|
||||
* `displayFinancialStatus` (we treat them as not paid).
|
||||
*/
|
||||
const QUERY = `#graphql
|
||||
query DraftOrderForOffer($id: ID!) {
|
||||
draftOrder(id: $id) {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
currencyCode
|
||||
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 } }
|
||||
image { url altText }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
ratePercentage
|
||||
priceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
purchasingEntity {
|
||||
... on PurchasingCompany {
|
||||
company { name }
|
||||
location {
|
||||
taxRegistrationId
|
||||
billingAddress {
|
||||
address1
|
||||
address2
|
||||
zip
|
||||
city
|
||||
countryCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface RawAdminResponse {
|
||||
data?: {
|
||||
draftOrder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
currencyCode: string;
|
||||
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 & { image?: { url: string | null } | null } }[] };
|
||||
purchasingEntity: {
|
||||
company?: { name: string } | null;
|
||||
location?: {
|
||||
taxRegistrationId: string | null;
|
||||
billingAddress: RawAddress | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDraftOrderForOffer(
|
||||
admin: AdminApiContext,
|
||||
draftOrderGid: string,
|
||||
): Promise<RawOrderForInvoice> {
|
||||
const response = await admin.graphql(QUERY, { variables: { id: draftOrderGid } });
|
||||
const json = (await response.json()) as RawAdminResponse;
|
||||
const draft = json.data?.draftOrder;
|
||||
if (!draft) {
|
||||
throw new Error(`Draft order ${draftOrderGid} not found.`);
|
||||
}
|
||||
|
||||
const purchasingCompany = draft.purchasingEntity?.company
|
||||
? {
|
||||
name: draft.purchasingEntity.company.name,
|
||||
vatId: draft.purchasingEntity.location?.taxRegistrationId ?? null,
|
||||
address: draft.purchasingEntity.location?.billingAddress ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Drafts don't have a numeric "order number" — use a hash of the GID as a
|
||||
// numeric proxy for the invoice-counter signature (not actually used when
|
||||
// generating offers, but kept non-zero to satisfy downstream code).
|
||||
const orderNumber = parseInt(draft.id.replace(/[^0-9]/g, "").slice(-9), 10) || 0;
|
||||
|
||||
return {
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
orderNumber,
|
||||
createdAt: draft.createdAt,
|
||||
processedAt: null,
|
||||
currencyCode: draft.currencyCode,
|
||||
displayFinancialStatus: null,
|
||||
paymentGatewayNames: [],
|
||||
requiresShipping: false,
|
||||
shippingLine: null,
|
||||
fulfillments: [],
|
||||
discountCodes: [],
|
||||
taxesIncluded: draft.taxesIncluded,
|
||||
customer: draft.customer,
|
||||
billingAddress: draft.billingAddress,
|
||||
shippingAddress: draft.shippingAddress,
|
||||
subtotalSet: draft.subtotalPriceSet,
|
||||
totalTaxSet: draft.totalTaxSet,
|
||||
totalPriceSet: draft.totalPriceSet,
|
||||
// Drafts have no concept of refunds.
|
||||
totalRefundedSet: null,
|
||||
taxLines: draft.taxLines || [],
|
||||
lineItems: (draft.lineItems?.edges || []).map((e) => {
|
||||
const node = e.node;
|
||||
return {
|
||||
title: node.title,
|
||||
sku: node.sku,
|
||||
quantity: node.quantity,
|
||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||
discountedUnitPriceSet: null,
|
||||
taxLines: node.taxLines,
|
||||
imageUrl: node.image?.url ?? null,
|
||||
};
|
||||
}),
|
||||
purchasingEntity: { company: purchasingCompany },
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export interface RawOrderForInvoice {
|
||||
processedAt: string | null;
|
||||
currencyCode: string;
|
||||
displayFinancialStatus: string | null;
|
||||
paymentGatewayNames: string[];
|
||||
/** True when the order contains at least one shippable line item. For
|
||||
* pickup orders this is `true` but `shippingAddress` is `null` — that
|
||||
* combination is the most reliable pickup signal we have without
|
||||
* hitting `read_merchant_managed_fulfillment_orders`. */
|
||||
requiresShipping: boolean;
|
||||
customer: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
@@ -22,10 +28,21 @@ export interface RawOrderForInvoice {
|
||||
shippingAddress: RawAddress | null;
|
||||
lineItems: RawLineItem[];
|
||||
taxLines: RawTaxLine[];
|
||||
shippingLine: RawShippingLine | null;
|
||||
fulfillments: RawFulfillment[];
|
||||
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
|
||||
* no codes were used. Manual / automatic discounts without a code are
|
||||
* not exposed here. */
|
||||
discountCodes: string[];
|
||||
taxesIncluded: boolean;
|
||||
subtotalSet: { shopMoney: RawMoney } | null;
|
||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||
totalPriceSet: { shopMoney: RawMoney } | null;
|
||||
/** Cumulative gross amount that has been refunded against this order
|
||||
* via Shopify (sum of all refund transactions). Always present on
|
||||
* real orders — may be `null` for synthetic / draft fixtures, in
|
||||
* which case the composer treats it as 0. */
|
||||
totalRefundedSet: { shopMoney: RawMoney } | null;
|
||||
purchasingEntity: {
|
||||
company?: {
|
||||
name: string;
|
||||
@@ -56,6 +73,10 @@ export interface RawLineItem {
|
||||
sku: string | null;
|
||||
quantity: number;
|
||||
originalUnitPriceSet: { shopMoney: RawMoney };
|
||||
/** Per-unit price after Shopify has allocated cart-level discounts to this
|
||||
* line. May be null when no discount applied (in which case use the
|
||||
* original price). */
|
||||
discountedUnitPriceSet: { shopMoney: RawMoney } | null;
|
||||
taxLines: RawTaxLine[];
|
||||
imageUrl: string | null;
|
||||
}
|
||||
@@ -67,6 +88,34 @@ export interface RawTaxLine {
|
||||
priceSet: { shopMoney: RawMoney };
|
||||
}
|
||||
|
||||
export interface RawShippingLine {
|
||||
title: string | null;
|
||||
code: string | null;
|
||||
source: string | null;
|
||||
carrierIdentifier: string | null;
|
||||
/** Lowercase string like "shipping", "pickup", "local_pickup". Used as
|
||||
* the primary pickup signal because it doesn't require the
|
||||
* fulfillment-orders scope. */
|
||||
deliveryCategory: string | null;
|
||||
originalPriceSet: { shopMoney: RawMoney } | null;
|
||||
discountedPriceSet: { shopMoney: RawMoney } | null;
|
||||
taxLines: RawTaxLine[];
|
||||
}
|
||||
|
||||
export interface RawTrackingInfo {
|
||||
number: string | null;
|
||||
url: string | null;
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
export interface RawFulfillment {
|
||||
/** ISO timestamp of when the fulfillment was created (i.e. when the goods
|
||||
* were dispatched / handed over). Used for the legally-required delivery
|
||||
* date on the invoice when present. */
|
||||
createdAt: string | null;
|
||||
trackingInfo: RawTrackingInfo[];
|
||||
}
|
||||
|
||||
const QUERY = `#graphql
|
||||
query OrderForInvoice($id: ID!) {
|
||||
order(id: $id) {
|
||||
@@ -77,7 +126,9 @@ const QUERY = `#graphql
|
||||
processedAt
|
||||
currencyCode
|
||||
displayFinancialStatus
|
||||
paymentGatewayNames
|
||||
taxesIncluded
|
||||
requiresShipping
|
||||
customer {
|
||||
firstName
|
||||
lastName
|
||||
@@ -107,12 +158,38 @@ const QUERY = `#graphql
|
||||
subtotalPriceSet { shopMoney { amount currencyCode } }
|
||||
totalTaxSet { shopMoney { amount currencyCode } }
|
||||
totalPriceSet { shopMoney { amount currencyCode } }
|
||||
totalRefundedSet { shopMoney { amount currencyCode } }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
ratePercentage
|
||||
priceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
discountCode
|
||||
discountCodes
|
||||
shippingLine {
|
||||
title
|
||||
code
|
||||
source
|
||||
carrierIdentifier
|
||||
deliveryCategory
|
||||
originalPriceSet { shopMoney { amount currencyCode } }
|
||||
discountedPriceSet { shopMoney { amount currencyCode } }
|
||||
taxLines {
|
||||
title
|
||||
rate
|
||||
ratePercentage
|
||||
priceSet { shopMoney { amount currencyCode } }
|
||||
}
|
||||
}
|
||||
fulfillments(first: 10) {
|
||||
createdAt
|
||||
trackingInfo {
|
||||
number
|
||||
url
|
||||
company
|
||||
}
|
||||
}
|
||||
lineItems(first: 250) {
|
||||
edges {
|
||||
node {
|
||||
@@ -120,6 +197,7 @@ const QUERY = `#graphql
|
||||
sku
|
||||
quantity
|
||||
originalUnitPriceSet { shopMoney { amount currencyCode } }
|
||||
discountedUnitPriceSet { shopMoney { amount currencyCode } }
|
||||
image { url altText }
|
||||
taxLines {
|
||||
title
|
||||
@@ -161,7 +239,9 @@ interface RawAdminResponse {
|
||||
processedAt: string | null;
|
||||
currencyCode: string;
|
||||
displayFinancialStatus: string | null;
|
||||
paymentGatewayNames: string[] | null;
|
||||
taxesIncluded: boolean;
|
||||
requiresShipping: boolean | null;
|
||||
customer: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
@@ -173,7 +253,12 @@ interface RawAdminResponse {
|
||||
subtotalPriceSet: { shopMoney: RawMoney } | null;
|
||||
totalTaxSet: { shopMoney: RawMoney } | null;
|
||||
totalPriceSet: { shopMoney: RawMoney } | null;
|
||||
totalRefundedSet: { shopMoney: RawMoney } | null;
|
||||
taxLines: RawTaxLine[];
|
||||
discountCode: string | null;
|
||||
discountCodes: string[] | null;
|
||||
shippingLine: RawShippingLine | null;
|
||||
fulfillments: RawFulfillment[] | null;
|
||||
lineItems: { edges: { node: RawLineItem }[] };
|
||||
purchasingEntity: {
|
||||
company?: { name: string } | null;
|
||||
@@ -213,14 +298,22 @@ export async function loadOrderForInvoice(
|
||||
processedAt: order.processedAt,
|
||||
currencyCode: order.currencyCode,
|
||||
displayFinancialStatus: order.displayFinancialStatus,
|
||||
paymentGatewayNames: order.paymentGatewayNames ?? [],
|
||||
taxesIncluded: order.taxesIncluded,
|
||||
requiresShipping: order.requiresShipping ?? false,
|
||||
customer: order.customer,
|
||||
billingAddress: order.billingAddress,
|
||||
shippingAddress: order.shippingAddress,
|
||||
subtotalSet: order.subtotalPriceSet,
|
||||
totalTaxSet: order.totalTaxSet,
|
||||
totalPriceSet: order.totalPriceSet,
|
||||
totalRefundedSet: order.totalRefundedSet ?? null,
|
||||
taxLines: order.taxLines || [],
|
||||
discountCodes: order.discountCodes && order.discountCodes.length > 0
|
||||
? order.discountCodes
|
||||
: (order.discountCode ? [order.discountCode] : []),
|
||||
shippingLine: order.shippingLine ?? null,
|
||||
fulfillments: order.fulfillments ?? [],
|
||||
lineItems: (order.lineItems?.edges || []).map((e) => {
|
||||
const node = e.node as unknown as RawLineItem & { image?: { url: string | null } | null };
|
||||
return {
|
||||
@@ -228,6 +321,7 @@ export async function loadOrderForInvoice(
|
||||
sku: node.sku,
|
||||
quantity: node.quantity,
|
||||
originalUnitPriceSet: node.originalUnitPriceSet,
|
||||
discountedUnitPriceSet: node.discountedUnitPriceSet ?? null,
|
||||
taxLines: node.taxLines,
|
||||
imageUrl: node.image?.url ?? null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import db from "../../db.server";
|
||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||
import { safeFetch, SafeFetchError } from "./safeFetch.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
|
||||
@@ -41,26 +42,28 @@ export async function getLogoDataUrl(
|
||||
return toDataUrl(cached.bytes, cached.contentType);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
let response: Awaited<ReturnType<typeof safeFetch>>;
|
||||
try {
|
||||
response = await fetch(logoUrl);
|
||||
response = await safeFetch(logoUrl, {
|
||||
maxBytes: MAX_BYTES,
|
||||
accept: "image/*",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SafeFetchError) {
|
||||
console.warn(`Logo fetch refused for ${shopDomain} (${err.code}): ${err.message}`);
|
||||
} else {
|
||||
console.warn(`Logo fetch failed for ${shopDomain}:`, err);
|
||||
}
|
||||
return cached ? toDataUrl(cached.bytes, cached.contentType) : undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
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") || "";
|
||||
const bytes = Buffer.from(response.bytes);
|
||||
const contentType = response.contentType || guessContentType(logoUrl);
|
||||
const etag = "";
|
||||
|
||||
await db.logoCache.upsert({
|
||||
where: { shopDomain },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Document,
|
||||
Image,
|
||||
Link,
|
||||
Page,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
import React from "react";
|
||||
|
||||
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
|
||||
import { getStrings } from "../i18n";
|
||||
import { getStrings, paymentStatusLabel as getPaymentStatusLabel } from "../i18n";
|
||||
import type { InvoiceLanguage } from "../i18n";
|
||||
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
|
||||
|
||||
@@ -21,6 +22,21 @@ const TEXT_DARK = "#1F2933";
|
||||
const TEXT_MUTED = "#6B7280";
|
||||
const TABLE_BORDER = "#E5E7EB";
|
||||
|
||||
/**
|
||||
* Returns true only for syntactically valid http(s) URLs. Used to gate
|
||||
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
|
||||
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
|
||||
* be smuggled into the document.
|
||||
*/
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === "https:" || u.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
@@ -51,12 +67,32 @@ const styles = StyleSheet.create({
|
||||
recipientBlock: {
|
||||
width: "55%",
|
||||
},
|
||||
recipientBlockFull: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 20,
|
||||
},
|
||||
recipientName: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontSize: 10,
|
||||
},
|
||||
shippingAddressBlock: {
|
||||
marginTop: 10,
|
||||
paddingTop: 6,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: TABLE_BORDER,
|
||||
},
|
||||
shippingAddressHeading: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: BRAND_BLUE,
|
||||
fontSize: 8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaBlock: {
|
||||
width: "40%",
|
||||
width: "45%",
|
||||
},
|
||||
metaBlockHeader: {
|
||||
width: "50%",
|
||||
},
|
||||
metaTable: {
|
||||
flexDirection: "column",
|
||||
@@ -72,6 +108,11 @@ const styles = StyleSheet.create({
|
||||
metaValue: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
unitOriginalStrike: {
|
||||
color: TEXT_MUTED,
|
||||
textDecoration: "line-through",
|
||||
fontSize: 7,
|
||||
},
|
||||
invoiceNumberBig: {
|
||||
color: BRAND_BLUE,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
@@ -245,7 +286,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`${t.invoice} ${invoice.number}`}
|
||||
title={`${invoice.kind === "offer" ? t.offer : t.invoice} ${invoice.number}`}
|
||||
author={invoice.issuer.companyName}
|
||||
creator="LinumIQ Invoice"
|
||||
>
|
||||
@@ -257,42 +298,117 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
</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}>
|
||||
{invoice.issuer.logoDataUrl ? (
|
||||
<Image src={invoice.issuer.logoDataUrl} style={styles.logo} />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
<View style={styles.metaBlockHeader}>
|
||||
<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.metaLabel}>{invoice.kind === "offer" ? t.offerDate : t.invoiceDate}</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
||||
</View>
|
||||
{invoice.kind !== "offer" && (
|
||||
<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}
|
||||
{invoice.kind === "invoice" && invoice.paymentGatewayNames.length > 0 && (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.paymentMethodLabel}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{invoice.paymentGatewayNames.map((n) => prettifyGatewayName(n, t.paymentGatewayLabels)).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.kind === "invoice" && (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.paymentStatusLabel}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{getPaymentStatusLabel(invoice.paymentStatus, t)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.kind === "invoice" && invoice.discountCodes.length > 0 ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.discountCodeLabel}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.discountCodes.join(", ")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{invoice.kind === "invoice" && invoice.isPickup ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.pickupLocationLabel}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{invoice.pickupLocationName ?? t.pickupLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : invoice.kind === "invoice" && invoice.shippingMethod ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{t.shippingMethodLabel}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.shippingMethod}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{invoice.kind === "invoice" && invoice.tracking.map((tr) => (
|
||||
<View key={tr.number} style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>
|
||||
{t.trackingLabel}
|
||||
{tr.company ? ` (${tr.company})` : ""}
|
||||
</Text>
|
||||
{tr.url && isHttpUrl(tr.url) ? (
|
||||
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
||||
) : (
|
||||
<Text style={styles.metaValue}>{tr.number}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.recipientBlockFull}>
|
||||
<View style={styles.recipientBlock}>
|
||||
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
||||
<Recipient recipient={invoice.recipient} />
|
||||
</View>
|
||||
{invoice.separateShippingAddress ? (
|
||||
<View style={styles.recipientBlock}>
|
||||
<Text style={styles.shippingAddressHeading}>{t.shippingAddressHeading}</Text>
|
||||
<Recipient recipient={invoice.separateShippingAddress} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number}
|
||||
{invoice.kind === "storno"
|
||||
? t.stornoInvoice
|
||||
: invoice.kind === "offer"
|
||||
? t.offer
|
||||
: t.invoice}{" "}
|
||||
Nr. {invoice.number}
|
||||
{invoice.kind === "invoice"
|
||||
&& invoice.orderName
|
||||
// Suppress the redundant "· Bestellnummer: #1004" suffix when
|
||||
// the invoice number is just the Shopify order number with the
|
||||
// configured prefix (default numbering mode) — they'd carry
|
||||
// identical trailing digits and only confuse the customer.
|
||||
&& invoice.number.replace(/\D+/g, "") !== invoice.orderName.replace(/\D+/g, "")
|
||||
? ` · ${t.orderNumberLabel}: ${invoice.orderName}`
|
||||
: ""}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
||||
{/* No salutation here on purpose — this is an invoice, not a
|
||||
* letter. Dropping the line saves vertical space and avoids
|
||||
* the formal/informal "Hallo," vs "Dear Sir or Madam" framing
|
||||
* that doesn't belong on a tax document. */}
|
||||
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
|
||||
|
||||
<View style={styles.table}>
|
||||
@@ -327,6 +443,24 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
{formatMoney(invoice.totals.gross, cur, invoice.language)}
|
||||
</Text>
|
||||
</View>
|
||||
{invoice.refundedAmount > 0 && (
|
||||
<>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.refundedLabel}</Text>
|
||||
<Text style={styles.totalValue}>
|
||||
{formatMoney(-invoice.refundedAmount, cur, invoice.language)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
|
||||
<Text style={styles.totalLabelBlue}>
|
||||
{invoice.requiresPayment ? t.outstandingLabel : t.finalAmountLabel}
|
||||
</Text>
|
||||
<Text style={styles.totalValueBoldBlue}>
|
||||
{formatMoney(invoice.totals.gross - invoice.refundedAmount, cur, invoice.language)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{invoice.notices.length > 0 && (
|
||||
@@ -341,6 +475,11 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{invoice.kind === "offer" ? (
|
||||
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
||||
{invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null}
|
||||
</Text>
|
||||
) : invoice.requiresPayment && (
|
||||
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
||||
{invoice.dueDate
|
||||
? t.paymentTerms(
|
||||
@@ -349,19 +488,28 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
)
|
||||
: t.paymentTermsImmediate}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{invoice.giroCodePngDataUrl && !invoice.paid && (
|
||||
{invoice.giroCodePngDataUrl && invoice.requiresPayment && (
|
||||
<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.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
|
||||
</Text>
|
||||
{invoice.issuer.bankName ? (
|
||||
<Text style={styles.giroDetails}>{t.bankLabel}: {invoice.issuer.bankName}</Text>
|
||||
) : null}
|
||||
<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)}
|
||||
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
|
||||
</Text>
|
||||
<Text style={styles.giroDetails}>
|
||||
{t.referenceLabel}: {invoice.number}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -393,14 +541,12 @@ function senderInline(issuer: IssuerData): string {
|
||||
.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 Header(_args: { issuer: IssuerData }) {
|
||||
// Deprecated: header rendering is now inlined in InvoiceDocument so the
|
||||
// logo and meta block can share a single row at the top of the page.
|
||||
return null;
|
||||
}
|
||||
void Header;
|
||||
|
||||
function Recipient({ recipient }: { recipient: RecipientData }) {
|
||||
const lines: string[] = [];
|
||||
@@ -448,7 +594,14 @@ function LineRow({
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
|
||||
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
|
||||
<View style={styles.colUnit}>
|
||||
{line.originalUnitPriceNet != null ? (
|
||||
<Text style={styles.unitOriginalStrike}>
|
||||
{formatMoney(line.originalUnitPriceNet, currency, language)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text>{formatMoney(line.unitPriceNet, currency, language)}</Text>
|
||||
</View>
|
||||
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -468,9 +621,21 @@ function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLan
|
||||
</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}
|
||||
{issuer.phone ? (
|
||||
<Text>
|
||||
{t.phoneLabel}: <Link src={toTelUrl(issuer.phone)}>{issuer.phone}</Link>
|
||||
</Text>
|
||||
) : null}
|
||||
{issuer.email ? (
|
||||
<Text>
|
||||
{t.emailLabel}: <Link src={`mailto:${issuer.email}`}>{issuer.email}</Link>
|
||||
</Text>
|
||||
) : null}
|
||||
{issuer.website ? (
|
||||
<Text>
|
||||
{t.webLabel}: <Link src={normaliseWebUrl(issuer.website)}>{issuer.website}</Link>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.footerCol}>
|
||||
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
|
||||
@@ -504,3 +669,54 @@ function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, la
|
||||
}
|
||||
return issuer.footerNote || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a website URL safe for `<Link src="...">` — adds an `https://` scheme
|
||||
* when the user typed something like `linumiq.com` or `www.linumiq.com`.
|
||||
*/
|
||||
function normaliseWebUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed.replace(/^\/\//, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `tel:` URL from a free-form phone string. RFC 3966 allows only
|
||||
* digits and a leading `+`, so we strip everything else (spaces, parens,
|
||||
* dashes, slashes, dots, internal letters). The display string above the
|
||||
* link keeps the human-readable formatting.
|
||||
*/
|
||||
function toTelUrl(phone: string): string {
|
||||
const cleaned = phone.replace(/[^\d+]/g, "");
|
||||
// Keep only a single leading '+' if present.
|
||||
const normalized = cleaned.startsWith("+")
|
||||
? "+" + cleaned.slice(1).replace(/\+/g, "")
|
||||
: cleaned.replace(/\+/g, "");
|
||||
return `tel:${normalized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Shopify payment-gateway machine name (e.g. `shopify_payments`,
|
||||
* `manual`, `bogus`) or a built-in manual-payment template name (e.g.
|
||||
* `Bank Deposit`, `Money Order`) into the localized customer-facing label
|
||||
* shown on the invoice. The Shopify Admin API only exposes English
|
||||
* template names — see `InvoiceStrings.paymentGatewayLabels` for the
|
||||
* rationale.
|
||||
*
|
||||
* Lookup is keyed on the *normalized* name (lowercased, separators
|
||||
* collapsed). Unknown gateways fall back to a title-cased rendering
|
||||
* of the raw name so we never silently print empty meta-rows.
|
||||
*/
|
||||
function prettifyGatewayName(
|
||||
name: string,
|
||||
labels: Record<string, string>,
|
||||
): string {
|
||||
const key = name.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " ");
|
||||
if (labels[key]) return labels[key];
|
||||
return key
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
||||
* hammering the network when regenerating an invoice multiple times.
|
||||
*/
|
||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
||||
const CACHE_MAX_ENTRIES = 200;
|
||||
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
|
||||
const MAX_IMAGES_PER_INVOICE = 100;
|
||||
/** Max concurrent image fetches per invoice. */
|
||||
const IMAGE_FETCH_CONCURRENCY = 6;
|
||||
|
||||
const cache = new Map<string, string>(); // url -> data URL
|
||||
|
||||
@@ -40,42 +47,62 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
|
||||
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
||||
: url;
|
||||
|
||||
let res: Response;
|
||||
let res: Awaited<ReturnType<typeof safeFetch>>;
|
||||
try {
|
||||
res = await fetch(requestUrl);
|
||||
res = await safeFetch(requestUrl, {
|
||||
maxBytes: MAX_BYTES,
|
||||
accept: "image/*",
|
||||
// Lock product images to Shopify's CDN — line item image URLs come
|
||||
// from the Admin API and should never point anywhere else.
|
||||
allowedHosts: SHOPIFY_CDN_HOSTS,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SafeFetchError) {
|
||||
console.warn(`Product image refused (${err.code}) for ${url}: ${err.message}`);
|
||||
} else {
|
||||
console.warn(`Product image fetch failed for ${url}:`, err);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
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;
|
||||
if (res.bytesRead === 0) return undefined;
|
||||
|
||||
const contentType = guessContentType(url, res.headers.get("content-type"));
|
||||
const contentType = guessContentType(url, res.contentType);
|
||||
// @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 b64 = Buffer.from(res.bytes).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).
|
||||
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
|
||||
* run with bounded concurrency and a hard cap on the number of images
|
||||
* embedded per invoice, so a large cart (Shopify allows hundreds of line
|
||||
* items) can't trigger an unbounded fan-out of network requests. 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);
|
||||
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
|
||||
let budget = MAX_IMAGES_PER_INVOICE;
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.imageUrl) continue;
|
||||
if (budget <= 0) break; // cap reached — remaining rows render iconless
|
||||
budget -= 1;
|
||||
tasks.push(
|
||||
limit(async () => {
|
||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
|
||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import db from "../../db.server";
|
||||
import type { ShopSettings } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Returns the canonical remittance reference for an order — i.e. the
|
||||
* exact string that should appear:
|
||||
* - on the printed invoice PDF (`invoice.number`),
|
||||
* - in the GiroCode QR payload,
|
||||
* - and in the customer-facing payment instructions on the
|
||||
* thank-you / customer-account pages.
|
||||
*
|
||||
* Banking systems treat each unique reference string as a separate
|
||||
* payment, so all three surfaces MUST use this single source of truth.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. The latest non-cancelled `Invoice` row for the order — guaranteed
|
||||
* to match what's printed on the PDF.
|
||||
* 2. Predicted default-mode number (`${prefix}${orderNumber}`). Safe
|
||||
* for the default `order_number` numbering mode and a sensible
|
||||
* best-guess for `prefix_sequential` before the invoice has been
|
||||
* generated (the customer just sees the order number with the
|
||||
* shop's invoice prefix instead of the bare Shopify "#1004").
|
||||
*/
|
||||
export async function resolveOrderRemittance(args: {
|
||||
shopDomain: string;
|
||||
orderGid: string;
|
||||
orderNumber: number | null | undefined;
|
||||
settings: Pick<ShopSettings, "invoicePrefix">;
|
||||
}): Promise<string> {
|
||||
const invoice = await db.invoice.findFirst({
|
||||
where: {
|
||||
shopDomain: args.shopDomain,
|
||||
orderId: args.orderGid,
|
||||
kind: "invoice",
|
||||
cancelledAt: null,
|
||||
},
|
||||
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
|
||||
select: { invoiceNumber: true },
|
||||
});
|
||||
if (invoice?.invoiceNumber) return invoice.invoiceNumber;
|
||||
|
||||
const prefix = args.settings.invoicePrefix || "";
|
||||
if (args.orderNumber != null) return `${prefix}${args.orderNumber}`;
|
||||
// Last-ditch: derive numeric tail from the GID.
|
||||
const tail = args.orderGid.split("/").pop() ?? "";
|
||||
return `${prefix}${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Selection helper for the "recent orders" / "recent drafts" lists.
|
||||
*
|
||||
* A single order can accumulate several invoice rows over its lifetime
|
||||
* (regenerations bump the `version`, cancel-and-reissue cancels the old row
|
||||
* and issues a new one). Crucially, a CANCELLED invoice can carry a HIGHER
|
||||
* `version` than the current active one, so picking "highest version wins"
|
||||
* would surface a stale cancelled invoice and hide the live one — the order
|
||||
* would render as if it had no invoice ("Generate" button) even though a
|
||||
* valid issued invoice exists.
|
||||
*
|
||||
* The correct representative for the UI is the latest NON-cancelled invoice;
|
||||
* only when every row is cancelled do we fall back to the latest cancelled
|
||||
* one (so the order can still show its "cancelled" state).
|
||||
*/
|
||||
|
||||
export interface RepresentativeInvoiceRow {
|
||||
orderId: string;
|
||||
version: number;
|
||||
cancelledAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of orderId -> representative invoice.
|
||||
*
|
||||
* @param invoices Invoice rows for the relevant orders. MUST already be sorted
|
||||
* by `version` descending (then `createdAt` descending), matching the
|
||||
* Prisma query order, so the first non-cancelled row encountered per order
|
||||
* is the highest-version active invoice.
|
||||
*/
|
||||
export function buildRepresentativeInvoiceMap<T extends RepresentativeInvoiceRow>(
|
||||
invoices: T[],
|
||||
): Map<string, T> {
|
||||
const byOrder = new Map<string, T>();
|
||||
for (const inv of invoices) {
|
||||
const existing = byOrder.get(inv.orderId);
|
||||
if (!existing) {
|
||||
byOrder.set(inv.orderId, inv);
|
||||
continue;
|
||||
}
|
||||
// Upgrade from a cancelled placeholder to the first active invoice seen.
|
||||
if (existing.cancelledAt && !inv.cancelledAt) {
|
||||
byOrder.set(inv.orderId, inv);
|
||||
}
|
||||
}
|
||||
return byOrder;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* SSRF-hardened `fetch` for use whenever the URL we're about to call could
|
||||
* be influenced by user input (shop settings, Shopify-supplied product
|
||||
* image URLs, DB-stored Files URLs, …).
|
||||
*
|
||||
* Defenses:
|
||||
* - Only `https:` is allowed by default. `http:` is allowed only for
|
||||
* localhost when `NODE_ENV !== "production"` (handy for local dev).
|
||||
* - Hostname is DNS-resolved and every returned address is checked
|
||||
* against private / loopback / link-local / unique-local ranges.
|
||||
* - The connection is then forced to the resolved IP (with the original
|
||||
* Host header preserved) to defeat DNS-rebinding.
|
||||
* - A hard request timeout is enforced (default 5 s).
|
||||
* - Response size is capped while reading; we abort once the limit is
|
||||
* exceeded instead of buffering the whole body first.
|
||||
* - Redirects are not followed — if the caller wants a redirected target
|
||||
* they have to re-validate it explicitly.
|
||||
*
|
||||
* The helper returns the raw bytes plus the response status / content-type
|
||||
* so callers can decide what to do with them.
|
||||
*/
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { Agent as HttpAgent } from "node:http";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */
|
||||
maxBytes?: number;
|
||||
/** Total request timeout in milliseconds (default 5000). */
|
||||
timeoutMs?: number;
|
||||
/** Optional `Accept` header. */
|
||||
accept?: string;
|
||||
/**
|
||||
* If non-empty, only hosts whose lowercase name equals one of these or
|
||||
* ends with `.<entry>` are allowed. Useful for locking calls to known
|
||||
* good CDNs (e.g. `cdn.shopify.com`).
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
}
|
||||
|
||||
export interface SafeFetchResult {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bytes: Uint8Array;
|
||||
bytesRead: number;
|
||||
}
|
||||
|
||||
export class SafeFetchError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = "SafeFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
/**
|
||||
* Default-deny address classifier backed by the well-vetted `ipaddr.js`
|
||||
* library. An address is considered safe to connect to ONLY if it is a
|
||||
* clearly public, globally-routable unicast address. Everything else —
|
||||
* loopback, private (RFC1918), link-local, unique-local, multicast,
|
||||
* reserved, unspecified, broadcast, carrier-grade NAT, plus the various
|
||||
* IPv4-in-IPv6 tunnelling/transition forms — is rejected.
|
||||
*
|
||||
* This closes IPv6 bypasses that string-prefix checks miss, e.g.:
|
||||
* - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
|
||||
* - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
|
||||
* - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
|
||||
*/
|
||||
function isSafePublicAddress(ip: string): boolean {
|
||||
let addr: ipaddr.IPv4 | ipaddr.IPv6;
|
||||
try {
|
||||
addr = ipaddr.parse(ip);
|
||||
} catch {
|
||||
// Unparseable => treat as unsafe.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (addr.kind() === "ipv4") {
|
||||
// Only globally-routable unicast IPv4 is allowed. `range()` returns
|
||||
// 'unicast' exclusively for public space; private/loopback/linkLocal/
|
||||
// carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
|
||||
return (addr as ipaddr.IPv4).range() === "unicast";
|
||||
}
|
||||
|
||||
const v6 = addr as ipaddr.IPv6;
|
||||
|
||||
// Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
|
||||
// validate the embedded IPv4 against the v4 policy.
|
||||
if (v6.isIPv4MappedAddress()) {
|
||||
return v6.toIPv4Address().range() === "unicast";
|
||||
}
|
||||
|
||||
// Deprecated IPv4-compatible addresses live in ::/96 (first 96 bits zero,
|
||||
// e.g. ::7f00:1 == ::127.0.0.1). ipaddr.js classifies these as plain
|
||||
// 'unicast', so unwrap the trailing 32 bits and validate as IPv4. This
|
||||
// also covers :: (unspecified) and ::1 (loopback), which map to
|
||||
// 0.0.0.0 / 0.0.0.1 and are denied by the IPv4 policy.
|
||||
const p = v6.parts;
|
||||
if (p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0 && p[5] === 0) {
|
||||
const v4 = new ipaddr.IPv4([(p[6] >> 8) & 0xff, p[6] & 0xff, (p[7] >> 8) & 0xff, p[7] & 0xff]);
|
||||
return v4.range() === "unicast";
|
||||
}
|
||||
|
||||
// Everything else: only true global unicast is allowed. This rejects
|
||||
// loopback, linkLocal (fe80::/10), uniqueLocal (fc00::/7), multicast,
|
||||
// reserved, 6to4, teredo, rfc6145/rfc6052 transition ranges, etc.
|
||||
return v6.range() === "unicast";
|
||||
}
|
||||
|
||||
function isPrivateAddress(ip: string): boolean {
|
||||
return !isSafePublicAddress(ip);
|
||||
}
|
||||
|
||||
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
|
||||
if (!allowed || allowed.length === 0) return true;
|
||||
const h = hostname.toLowerCase();
|
||||
return allowed.some((entry) => {
|
||||
const e = entry.toLowerCase();
|
||||
return h === e || h.endsWith(`.${e}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a hostname to an IPv4/IPv6 address that has been vetted against
|
||||
* the private/loopback ranges. Throws `SafeFetchError` if no safe address
|
||||
* can be obtained.
|
||||
*/
|
||||
async function resolveSafeAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
// If the hostname is already an IP literal, validate it directly.
|
||||
if (net.isIP(hostname)) {
|
||||
const family = net.isIPv6(hostname) ? 6 : 4;
|
||||
if (isPrivateAddress(hostname)) {
|
||||
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
|
||||
}
|
||||
return { address: hostname, family };
|
||||
}
|
||||
let results: { address: string; family: number }[];
|
||||
try {
|
||||
results = await dnsLookup(hostname, { all: true });
|
||||
} catch (err) {
|
||||
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
|
||||
}
|
||||
for (const r of results) {
|
||||
if (isPrivateAddress(r.address)) {
|
||||
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
|
||||
}
|
||||
}
|
||||
const first = results[0];
|
||||
if (!first) throw new SafeFetchError("dns-empty", `${hostname} resolved to no addresses`);
|
||||
return { address: first.address, family: first.family };
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an SSRF-safe HTTP(S) GET. Throws `SafeFetchError` for policy
|
||||
* violations; throws plain `Error` for transport failures (mirroring the
|
||||
* standard `fetch` error model).
|
||||
*/
|
||||
export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Promise<SafeFetchResult> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new SafeFetchError("bad-url", `Invalid URL: ${rawUrl}`);
|
||||
}
|
||||
|
||||
const allowHttp =
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
(url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1");
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && allowHttp)) {
|
||||
throw new SafeFetchError("bad-scheme", `Refusing non-https URL: ${url.protocol}//${url.hostname}`);
|
||||
}
|
||||
|
||||
if (!hostMatchesAllowlist(url.hostname, opts.allowedHosts)) {
|
||||
throw new SafeFetchError("host-not-allowed", `Host ${url.hostname} is not on the allowlist`);
|
||||
}
|
||||
|
||||
const { address, family } = await resolveSafeAddress(url.hostname);
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
// Pin the resolved IP. We pass an Agent with a custom `lookup` that always
|
||||
// returns our pre-validated address, so the actual TCP connect can't be
|
||||
// re-resolved to something else (DNS-rebinding defense).
|
||||
//
|
||||
// Note: Node 20+ enables Happy Eyeballs (`autoSelectFamily: true`) by
|
||||
// default on the http/https agents. Happy Eyeballs calls `lookup` with
|
||||
// `{ all: true }` and expects the callback to receive an *array* of
|
||||
// `{ address, family }` records. If we ignore that and always invoke the
|
||||
// 3-arg form, the connector hands `undefined` to `socket.connect()`,
|
||||
// which then throws `Invalid IP address: undefined`.
|
||||
type LookupCb =
|
||||
| ((err: NodeJS.ErrnoException | null, address: string, family: number) => void)
|
||||
| ((err: NodeJS.ErrnoException | null, addresses: { address: string; family: number }[]) => void);
|
||||
const pinnedLookup = (
|
||||
_hostname: string,
|
||||
optionsOrCb: { all?: boolean; family?: number } | LookupCb,
|
||||
maybeCb?: LookupCb,
|
||||
) => {
|
||||
let options: { all?: boolean; family?: number } = {};
|
||||
let cb: LookupCb;
|
||||
if (typeof optionsOrCb === "function") {
|
||||
cb = optionsOrCb;
|
||||
} else {
|
||||
options = optionsOrCb ?? {};
|
||||
cb = maybeCb as LookupCb;
|
||||
}
|
||||
if (options.all) {
|
||||
(cb as (err: NodeJS.ErrnoException | null, addresses: { address: string; family: number }[]) => void)(
|
||||
null,
|
||||
[{ address, family }],
|
||||
);
|
||||
} else {
|
||||
(cb as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
|
||||
null,
|
||||
address,
|
||||
family,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isHttps = url.protocol === "https:";
|
||||
const agent = isHttps
|
||||
? new HttpsAgent({ keepAlive: false, lookup: pinnedLookup as never })
|
||||
: new HttpAgent({ keepAlive: false, lookup: pinnedLookup as never });
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Host: url.host,
|
||||
"User-Agent": "linumiq-invoice/1.0 (+https://linumiq.com)",
|
||||
};
|
||||
if (opts.accept) headers["Accept"] = opts.accept;
|
||||
|
||||
const requestOptions: http.RequestOptions = {
|
||||
method: "GET",
|
||||
host: url.hostname,
|
||||
port: url.port ? parseInt(url.port, 10) : isHttps ? 443 : 80,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
agent,
|
||||
// Defeat redirects (Node's http doesn't follow by default).
|
||||
};
|
||||
|
||||
return new Promise<SafeFetchResult>((resolve, reject) => {
|
||||
const lib = isHttps ? https : http;
|
||||
const req = lib.request(requestOptions, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
// Reject 3xx — caller must explicitly re-call with the new URL.
|
||||
if (status >= 300 && status < 400) {
|
||||
res.resume();
|
||||
reject(new SafeFetchError("redirect-not-allowed", `Refusing redirect ${status} from ${rawUrl}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
res.destroy(new SafeFetchError("too-large", `Response exceeded ${maxBytes} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
const buf = Buffer.concat(chunks, total);
|
||||
resolve({
|
||||
status,
|
||||
contentType: res.headers["content-type"] ?? null,
|
||||
bytes: new Uint8Array(buf),
|
||||
bytesRead: total,
|
||||
});
|
||||
});
|
||||
res.on("error", (err) => reject(err));
|
||||
});
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new SafeFetchError("timeout", `Request to ${url.hostname} exceeded ${timeoutMs}ms`));
|
||||
});
|
||||
req.on("error", (err) => reject(err));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Common allowlist for Shopify-served assets (CDN + Files). */
|
||||
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
|
||||
|
||||
/**
|
||||
* Boundary validation for merchant-supplied URLs (e.g. the logo URL saved in
|
||||
* settings). Requires a syntactically valid `https:` URL whose host is a DNS
|
||||
* name rather than an IP literal (v4 or v6). Returns a user-facing error
|
||||
* string when the URL is unacceptable, or `null` when it is fine to store.
|
||||
*
|
||||
* This is a defence-in-depth boundary check; `safeFetch` remains the runtime
|
||||
* backstop that re-validates the resolved address at fetch time.
|
||||
*/
|
||||
export function validateMerchantHttpsUrl(raw: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return "Enter a valid URL including the https:// prefix.";
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
return "Logo URL must use https://.";
|
||||
}
|
||||
// URL.hostname wraps IPv6 literals in brackets; strip them before checking.
|
||||
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
if (net.isIP(host) !== 0) {
|
||||
return "Logo URL must point to a domain name, not an IP address.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { optionalEnv } from "../config/env.server";
|
||||
|
||||
/**
|
||||
* Resolves the GiroCode URL signing key lazily (per call, not at module load)
|
||||
* so the process can boot even when only the fallback secret is present.
|
||||
*
|
||||
* Prefers the dedicated `GIROCODE_SIGNING_KEY`; falls back to
|
||||
* `SHOPIFY_API_SECRET` ONLY when the dedicated key is unset, so existing
|
||||
* signed URLs and deployments keep working. Throws if neither is set
|
||||
* (fail closed) — an empty key would make signatures forgeable.
|
||||
*/
|
||||
function getSigningKey(): string {
|
||||
const key = optionalEnv("GIROCODE_SIGNING_KEY") ?? optionalEnv("SHOPIFY_API_SECRET");
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"GiroCode signing key missing: set GIROCODE_SIGNING_KEY (preferred) " +
|
||||
"or SHOPIFY_API_SECRET.",
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function hmac(payload: string): string {
|
||||
return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export interface GiroCodeUrlParams {
|
||||
shop: string;
|
||||
orderId: string;
|
||||
exp: number; // unix seconds
|
||||
}
|
||||
|
||||
export function signGiroCodeUrl(params: GiroCodeUrlParams): string {
|
||||
const base = `shop=${params.shop}&orderId=${params.orderId}&exp=${params.exp}`;
|
||||
const sig = hmac(base);
|
||||
return `${base}&sig=${sig}`;
|
||||
}
|
||||
|
||||
export function verifyGiroCodeUrl(query: URLSearchParams): { ok: boolean; shop?: string; orderId?: string; reason?: string } {
|
||||
const shop = query.get("shop") || "";
|
||||
const orderId = query.get("orderId") || "";
|
||||
const exp = parseInt(query.get("exp") || "0", 10);
|
||||
const sig = query.get("sig") || "";
|
||||
if (!shop || !orderId || !exp || !sig) return { ok: false, reason: "missing-params" };
|
||||
if (Date.now() / 1000 > exp) return { ok: false, reason: "expired" };
|
||||
const expected = hmac(`shop=${shop}&orderId=${orderId}&exp=${exp}`);
|
||||
// timing-safe compare
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return { ok: false, reason: "bad-sig" };
|
||||
if (!crypto.timingSafeEqual(a, b)) return { ok: false, reason: "bad-sig" };
|
||||
return { ok: true, shop, orderId };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InvoiceLanguage } from "./i18n";
|
||||
import type { InvoiceLanguage, PaymentStatus } from "./i18n";
|
||||
|
||||
/**
|
||||
* The view model passed into the PDF renderer. Decouples the PDF layer from
|
||||
@@ -10,7 +10,7 @@ export interface InvoiceViewModel {
|
||||
currency: string;
|
||||
|
||||
// Identity
|
||||
kind: "invoice" | "storno";
|
||||
kind: "invoice" | "storno" | "offer";
|
||||
number: string;
|
||||
/** Only set for storno: the original invoice number being cancelled. */
|
||||
cancelsNumber?: string;
|
||||
@@ -39,7 +39,69 @@ export interface InvoiceViewModel {
|
||||
giroCodePngDataUrl?: string;
|
||||
|
||||
// Status flags
|
||||
paid: boolean;
|
||||
/** Condensed payment status derived from Shopify's
|
||||
* `displayFinancialStatus`. */
|
||||
paymentStatus: PaymentStatus;
|
||||
/**
|
||||
* True when this document represents an *outstanding* request for
|
||||
* money — i.e. the customer still owes the issuer something. False
|
||||
* when the invoice has already been settled (`paid`), the order has
|
||||
* been fully refunded (`refunded`), or the document is structurally
|
||||
* not a payment request (offers, cancellation invoices).
|
||||
*
|
||||
* Drives the GiroCode generation gate, the GiroCode/payment-block
|
||||
* render gate, and the payment-terms paragraph below the items
|
||||
* table. Without this flag a fully refunded order would still be
|
||||
* printed with a SEPA QR code asking the customer to wire the
|
||||
* original total.
|
||||
*/
|
||||
requiresPayment: boolean;
|
||||
/** Cumulative gross amount that has been refunded against the
|
||||
* underlying Shopify order, in the same currency as `totals.gross`.
|
||||
* 0 when there has been no refund (the common case) or when the
|
||||
* document is structurally not subject to refunds (storno / offer).
|
||||
* When > 0 the renderer adds two extra rows beneath the gross total:
|
||||
* a negative "Zurückerstattet" row and a final "Offener Betrag"
|
||||
* row showing `gross - refundedAmount` so the printed PDF mirrors
|
||||
* what the merchant sees on the Shopify order page. */
|
||||
refundedAmount: number;
|
||||
/** Names of the payment gateways used (e.g. ["bogus"], ["manual",
|
||||
* "shopify_payments"]). Empty when unknown / draft. */
|
||||
paymentGatewayNames: string[];
|
||||
|
||||
/** Shopify's human-friendly order identifier (e.g. "#1004"). Distinct from
|
||||
* the sequential `number` used as the invoice number. */
|
||||
orderName: string;
|
||||
|
||||
/** Shipping address — only set when it differs from the billing address.
|
||||
* Renderer uses this to show a separate delivery-address block. */
|
||||
separateShippingAddress?: RecipientData;
|
||||
|
||||
/** Human-readable shipping method title (e.g. "Standard", "DHL Express").
|
||||
* Empty / undefined when there is no shipping line (digital orders). */
|
||||
shippingMethod?: string;
|
||||
|
||||
/** Tracking entries collected from order fulfillments. Empty when the
|
||||
* order is unfulfilled or has no tracking. */
|
||||
tracking: TrackingInfo[];
|
||||
|
||||
/** Discount codes applied to the cart (e.g. `["SUMMER10"]`). Empty when
|
||||
* none. */
|
||||
discountCodes: string[];
|
||||
|
||||
/** True when the customer chose local pickup (so we shouldn't render the
|
||||
* pickup-location address as a "delivery address"). */
|
||||
isPickup: boolean;
|
||||
|
||||
/** Name of the pickup location (e.g. "Lager Graz"). Set only when
|
||||
* `isPickup` is true and the location name was available. */
|
||||
pickupLocationName?: string;
|
||||
}
|
||||
|
||||
export interface TrackingInfo {
|
||||
number: string;
|
||||
url?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface IssuerData {
|
||||
@@ -82,8 +144,13 @@ export interface InvoiceLine {
|
||||
title: string;
|
||||
/** Raw quantity (e.g. 6). */
|
||||
quantity: number;
|
||||
/** Net unit price (excluding tax). */
|
||||
/** Net unit price (excluding tax). When a discount applies, this is the
|
||||
* effective discounted price actually charged. */
|
||||
unitPriceNet: number;
|
||||
/** Original net unit price BEFORE any discount allocation. Only set when
|
||||
* it differs from `unitPriceNet`. The renderer uses this to display a
|
||||
* strikethrough original next to the discounted price. */
|
||||
originalUnitPriceNet?: number;
|
||||
/** Net total = quantity * unitPriceNet. */
|
||||
totalNet: number;
|
||||
/** Optional SKU for display under the title. */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Thin wrapper around `@shopify/shopify-app-session-storage-prisma` that
|
||||
* encrypts `accessToken` / `refreshToken` at rest using field-level AES-256-GCM.
|
||||
*
|
||||
* Tokens are encrypted before being handed to the underlying storage and
|
||||
* decrypted after they are loaded back out. `decryptField` is backward
|
||||
* compatible, so any legacy plaintext tokens already in the DB keep working.
|
||||
*/
|
||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||
import type { Session } from "@shopify/shopify-api";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { decryptField, encryptField } from "../crypto/fieldCrypto.server";
|
||||
|
||||
type SessionTokenFields = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
function encryptTokens(session: Session): Session {
|
||||
const s = session as Session & SessionTokenFields;
|
||||
if (s.accessToken) s.accessToken = encryptField(s.accessToken);
|
||||
if (s.refreshToken) s.refreshToken = encryptField(s.refreshToken);
|
||||
return session;
|
||||
}
|
||||
|
||||
function decryptTokens(session: Session): Session {
|
||||
const s = session as Session & SessionTokenFields;
|
||||
if (s.accessToken) s.accessToken = decryptField(s.accessToken);
|
||||
if (s.refreshToken) s.refreshToken = decryptField(s.refreshToken);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a Session so we never mutate the caller's instance when encrypting
|
||||
* for storage. The Prisma session storage only reads plain properties, so a
|
||||
* shallow structured copy via the Session class is sufficient.
|
||||
*/
|
||||
function cloneSession(session: Session): Session {
|
||||
return Object.assign(
|
||||
Object.create(Object.getPrototypeOf(session)),
|
||||
session,
|
||||
) as Session;
|
||||
}
|
||||
|
||||
export class EncryptedPrismaSessionStorage extends PrismaSessionStorage<PrismaClient> {
|
||||
async storeSession(session: Session): Promise<boolean> {
|
||||
return super.storeSession(encryptTokens(cloneSession(session)));
|
||||
}
|
||||
|
||||
async loadSession(id: string): Promise<Session | undefined> {
|
||||
const session = await super.loadSession(id);
|
||||
return session ? decryptTokens(session) : session;
|
||||
}
|
||||
|
||||
async findSessionsByShop(shop: string): Promise<Session[]> {
|
||||
const sessions = await super.findSessionsByShop(shop);
|
||||
return sessions.map((s) => decryptTokens(s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import pLimit from "p-limit";
|
||||
import type { WebhookReservation } from "./dedupe.server";
|
||||
|
||||
/**
|
||||
* Background runner for webhook side-effects.
|
||||
*
|
||||
* Shopify expects a 200 response within ~5 seconds, otherwise it considers
|
||||
* the delivery failed and retries it. Heavy automation work (PDF render,
|
||||
* Shopify Files upload, SMTP send) routinely exceeded that budget, which
|
||||
* caused duplicate invoice emails before we added the dedupe table.
|
||||
*
|
||||
* Returning the response immediately and finishing the work afterwards keeps
|
||||
* Shopify happy. Two problems with a naive `void work()`:
|
||||
*
|
||||
* 1. DoS / resource exhaustion — an order burst would spawn unbounded
|
||||
* concurrent PDF renders + SMTP sends. We cap concurrency with a small
|
||||
* in-process queue (`p-limit`); excess tasks queue instead of piling up.
|
||||
* 2. Data loss on restart — `void work()` is invisible to shutdown, so a
|
||||
* container stop (SIGTERM) killed in-flight invoice work mid-send. We
|
||||
* track in-flight tasks and drain them (bounded) on SIGTERM/SIGINT.
|
||||
*
|
||||
* Reserve/commit dedupe (see dedupe.server.ts) is integrated here: on success
|
||||
* we `commit()` the reservation (permanently deduped); on failure we
|
||||
* `release()` it so Shopify's retry re-runs the work instead of being dropped
|
||||
* as a duplicate.
|
||||
*/
|
||||
|
||||
const CONCURRENCY = Math.max(1, Number(process.env.WEBHOOK_CONCURRENCY) || 4);
|
||||
const DRAIN_TIMEOUT_MS = Math.max(
|
||||
1000,
|
||||
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS) || 25_000,
|
||||
);
|
||||
|
||||
const limit = pLimit(CONCURRENCY);
|
||||
const inFlight = new Set<Promise<unknown>>();
|
||||
let draining = false;
|
||||
|
||||
export function runWebhookInBackground(
|
||||
description: string,
|
||||
work: () => Promise<unknown>,
|
||||
reservation?: WebhookReservation | null,
|
||||
): void {
|
||||
if (draining) {
|
||||
// The process is shutting down. We still enqueue so the drain awaits this
|
||||
// task — the server has already stopped listening, so this is at most the
|
||||
// tail end of the last accepted request.
|
||||
console.warn(`[webhook-queue] enqueuing task during shutdown drain: ${description}`);
|
||||
}
|
||||
|
||||
const task = limit(async () => {
|
||||
try {
|
||||
await work();
|
||||
await reservation?.commit();
|
||||
} catch (err) {
|
||||
console.error(`background webhook task '${description}' failed:`, err);
|
||||
// Drop the dedupe reservation so Shopify's retry re-runs the work.
|
||||
try {
|
||||
await reservation?.release();
|
||||
} catch (releaseErr) {
|
||||
console.error(
|
||||
`background webhook task '${description}': failed to release dedupe reservation:`,
|
||||
releaseErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inFlight.add(task);
|
||||
void task.finally(() => inFlight.delete(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop accepting new work (best-effort) and await in-flight + queued tasks,
|
||||
* bounded by `timeoutMs`, so a container stop drains invoice work instead of
|
||||
* killing it mid-send. Idempotent.
|
||||
*/
|
||||
export async function drainWebhookQueue(timeoutMs = DRAIN_TIMEOUT_MS): Promise<void> {
|
||||
draining = true;
|
||||
if (inFlight.size === 0) return;
|
||||
|
||||
console.log(
|
||||
`[webhook-queue] draining ${inFlight.size} in-flight webhook task(s) (timeout ${timeoutMs}ms)...`,
|
||||
);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
});
|
||||
|
||||
await Promise.race([Promise.allSettled([...inFlight]), timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
if (inFlight.size > 0) {
|
||||
console.warn(
|
||||
`[webhook-queue] drain timed out with ${inFlight.size} task(s) still running`,
|
||||
);
|
||||
} else {
|
||||
console.log("[webhook-queue] drain complete");
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge for the custom server (server.js), which loads only the bundled
|
||||
// build and cannot import this module directly. It awaits this drain before
|
||||
// calling process.exit during graceful shutdown.
|
||||
type DrainGlobal = typeof globalThis & {
|
||||
__linumiqWebhookDrain?: typeof drainWebhookQueue;
|
||||
};
|
||||
(globalThis as DrainGlobal).__linumiqWebhookDrain = drainWebhookQueue;
|
||||
|
||||
// Safety net for runtimes that don't go through server.js (e.g. `shopify app
|
||||
// dev`): stop accepting work and best-effort drain. The custom server awaits
|
||||
// the same (idempotent) drain before exiting.
|
||||
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||
process.once(signal, () => {
|
||||
void drainWebhookQueue();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import db from "../../db.server";
|
||||
|
||||
/**
|
||||
* Periodic TTL cleanup for the `ProcessedWebhook` idempotency table.
|
||||
*
|
||||
* The table grows by one row per Shopify webhook delivery and is never read
|
||||
* after the retry window closes, so without pruning it grows unbounded —
|
||||
* eventually a disk/space DoS. We only need rows for as long as Shopify might
|
||||
* retry a delivery (hours), so a generous retention window of a few days is
|
||||
* ample while keeping the table small.
|
||||
*/
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
|
||||
export interface CleanupDeps {
|
||||
db: {
|
||||
processedWebhook: {
|
||||
deleteMany: (args: {
|
||||
where: { receivedAt: { lt: Date } };
|
||||
}) => Promise<{ count: number }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let scheduled = false;
|
||||
|
||||
async function runCleanup(deps: CleanupDeps): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - RETENTION_MS);
|
||||
const { count } = await deps.db.processedWebhook.deleteMany({
|
||||
where: { receivedAt: { lt: cutoff } },
|
||||
});
|
||||
if (count > 0) {
|
||||
console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort housekeeping — never throw into the caller.
|
||||
console.warn("webhook-cleanup: prune failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently schedule the hourly cleanup. Safe to call on every webhook —
|
||||
* the first call starts a single unref'd interval and runs an immediate
|
||||
* sweep; subsequent calls are no-ops.
|
||||
*
|
||||
* Because this is only ever invoked while handling a live webhook request, it
|
||||
* never runs during `prisma generate` / `react-router build` or other CLI
|
||||
* contexts. The interval is `unref`'d so it can never keep the process alive.
|
||||
*/
|
||||
export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void runCleanup(deps);
|
||||
}, INTERVAL_MS);
|
||||
// Don't let the housekeeping interval keep the event loop alive on shutdown.
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
|
||||
// Kick off an immediate sweep so a long-lived process prunes promptly.
|
||||
void runCleanup(deps);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import db from "../../db.server";
|
||||
import { ensureWebhookCleanupScheduled } from "./cleanup.server";
|
||||
|
||||
/**
|
||||
* How long a `status="processing"` reservation is considered "live" before we
|
||||
* assume the worker that claimed it crashed mid-process. After this window a
|
||||
* stale reservation may be reclaimed and the work retried.
|
||||
*/
|
||||
const STALE_LEASE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface ProcessedRow {
|
||||
webhookId: string;
|
||||
status: string;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape of the Prisma client surface we use — declared inline so the
|
||||
* helper can be unit-tested with a tiny stub instead of a real database.
|
||||
*/
|
||||
export interface DedupeDeps {
|
||||
db: {
|
||||
processedWebhook: {
|
||||
create: (args: {
|
||||
data: { webhookId: string; topic: string; shopDomain: string; status: string };
|
||||
}) => Promise<unknown>;
|
||||
findUnique: (args: { where: { webhookId: string } }) => Promise<ProcessedRow | null>;
|
||||
update: (args: {
|
||||
where: { webhookId: string };
|
||||
data: { status?: string; receivedAt?: Date };
|
||||
}) => Promise<unknown>;
|
||||
delete: (args: { where: { webhookId: string } }) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A claim on a single Shopify webhook delivery. Obtained from
|
||||
* {@link reserveWebhook}. The caller MUST eventually `commit()` (work
|
||||
* succeeded — the delivery is permanently deduped) or `release()` (work
|
||||
* failed — drop the reservation so Shopify's retry re-runs the work).
|
||||
*
|
||||
* `commit`/`release` are no-ops for reservations without a webhook id (unit
|
||||
* tests / non-Shopify callers) and for the fail-open path.
|
||||
*/
|
||||
export interface WebhookReservation {
|
||||
webhookId: string | null;
|
||||
commit: () => Promise<void>;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
function noopReservation(webhookId: string | null): WebhookReservation {
|
||||
return {
|
||||
webhookId,
|
||||
commit: async () => {},
|
||||
release: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function isP2002(err: unknown): boolean {
|
||||
// Duck-typed so callers can stub the db without pulling in the real
|
||||
// `Prisma` namespace. P2002 = unique-constraint violation.
|
||||
return (err as { code?: string } | null)?.code === "P2002";
|
||||
}
|
||||
|
||||
function makeReservation(
|
||||
webhookId: string,
|
||||
shop: string,
|
||||
topic: string,
|
||||
deps: DedupeDeps,
|
||||
): WebhookReservation {
|
||||
return {
|
||||
webhookId,
|
||||
commit: async () => {
|
||||
try {
|
||||
await deps.db.processedWebhook.update({
|
||||
where: { webhookId },
|
||||
data: { status: "done" },
|
||||
});
|
||||
} catch (err) {
|
||||
// The work already succeeded; a failed commit just risks a later
|
||||
// duplicate (which the side-effect code is expected to tolerate).
|
||||
console.warn(`dedupe: failed to commit webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
}
|
||||
},
|
||||
release: async () => {
|
||||
try {
|
||||
await deps.db.processedWebhook.delete({ where: { webhookId } });
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to release webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve this Shopify webhook delivery for processing.
|
||||
*
|
||||
* Shopify retries a delivery (re-using the same `X-Shopify-Webhook-Id`) when
|
||||
* it doesn't receive a 200 within its ~5s timeout. Naively recording the id as
|
||||
* "processed" *before* doing the work meant that if the heavy background work
|
||||
* later failed (SMTP/GraphQL/PDF error), Shopify's retry was dropped as a
|
||||
* duplicate and the invoice was never sent.
|
||||
*
|
||||
* This uses a two-phase reserve/commit keyed on the webhook id, with the
|
||||
* unique `webhookId` primary key as the concurrency lock:
|
||||
*
|
||||
* - RESERVE: insert a `status="processing"` row. A unique-constraint
|
||||
* violation (`P2002`) means the id is already claimed; we then inspect the
|
||||
* existing row:
|
||||
* - `done` → genuine duplicate → return `null` (skip).
|
||||
* - `processing`, fresh → another delivery is in flight → `null`.
|
||||
* - `processing`, stale → previous worker crashed → reclaim & retry.
|
||||
* - COMMIT (caller, on success) → flip the row to `status="done"`.
|
||||
* - RELEASE (caller, on failure) → delete the row so a retry reprocesses.
|
||||
*
|
||||
* Returns a {@link WebhookReservation} when the caller should process the
|
||||
* delivery, or `null` when it must short-circuit (duplicate / concurrent).
|
||||
*
|
||||
* Fail-open: a dedupe-table error (other than P2002) never silently drops a
|
||||
* webhook — we return a no-op reservation and let the work run.
|
||||
*/
|
||||
export async function reserveWebhook(
|
||||
request: Request,
|
||||
shop: string,
|
||||
topic: string,
|
||||
deps: DedupeDeps = { db },
|
||||
): Promise<WebhookReservation | null> {
|
||||
// Opportunistically schedule TTL cleanup (runtime-only; never in build/CLI
|
||||
// since this is reached only while handling a live webhook request).
|
||||
ensureWebhookCleanupScheduled();
|
||||
|
||||
const webhookId = request.headers.get("x-shopify-webhook-id");
|
||||
if (!webhookId) {
|
||||
// No id (unit tests / non-Shopify callers): process without dedupe.
|
||||
return noopReservation(null);
|
||||
}
|
||||
|
||||
const reservation = makeReservation(webhookId, shop, topic, deps);
|
||||
|
||||
try {
|
||||
await deps.db.processedWebhook.create({
|
||||
data: { webhookId, topic, shopDomain: shop, status: "processing" },
|
||||
});
|
||||
return reservation;
|
||||
} catch (err) {
|
||||
if (!isP2002(err)) {
|
||||
// Don't fail (or silently drop) a webhook on a logging-table issue.
|
||||
console.warn(`dedupe: failed to reserve webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
return noopReservation(webhookId);
|
||||
}
|
||||
}
|
||||
|
||||
// A row already exists. Classify it.
|
||||
let existing: ProcessedRow | null = null;
|
||||
try {
|
||||
existing = await deps.db.processedWebhook.findUnique({ where: { webhookId } });
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to load existing webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
// Another worker owns the row and we can't classify it — be safe and skip.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
// Raced with a release/delete between create() and findUnique(); reclaim.
|
||||
return reservation;
|
||||
}
|
||||
|
||||
if (existing.status === "done") {
|
||||
console.log(
|
||||
`dedupe: skipping already-processed ${topic} for ${shop} (webhookId=${webhookId})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const age = Date.now() - new Date(existing.receivedAt).getTime();
|
||||
if (age > STALE_LEASE_MS) {
|
||||
// The worker that reserved this crashed mid-process (or left a stale row).
|
||||
// Renew the lease and retry the work.
|
||||
try {
|
||||
await deps.db.processedWebhook.update({
|
||||
where: { webhookId },
|
||||
data: { status: "processing", receivedAt: new Date() },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to reclaim stale webhook ${webhookId}:`, err);
|
||||
return null;
|
||||
}
|
||||
console.log(
|
||||
`dedupe: reclaiming stale ${topic} reservation for ${shop} ` +
|
||||
`(webhookId=${webhookId}, age=${Math.round(age / 1000)}s)`,
|
||||
);
|
||||
return reservation;
|
||||
}
|
||||
|
||||
// A fresh "processing" row: another delivery is actively working on it.
|
||||
// Skip this concurrent delivery. Shopify will retry; if the active worker
|
||||
// fails it releases the reservation so a later retry reprocesses.
|
||||
console.log(
|
||||
`dedupe: ${topic} for ${shop} already in-flight (webhookId=${webhookId}); ` +
|
||||
`skipping concurrent delivery`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -4,17 +4,18 @@ import {
|
||||
AppDistribution,
|
||||
shopifyApp,
|
||||
} from "@shopify/shopify-app-react-router/server";
|
||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||
import prisma from "./db.server";
|
||||
import { requireEnv } from "./services/config/env.server";
|
||||
import { EncryptedPrismaSessionStorage } from "./services/session/encryptedSessionStorage.server";
|
||||
|
||||
const shopify = shopifyApp({
|
||||
apiKey: process.env.SHOPIFY_API_KEY,
|
||||
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||
apiKey: requireEnv("SHOPIFY_API_KEY"),
|
||||
apiSecretKey: requireEnv("SHOPIFY_API_SECRET"),
|
||||
apiVersion: ApiVersion.October25,
|
||||
scopes: process.env.SCOPES?.split(","),
|
||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||
appUrl: requireEnv("SHOPIFY_APP_URL"),
|
||||
authPathPrefix: "/auth",
|
||||
sessionStorage: new PrismaSessionStorage(prisma),
|
||||
sessionStorage: new EncryptedPrismaSessionStorage(prisma),
|
||||
distribution: AppDistribution.SingleMerchant,
|
||||
future: {
|
||||
expiringOfflineAccessTokens: true,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,39 @@
|
||||
# DEV environment for linumiq-invoice (custom app installed on linumiq-dev.myshopify.com).
|
||||
# Copy to `.env.dev` on the server (in /docker/linumiq-invoice/dev/) and fill in real values.
|
||||
# NEVER commit the real file.
|
||||
|
||||
# --- Shopify app credentials ---
|
||||
# Partner Dashboard → Apps → linumiq-invoice-dev → API credentials.
|
||||
SHOPIFY_API_KEY=fbc263e6cc28e8de031878d2a0f17444
|
||||
SHOPIFY_API_SECRET=REPLACE_ME
|
||||
|
||||
# Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.dev.toml.
|
||||
SHOPIFY_APP_URL=https://invoice-app-dev.linumiq.com
|
||||
|
||||
# Single-merchant lock-in: only this myshopify domain may install the app.
|
||||
ALLOWED_SHOP=linumiq-dev.myshopify.com
|
||||
|
||||
# Must match `scopes` in shopify.app.dev.toml.
|
||||
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
|
||||
|
||||
# --- Secrets at rest ---
|
||||
# Field-level encryption key for secrets stored in the DB (SMTP password,
|
||||
# Shopify session access/refresh tokens). Must be base64 of exactly 32 bytes.
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
DATA_ENCRYPTION_KEY=REPLACE_ME_BASE64_32_BYTES
|
||||
|
||||
# Dedicated HMAC key for signing public GiroCode URLs. base64 of 32 bytes.
|
||||
# If unset, the app falls back to SHOPIFY_API_SECRET (kept for backward compat).
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
GIROCODE_SIGNING_KEY=REPLACE_ME_BASE64_32_BYTES
|
||||
|
||||
# --- Runtime ---
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# DATABASE_URL is set in docker-compose.dev.yml (file:/data/prod.sqlite on the bind mount).
|
||||
|
||||
# --- Email (optional) ---
|
||||
# Archival BCC for every invoice email. Off by default for privacy/GDPR.
|
||||
# Set to a single address or a comma-separated list to opt in.
|
||||
# INVOICE_BCC=archive@example.com
|
||||
@@ -0,0 +1,23 @@
|
||||
# PROD environment for linumiq-invoice (custom app installed on shop.linumiq.com / 5aiizq-ti.myshopify.com).
|
||||
# Copy to `.env.prod` on the server (in /docker/linumiq-invoice/prod/) and fill in real values.
|
||||
# NEVER commit the real file.
|
||||
|
||||
# --- Shopify app credentials ---
|
||||
# Partner Dashboard → Apps → linumiq-invoice (prod) → API credentials.
|
||||
SHOPIFY_API_KEY=REPLACE_ME
|
||||
SHOPIFY_API_SECRET=REPLACE_ME
|
||||
|
||||
# Public URL Shopify uses for OAuth, webhooks and admin embedding. Must match shopify.app.prod.toml.
|
||||
SHOPIFY_APP_URL=https://invoice-app.linumiq.com
|
||||
|
||||
# Single-merchant lock-in: only this myshopify domain may install the app.
|
||||
ALLOWED_SHOP=5aiizq-ti.myshopify.com
|
||||
|
||||
# Must match `scopes` in shopify.app.prod.toml.
|
||||
SCOPES=read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files
|
||||
|
||||
# --- Runtime ---
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# DATABASE_URL is set in docker-compose.prod.yml (file:/data/prod.sqlite on the bind mount).
|
||||
@@ -1,10 +1,27 @@
|
||||
# Append to your existing Caddyfile (or include via `import`).
|
||||
# DNS A/AAAA record for invoice-app.linumiq.com must point to this server first,
|
||||
# otherwise Caddy will fail to obtain a Let's Encrypt certificate.
|
||||
# DNS A/AAAA records for both subdomains must point to this server first
|
||||
# (a wildcard *.linumiq.com record is sufficient).
|
||||
#
|
||||
# Caddy runs in Docker on the `caddy_net` network and reaches each app by
|
||||
# container name (the apps do not publish host ports).
|
||||
|
||||
# Caddy runs in Docker on the `caddy_net` network and reaches the app by
|
||||
# container name (the app does not publish a host port).
|
||||
# DEV — installed on linumiq-dev.myshopify.com
|
||||
invoice-app-dev.linumiq.com {
|
||||
encode zstd gzip
|
||||
# Security response headers. NOTE: deliberately no X-Frame-Options here —
|
||||
# this is an embedded Shopify app, and framing is governed by the
|
||||
# Content-Security-Policy `frame-ancestors` directive that the Shopify
|
||||
# library injects via addDocumentResponseHeaders (see app/entry.server.tsx).
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
reverse_proxy linumiq-invoice-dev:3000
|
||||
}
|
||||
|
||||
# PROD — installed on shop.linumiq.com (5aiizq-ti.myshopify.com)
|
||||
invoice-app.linumiq.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy linumiq-invoice:3000
|
||||
reverse_proxy linumiq-invoice-prod:3000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Deployment
|
||||
|
||||
Two independent deployments share the same codebase and Docker image build:
|
||||
|
||||
| env | container | backend domain | install target | partner-dashboard app | shopify config |
|
||||
| ---- | --------------------- | ------------------------------- | ------------------------------------------------ | ----------------------- | ------------------------ |
|
||||
| dev | `linumiq-invoice-dev` | `invoice-app-dev.linumiq.com` | `linumiq-dev.myshopify.com` | `linumiq-invoice-dev` | `shopify.app.dev.toml` |
|
||||
| prod | `linumiq-invoice-prod`| `invoice-app.linumiq.com` | `5aiizq-ti.myshopify.com` (= `shop.linumiq.com`) | `linumiq-invoice` (prod)| `shopify.app.prod.toml` |
|
||||
|
||||
## Server layout (root server)
|
||||
|
||||
```
|
||||
/docker/linumiq-invoice/
|
||||
├── git/ # checkout of this repo (git pull here)
|
||||
├── dev/
|
||||
│ ├── docker-compose.yml # symlink → ../git/deploy/docker-compose.dev.yml
|
||||
│ ├── .env.dev # secrets (NOT in git)
|
||||
│ └── data/ # bind-mounted SQLite + cached assets
|
||||
└── prod/
|
||||
├── docker-compose.yml # symlink → ../git/deploy/docker-compose.prod.yml
|
||||
├── .env.prod # secrets (NOT in git)
|
||||
└── data/ # bind-mounted SQLite + cached assets
|
||||
```
|
||||
|
||||
Both containers attach to the external `caddy_net` Docker network. Caddy reverse-proxies each subdomain to the correct container by name (see `Caddyfile.snippet`).
|
||||
|
||||
## First-time setup on the server
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /docker/linumiq-invoice/{git,dev/data,prod/data}
|
||||
sudo chown -R "$USER" /docker/linumiq-invoice
|
||||
|
||||
cd /docker/linumiq-invoice
|
||||
git clone git@git.linumiq.com:LinumIQ/linumiq-invoice.git git
|
||||
|
||||
# DEV
|
||||
cd /docker/linumiq-invoice/dev
|
||||
ln -s ../git/deploy/docker-compose.dev.yml docker-compose.yml
|
||||
cp ../git/deploy/.env.dev.example .env.dev # then edit secrets
|
||||
docker compose up -d --build
|
||||
|
||||
# PROD
|
||||
cd /docker/linumiq-invoice/prod
|
||||
ln -s ../git/deploy/docker-compose.prod.yml docker-compose.yml
|
||||
cp ../git/deploy/.env.prod.example .env.prod # then edit secrets
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Append `Caddyfile.snippet` to your Caddy config and `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
||||
|
||||
## Container runs as a non-root user (uid 1000)
|
||||
|
||||
The image runs as the unprivileged `node` user (uid/gid **1000**), not root. The
|
||||
SQLite database is written to the `/data` bind mount, so the **host** directory
|
||||
mounted at `/data` (e.g. `/docker/linumiq-invoice/dev/data` and
|
||||
`…/prod/data`) must be writable by uid 1000, otherwise `prisma migrate deploy`
|
||||
and DB writes fail on startup:
|
||||
|
||||
```bash
|
||||
sudo chown -R 1000:1000 /docker/linumiq-invoice/dev/data
|
||||
sudo chown -R 1000:1000 /docker/linumiq-invoice/prod/data
|
||||
```
|
||||
|
||||
The dev container additionally runs with a **read-only root filesystem**
|
||||
(`read_only: true` + `tmpfs: /tmp`), `no-new-privileges`, all Linux capabilities
|
||||
dropped, and memory/pids/cpu limits. The app only writes to the `/data` bind
|
||||
mount and the tmpfs `/tmp`, so this is safe. (The prod compose is intentionally
|
||||
left unchanged.)
|
||||
|
||||
## Day-to-day redeploy
|
||||
|
||||
```bash
|
||||
cd /docker/linumiq-invoice/git && git pull
|
||||
cd /docker/linumiq-invoice/dev && docker compose up -d --build # update dev
|
||||
cd /docker/linumiq-invoice/prod && docker compose up -d --build # update prod
|
||||
```
|
||||
|
||||
Run only the env you want to update.
|
||||
|
||||
## Pushing config / extension changes to Shopify
|
||||
|
||||
From your dev machine (after `git pull` to keep configs in sync):
|
||||
|
||||
```bash
|
||||
# DEV → linumiq-dev.myshopify.com
|
||||
npx shopify app config use shopify.app.dev.toml
|
||||
npx shopify app deploy --allow-updates
|
||||
|
||||
# PROD → shop.linumiq.com
|
||||
npx shopify app config use shopify.app.prod.toml
|
||||
npx shopify app deploy --allow-updates
|
||||
```
|
||||
|
||||
The currently selected config is stored in `.shopify/project.json` (gitignored), so each developer machine remembers its own choice.
|
||||
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: /docker/linumiq-invoice/git/
|
||||
dockerfile: Dockerfile
|
||||
image: linumiq-invoice:dev
|
||||
container_name: linumiq-invoice-dev
|
||||
restart: unless-stopped
|
||||
# --- Container hardening (DEV) ---------------------------------------
|
||||
# Prevent privilege escalation and drop all Linux capabilities (the app
|
||||
# is a plain Node HTTP server — it needs none).
|
||||
security_opt:
|
||||
- "no-new-privileges:true"
|
||||
cap_drop:
|
||||
- ALL
|
||||
# Read-only root filesystem: the app never writes to the image at runtime
|
||||
# (Prisma client is baked at build; the SQLite DB lives on the /data bind
|
||||
# mount; logo/image caches live in the DB or in-memory). npm/Prisma
|
||||
# incidental writes are redirected to the tmpfs /tmp (see Dockerfile env).
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
# Resource limits (Compose v2 / docker compose, non-swarm).
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
cpus: 1.5
|
||||
env_file:
|
||||
- .env.dev
|
||||
environment:
|
||||
# SQLite file lives on a bind mount so it survives image rebuilds.
|
||||
DATABASE_URL: "file:/data/prod.sqlite"
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
volumes:
|
||||
- /docker/linumiq-invoice/dev/data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- caddy_net
|
||||
|
||||
networks:
|
||||
caddy_net:
|
||||
name: caddy_net
|
||||
external: true
|
||||
@@ -1,22 +1,22 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
context: /docker/linumiq-invoice/git/
|
||||
dockerfile: Dockerfile
|
||||
image: linumiq-invoice:latest
|
||||
container_name: linumiq-invoice
|
||||
image: linumiq-invoice:prod
|
||||
container_name: linumiq-invoice-prod
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.production
|
||||
- .env.prod
|
||||
environment:
|
||||
# SQLite file lives on a named volume so it survives image rebuilds.
|
||||
# SQLite file lives on a bind mount so it survives image rebuilds.
|
||||
DATABASE_URL: "file:/data/prod.sqlite"
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
volumes:
|
||||
- /docker/linumiq-invoice/data:/data
|
||||
- /docker/linumiq-invoice/prod/data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz", "||", "exit", "0"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "customer-account-payment",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@preact/signals": "^1.3.0",
|
||||
"@shopify/ui-extensions": "^2026.1.0",
|
||||
"preact": "^10.22.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '@shopify/ui-extensions';
|
||||
|
||||
//@ts-ignore
|
||||
declare module './src/CustomerAccount.tsx' {
|
||||
const shopify: import('@shopify/ui-extensions/customer-account.order-status.payment-details.render-after').Api;
|
||||
const globalThis: { shopify: typeof shopify };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
api_version = "2026-01"
|
||||
|
||||
[[extensions]]
|
||||
name = "Invoice payment instructions (account)"
|
||||
handle = "customer-account-payment"
|
||||
type = "ui_extension"
|
||||
uid = "linumiq-customer-account-payment"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "customer-account.order-status.payment-details.render-after"
|
||||
module = "./src/CustomerAccount.tsx"
|
||||
|
||||
[extensions.capabilities]
|
||||
network_access = true
|
||||
api_access = false
|
||||
@@ -0,0 +1,110 @@
|
||||
import "@shopify/ui-extensions/preact";
|
||||
import { render } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
const APP_URL_PROD = "https://invoice-app.linumiq.com";
|
||||
const APP_URL_DEV = "https://invoice-app-dev.linumiq.com";
|
||||
const DEV_SHOPS = new Set(["linumiq-dev.myshopify.com"]);
|
||||
|
||||
function resolveAppUrl(shopify: any): string {
|
||||
const shop: string | undefined =
|
||||
shopify?.shop?.myshopifyDomain ?? shopify?.shop?.value?.myshopifyDomain;
|
||||
if (shop && DEV_SHOPS.has(shop)) return APP_URL_DEV;
|
||||
return APP_URL_PROD;
|
||||
}
|
||||
|
||||
interface PaymentInstructions {
|
||||
language: "de" | "en";
|
||||
heading: string;
|
||||
giroCodeUrl: string;
|
||||
recipient: string;
|
||||
bankName: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
amountFormatted: string;
|
||||
reference: string;
|
||||
dueDateFormatted: string | null;
|
||||
instructions: string;
|
||||
labels: {
|
||||
recipient: string;
|
||||
bank: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
amount: string;
|
||||
reference: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
render(<Extension />, document.body);
|
||||
};
|
||||
|
||||
function Extension() {
|
||||
const shopify = (globalThis as any).shopify;
|
||||
const [data, setData] = useState<PaymentInstructions | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const orderId: string | undefined = shopify?.order?.value?.id;
|
||||
if (!orderId) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const token: string = await shopify.sessionToken.get();
|
||||
const appUrl = resolveAppUrl(shopify);
|
||||
const res = await fetch(
|
||||
`${appUrl}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
showPaymentInstructions: boolean;
|
||||
payload?: PaymentInstructions;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (json.showPaymentInstructions && json.payload) {
|
||||
setData(json.payload);
|
||||
}
|
||||
} catch {
|
||||
// swallow; render nothing
|
||||
} finally {
|
||||
if (!cancelled) setDone(true);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!done || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<s-section heading={data.heading}>
|
||||
<s-paragraph>{data.instructions}</s-paragraph>
|
||||
<s-grid gridTemplateColumns="200px 1fr" gap="base" alignItems="start">
|
||||
<s-image src={data.giroCodeUrl} alt="GiroCode" inlineSize="fill" aspectRatio="1" />
|
||||
<s-stack direction="block" gap="small-200">
|
||||
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
||||
{data.bankName ? (
|
||||
<s-text>{data.labels.bank}: {data.bankName}</s-text>
|
||||
) : null}
|
||||
<s-text>{data.labels.iban}: {data.iban}</s-text>
|
||||
{data.bic ? (
|
||||
<s-text>{data.labels.bic}: {data.bic}</s-text>
|
||||
) : null}
|
||||
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
|
||||
<s-text>{data.labels.reference}: {data.reference}</s-text>
|
||||
</s-stack>
|
||||
</s-grid>
|
||||
</s-section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -6,11 +6,11 @@ name = "Generate invoice"
|
||||
type = "flow_action"
|
||||
handle = "flow-generate-invoice"
|
||||
description = "Generates an Austria-compliant PDF invoice for the given order and uploads it to Shopify Files."
|
||||
runtime_url = "https://example.com/api/flow/generate-invoice"
|
||||
runtime_url = "https://invoice-app.linumiq.com/api/flow/generate-invoice"
|
||||
|
||||
[extensions.settings]
|
||||
[settings]
|
||||
|
||||
[[extensions.settings.fields]]
|
||||
[[settings.fields]]
|
||||
type = "single_line_text_field"
|
||||
key = "order_id"
|
||||
name = "Order ID"
|
||||
|
||||
@@ -6,18 +6,18 @@ name = "Send invoice email"
|
||||
type = "flow_action"
|
||||
handle = "flow-send-invoice-email"
|
||||
description = "Sends the generated PDF invoice via email to the order's customer (or an override address)."
|
||||
runtime_url = "https://example.com/api/flow/send-invoice-email"
|
||||
runtime_url = "https://invoice-app.linumiq.com/api/flow/send-invoice-email"
|
||||
|
||||
[extensions.settings]
|
||||
[settings]
|
||||
|
||||
[[extensions.settings.fields]]
|
||||
[[settings.fields]]
|
||||
type = "single_line_text_field"
|
||||
key = "order_id"
|
||||
name = "Order ID"
|
||||
description = "The order's GID (use Liquid: {{order.id}} when configuring the workflow)."
|
||||
required = true
|
||||
|
||||
[[extensions.settings.fields]]
|
||||
[[settings.fields]]
|
||||
type = "single_line_text_field"
|
||||
key = "recipient_email_override"
|
||||
name = "Recipient email (optional)"
|
||||
|
||||
@@ -30,11 +30,16 @@ function Extension() {
|
||||
const [payload, setPayload] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<null | "generate" | "send" | "cancel_reissue">(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionInfo, setActionInfo] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}/invoice`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -49,7 +54,36 @@ function Extension() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId]);
|
||||
}, [orderId, reloadKey]);
|
||||
|
||||
async function trigger(action: "generate" | "send" | "cancel_reissue") {
|
||||
if (!orderId) return;
|
||||
setBusy(action);
|
||||
setActionError(null);
|
||||
setActionInfo(null);
|
||||
try {
|
||||
const body = new URLSearchParams({ action });
|
||||
const res = await fetch(`/api/orders/${orderId}/invoice`, { method: "POST", body });
|
||||
const txt = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
|
||||
setActionInfo(
|
||||
action === "send"
|
||||
? "Invoice email sent."
|
||||
: action === "cancel_reissue"
|
||||
? "Cancelled and reissued."
|
||||
: "Invoice generated.",
|
||||
);
|
||||
setReloadKey((k) => k + 1);
|
||||
} catch (e: any) {
|
||||
setActionError(e?.message ?? "Action failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const latest = payload?.latest;
|
||||
const hasInvoice = !!latest && !latest.cancelledAt;
|
||||
const sent = !!latest?.sentAt;
|
||||
|
||||
return (
|
||||
<s-admin-block heading="Invoice">
|
||||
@@ -57,22 +91,54 @@ function Extension() {
|
||||
<s-text>Loading…</s-text>
|
||||
) : error ? (
|
||||
<s-banner tone="critical">{error}</s-banner>
|
||||
) : !payload?.latest ? (
|
||||
<s-text>No invoice yet for this order.</s-text>
|
||||
) : (
|
||||
<s-stack gap="200">
|
||||
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text>
|
||||
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text>
|
||||
<s-badge tone={payload.latest.sentAt ? "success" : "info"}>
|
||||
{payload.latest.sentAt ? "Sent" : "Not sent"}
|
||||
</s-badge>
|
||||
{payload.latest.pdfUrl ? (
|
||||
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link>
|
||||
{latest ? (
|
||||
<s-stack gap="100">
|
||||
<s-text weight="bold">
|
||||
{latest.invoiceNumber} (v{latest.version})
|
||||
</s-text>
|
||||
<s-text>Issued {new Date(latest.issuedAt).toLocaleDateString()}</s-text>
|
||||
<s-badge tone={sent ? "success" : "info"}>{sent ? "Sent" : "Not sent"}</s-badge>
|
||||
{latest.pdfUrl ? (
|
||||
<s-link href={latest.pdfUrl} target="_blank">
|
||||
View PDF
|
||||
</s-link>
|
||||
) : null}
|
||||
{payload.history.length > 1 ? (
|
||||
<s-text tone="subdued">{payload.history.length} versions in history</s-text>
|
||||
{payload!.history.length > 1 ? (
|
||||
<s-text tone="subdued">{payload!.history.length} versions in history</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-text>No invoice yet for this order.</s-text>
|
||||
)}
|
||||
|
||||
{actionError ? <s-banner tone="critical">{actionError}</s-banner> : null}
|
||||
{actionInfo ? <s-banner tone="success">{actionInfo}</s-banner> : null}
|
||||
|
||||
<s-stack direction="inline" gap="100">
|
||||
{!hasInvoice ? (
|
||||
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
|
||||
{busy === "generate" ? "Generating…" : "Generate"}
|
||||
</s-button>
|
||||
) : !sent ? (
|
||||
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
|
||||
{busy === "generate" ? "Working…" : "Regenerate"}
|
||||
</s-button>
|
||||
) : (
|
||||
<s-button
|
||||
onClick={() => trigger("cancel_reissue")}
|
||||
disabled={busy !== null}
|
||||
tone="critical"
|
||||
>
|
||||
{busy === "cancel_reissue" ? "Working…" : "Cancel & reissue"}
|
||||
</s-button>
|
||||
)}
|
||||
<s-button onClick={() => trigger("send")} disabled={busy !== null}>
|
||||
{busy === "send" ? "Sending…" : sent ? "Re-send" : "Send"}
|
||||
</s-button>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
)}
|
||||
</s-admin-block>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "invoice-thank-you-payment",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@preact/signals": "^1.3.0",
|
||||
"@shopify/ui-extensions": "^2026.1.0",
|
||||
"preact": "^10.22.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '@shopify/ui-extensions';
|
||||
|
||||
//@ts-ignore
|
||||
declare module './src/Checkout.tsx' {
|
||||
const shopify: import('@shopify/ui-extensions/purchase.thank-you.block.render').Api;
|
||||
const globalThis: { shopify: typeof shopify };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
api_version = "2026-01"
|
||||
|
||||
[[extensions]]
|
||||
name = "Invoice payment instructions"
|
||||
handle = "invoice-thank-you-payment"
|
||||
type = "ui_extension"
|
||||
uid = "linumiq-invoice-thank-you-payment"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "purchase.thank-you.block.render"
|
||||
module = "./src/Checkout.tsx"
|
||||
|
||||
[extensions.capabilities]
|
||||
network_access = true
|
||||
api_access = false
|
||||
@@ -0,0 +1,110 @@
|
||||
import "@shopify/ui-extensions/preact";
|
||||
import { render } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
const APP_URL_PROD = "https://invoice-app.linumiq.com";
|
||||
const APP_URL_DEV = "https://invoice-app-dev.linumiq.com";
|
||||
const DEV_SHOPS = new Set(["linumiq-dev.myshopify.com"]);
|
||||
|
||||
function resolveAppUrl(shopify: any): string {
|
||||
const shop: string | undefined =
|
||||
shopify?.shop?.myshopifyDomain ?? shopify?.shop?.value?.myshopifyDomain;
|
||||
if (shop && DEV_SHOPS.has(shop)) return APP_URL_DEV;
|
||||
return APP_URL_PROD;
|
||||
}
|
||||
|
||||
interface PaymentInstructions {
|
||||
language: "de" | "en";
|
||||
heading: string;
|
||||
giroCodeUrl: string;
|
||||
recipient: string;
|
||||
bankName: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
amountFormatted: string;
|
||||
reference: string;
|
||||
dueDateFormatted: string | null;
|
||||
instructions: string;
|
||||
labels: {
|
||||
recipient: string;
|
||||
bank: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
amount: string;
|
||||
reference: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
render(<Extension />, document.body);
|
||||
};
|
||||
|
||||
function Extension() {
|
||||
const shopify = (globalThis as any).shopify;
|
||||
const [data, setData] = useState<PaymentInstructions | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const orderId: string | undefined = shopify?.orderConfirmation?.value?.order?.id;
|
||||
if (!orderId) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const token: string = await shopify.sessionToken.get();
|
||||
const appUrl = resolveAppUrl(shopify);
|
||||
const res = await fetch(
|
||||
`${appUrl}/api/public/payment-info?orderId=${encodeURIComponent(orderId)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
showPaymentInstructions: boolean;
|
||||
payload?: PaymentInstructions;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (json.showPaymentInstructions && json.payload) {
|
||||
setData(json.payload);
|
||||
}
|
||||
} catch {
|
||||
// swallow; render nothing
|
||||
} finally {
|
||||
if (!cancelled) setDone(true);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!done || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<s-section heading={data.heading}>
|
||||
<s-paragraph>{data.instructions}</s-paragraph>
|
||||
<s-grid gridTemplateColumns="200px 1fr" gap="base" alignItems="start">
|
||||
<s-image src={data.giroCodeUrl} alt="GiroCode" inlineSize="fill" aspectRatio="1" />
|
||||
<s-stack direction="block" gap="small-200">
|
||||
<s-text>{data.labels.recipient}: {data.recipient}</s-text>
|
||||
{data.bankName ? (
|
||||
<s-text>{data.labels.bank}: {data.bankName}</s-text>
|
||||
) : null}
|
||||
<s-text>{data.labels.iban}: {data.iban}</s-text>
|
||||
{data.bic ? (
|
||||
<s-text>{data.labels.bic}: {data.bic}</s-text>
|
||||
) : null}
|
||||
<s-text>{data.labels.amount}: {data.amountFormatted}</s-text>
|
||||
<s-text>{data.labels.reference}: {data.reference}</s-text>
|
||||
</s-stack>
|
||||
</s-grid>
|
||||
</s-section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Generated
+795
-40
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -9,15 +9,16 @@
|
||||
"deploy": "shopify app deploy",
|
||||
"config:use": "shopify app config use",
|
||||
"env": "shopify app env",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"docker-start": "npm run setup && npm run start",
|
||||
"start": "node ./server.js",
|
||||
"docker-start": "prisma migrate deploy && npm run start",
|
||||
"setup": "prisma generate && prisma migrate deploy",
|
||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"shopify": "shopify",
|
||||
"prisma": "prisma",
|
||||
"graphql-codegen": "graphql-codegen",
|
||||
"vite": "vite",
|
||||
"typecheck": "react-router typegen && tsc --noEmit"
|
||||
"typecheck": "react-router typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -27,15 +28,29 @@
|
||||
"@prisma/client": "^6.16.3",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@react-router/dev": "^7.12.0",
|
||||
"@react-router/express": "^7.14.2",
|
||||
"@react-router/fs-routes": "^7.12.0",
|
||||
"@react-router/node": "^7.12.0",
|
||||
"@react-router/serve": "^7.12.0",
|
||||
"@shopify/app-bridge-react": "^4.2.4",
|
||||
"@shopify/shopify-app-react-router": "^1.1.0",
|
||||
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||
"@tiptap/extension-color": "^3.23.1",
|
||||
"@tiptap/extension-image": "^3.23.1",
|
||||
"@tiptap/extension-link": "^3.23.1",
|
||||
"@tiptap/extension-text-style": "^3.23.1",
|
||||
"@tiptap/pm": "^3.23.1",
|
||||
"@tiptap/react": "^3.23.1",
|
||||
"@tiptap/starter-kit": "^3.23.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.22.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"ipaddr.js": "^2.4.0",
|
||||
"isbot": "^5.1.31",
|
||||
"morgan": "^1.10.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"p-limit": "^3.1.0",
|
||||
"prisma": "^6.16.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- 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 '',
|
||||
"emailSubjectDe" TEXT NOT NULL DEFAULT '',
|
||||
"emailBodyHtmlDe" TEXT NOT NULL DEFAULT '',
|
||||
"emailSubjectEn" TEXT NOT NULL DEFAULT '',
|
||||
"emailBodyHtmlEn" 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", "footerNoteEn", "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", "footerNoteEn", "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;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnWireTransferPlaced" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "ShopSettings" ADD COLUMN "autoEmailOnFulfilledNonWireTransfer" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "ShopSettings" ADD COLUMN "wireTransferGatewayNames" TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Drop the column added by the previous migration; we now classify
|
||||
-- wire-transfer orders by querying OrderTransaction.manualPaymentGateway
|
||||
-- via the GraphQL Admin API instead of by configurable name match.
|
||||
ALTER TABLE "ShopSettings" DROP COLUMN "wireTransferGatewayNames";
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Idempotency table for inbound Shopify webhooks. We insert a row keyed on
|
||||
-- the X-Shopify-Webhook-Id header at the start of webhook processing; a
|
||||
-- duplicate insert (P2002) means Shopify retried a delivery we've already
|
||||
-- seen, so we short-circuit and return 200 without doing the work twice.
|
||||
CREATE TABLE "ProcessedWebhook" (
|
||||
"webhookId" TEXT NOT NULL PRIMARY KEY,
|
||||
"topic" TEXT NOT NULL,
|
||||
"shopDomain" TEXT NOT NULL,
|
||||
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx"
|
||||
ON "ProcessedWebhook"("shopDomain", "topic");
|
||||
@@ -0,0 +1,16 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_ProcessedWebhook" (
|
||||
"webhookId" TEXT NOT NULL PRIMARY KEY,
|
||||
"topic" TEXT NOT NULL,
|
||||
"shopDomain" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'done',
|
||||
"receivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
INSERT INTO "new_ProcessedWebhook" ("receivedAt", "shopDomain", "topic", "webhookId") SELECT "receivedAt", "shopDomain", "topic", "webhookId" FROM "ProcessedWebhook";
|
||||
DROP TABLE "ProcessedWebhook";
|
||||
ALTER TABLE "new_ProcessedWebhook" RENAME TO "ProcessedWebhook";
|
||||
CREATE INDEX "ProcessedWebhook_shopDomain_topic_idx" ON "ProcessedWebhook"("shopDomain", "topic");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -93,6 +93,19 @@ model ShopSettings {
|
||||
smtpFromEmail String @default("")
|
||||
smtpReplyTo String @default("")
|
||||
|
||||
// Email templates (HTML, with {{var}} placeholders). Empty = use defaults.
|
||||
emailSubjectDe String @default("")
|
||||
emailBodyHtmlDe String @default("")
|
||||
emailSubjectEn String @default("")
|
||||
emailBodyHtmlEn String @default("")
|
||||
|
||||
// Automations (webhook-driven, as a fallback to Shopify Flow which only
|
||||
// exposes custom-app actions on Plus stores).
|
||||
// 1) Wire-transfer order is placed → auto-email the invoice immediately.
|
||||
autoEmailOnWireTransferPlaced Boolean @default(false)
|
||||
// 2) Order is fulfilled and is NOT a wire-transfer order → auto-email.
|
||||
autoEmailOnFulfilledNonWireTransfer Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -169,6 +182,24 @@ model EmailLog {
|
||||
@@index([shopDomain, invoiceId])
|
||||
}
|
||||
|
||||
// Idempotency table for inbound Shopify webhooks. See
|
||||
// `app/services/webhooks/dedupe.server.ts` for details.
|
||||
model ProcessedWebhook {
|
||||
webhookId String @id
|
||||
topic String
|
||||
shopDomain String
|
||||
// Reserve/commit lifecycle for at-least-once side-effect processing:
|
||||
// "processing" — reserved, side-effect work in flight (acts as the lock)
|
||||
// "done" — work completed successfully; future retries are dropped
|
||||
// A "processing" row older than the stale lease (see dedupe.server.ts) is
|
||||
// treated as a crashed reservation and may be reclaimed. Existing rows
|
||||
// migrated from the old (record-before-work) design default to "done".
|
||||
status String @default("done")
|
||||
receivedAt DateTime @default(now())
|
||||
|
||||
@@index([shopDomain, topic])
|
||||
}
|
||||
|
||||
// Per-shop logo bytes cache. Avoids fetching the logo from Shopify Files on
|
||||
// every PDF render.
|
||||
model LogoCache {
|
||||
|
||||
+482
-12
@@ -134,7 +134,10 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
processedAt: "2026-04-15T10:00:00Z",
|
||||
currencyCode: "EUR",
|
||||
displayFinancialStatus: "PENDING",
|
||||
paymentGatewayNames: ["manual"],
|
||||
taxesIncluded: false,
|
||||
requiresShipping: true,
|
||||
discountCodes: [],
|
||||
customer: {
|
||||
firstName: "Lukas",
|
||||
lastName: "Schmidhofer",
|
||||
@@ -151,13 +154,48 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
province: null,
|
||||
countryCode: "AT",
|
||||
},
|
||||
shippingAddress: null,
|
||||
shippingAddress: {
|
||||
name: "Lukas Schmidhofer",
|
||||
company: "Schmidhofer Dienstleistungen",
|
||||
address1: "Lagerweg 4",
|
||||
address2: null,
|
||||
zip: "8020",
|
||||
city: "Graz",
|
||||
province: null,
|
||||
countryCode: "AT",
|
||||
},
|
||||
shippingLine: {
|
||||
title: "Standardversand",
|
||||
code: "STD",
|
||||
source: "shopify",
|
||||
carrierIdentifier: null,
|
||||
deliveryCategory: "shipping",
|
||||
originalPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
|
||||
discountedPriceSet: { shopMoney: { amount: "5.00", currencyCode: "EUR" } },
|
||||
taxLines: [
|
||||
{
|
||||
title: "USt 20%",
|
||||
rate: 0.2,
|
||||
ratePercentage: 20,
|
||||
priceSet: { shopMoney: { amount: "1.00", currencyCode: "EUR" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
fulfillments: [
|
||||
{
|
||||
createdAt: "2026-05-13T10:30:00.000Z",
|
||||
trackingInfo: [
|
||||
{ number: "JJD0099887766", url: "https://example.test/track/JJD0099887766", company: "DHL" },
|
||||
],
|
||||
},
|
||||
],
|
||||
lineItems: [
|
||||
{
|
||||
title: "Bluetooth Tracker",
|
||||
sku: "BT-TRK-001",
|
||||
quantity: qty,
|
||||
originalUnitPriceSet: { shopMoney: { amount: unitNet.toFixed(2), currencyCode: "EUR" } },
|
||||
discountedUnitPriceSet: null,
|
||||
imageUrl: "file://product-image", // placeholder; the smoke script inlines a real data: URL on the composed line below.
|
||||
taxLines: [
|
||||
{
|
||||
@@ -178,8 +216,9 @@ function buildAtB2BOrder(): RawOrderForInvoice {
|
||||
},
|
||||
],
|
||||
subtotalSet: { shopMoney: { amount: lineNet.toFixed(2), currencyCode: "EUR" } },
|
||||
totalTaxSet: { shopMoney: { amount: lineTax.toFixed(2), currencyCode: "EUR" } },
|
||||
totalPriceSet: { shopMoney: { amount: lineGross.toFixed(2), currencyCode: "EUR" } },
|
||||
totalTaxSet: { shopMoney: { amount: (lineTax + 1.0).toFixed(2), currencyCode: "EUR" } },
|
||||
totalPriceSet: { shopMoney: { amount: (lineGross + 6.0).toFixed(2), currencyCode: "EUR" } },
|
||||
totalRefundedSet: null,
|
||||
purchasingEntity: {
|
||||
company: {
|
||||
name: "Schmidhofer Dienstleistungen",
|
||||
@@ -202,6 +241,10 @@ function buildEuB2BReverseChargeOrder(): RawOrderForInvoice {
|
||||
o.purchasingEntity!.company!.vatId = "DE123456789";
|
||||
o.lineItems[0].taxLines = [];
|
||||
o.taxLines = [];
|
||||
// No VAT for reverse-charge; clear shipping VAT too.
|
||||
o.shippingLine = null;
|
||||
o.fulfillments = [];
|
||||
o.shippingAddress = null;
|
||||
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
||||
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
||||
return o;
|
||||
@@ -216,12 +259,100 @@ function buildExportOrder(): RawOrderForInvoice {
|
||||
o.billingAddress!.city = "New York";
|
||||
o.lineItems[0].taxLines = [];
|
||||
o.taxLines = [];
|
||||
o.shippingLine = null;
|
||||
o.fulfillments = [];
|
||||
o.shippingAddress = null;
|
||||
o.totalTaxSet = { shopMoney: { amount: "0.00", currencyCode: "EUR" } };
|
||||
o.totalPriceSet = { shopMoney: { amount: "35.94", currencyCode: "EUR" } };
|
||||
o.customer!.locale = "en";
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the AT B2B order with a per-line discount: unit price stays
|
||||
* 7.19 EUR gross (5.99 net) but Shopify allocated a 1.00 EUR/unit discount,
|
||||
* so the discounted unit price is 6.19 gross (5.16 net). Also adds an
|
||||
* order-level discount code ("SUMMER10") for the meta-block render.
|
||||
*/
|
||||
function buildDiscountedOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.discountCodes = ["SUMMER10"];
|
||||
// Discount of 1.00 EUR/unit applied: net unit drops from 5.99 to 4.99,
|
||||
// qty 6 → 29.94 net, tax (20%) = 5.99.
|
||||
o.lineItems[0].discountedUnitPriceSet = {
|
||||
shopMoney: { amount: "4.99", currencyCode: "EUR" },
|
||||
};
|
||||
o.lineItems[0].taxLines = [
|
||||
{
|
||||
title: "USt 20%",
|
||||
rate: 0.2,
|
||||
ratePercentage: 20,
|
||||
priceSet: { shopMoney: { amount: "5.99", currencyCode: "EUR" } },
|
||||
},
|
||||
];
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the AT B2B order whose shipping line is local pickup. The
|
||||
* "shipping address" still carries the pickup-location address (as Shopify
|
||||
* does), but the composer should detect the pickup and suppress it.
|
||||
*/
|
||||
function buildPickupOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.shippingLine = {
|
||||
title: "Lager Graz",
|
||||
code: "Pickup",
|
||||
source: "shopify",
|
||||
carrierIdentifier: null,
|
||||
deliveryCategory: "pickup",
|
||||
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
taxLines: [],
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
/** Pickup variant where neither title/code nor source mention "pickup" —
|
||||
* detection must rely purely on `deliveryCategory`. Mirrors what we
|
||||
* observed on a real Shopify Local Pickup install. */
|
||||
function buildCategoryOnlyPickupOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.shippingLine = {
|
||||
title: "Lager Graz",
|
||||
code: "Standard",
|
||||
source: "shopify",
|
||||
carrierIdentifier: null,
|
||||
deliveryCategory: "pickup",
|
||||
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
taxLines: [],
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
/** Pickup variant matching a REAL observed order on
|
||||
* linumiq-dev.myshopify.com (#1032): Shopify's built-in "Shop location"
|
||||
* rate. NO "pickup" string anywhere, deliveryCategory is `null`, and
|
||||
* shippingAddress is also `null` — detection must rely on
|
||||
* `requiresShipping && shippingAddress == null`. */
|
||||
function buildShopLocationPickupOrder(): RawOrderForInvoice {
|
||||
const o = buildAtB2BOrder();
|
||||
o.shippingLine = {
|
||||
title: "Shop location",
|
||||
code: "Shop location",
|
||||
source: "shopify",
|
||||
carrierIdentifier: null,
|
||||
deliveryCategory: null,
|
||||
originalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
discountedPriceSet: { shopMoney: { amount: "0.00", currencyCode: "EUR" } },
|
||||
taxLines: [],
|
||||
};
|
||||
o.shippingAddress = null;
|
||||
o.requiresShipping = true;
|
||||
return o;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Run assertions
|
||||
// ------------------------------------------------------------------
|
||||
@@ -264,20 +395,33 @@ async function main() {
|
||||
assertEq("currency", vm.currency, "EUR");
|
||||
assert("isB2B detected", vm.isB2B);
|
||||
assertEq("recipientVatId", vm.recipientVatId, "ATU57680511");
|
||||
assertEq("line count", vm.lines.length, 1);
|
||||
assertEq("line count (1 product + 1 shipping)", vm.lines.length, 2);
|
||||
const ln = vm.lines[0];
|
||||
assertEq("line title", ln.title, "Bluetooth Tracker");
|
||||
assertEq("line qty", ln.quantity, 6);
|
||||
assertNear("line unit net", ln.unitPriceNet, 5.99);
|
||||
assertNear("line total net", ln.totalNet, 35.94);
|
||||
assertNear("net total", vm.totals.net, 35.94);
|
||||
const shipLine = vm.lines[1];
|
||||
assert("shipping line title prefixed", shipLine.title.startsWith("Versand"),
|
||||
`got "${shipLine.title}"`);
|
||||
assertNear("shipping line net", shipLine.totalNet, 5.0);
|
||||
assertNear("net total (incl. shipping)", vm.totals.net, 40.94);
|
||||
assertEq("vat breakdown rows", vm.totals.vatBreakdown.length, 1);
|
||||
assertNear("vat amount", vm.totals.vatBreakdown[0].tax, 7.19);
|
||||
assertNear("vat amount (incl. shipping VAT)", vm.totals.vatBreakdown[0].tax, 8.19);
|
||||
assertEq("vat rate %", vm.totals.vatBreakdown[0].ratePct, 20);
|
||||
assertNear("gross", vm.totals.gross, 43.13);
|
||||
assertNear("gross (incl. shipping)", vm.totals.gross, 49.13);
|
||||
assertEq("no notices for AT B2B with VAT charged", vm.notices.length, 0);
|
||||
assert("due date 14 days after invoice date", !!vm.dueDate
|
||||
&& Math.round((vm.dueDate.getTime() - vm.invoiceDate.getTime()) / 86400000) === 14);
|
||||
assertEq("paymentGatewayNames propagated", vm.paymentGatewayNames.join(","), "manual");
|
||||
assertEq("paymentStatus derived from displayFinancialStatus=PENDING", vm.paymentStatus, "unpaid");
|
||||
assertEq("orderName propagated", vm.orderName, "#1004");
|
||||
assertEq("shippingMethod propagated", vm.shippingMethod, "Standardversand");
|
||||
assertEq("tracking entries", vm.tracking.length, 1);
|
||||
assertEq("tracking number", vm.tracking[0].number, "JJD0099887766");
|
||||
assertEq("tracking carrier", vm.tracking[0].company, "DHL");
|
||||
assert("separateShippingAddress detected (differs from billing)",
|
||||
vm.separateShippingAddress?.addressLine1 === "Lagerweg 4");
|
||||
|
||||
console.log("• EU B2B reverse-charge notice");
|
||||
const euOrder = buildEuB2BReverseChargeOrder();
|
||||
@@ -343,15 +487,16 @@ async function main() {
|
||||
assertEq("kind = storno", storno.kind, "storno");
|
||||
assertEq("cancelsNumber populated", storno.cancelsNumber, "RE-1004");
|
||||
assert("dueDate suppressed for storno", storno.dueDate == null);
|
||||
assertEq("line count preserved", storno.lines.length, 1);
|
||||
assertEq("line count preserved", storno.lines.length, 2);
|
||||
assertNear("line qty preserved (only money negated)", storno.lines[0].quantity, 6);
|
||||
assertNear("line unit price negated", storno.lines[0].unitPriceNet, -5.99);
|
||||
assertNear("line totalNet negated", storno.lines[0].totalNet, -35.94);
|
||||
assertNear("totals.net negated", storno.totals.net, -35.94);
|
||||
assertNear("totals.totalVat negated", storno.totals.totalVat, -7.19);
|
||||
assertNear("totals.gross negated", storno.totals.gross, -43.13);
|
||||
assertNear("shipping line totalNet negated", storno.lines[1].totalNet, -5.0);
|
||||
assertNear("totals.net negated", storno.totals.net, -40.94);
|
||||
assertNear("totals.totalVat negated", storno.totals.totalVat, -8.19);
|
||||
assertNear("totals.gross negated", storno.totals.gross, -49.13);
|
||||
assertEq("vat breakdown row count preserved", storno.totals.vatBreakdown.length, 1);
|
||||
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -7.19);
|
||||
assertNear("vat breakdown tax negated", storno.totals.vatBreakdown[0].tax, -8.19);
|
||||
|
||||
console.log("• Render storno PDF");
|
||||
storno.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
@@ -363,6 +508,156 @@ async function main() {
|
||||
assert("storno PDF > 5 KB", stornoBuf.length > 5_000, `actual ${stornoBuf.length}`);
|
||||
console.log(` → wrote ${stornoOut} (${stornoBuf.length} bytes)`);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Refunded order: GiroCode + payment-terms must be suppressed
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Refunded order (REFUNDED) suppresses GiroCode + payment terms");
|
||||
{
|
||||
const baseRefunded = buildAtB2BOrder();
|
||||
const refundedOrder = {
|
||||
...baseRefunded,
|
||||
displayFinancialStatus: "REFUNDED",
|
||||
// Mirror the full gross as refunded so the new "Offener Betrag"
|
||||
// row should print 0,00 \u20ac.
|
||||
totalRefundedSet: baseRefunded.totalPriceSet,
|
||||
};
|
||||
const refundedVm = composeInvoice({
|
||||
order: refundedOrder, settings: settings as never, invoiceNumber: "RE-1014",
|
||||
});
|
||||
assertEq("paymentStatus=refunded", refundedVm.paymentStatus, "refunded");
|
||||
assert("requiresPayment=false for refunded", refundedVm.requiresPayment === false);
|
||||
assertNear("refundedAmount mirrors totalRefundedSet", refundedVm.refundedAmount, refundedVm.totals.gross);
|
||||
refundedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
// The orchestrator gates GiroCode generation on requiresPayment too \u2014
|
||||
// simulate a stale QR data URL anyway and verify the PDF render-gate
|
||||
// independently refuses to render it.
|
||||
refundedVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
|
||||
beneficiaryName: settings.companyName,
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
amount: refundedVm.totals.gross,
|
||||
remittance: refundedVm.number,
|
||||
});
|
||||
const refundedText = await pdfToText(await renderInvoicePdf(refundedVm));
|
||||
assert("Refunded PDF does NOT show GiroCode caption",
|
||||
!refundedText.includes("GiroCode"));
|
||||
assert("Refunded PDF does NOT show DE payment terms",
|
||||
!refundedText.includes("Bitte \u00fcberweise"));
|
||||
assert("Refunded PDF still shows the 'Erstattet' status row",
|
||||
refundedText.includes("Erstattet"));
|
||||
assert("Refunded PDF shows the 'Zur\u00fcckerstattet' totals row",
|
||||
refundedText.includes("Zur\u00fcckerstattet"));
|
||||
assert("Refunded PDF labels the final row 'Endbetrag' (nothing is outstanding)",
|
||||
refundedText.includes("Endbetrag") && !refundedText.includes("Offener Betrag"));
|
||||
assert("Refunded PDF shows 0,00 EUR as outstanding",
|
||||
refundedText.includes("0,00 EUR"));
|
||||
}
|
||||
|
||||
// Same gating must apply to PAID orders.
|
||||
console.log("• Paid order (PAID) suppresses GiroCode + payment terms");
|
||||
{
|
||||
const paidOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "PAID" };
|
||||
const paidVm = composeInvoice({
|
||||
order: paidOrder, settings: settings as never, invoiceNumber: "RE-1015",
|
||||
});
|
||||
assert("requiresPayment=false for paid", paidVm.requiresPayment === false);
|
||||
paidVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
paidVm.giroCodePngDataUrl = await buildGiroCodeDataUrl({
|
||||
beneficiaryName: settings.companyName,
|
||||
iban: settings.iban,
|
||||
bic: settings.bic,
|
||||
amount: paidVm.totals.gross,
|
||||
remittance: paidVm.number,
|
||||
});
|
||||
const paidText = await pdfToText(await renderInvoicePdf(paidVm));
|
||||
assert("Paid PDF does NOT show GiroCode caption", !paidText.includes("GiroCode"));
|
||||
assert("Paid PDF does NOT show DE payment terms", !paidText.includes("Bitte überweise"));
|
||||
assert("Paid PDF shows the 'Bezahlt' status row", paidText.includes("Bezahlt"));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Partial refund on a paid order: status stays "Bezahlt", the final
|
||||
// row is labelled "Endbetrag" (not "Offener Betrag"), and the kept
|
||||
// amount is shown.
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Partial refund on a paid order (PARTIALLY_REFUNDED)");
|
||||
{
|
||||
const basePartial = buildAtB2BOrder();
|
||||
const grossStr = basePartial.totalPriceSet?.shopMoney.amount ?? "0";
|
||||
const grossNum = parseFloat(grossStr);
|
||||
const partialRefund = +(grossNum * 0.25).toFixed(2);
|
||||
const partialOrder = {
|
||||
...basePartial,
|
||||
displayFinancialStatus: "PARTIALLY_REFUNDED",
|
||||
totalRefundedSet: { shopMoney: { amount: partialRefund.toFixed(2), currencyCode: "EUR" } },
|
||||
};
|
||||
const partialVm = composeInvoice({
|
||||
order: partialOrder, settings: settings as never, invoiceNumber: "RE-1016",
|
||||
});
|
||||
assertEq("paymentStatus reclassified to paid (partial refund < gross)",
|
||||
partialVm.paymentStatus, "paid");
|
||||
assert("requiresPayment=false for partially refunded paid order",
|
||||
partialVm.requiresPayment === false);
|
||||
assertNear("refundedAmount mirrors partial refund",
|
||||
partialVm.refundedAmount, partialRefund);
|
||||
partialVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const partialText = await pdfToText(await renderInvoicePdf(partialVm));
|
||||
assert("Partial-refund PDF shows 'Bezahlt' status row (not Erstattet)",
|
||||
partialText.includes("Bezahlt") && !partialText.includes("Erstattet"));
|
||||
assert("Partial-refund PDF shows 'Zurückerstattet' totals row",
|
||||
partialText.includes("Zurückerstattet"));
|
||||
assert("Partial-refund PDF labels the final row 'Endbetrag' (not 'Offener Betrag')",
|
||||
partialText.includes("Endbetrag") && !partialText.includes("Offener Betrag"));
|
||||
assert("Partial-refund PDF does NOT show GiroCode caption",
|
||||
!partialText.includes("GiroCode"));
|
||||
assert("Partial-refund PDF does NOT show DE payment terms",
|
||||
!partialText.includes("Bitte überweise"));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Defensive: PARTIALLY_REFUNDED where the refund equals the gross
|
||||
// (Shopify hasn't flipped to REFUNDED yet) must still classify as
|
||||
// refunded.
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• PARTIALLY_REFUNDED with refund==gross stays 'refunded'");
|
||||
{
|
||||
const baseFull = buildAtB2BOrder();
|
||||
const fullRefundOrder = {
|
||||
...baseFull,
|
||||
displayFinancialStatus: "PARTIALLY_REFUNDED",
|
||||
totalRefundedSet: baseFull.totalPriceSet,
|
||||
};
|
||||
const vmFull = composeInvoice({
|
||||
order: fullRefundOrder, settings: settings as never, invoiceNumber: "RE-1017",
|
||||
});
|
||||
assertEq("paymentStatus stays refunded when refund==gross",
|
||||
vmFull.paymentStatus, "refunded");
|
||||
assert("requiresPayment still false", vmFull.requiresPayment === false);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// VOIDED: authorisation cancelled before capture. No money received,
|
||||
// none owed. Must classify as "voided" (not "unpaid") and suppress
|
||||
// GiroCode + payment terms.
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Voided order (VOIDED) classifies as voided, no GiroCode");
|
||||
{
|
||||
const voidedOrder = { ...buildAtB2BOrder(), displayFinancialStatus: "VOIDED" };
|
||||
const voidedVm = composeInvoice({
|
||||
order: voidedOrder, settings: settings as never, invoiceNumber: "RE-1018",
|
||||
});
|
||||
assertEq("paymentStatus=voided for VOIDED", voidedVm.paymentStatus, "voided");
|
||||
assert("requiresPayment=false for voided", voidedVm.requiresPayment === false);
|
||||
voidedVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const voidedText = await pdfToText(await renderInvoicePdf(voidedVm));
|
||||
assert("Voided PDF shows the 'Annulliert' status row",
|
||||
voidedText.includes("Annulliert"));
|
||||
assert("Voided PDF does NOT show GiroCode caption",
|
||||
!voidedText.includes("GiroCode"));
|
||||
assert("Voided PDF does NOT show DE payment terms",
|
||||
!voidedText.includes("Bitte überweise"));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Footer note translation
|
||||
// ----------------------------------------------------------------
|
||||
@@ -398,6 +693,181 @@ async function main() {
|
||||
!/Thank you for your purchase\.[\s\S]{0,40}Gerhard Berger/.test(enText),
|
||||
);
|
||||
|
||||
// Informal German tone (du/dein) — make sure no formal "Sie/Ihren" remains
|
||||
// in the strings we control (footer / signature lines come from settings).
|
||||
// The PDF intentionally has NO salutation — this is an invoice, not a
|
||||
// letter. Both formal ("Sehr geehrte …") and informal ("Hallo,") are
|
||||
// suppressed.
|
||||
assert("DE PDF has no 'Hallo,' salutation", !deText.includes("Hallo,"));
|
||||
assert("DE PDF has no 'Sehr geehrte Damen und Herren' salutation", !deText.includes("Sehr geehrte Damen und Herren"));
|
||||
assert("DE PDF uses informal 'deine Bestellung'", deText.includes("deine Bestellung"));
|
||||
assert(
|
||||
"DE PDF payment-terms uses informal 'überweise … für dich'",
|
||||
deText.includes("Bitte überweise") && deText.includes("für dich"),
|
||||
);
|
||||
assert("DE PDF shows payment status row", deText.includes("Zahlstatus"));
|
||||
assert("DE PDF shows payment status value 'Offen' for PENDING", deText.includes("Offen"));
|
||||
assert("DE PDF shows payment method row", deText.includes("Zahlart"));
|
||||
// The Shopify Admin GraphQL API returns the *English* template name for
|
||||
// built-in manual payment gateways even on German-locale shops — we
|
||||
// localize it ourselves via i18n.paymentGatewayLabels so the PDF matches
|
||||
// what the customer saw on the order-confirmation page.
|
||||
assert("DE PDF localizes 'manual' gateway to 'Manuelle Zahlung'",
|
||||
deText.includes("Manuelle Zahlung"));
|
||||
assert("DE PDF no longer shows raw English 'Manual' as gateway label",
|
||||
!/Zahlart[\s\S]{0,20}Manual\b/.test(deText));
|
||||
assert("EN PDF shows payment status row", enText.includes("Payment status"));
|
||||
assert("EN PDF shows payment status value 'Outstanding' for PENDING", enText.includes("Outstanding"));
|
||||
|
||||
// Shipment + order-number block.
|
||||
assert("DE PDF shows order number row 'Bestellnummer'", deText.includes("Bestellnummer"));
|
||||
assert("DE PDF shows Shopify order name '#1004'", deText.includes("#1004"));
|
||||
assert("DE PDF shows shipping method row 'Versandart'", deText.includes("Versandart"));
|
||||
assert("DE PDF shows shipping method value 'Standardversand'", deText.includes("Standardversand"));
|
||||
assert("DE PDF shows tracking row 'Sendungsnummer'", deText.includes("Sendungsnummer"));
|
||||
assert("DE PDF shows tracking number", deText.includes("JJD0099887766"));
|
||||
assert("DE PDF shows shipping line item with prefix", deText.includes("Versand"));
|
||||
assert("DE PDF shows separate delivery address heading", deText.includes("Lieferadresse"));
|
||||
assert("DE PDF shows shipping address line", deText.includes("Lagerweg 4"));
|
||||
assert("EN PDF shows order number row 'Order no.'", enText.includes("Order no."));
|
||||
assert("EN PDF shows shipping method row 'Shipping method'", enText.includes("Shipping method"));
|
||||
assert("EN PDF shows tracking row 'Tracking no.'", enText.includes("Tracking no."));
|
||||
assert("EN PDF shows separate delivery address heading", enText.includes("Shipping address"));
|
||||
|
||||
// Order-number suppression: when the invoice number's trailing digits
|
||||
// match the Shopify order name (default numbering mode), the redundant
|
||||
// "· Bestellnummer: #1004" suffix should be dropped from the title.
|
||||
const sameNumVm = composeInvoice({
|
||||
order, settings: settings as never, invoiceNumber: "RE-1004",
|
||||
});
|
||||
sameNumVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const sameNumText = await pdfToText(await renderInvoicePdf(sameNumVm));
|
||||
assert("PDF suppresses 'Bestellnummer' suffix when invoice# matches order#",
|
||||
!sameNumText.includes("Bestellnummer"));
|
||||
assert("PDF still shows the invoice number itself when suppressed",
|
||||
sameNumText.includes("RE-1004"));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Delivery date follows latest fulfillment, not processedAt
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Delivery date is taken from latest fulfillment");
|
||||
// The AT B2B fixture has processedAt 2026-04-15 and fulfillment.createdAt
|
||||
// 2026-05-13 — the composer must pick the fulfillment.
|
||||
assertEq(
|
||||
"vm.deliveryDate matches fulfillment.createdAt",
|
||||
vm.deliveryDate.toISOString().slice(0, 10),
|
||||
"2026-05-13",
|
||||
);
|
||||
// EU/Export variants have no fulfillments, so delivery date == invoice date.
|
||||
assertEq(
|
||||
"EU vm.deliveryDate falls back to invoiceDate when unfulfilled",
|
||||
euVm.deliveryDate.toISOString().slice(0, 10),
|
||||
euVm.invoiceDate.toISOString().slice(0, 10),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Discount: per-line strikethrough + cart code
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Discount (per-line + cart-level)");
|
||||
const discOrder = buildDiscountedOrder();
|
||||
const discVm = composeInvoice({
|
||||
order: discOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1020",
|
||||
});
|
||||
assertEq("discountCodes propagated", discVm.discountCodes.join(","), "SUMMER10");
|
||||
assertNear("discounted unit net (~4.99)", discVm.lines[0].unitPriceNet, 4.99);
|
||||
assert(
|
||||
"originalUnitPriceNet populated when discounted differs",
|
||||
discVm.lines[0].originalUnitPriceNet != null,
|
||||
);
|
||||
assertNear(
|
||||
"originalUnitPriceNet matches pre-discount net (~5.99)",
|
||||
discVm.lines[0].originalUnitPriceNet ?? 0,
|
||||
5.99,
|
||||
);
|
||||
discVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const discPdf = await renderInvoicePdf(discVm);
|
||||
const discText = await pdfToText(discPdf);
|
||||
assert("DE PDF shows discount-code label", discText.includes("Rabattcode"));
|
||||
assert("DE PDF shows discount code value", discText.includes("SUMMER10"));
|
||||
const discEnVm = composeInvoice({
|
||||
order: discOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1021",
|
||||
forceLanguage: "en",
|
||||
});
|
||||
discEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const discEnText = await pdfToText(await renderInvoicePdf(discEnVm));
|
||||
assert("EN PDF shows discount-code label", discEnText.includes("Discount code"));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pickup: separate shipping address suppressed; method localized
|
||||
// ----------------------------------------------------------------
|
||||
console.log("• Local pickup");
|
||||
const pickupOrder = buildPickupOrder();
|
||||
const pickupVm = composeInvoice({
|
||||
order: pickupOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1030",
|
||||
});
|
||||
assert("isPickup detected via shippingLine heuristic", pickupVm.isPickup);
|
||||
assertEq("pickupLocationName propagated from shippingLine.title", pickupVm.pickupLocationName, "Lager Graz");
|
||||
assert("shippingMethod cleared for pickup (renderer uses pickup row instead)",
|
||||
pickupVm.shippingMethod == null);
|
||||
assert(
|
||||
"separateShippingAddress suppressed for pickup",
|
||||
pickupVm.separateShippingAddress == null,
|
||||
);
|
||||
pickupVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const pickupText = await pdfToText(await renderInvoicePdf(pickupVm));
|
||||
assert("DE pickup PDF shows 'Abholort' label", pickupText.includes("Abholort"));
|
||||
assert("DE pickup PDF shows location name", pickupText.includes("Lager Graz"));
|
||||
assert("DE pickup PDF does NOT show 'Versandart'", !pickupText.includes("Versandart"));
|
||||
assert(
|
||||
"DE pickup PDF does NOT render pickup-location address as delivery address",
|
||||
!pickupText.includes("Lieferadresse"),
|
||||
);
|
||||
|
||||
// EN translation
|
||||
const pickupEnVm = composeInvoice({
|
||||
order: pickupOrder,
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1031",
|
||||
forceLanguage: "en",
|
||||
});
|
||||
pickupEnVm.issuer.logoDataUrl = vm.issuer.logoDataUrl;
|
||||
const pickupEnText = await pdfToText(await renderInvoicePdf(pickupEnVm));
|
||||
assert("EN pickup PDF shows 'Pick-up location' label", pickupEnText.includes("Pick-up location"));
|
||||
|
||||
// Real-world pickup variant: shippingLine has no "pickup" keyword in
|
||||
// title/code/source — only `deliveryCategory` says it's pickup.
|
||||
const categoryPickupVm = composeInvoice({
|
||||
order: buildCategoryOnlyPickupOrder(),
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1033",
|
||||
});
|
||||
assert("isPickup detected from deliveryCategory alone", categoryPickupVm.isPickup);
|
||||
assertEq("pickupLocationName from title when category-only",
|
||||
categoryPickupVm.pickupLocationName, "Lager Graz");
|
||||
assert("shippingMethod cleared in category-only pickup",
|
||||
categoryPickupVm.shippingMethod == null);
|
||||
|
||||
// Real-world "Shop location" pickup (matches dev order #1032): no
|
||||
// "pickup" keyword anywhere, deliveryCategory null, shippingAddress null.
|
||||
// The only signal is `requiresShipping && !shippingAddress`.
|
||||
const shopLocPickupVm = composeInvoice({
|
||||
order: buildShopLocationPickupOrder(),
|
||||
settings: settings as never,
|
||||
invoiceNumber: "RE-1034",
|
||||
});
|
||||
assert("isPickup detected from missing shippingAddress (Shop location rate)",
|
||||
shopLocPickupVm.isPickup);
|
||||
assertEq("pickupLocationName from shippingLine.title for Shop location",
|
||||
shopLocPickupVm.pickupLocationName, "Shop location");
|
||||
assert("shippingMethod cleared for Shop location pickup",
|
||||
shopLocPickupVm.shippingMethod == null);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Custom production server for the React Router build.
|
||||
//
|
||||
// Replaces `react-router-serve` so we can:
|
||||
// - prefix every console line with an ISO timestamp,
|
||||
// - use a richer morgan format (with timestamp + content-length),
|
||||
// - skip access logs for successful /healthz probes (they would otherwise
|
||||
// drown out everything useful — Docker/Caddy poll them every couple of
|
||||
// seconds).
|
||||
//
|
||||
// Behaviour is otherwise intentionally identical to `@react-router/serve`'s
|
||||
// CLI (compression, /assets immutable cache, public/ static, SIGTERM/SIGINT
|
||||
// handling).
|
||||
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import compression from "compression";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console timestamps — patch BEFORE anything else logs.
|
||||
// ---------------------------------------------------------------------------
|
||||
const ts = () => `[${new Date().toISOString()}]`;
|
||||
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
||||
const original = console[level].bind(console);
|
||||
console[level] = (...args) => original(ts(), ...args);
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const HOST = process.env.HOST;
|
||||
|
||||
const buildModule = await import("./build/server/index.js");
|
||||
const build = buildModule.default ?? buildModule;
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
// The app runs behind a single reverse proxy (Caddy) that sets
|
||||
// X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real
|
||||
// client IP for rate limiting, without trusting arbitrary client-supplied
|
||||
// forwarding headers (which `trust proxy: true` would).
|
||||
app.set("trust proxy", 1);
|
||||
app.use(compression());
|
||||
|
||||
// Static assets emitted by the React Router build.
|
||||
app.use(
|
||||
"/assets",
|
||||
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
|
||||
);
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
app.use(express.static("public", { maxAge: "1h" }));
|
||||
|
||||
// Access log: ISO timestamp + standard request info; suppress healthy
|
||||
// /healthz polls so real traffic stays visible.
|
||||
morgan.token("isotime", () => new Date().toISOString());
|
||||
// Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig`
|
||||
// and any `token`) from the logged URL so signed-URL secrets / bearer tokens
|
||||
// never land in access logs. Other query params are preserved.
|
||||
morgan.token("safeurl", (req) => {
|
||||
const raw = req.originalUrl || req.url || "";
|
||||
const qIdx = raw.indexOf("?");
|
||||
if (qIdx === -1) return raw;
|
||||
const path = raw.slice(0, qIdx);
|
||||
const params = new URLSearchParams(raw.slice(qIdx + 1));
|
||||
for (const key of ["sig", "token"]) {
|
||||
if (params.has(key)) params.set(key, "REDACTED");
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
});
|
||||
app.use(
|
||||
morgan(
|
||||
":isotime :method :safeurl :status :res[content-length] - :response-time ms",
|
||||
{
|
||||
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Per-IP rate limiting for the public, unauthenticated-at-the-edge API
|
||||
// surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the
|
||||
// embedded admin are intentionally NOT rate limited here — webhooks can burst
|
||||
// legitimately and are already HMAC-verified.
|
||||
const publicApiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 60, // 60 requests / minute / IP
|
||||
standardHeaders: true, // RateLimit-* headers
|
||||
legacyHeaders: false,
|
||||
message: { error: "rate-limited" },
|
||||
});
|
||||
app.use("/api/public", publicApiLimiter);
|
||||
|
||||
app.all(
|
||||
"*",
|
||||
createRequestHandler({ build, mode: process.env.NODE_ENV }),
|
||||
);
|
||||
|
||||
const onListen = () => {
|
||||
console.log(
|
||||
`[server] listening on http://${HOST ?? "localhost"}:${PORT}`,
|
||||
);
|
||||
};
|
||||
|
||||
const server = HOST
|
||||
? app.listen(PORT, HOST, onListen)
|
||||
: app.listen(PORT, onListen);
|
||||
|
||||
for (const signal of ["SIGTERM", "SIGINT"]) {
|
||||
process.once(signal, async () => {
|
||||
console.log(`[server] received ${signal}, shutting down`);
|
||||
// Stop accepting new connections.
|
||||
server.close((err) => {
|
||||
if (err) console.error("[server] close error:", err);
|
||||
});
|
||||
// Drain in-flight background webhook work (PDF render / SMTP send) before
|
||||
// exiting so a container stop doesn't lose invoice work mid-send. The
|
||||
// background queue exposes this bridge because server.js loads only the
|
||||
// bundled build and can't import the module directly.
|
||||
try {
|
||||
const drain = globalThis.__linumiqWebhookDrain;
|
||||
if (typeof drain === "function") await drain();
|
||||
} catch (err) {
|
||||
console.error("[server] webhook drain error:", err);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "fbc263e6cc28e8de031878d2a0f17444"
|
||||
application_url = "https://invoice-app-dev.linumiq.com"
|
||||
embedded = true
|
||||
name = "linumiq-invoice-dev"
|
||||
|
||||
[access_scopes]
|
||||
# Read orders + customers + companies (B2B) for invoice data.
|
||||
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
|
||||
# write_orders required to write the order metafield linking the latest PDF.
|
||||
# read_all_orders allows access to orders older than 60 days for backfill.
|
||||
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-07"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/app/uninstalled"
|
||||
topics = [ "app/uninstalled" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/app/scopes_update"
|
||||
topics = [ "app/scopes_update" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/create"
|
||||
topics = [ "orders/create" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/updated"
|
||||
topics = [ "orders/updated" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/fulfilled"
|
||||
topics = [ "orders/fulfilled" ]
|
||||
|
||||
[auth]
|
||||
redirect_urls = [
|
||||
"https://invoice-app-dev.linumiq.com/auth/callback",
|
||||
"https://invoice-app-dev.linumiq.com/auth/shopify/callback",
|
||||
"https://invoice-app-dev.linumiq.com/api/auth/callback",
|
||||
]
|
||||
|
||||
[build]
|
||||
automatically_update_urls_on_dev = true
|
||||
@@ -0,0 +1,46 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "c5cb7360d17d3a4643ece1eb5f4ca417"
|
||||
application_url = "https://invoice-app.linumiq.com"
|
||||
embedded = true
|
||||
name = "linumiq-invoice"
|
||||
|
||||
[access_scopes]
|
||||
# Read orders + customers + companies (B2B) for invoice data.
|
||||
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
|
||||
# write_orders required to write the order metafield linking the latest PDF.
|
||||
# read_all_orders allows access to orders older than 60 days for backfill.
|
||||
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-07"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/app/uninstalled"
|
||||
topics = [ "app/uninstalled" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/app/scopes_update"
|
||||
topics = [ "app/scopes_update" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/create"
|
||||
topics = [ "orders/create" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/updated"
|
||||
topics = [ "orders/updated" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/fulfilled"
|
||||
topics = [ "orders/fulfilled" ]
|
||||
|
||||
[auth]
|
||||
redirect_urls = [
|
||||
"https://invoice-app.linumiq.com/auth/callback",
|
||||
"https://invoice-app.linumiq.com/auth/shopify/callback",
|
||||
"https://invoice-app.linumiq.com/api/auth/callback",
|
||||
]
|
||||
|
||||
[build]
|
||||
automatically_update_urls_on_dev = false
|
||||
+5
-1
@@ -10,7 +10,7 @@ name = "linumiq-invoice"
|
||||
# read_files / write_files for the generated PDFs uploaded to Shopify Files.
|
||||
# write_orders required to write the order metafield linking the latest PDF.
|
||||
# read_all_orders allows access to orders older than 60 days for backfill.
|
||||
scopes = "read_orders,write_orders,read_all_orders,read_customers,read_companies,read_files,write_files"
|
||||
scopes = "read_orders,write_orders,read_all_orders,read_draft_orders,read_customers,read_companies,read_files,write_files"
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-07"
|
||||
@@ -31,6 +31,10 @@ api_version = "2026-07"
|
||||
uri = "/webhooks/orders/updated"
|
||||
topics = [ "orders/updated" ]
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/fulfilled"
|
||||
topics = [ "orders/fulfilled" ]
|
||||
|
||||
[auth]
|
||||
redirect_urls = [
|
||||
"https://invoice-app.linumiq.com/auth/callback",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { pickLanguage } from "../app/services/invoice/i18n";
|
||||
import { reserveWebhook, type DedupeDeps } from "../app/services/webhooks/dedupe.server";
|
||||
|
||||
describe("pickLanguage", () => {
|
||||
it("returns 'de' only for explicit German locales", () => {
|
||||
assert.equal(pickLanguage("de"), "de");
|
||||
assert.equal(pickLanguage("de-AT"), "de");
|
||||
assert.equal(pickLanguage("de-DE"), "de");
|
||||
assert.equal(pickLanguage("de_CH"), "de");
|
||||
assert.equal(pickLanguage("DE-AT"), "de"); // case-insensitive
|
||||
});
|
||||
|
||||
it("returns 'en' for non-German locales (regression: it/fr/es no longer fall back to de)", () => {
|
||||
assert.equal(pickLanguage("en"), "en");
|
||||
assert.equal(pickLanguage("en-US"), "en");
|
||||
assert.equal(pickLanguage("it"), "en");
|
||||
assert.equal(pickLanguage("it-IT"), "en");
|
||||
assert.equal(pickLanguage("fr"), "en");
|
||||
assert.equal(pickLanguage("fr-FR"), "en");
|
||||
assert.equal(pickLanguage("es"), "en");
|
||||
assert.equal(pickLanguage("hu-HU"), "en");
|
||||
});
|
||||
|
||||
it("falls back to 'de' for empty/unknown input so the per-shop default chain still works", () => {
|
||||
assert.equal(pickLanguage(undefined), "de");
|
||||
assert.equal(pickLanguage(null), "de");
|
||||
assert.equal(pickLanguage(""), "de");
|
||||
});
|
||||
});
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
return new Request("https://example.com/webhooks/test", {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
type ExistingRow = { webhookId: string; status: string; receivedAt: Date } | null;
|
||||
|
||||
/**
|
||||
* Build a DedupeDeps stub.
|
||||
* - "ok" : create() succeeds (fresh reservation).
|
||||
* - "p2002" : create() conflicts; findUnique() returns `existing`.
|
||||
* - "boom" : create() throws a non-P2002 error (fail-open).
|
||||
*/
|
||||
function makeDeps(
|
||||
behaviour: "ok" | "p2002" | "boom",
|
||||
existing: ExistingRow = null,
|
||||
): DedupeDeps & { calls: { commit: number; release: number; update: number } } {
|
||||
const calls = { commit: 0, release: 0, update: 0 };
|
||||
return {
|
||||
calls,
|
||||
db: {
|
||||
processedWebhook: {
|
||||
create: async () => {
|
||||
if (behaviour === "ok") return {};
|
||||
if (behaviour === "p2002") {
|
||||
const err = new Error("Unique constraint failed") as Error & { code?: string };
|
||||
err.code = "P2002";
|
||||
throw err;
|
||||
}
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
findUnique: async () => existing,
|
||||
update: async () => {
|
||||
calls.update += 1;
|
||||
calls.commit += 1;
|
||||
return {};
|
||||
},
|
||||
delete: async () => {
|
||||
calls.release += 1;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reserveWebhook", () => {
|
||||
it("returns a reservation on first delivery (insert succeeds)", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
|
||||
assert.ok(res, "expected a reservation");
|
||||
assert.equal(res!.webhookId, "abc-123");
|
||||
});
|
||||
|
||||
it("returns null for an already-processed (done) delivery", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const deps = makeDeps("p2002", {
|
||||
webhookId: "abc-123",
|
||||
status: "done",
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
|
||||
});
|
||||
|
||||
it("returns null for a fresh in-flight (processing) delivery", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const deps = makeDeps("p2002", {
|
||||
webhookId: "abc-123",
|
||||
status: "processing",
|
||||
receivedAt: new Date(), // fresh lease
|
||||
});
|
||||
assert.equal(await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps), null);
|
||||
});
|
||||
|
||||
it("reclaims a stale (crashed) processing reservation", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const deps = makeDeps("p2002", {
|
||||
webhookId: "abc-123",
|
||||
status: "processing",
|
||||
receivedAt: new Date(Date.now() - 10 * 60 * 1000), // 10 min ago > 5 min lease
|
||||
});
|
||||
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
|
||||
assert.ok(res, "expected to reclaim the stale reservation");
|
||||
assert.equal(deps.calls.update, 1, "stale reclaim should renew the lease via update()");
|
||||
});
|
||||
|
||||
it("commit() flips the row to done; release() deletes it", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const deps = makeDeps("ok");
|
||||
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", deps);
|
||||
await res!.commit();
|
||||
assert.equal(deps.calls.commit, 1);
|
||||
await res!.release();
|
||||
assert.equal(deps.calls.release, 1);
|
||||
});
|
||||
|
||||
it("fails open (returns a no-op reservation) when the dedupe table errors", async () => {
|
||||
const req = makeRequest({ "x-shopify-webhook-id": "abc-123" });
|
||||
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("boom"));
|
||||
assert.ok(res, "fail-open must still process — never silently drop a webhook");
|
||||
assert.equal(res!.webhookId, "abc-123");
|
||||
});
|
||||
|
||||
it("returns a no-op reservation when the X-Shopify-Webhook-Id header is missing", async () => {
|
||||
const req = makeRequest();
|
||||
const res = await reserveWebhook(req, "shop.myshopify.com", "ORDERS_CREATE", makeDeps("ok"));
|
||||
assert.ok(res, "missing id => process without dedupe");
|
||||
assert.equal(res!.webhookId, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
buildRepresentativeInvoiceMap,
|
||||
type RepresentativeInvoiceRow,
|
||||
} from "../app/services/invoice/representativeInvoice";
|
||||
|
||||
const d = (iso: string) => new Date(iso);
|
||||
|
||||
describe("buildRepresentativeInvoiceMap", () => {
|
||||
it("prefers the active invoice even when a cancelled row has a higher version (regression: order #1032)", () => {
|
||||
// Rows as returned by Prisma: version desc, then createdAt desc.
|
||||
// This mirrors the real #1032 state: a cancelled v8 sorted ahead of the
|
||||
// live, issued v7. The naive "first row wins" picked v8 and the order
|
||||
// rendered as if it had no invoice.
|
||||
const rows: RepresentativeInvoiceRow[] = [
|
||||
{ orderId: "gid://shopify/Order/1032", version: 8, cancelledAt: d("2026-05-31T08:59:05Z") },
|
||||
{ orderId: "gid://shopify/Order/1032", version: 7, cancelledAt: null },
|
||||
{ orderId: "gid://shopify/Order/1032", version: 1, cancelledAt: d("2026-05-15T13:05:14Z") },
|
||||
];
|
||||
|
||||
const map = buildRepresentativeInvoiceMap(rows);
|
||||
const rep = map.get("gid://shopify/Order/1032");
|
||||
|
||||
assert.ok(rep, "expected a representative invoice");
|
||||
assert.equal(rep!.version, 7, "should select the active v7, not the cancelled v8");
|
||||
assert.equal(rep!.cancelledAt, null);
|
||||
});
|
||||
|
||||
it("falls back to the latest cancelled invoice when none are active", () => {
|
||||
const rows: RepresentativeInvoiceRow[] = [
|
||||
{ orderId: "gid://shopify/Order/1", version: 3, cancelledAt: d("2026-05-31T10:00:00Z") },
|
||||
{ orderId: "gid://shopify/Order/1", version: 1, cancelledAt: d("2026-05-15T10:00:00Z") },
|
||||
];
|
||||
|
||||
const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/1");
|
||||
assert.ok(rep);
|
||||
assert.equal(rep!.version, 3, "highest-version cancelled wins when nothing is active");
|
||||
});
|
||||
|
||||
it("keeps the highest-version active invoice when multiple are active", () => {
|
||||
const rows: RepresentativeInvoiceRow[] = [
|
||||
{ orderId: "gid://shopify/Order/2", version: 5, cancelledAt: null },
|
||||
{ orderId: "gid://shopify/Order/2", version: 4, cancelledAt: null },
|
||||
];
|
||||
|
||||
const rep = buildRepresentativeInvoiceMap(rows).get("gid://shopify/Order/2");
|
||||
assert.equal(rep!.version, 5);
|
||||
});
|
||||
|
||||
it("handles multiple orders independently", () => {
|
||||
const rows: RepresentativeInvoiceRow[] = [
|
||||
{ orderId: "A", version: 9, cancelledAt: d("2026-05-31T00:00:00Z") },
|
||||
{ orderId: "A", version: 2, cancelledAt: null },
|
||||
{ orderId: "B", version: 1, cancelledAt: null },
|
||||
];
|
||||
|
||||
const map = buildRepresentativeInvoiceMap(rows);
|
||||
assert.equal(map.get("A")!.version, 2);
|
||||
assert.equal(map.get("B")!.version, 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user