Files
linumiq-invoice/app/services/invoice/loadDraftOrderForOffer.server.ts
T
Gerhard Scheikl 55a0dd03f2 feat(invoice): informal German tone + show payment method and status
- i18n.de: switch Sie/Ihren to du/dein for salutation, thank-you line,
  customer-VAT label and payment-terms paragraph. Closing line was
  already informal.
- i18n: add paymentMethodLabel/paymentStatusLabel + per-status labels
  (paid/unpaid/partial/refunded) for both DE and EN, plus
  derivePaymentStatus helper that condenses Shopify's
  displayFinancialStatus (PAID, PARTIALLY_PAID, REFUNDED, …) into a
  4-value enum.
- loadOrderForInvoice: query Order.paymentGatewayNames and propagate it
  on the raw view-model.
- composeInvoice + types: expose paymentStatus + paymentGatewayNames on
  InvoiceViewModel (filtered/trimmed). loadDraftOrderForOffer keeps
  paymentGatewayNames empty (drafts have no gateway yet).
- InvoiceDocument: render two new meta rows on real invoices —
  'Zahlart / Payment method' (joined, prettified gateway names) and
  'Zahlstatus / Payment status' (translated label). Storno + offer kinds
  intentionally omit them.
- scripts/render-sample.ts: extend smoke checks to assert the informal
  DE wording, the new payment-method/status rows and the
  paymentStatus/paymentGatewayNames composer outputs.
2026-05-15 11:26:26 +02:00

186 lines
5.0 KiB
TypeScript

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: [],
taxesIncluded: draft.taxesIncluded,
customer: draft.customer,
billingAddress: draft.billingAddress,
shippingAddress: draft.shippingAddress,
subtotalSet: draft.subtotalPriceSet,
totalTaxSet: draft.totalTaxSet,
totalPriceSet: draft.totalPriceSet,
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,
taxLines: node.taxLines,
imageUrl: node.image?.url ?? null,
};
}),
purchasingEntity: { company: purchasingCompany },
};
}