Files
linumiq-invoice/app/services/invoice/composeInvoice.ts
T
Gerhard Scheikl 40ee895719 feat(invoice): show refund + outstanding-amount rows on the PDF
When a Shopify order has been (partially or fully) refunded the PDF
now mirrors the order-page totals block:

  Gesamtbetrag brutto      629,95 EUR
  Zurückerstattet         -629,95 EUR
  Offener Betrag             0,00 EUR

So the customer immediately sees that nothing is owed any more, even
though the original invoice gross stays unchanged for tax-document
correctness (the refund is itemised as a separate row, not subtracted
from the line totals).

Plumbing:

  - GraphQL: added `totalRefundedSet` to OrderForInvoice query.
  - RawOrderForInvoice: new optional `totalRefundedSet` field
    (null for drafts/offers — they never have refunds).
  - InvoiceViewModel: new `refundedAmount: number` (gross, in the
    same currency as `totals.gross`). Always present, 0 for storno
    and offer documents and for non-refunded invoices.
  - composeInvoice parses the gross refund out of `totalRefundedSet`
    (defensive parseFloat, clamped to >= 0).
  - InvoiceDocument renders the two extra rows under `grossTotal`
    only when `refundedAmount > 0`. Uses the existing total-row
    styles for visual consistency.
  - i18n: added `refundedLabel` ("Zurückerstattet" / "Refunded") and
    `outstandingLabel` ("Offener Betrag" / "Outstanding amount") to
    both languages.

Verification: render-sample fixture now mirrors the full gross as
refunded and asserts the PDF text contains "Zurückerstattet",
"Offener Betrag", and "0,00 EUR" as the final outstanding row, on
top of the previous suppressions (no GiroCode, no payment terms).
tsc / smoke / tests / build all green.
2026-05-15 16:45:09 +02:00

564 lines
19 KiB
TypeScript

import type { ShopSettings } from "@prisma/client";
import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server";
import type {
InvoiceLine,
InvoiceNotice,
InvoiceTotals,
InvoiceViewModel,
IssuerData,
RecipientData,
TrackingInfo,
VatBreakdownEntry,
} from "./types";
import { addDays } from "./format";
import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n";
interface ComposeArgs {
order: RawOrderForInvoice;
settings: ShopSettings;
invoiceNumber: string;
/** Language override (e.g. for Storno copies). */
forceLanguage?: InvoiceLanguage;
/**
* When set, produces a Stornorechnung view model: line and total amounts
* are negated, `kind` is `"storno"`, and `cancelsNumber` references the
* original invoice number. Notices, GiroCode and payment-due date are
* suppressed (a storno is informational, not a request for payment).
*/
storno?: { cancelsNumber: string };
/** Optional override for invoice/delivery date (defaults to order date). */
issueDate?: Date;
/**
* 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({
order,
settings,
invoiceNumber,
forceLanguage,
storno,
issueDate,
offer,
}: ComposeArgs): InvoiceViewModel {
const language = forceLanguage
?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage);
const issuer = mapIssuer(settings);
const recipient = mapRecipient(order);
const isB2B = !!order.purchasingEntity?.company;
const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined;
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);
// §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";
const paymentStatus = derivePaymentStatus(order.displayFinancialStatus);
// 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";
// 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);
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 = {
net: -totals.net,
vatBreakdown: totals.vatBreakdown.map((v) => ({
ratePct: v.ratePct,
net: -v.net,
tax: -v.tax,
})),
totalVat: -totals.totalVat,
gross: -totals.gross,
};
// Notices are still relevant (e.g. reverse-charge), but the storno is not
// a payment request — leave them in place for legal symmetry.
}
return {
language,
currency: order.currencyCode,
kind: storno ? "storno" : offer ? "offer" : "invoice",
number: invoiceNumber,
cancelsNumber: storno?.cancelsNumber,
invoiceDate,
deliveryDate,
dueDate,
issuer,
recipient,
isB2B,
recipientVatId,
lines,
totals,
notices,
paid,
paymentStatus,
requiresPayment,
refundedAmount,
paymentGatewayNames,
orderName: order.name,
separateShippingAddress,
shippingMethod,
tracking,
discountCodes: order.discountCodes ?? [],
isPickup,
pickupLocationName: pickupInfo?.locationName ?? undefined,
};
}
function mapIssuer(s: ShopSettings): IssuerData {
return {
companyName: s.companyName,
legalForm: s.legalForm,
ownerName: s.ownerName,
addressLine1: s.addressLine1,
addressLine2: s.addressLine2,
postalCode: s.postalCode,
city: s.city,
countryCode: s.countryCode,
phone: s.phone,
email: s.email,
website: s.website,
vatId: s.vatId,
taxNumber: s.taxNumber,
registrationNo: s.registrationNo,
registrationCourt: s.registrationCourt,
bankName: s.bankName,
iban: s.iban,
bic: s.bic,
footerNote: s.footerNote,
footerNoteEn: s.footerNoteEn,
};
}
function mapRecipient(order: RawOrderForInvoice): RecipientData {
// Prefer billingAddress; fall back to shippingAddress; fall back to customer name only.
const a = order.billingAddress ?? order.shippingAddress ?? null;
const customerFullName = [order.customer?.firstName, order.customer?.lastName]
.filter(Boolean)
.join(" ")
.trim();
if (!a) {
return {
name: customerFullName,
company: order.purchasingEntity?.company?.name ?? "",
addressLine1: "",
addressLine2: "",
postalCode: "",
city: "",
countryCode: "",
};
}
return {
name: a.name ?? customerFullName,
company: a.company ?? order.purchasingEntity?.company?.name ?? "",
addressLine1: a.address1 ?? "",
addressLine2: a.address2 ?? "",
postalCode: a.zip ?? "",
city: a.city ?? "",
countryCode: a.countryCode ?? "",
};
}
function mapLinesAndTotals(
order: RawOrderForInvoice,
opts: { shippingItemPrefix: string },
): {
lines: InvoiceLine[];
totals: InvoiceTotals;
} {
const taxesIncluded = order.taxesIncluded;
const linesOut: InvoiceLine[] = [];
const vatMap = new Map<number, VatBreakdownEntry>();
let netSum = 0;
order.lineItems.forEach((li, idx) => {
const qty = li.quantity;
// 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),
0,
);
// If taxes are included in the unit price, subtract them to get net.
const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax);
const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty;
const unitNet = qty > 0 ? lineNet / qty : 0;
// 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,
title: li.title,
sku: li.sku ?? undefined,
quantity: qty,
unitPriceNet: round2(unitNet),
originalUnitPriceNet: hasDiscount ? round2(originalUnitNet) : undefined,
totalNet: round2(lineNet),
imageUrl: li.imageUrl ?? undefined,
});
netSum += lineNet;
li.taxLines.forEach((t) => accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), lineNet));
void lineGross;
});
// Prefer order-level taxLines for the breakdown grouping if line-level is missing.
if (vatMap.size === 0 && order.taxLines.length > 0) {
order.taxLines.forEach((t) => {
const tax = parseFloat(t.priceSet.shopMoney.amount);
// We don't have per-rate net from the order level; approximate by inferring from rate.
const rate = normaliseRate(t);
const net = rate > 0 ? tax / (rate / 100) : 0;
const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 };
entry.net += net;
entry.tax += tax;
vatMap.set(rate, entry);
});
}
// 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)
.sort((a, b) => a.ratePct - b.ratePct);
const totalVat = vatBreakdown.reduce((acc, e) => acc + e.tax, 0);
const grossFromOrder = order.totalPriceSet
? parseFloat(order.totalPriceSet.shopMoney.amount)
: netSum + totalVat;
return {
lines: linesOut,
totals: {
net: round2(netSum),
vatBreakdown,
totalVat: round2(totalVat),
gross: round2(grossFromOrder),
},
};
}
function accumulateVat(
vatMap: Map<number, VatBreakdownEntry>,
t: RawTaxLine,
taxAmount: number,
lineNet: number,
) {
if (taxAmount <= 0) return;
const rate = normaliseRate(t);
const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 };
entry.net += lineNet;
entry.tax += taxAmount;
vatMap.set(rate, entry);
}
function normaliseRate(t: RawTaxLine): number {
if (t.ratePercentage != null) return Number(t.ratePercentage);
if (t.rate != null) {
const r = Number(t.rate);
return r <= 1 ? r * 100 : r;
}
return 0;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function deriveNotices({
order,
settings,
isB2B,
}: {
order: RawOrderForInvoice;
settings: ShopSettings;
isB2B: boolean;
}): InvoiceNotice[] {
const notices: InvoiceNotice[] = [];
const totalTax = order.totalTaxSet
? parseFloat(order.totalTaxSet.shopMoney.amount)
: 0;
const recipientCountry =
order.billingAddress?.countryCode || order.shippingAddress?.countryCode || "";
const issuerCountry = settings.countryCode || "AT";
if (settings.kleinunternehmer) {
notices.push({ kind: "kleinunternehmer" });
return notices; // exclusive of the others
}
if (totalTax === 0) {
if (
isB2B &&
recipientCountry &&
recipientCountry !== issuerCountry &&
isEuCountry(recipientCountry)
) {
notices.push({ kind: "reverseCharge" });
} else if (recipientCountry && !isEuCountry(recipientCountry)) {
notices.push({ kind: "export" });
}
}
return notices;
}
const EU_COUNTRIES = new Set([
"AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE",
"IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK",
]);
function isEuCountry(code: string): boolean {
return EU_COUNTRIES.has(code.toUpperCase());
}
/**
* 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));
}