From 62245974970d2ea675d121c51152b0bb0da12eae Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 19:26:33 +0200 Subject: [PATCH] feat(offers): generate Angebot/Offer PDFs for draft orders --- app/routes/api.orders.$orderId.invoice.tsx | 18 +- app/routes/app.invoices.tsx | 174 ++++++++++++++++- app/services/invoice/composeInvoice.ts | 21 +- .../invoice/generateInvoice.server.tsx | 66 +++++-- app/services/invoice/i18n.ts | 12 ++ .../invoice/loadDraftOrderForOffer.server.ts | 184 ++++++++++++++++++ app/services/invoice/pdf/InvoiceDocument.tsx | 29 ++- app/services/invoice/types.ts | 2 +- 8 files changed, 465 insertions(+), 41 deletions(-) create mode 100644 app/services/invoice/loadDraftOrderForOffer.server.ts diff --git a/app/routes/api.orders.$orderId.invoice.tsx b/app/routes/api.orders.$orderId.invoice.tsx index cc3f53c..a4369e3 100644 --- a/app/routes/api.orders.$orderId.invoice.tsx +++ b/app/routes/api.orders.$orderId.invoice.tsx @@ -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); diff --git a/app/routes/app.invoices.tsx b/app/routes/app.invoices.tsx index b02614b..ac29042 100644 --- a/app/routes/app.invoices.tsx +++ b/app/routes/app.invoices.tsx @@ -20,6 +20,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 +49,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 +75,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 { @@ -103,6 +132,56 @@ 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; + }>; + }; + }; + }; + 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(); + 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 +191,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { orders, + drafts, filter, counts: { all: allCount, with: withCount, missing: missingCount }, }; @@ -134,7 +214,7 @@ function formatMoney(amount: string, currency: string): string { } export default function InvoicesPage() { - const { orders, filter, counts } = useLoaderData(); + const { orders, drafts, filter, counts } = useLoaderData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; @@ -195,6 +275,40 @@ export default function InvoicesPage() { )} + + + + Generate a PDF offer (Angebot) for any open draft order. The + offer's number is the draft order name (e.g. D1). + + + + {drafts.length === 0 ? ( + + No open draft orders + + Create a draft order in Shopify and refresh this page. + + + ) : ( + + + Draft + Customer + Date + Total + Offer + Actions + + + {drafts.map((d) => ( + + ))} + + + )} + + @@ -319,3 +433,61 @@ function OrderRow({ order }: { order: RecentOrder }) { ); } + +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 ( + + + + + {draft.name} + + + + {draft.customerName} + {dateFmt.format(new Date(draft.createdAt))} + {formatMoney(draft.totalPrice, draft.currency)} + + {draft.hasOffer ? ( + + + {draft.offerNumber} + Issued + {draft.offerVersion && draft.offerVersion > 1 ? ( + v{draft.offerVersion} + ) : null} + + {fetcher.data?.error ? ( + {fetcher.data.error} + ) : null} + + ) : ( + + )} + + + + {draft.pdfUrl ? ( + + PDF + + ) : null} + + + + {isBusy ? "Working…" : buttonLabel} + + + + + + ); +} diff --git a/app/services/invoice/composeInvoice.ts b/app/services/invoice/composeInvoice.ts index 3e7d581..a71cf86 100644 --- a/app/services/invoice/composeInvoice.ts +++ b/app/services/invoice/composeInvoice.ts @@ -28,6 +28,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 +45,7 @@ export function composeInvoice({ forceLanguage, storno, issueDate, + offer, }: ComposeArgs): InvoiceViewModel { const language = forceLanguage ?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage); @@ -51,9 +60,13 @@ export function composeInvoice({ const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt); const deliveryDate = invoiceDate; - const dueDate = !storno && settings.paymentTermDays > 0 - ? addDays(invoiceDate, settings.paymentTermDays) - : undefined; + // 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"; @@ -80,7 +93,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, diff --git a/app/services/invoice/generateInvoice.server.tsx b/app/services/invoice/generateInvoice.server.tsx index de34c6f..26a8c22 100644 --- a/app/services/invoice/generateInvoice.server.tsx +++ b/app/services/invoice/generateInvoice.server.tsx @@ -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 { 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 - : await allocateInvoiceNumber(settings, order.orderNumber); + : 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,8 +95,9 @@ 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 for invoices that are unpaid + IBAN configured + enabled). if ( + kind === "invoice" && settings.giroCodeEnabled && settings.iban && !viewModel.paid && @@ -101,12 +117,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; @@ -141,7 +158,7 @@ export async function generateInvoice( orderNumber: order.orderNumber, invoiceNumber, language: viewModel.language, - kind: "invoice", + kind, version: 1, pdfFileGid: upload.fileGid, pdfUrl: upload.url, @@ -152,15 +169,18 @@ 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). - try { - await writeOrderMetafields(admin, orderGid, { - pdfUrl: upload.url, - number: invoiceNumber, - version: invoice.version, - }); - } catch (err) { - console.warn("Order metafield write failed:", err); + // 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, + number: invoiceNumber, + version: invoice.version, + }); + } catch (err) { + console.warn("Order metafield write failed:", err); + } } return { @@ -179,6 +199,12 @@ 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, "_"); } diff --git a/app/services/invoice/i18n.ts b/app/services/invoice/i18n.ts index 48cea44..0781f60 100644 --- a/app/services/invoice/i18n.ts +++ b/app/services/invoice/i18n.ts @@ -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; @@ -53,6 +57,10 @@ export interface InvoiceStrings { 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", @@ -106,6 +114,10 @@ const de: InvoiceStrings = { 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", diff --git a/app/services/invoice/loadDraftOrderForOffer.server.ts b/app/services/invoice/loadDraftOrderForOffer.server.ts new file mode 100644 index 0000000..bc8de74 --- /dev/null +++ b/app/services/invoice/loadDraftOrderForOffer.server.ts @@ -0,0 +1,184 @@ +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 { + 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, + 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 }, + }; +} diff --git a/app/services/invoice/pdf/InvoiceDocument.tsx b/app/services/invoice/pdf/InvoiceDocument.tsx index cb25d7f..2845b28 100644 --- a/app/services/invoice/pdf/InvoiceDocument.tsx +++ b/app/services/invoice/pdf/InvoiceDocument.tsx @@ -246,7 +246,7 @@ export function InvoiceDocument({ invoice }: DocProps) { return ( @@ -268,17 +268,19 @@ export function InvoiceDocument({ invoice }: DocProps) { - {t.invoiceNumber} + {invoice.kind === "offer" ? t.offerNumber : t.invoiceNumber} {invoice.number} - {t.invoiceDate} + {invoice.kind === "offer" ? t.offerDate : t.invoiceDate} {formatDate(invoice.invoiceDate, invoice.language)} - - {t.deliveryDate} - {formatDate(invoice.deliveryDate, invoice.language)} - + {invoice.kind !== "offer" && ( + + {t.deliveryDate} + {formatDate(invoice.deliveryDate, invoice.language)} + + )} {invoice.recipientVatId ? ( {t.customerVatId} @@ -290,7 +292,12 @@ export function InvoiceDocument({ invoice }: DocProps) { - {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} {t.salutationGeneric} @@ -342,7 +349,11 @@ export function InvoiceDocument({ invoice }: DocProps) { )} - {!invoice.paid && ( + {invoice.kind === "offer" ? ( + + {invoice.dueDate ? t.offerValidUntil(formatDate(invoice.dueDate, invoice.language)) : null} + + ) : !invoice.paid && ( {invoice.dueDate ? t.paymentTerms( diff --git a/app/services/invoice/types.ts b/app/services/invoice/types.ts index a8de518..77ab8bd 100644 --- a/app/services/invoice/types.ts +++ b/app/services/invoice/types.ts @@ -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;