Files
linumiq-invoice/app/services/invoice/composeInvoice.ts
T
Gerhard Scheikl 91c1a74c1b fix(invoice): partial refund stays "Bezahlt" + use "Endbetrag" final-row label
Two related bugs surfaced when a paid order was partially refunded
(Shopify flips `displayFinancialStatus` to PARTIALLY_REFUNDED as soon
as *any* refund is posted, even a small one):

1. The status row showed "Erstattet" / "Refunded" even though the
   customer paid in full and the merchant kept the difference. The
   correct status is "Bezahlt" / "Paid" — only when the refund equals
   (or, defensively, exceeds) the gross is the order genuinely
   refunded.

2. The final row beneath the new refund block was labelled "Offener
   Betrag" / "Outstanding amount", falsely suggesting the customer
   still owes the kept portion. For an order that has been refunded
   but is no longer owing anything, that row is just the final amount
   the merchant kept — "Endbetrag" / "Total".

Truth table now implemented:

  displayFinancialStatus | refunded     | paymentStatus | final-row label
  -----------------------+--------------+---------------+-----------------
  PAID                   | 0            | paid          | (no refund rows)
  PAID                   | >0           | paid          | Endbetrag
  PARTIALLY_REFUNDED     | < gross      | paid (NEW)    | Endbetrag (NEW)
  PARTIALLY_REFUNDED     | == gross     | refunded      | Endbetrag
  REFUNDED               | == gross     | refunded      | Endbetrag
  PARTIALLY_PAID         | 0            | partial       | (no refund rows)
  PARTIALLY_PAID         | >0 (exotic)  | partial       | Offener Betrag
  PENDING/AUTHORIZED/etc | 0            | unpaid        | (no refund rows)
  storno / offer         | 0 (forced)   | n/a           | n/a

Implementation:

  - composeInvoice.ts: after computing refundedAmount, reclassify
    paymentStatus="refunded" → "paid" when 0 < refundedAmount <
    totals.gross. requiresPayment is derived from paymentStatus, so
    it correctly stays false for partial-refund-on-paid (no GiroCode,
    no payment terms — nothing is owed).
  - i18n.ts: new `finalAmountLabel` ("Endbetrag" / "Total") in both
    languages.
  - InvoiceDocument.tsx: the final-row label now picks
    outstandingLabel vs. finalAmountLabel based on requiresPayment,
    so PARTIALLY_PAID with a refund still says "Offener Betrag"
    while PARTIALLY_REFUNDED says "Endbetrag".

Verification: render-sample now runs four refund scenarios — paid +
no refund (regression guard), full refund (status=Erstattet, final
row=Endbetrag 0,00 EUR), partial refund on a paid order (status=
Bezahlt, final row=Endbetrag, no Erstattet), and PARTIALLY_REFUNDED
with refund==gross (status stays refunded). tsc / smoke / tests /
build all green.
2026-05-15 16:56:45 +02:00

579 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";
// 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";
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));
}