Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
All checks were successful
Build and Push / build (push) Successful in 1m42s
This commit is contained in:
@@ -356,45 +356,60 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
max: remainingRefundable.toFixed(2),
|
max: remainingRefundable.toFixed(2),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<input
|
<div className="flex flex-col gap-1">
|
||||||
type="number"
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
step="0.01"
|
{t("refundAmountLabel")}
|
||||||
min="0.01"
|
</label>
|
||||||
max={remainingRefundable}
|
<input
|
||||||
placeholder="CHF"
|
type="number"
|
||||||
value={refundAmount}
|
step="0.01"
|
||||||
onChange={(e) => setRefundAmount(e.target.value)}
|
min="0.01"
|
||||||
className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
max={remainingRefundable}
|
||||||
autoFocus
|
placeholder="CHF"
|
||||||
/>
|
value={refundAmount}
|
||||||
<input
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
type="text"
|
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||||
placeholder={t("refundReasonPlaceholder")}
|
autoFocus
|
||||||
value={refundReason}
|
/>
|
||||||
onChange={(e) => setRefundReason(e.target.value)}
|
<span className="text-[10px] text-text-muted italic">
|
||||||
maxLength={500}
|
{t("refundAmountInclVatHint")}
|
||||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
</span>
|
||||||
/>
|
</div>
|
||||||
<button
|
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
||||||
onClick={refundInvoice}
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
disabled={busyAction !== null}
|
{t("refundReasonLabel")}
|
||||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
</label>
|
||||||
>
|
<input
|
||||||
{busyAction === "refund"
|
type="text"
|
||||||
? t("saving")
|
placeholder={t("refundReasonPlaceholder")}
|
||||||
: t("confirmRefund")}
|
value={refundReason}
|
||||||
</button>
|
onChange={(e) => setRefundReason(e.target.value)}
|
||||||
<button
|
maxLength={500}
|
||||||
onClick={() => {
|
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
setRefundOpen(false);
|
/>
|
||||||
setRefundAmount("");
|
</div>
|
||||||
setRefundReason("");
|
<div className="flex items-center gap-2 self-end">
|
||||||
}}
|
<button
|
||||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
onClick={refundInvoice}
|
||||||
>
|
disabled={busyAction !== null}
|
||||||
{t("cancel")}
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
{busyAction === "refund"
|
||||||
|
? t("saving")
|
||||||
|
: t("confirmRefund")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,44 +31,18 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Svg,
|
|
||||||
Polygon,
|
|
||||||
Polyline,
|
|
||||||
renderToBuffer,
|
renderToBuffer,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Brand constants — edit here to tweak look without touching layout
|
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||||
|
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||||
|
// share the same source of truth so a brand change applies to every
|
||||||
|
// PDF the portal produces.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const BRAND = {
|
|
||||||
name: "PieCed IT",
|
|
||||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
|
||||||
primary: "#10B981",
|
|
||||||
// Slightly darker emerald for headings.
|
|
||||||
primaryDark: "#0a8060",
|
|
||||||
textColor: "#1a1a1a",
|
|
||||||
mutedColor: "#666",
|
|
||||||
borderColor: "#d4d4d4",
|
|
||||||
// Issuer block — change these to your real legal info.
|
|
||||||
issuer: {
|
|
||||||
legalName: "PieCed IT",
|
|
||||||
addressLine1: "Cedric Mosimann",
|
|
||||||
addressLine2: "[Strasse Nr.]",
|
|
||||||
postalCity: "[PLZ] Basel",
|
|
||||||
country: "Switzerland",
|
|
||||||
email: "billing@pieced.ch",
|
|
||||||
web: "pieced.ch",
|
|
||||||
// Show "MWST-Nr. ..." on PDF when set.
|
|
||||||
vatNumber: null as string | null,
|
|
||||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
|
||||||
bankName: "[Bank name]",
|
|
||||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
|
||||||
bankBic: "[BIC]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Localized strings
|
// Localized strings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -358,62 +332,6 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logo — inlined SVG primitives
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
|
||||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
|
||||||
* Width/height are independent of the original viewBox so we can
|
|
||||||
* scale it without losing stroke quality.
|
|
||||||
*/
|
|
||||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
|
||||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
|
||||||
{/* H1 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H2 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H3 outline */}
|
|
||||||
<Polygon
|
|
||||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H4 solid */}
|
|
||||||
<Polygon
|
|
||||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
/>
|
|
||||||
{/* H5 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
{/* H6 partial */}
|
|
||||||
<Polyline
|
|
||||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
|
||||||
fill="none"
|
|
||||||
stroke="#10B981"
|
|
||||||
strokeWidth={1.8}
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Credit-note PDF rendering via @react-pdf/renderer.
|
* Credit-note PDF rendering via @react-pdf/renderer.
|
||||||
*
|
*
|
||||||
* Phase 7. Mirrors billing-pdf.tsx in layout but with:
|
* Phase 7. Renders the same brand identity as the invoice PDF
|
||||||
* - Title "Gutschrift" / "Credit note" / "Note de crédit" / "Nota di credito"
|
* (hexagon logo, issuer block, layout) with one accent override:
|
||||||
* - Red accent (vs invoice's emerald) so the document is visually
|
* red instead of emerald. That difference is enough to make voids
|
||||||
* unmistakable from an invoice
|
* and refunds visually unmistakable from an invoice at a glance,
|
||||||
* - References the original invoice number prominently
|
* while keeping every other element (logo shape, fonts, structure,
|
||||||
* - One amount line ("Refund for invoice 2026-00042" or
|
* issuer info, page footer) identical so the document family reads
|
||||||
* "Voided invoice 2026-00042") with VAT broken out
|
* as one brand.
|
||||||
* - Optional reason text below the amount
|
|
||||||
* - No bank-transfer instructions (refunds flow the other way:
|
|
||||||
* either Stripe → customer's card, or PieCed → customer's bank
|
|
||||||
* for invoice-paid cases — neither requires the customer to
|
|
||||||
* do anything)
|
|
||||||
*
|
*
|
||||||
* Issuer block and brand constants are intentionally duplicated
|
* Brand + Logo come from lib/pdf-brand. Edit there to change
|
||||||
* from billing-pdf.tsx for now. A future refactor can hoist them
|
* issuer info, colours, or the logo glyph — both invoice and
|
||||||
* into lib/pdf-brand.ts; doing so today is out of scope.
|
* credit-note PDFs pick the changes up.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -26,71 +21,32 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Svg,
|
|
||||||
Polygon,
|
|
||||||
Polyline,
|
|
||||||
renderToBuffer,
|
renderToBuffer,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import type { CreditNote, Invoice } from "@/types";
|
import type { CreditNote, Invoice } from "@/types";
|
||||||
|
import { BRAND, Logo, ACCENT_CREDIT_NOTE } from "./pdf-brand";
|
||||||
|
|
||||||
|
const ACCENT = ACCENT_CREDIT_NOTE;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Brand constants — keep in sync with billing-pdf.tsx until the
|
// Localized strings
|
||||||
// shared brand module lands.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BRAND = {
|
|
||||||
name: "PieCed IT",
|
|
||||||
// Red accent for credit notes — visually distinct from the invoice
|
|
||||||
// emerald so customers can't confuse the two at a glance.
|
|
||||||
primary: "#DC2626",
|
|
||||||
primaryDark: "#991B1B",
|
|
||||||
textColor: "#1a1a1a",
|
|
||||||
mutedColor: "#666",
|
|
||||||
borderColor: "#d4d4d4",
|
|
||||||
issuer: {
|
|
||||||
legalName: "PieCed IT",
|
|
||||||
addressLine1: "Cedric Mosimann",
|
|
||||||
addressLine2: "[Strasse Nr.]",
|
|
||||||
postalCity: "[PLZ] Basel",
|
|
||||||
country: "Switzerland",
|
|
||||||
email: "billing@pieced.ch",
|
|
||||||
web: "pieced.ch",
|
|
||||||
vatNumber: null as string | null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Localized strings (mirrors PdfStrings in billing-pdf.tsx, adapted
|
|
||||||
// for the credit-note context)
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CreditNoteStrings {
|
interface CreditNoteStrings {
|
||||||
/** Document title at the top — "Gutschrift" etc. */
|
|
||||||
creditNote: string;
|
creditNote: string;
|
||||||
/** "Credit note no." label */
|
|
||||||
creditNoteNumber: string;
|
creditNoteNumber: string;
|
||||||
/** "Issue date" label */
|
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
/** "Bill to" label, same as invoice */
|
|
||||||
billTo: string;
|
billTo: string;
|
||||||
/** "Attn:" / "z.Hd." prefix */
|
|
||||||
attentionPrefix: string;
|
attentionPrefix: string;
|
||||||
/** "Reference invoice" — links the credit note back to the original */
|
|
||||||
referenceInvoice: string;
|
referenceInvoice: string;
|
||||||
/** "Reason" label for the free-text reason block */
|
|
||||||
reason: string;
|
reason: string;
|
||||||
/** Body text describing what this credit note is for. Takes the
|
|
||||||
* invoice number as a `{number}` placeholder. */
|
|
||||||
voidLineLabel: string;
|
voidLineLabel: string;
|
||||||
refundLineLabel: string;
|
refundLineLabel: string;
|
||||||
/** Totals labels — same words as invoice but separated for clarity */
|
|
||||||
subtotal: string;
|
subtotal: string;
|
||||||
vatLabel: string;
|
vatLabel: string;
|
||||||
totalCredited: string;
|
totalCredited: string;
|
||||||
/** Footer note explaining the document */
|
|
||||||
footerVoidNote: string;
|
footerVoidNote: string;
|
||||||
footerRefundNote: string;
|
footerRefundNote: string;
|
||||||
/** VAT note (reverse-charge or normal) — same logic as invoice */
|
|
||||||
vatNoteSwiss: string;
|
vatNoteSwiss: string;
|
||||||
vatNoteReverseCharge: string;
|
vatNoteReverseCharge: string;
|
||||||
vatNoteOutOfScope: string;
|
vatNoteOutOfScope: string;
|
||||||
@@ -195,51 +151,38 @@ const MESSAGES: Record<string, CreditNoteStrings> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function pickStrings(locale: string): CreditNoteStrings {
|
function pickStrings(locale: string): CreditNoteStrings {
|
||||||
return MESSAGES[locale] ?? MESSAGES.de;
|
return MESSAGES[locale] ?? MESSAGES.de;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swiss number formatting — matches billing-pdf for consistency
|
||||||
function fmtChf(n: number): string {
|
function fmtChf(n: number): string {
|
||||||
return n.toLocaleString("de-CH", {
|
const fixed = n.toFixed(2);
|
||||||
minimumFractionDigits: 2,
|
const [intPart, decPart] = fixed.split(".");
|
||||||
maximumFractionDigits: 2,
|
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
});
|
return decPart ? `${withSep}.${decPart}` : withSep;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso: string, locale: string): string {
|
function fmtDate(iso: string, locale: string): string {
|
||||||
const d = new Date(iso);
|
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||||
const localeMap: Record<string, string> = {
|
if (locale === "en") {
|
||||||
de: "de-CH",
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||||
en: "en-GB",
|
year: "numeric",
|
||||||
fr: "fr-CH",
|
month: "short",
|
||||||
it: "it-CH",
|
day: "numeric",
|
||||||
};
|
});
|
||||||
return d.toLocaleDateString(localeMap[locale] ?? "de-CH", {
|
}
|
||||||
year: "numeric",
|
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickVatNote(
|
function pickVatNote(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
strings: CreditNoteStrings
|
strings: CreditNoteStrings
|
||||||
): string | null {
|
): string | null {
|
||||||
// Mirror the invoice's VAT note logic — the credit note's VAT
|
|
||||||
// treatment must match the original invoice's, otherwise the
|
|
||||||
// accounting wouldn't reconcile.
|
|
||||||
const country = invoice.billingSnapshot.country?.toUpperCase();
|
const country = invoice.billingSnapshot.country?.toUpperCase();
|
||||||
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
||||||
if (country === "CH" || country === "LI") {
|
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
|
||||||
return strings.vatNoteSwiss;
|
if (hasVat) return strings.vatNoteReverseCharge;
|
||||||
}
|
|
||||||
if (hasVat) {
|
|
||||||
return strings.vatNoteReverseCharge;
|
|
||||||
}
|
|
||||||
return strings.vatNoteOutOfScope;
|
return strings.vatNoteOutOfScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,15 +207,15 @@ const styles = StyleSheet.create({
|
|||||||
logoBlock: { flexDirection: "row", alignItems: "center" },
|
logoBlock: { flexDirection: "row", alignItems: "center" },
|
||||||
brandName: {
|
brandName: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: BRAND.primaryDark,
|
color: ACCENT.primaryDark,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
},
|
},
|
||||||
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||||
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
issuerName: { fontSize: 11, color: ACCENT.primaryDark, marginBottom: 2 },
|
||||||
docTitle: {
|
docTitle: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
color: BRAND.primaryDark,
|
color: ACCENT.primaryDark,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
},
|
},
|
||||||
@@ -289,7 +232,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
backgroundColor: "#fdf2f2",
|
backgroundColor: "#fdf2f2",
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 3,
|
||||||
borderLeftColor: BRAND.primary,
|
borderLeftColor: ACCENT.primary,
|
||||||
},
|
},
|
||||||
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||||
billToName: { fontSize: 11, marginBottom: 2 },
|
billToName: { fontSize: 11, marginBottom: 2 },
|
||||||
@@ -302,7 +245,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
amountHeader: {
|
amountHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
backgroundColor: BRAND.primaryDark,
|
backgroundColor: ACCENT.primaryDark,
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
@@ -318,11 +261,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
amountDesc: { flex: 1 },
|
amountDesc: { flex: 1 },
|
||||||
amountValue: { width: 90, textAlign: "right" },
|
amountValue: { width: 90, textAlign: "right" },
|
||||||
totals: {
|
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
|
||||||
marginLeft: "auto",
|
|
||||||
width: 220,
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
totalsRow: {
|
totalsRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -334,17 +273,17 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: BRAND.primaryDark,
|
borderTopColor: ACCENT.primaryDark,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
totalsGrandLabel: {
|
totalsGrandLabel: {
|
||||||
color: BRAND.primaryDark,
|
color: ACCENT.primaryDark,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
},
|
},
|
||||||
totalsGrandValue: {
|
totalsGrandValue: {
|
||||||
color: BRAND.primaryDark,
|
color: ACCENT.primaryDark,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
@@ -386,26 +325,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mini SVG logo — identical shape to billing-pdf, just recolored
|
|
||||||
// to the credit-note red so it matches the document accent.
|
|
||||||
function Logo() {
|
|
||||||
return (
|
|
||||||
<Svg width={26} height={26} viewBox="0 0 100 100">
|
|
||||||
<Polygon points="50,15 80,40 65,80 35,80 20,40" fill={BRAND.primary} />
|
|
||||||
<Polyline
|
|
||||||
points="35,80 50,60 65,80"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth={3}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main document
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface CreditNotePdfProps {
|
interface CreditNotePdfProps {
|
||||||
creditNote: CreditNote;
|
creditNote: CreditNote;
|
||||||
invoice: Invoice;
|
invoice: Invoice;
|
||||||
@@ -417,20 +336,24 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
const vatNote = pickVatNote(invoice, strings);
|
const vatNote = pickVatNote(invoice, strings);
|
||||||
const amountLabelTemplate =
|
const amountLabelTemplate =
|
||||||
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
||||||
const amountLabel = amountLabelTemplate.replace("{number}", invoice.invoiceNumber);
|
const amountLabel = amountLabelTemplate.replace(
|
||||||
|
"{number}",
|
||||||
|
invoice.invoiceNumber
|
||||||
|
);
|
||||||
const footerNote =
|
const footerNote =
|
||||||
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
||||||
// Subtotal + VAT breakdown. We carry the VAT proportion from the
|
// Stored convention: amount_chf is gross (incl. VAT),
|
||||||
// credit note row itself (set by billing.ts based on the invoice's
|
// vat_amount_chf is the VAT portion. Subtotal computed for
|
||||||
// VAT rate × the refund/void amount).
|
// display.
|
||||||
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
{/* Header: logo+brand left, issuer block right */}
|
{/* Header — SAME hexagon logo as the invoice, tinted red.
|
||||||
|
Issuer block from BRAND.issuer (shared with invoice). */}
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<View style={styles.logoBlock}>
|
<View style={styles.logoBlock}>
|
||||||
<Logo />
|
<Logo size={42} color={ACCENT.primary} />
|
||||||
<Text style={styles.brandName}>{BRAND.name}</Text>
|
<Text style={styles.brandName}>{BRAND.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.issuerBlock}>
|
<View style={styles.issuerBlock}>
|
||||||
@@ -449,7 +372,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
|
|
||||||
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
||||||
|
|
||||||
{/* Meta row: number, issue date, reference invoice */}
|
|
||||||
<View style={styles.metaTable}>
|
<View style={styles.metaTable}>
|
||||||
<View style={styles.metaCol}>
|
<View style={styles.metaCol}>
|
||||||
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
||||||
@@ -467,7 +389,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bill-to (mirrors invoice block) */}
|
|
||||||
<View style={styles.billTo}>
|
<View style={styles.billTo}>
|
||||||
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
||||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
@@ -484,7 +405,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Amount line — single row, the credit note isn't itemized */}
|
|
||||||
<View style={styles.amountTable}>
|
<View style={styles.amountTable}>
|
||||||
<View style={styles.amountHeader}>
|
<View style={styles.amountHeader}>
|
||||||
<Text style={styles.amountDesc}> </Text>
|
<Text style={styles.amountDesc}> </Text>
|
||||||
@@ -496,7 +416,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<View style={styles.totals}>
|
<View style={styles.totals}>
|
||||||
<View style={styles.totalsRow}>
|
<View style={styles.totalsRow}>
|
||||||
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
||||||
@@ -520,7 +439,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Reason block — only if the admin provided one */}
|
|
||||||
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
||||||
<View style={styles.reasonBox}>
|
<View style={styles.reasonBox}>
|
||||||
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
||||||
@@ -528,13 +446,11 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer note explaining what this document is */}
|
|
||||||
<View style={styles.noteBox}>
|
<View style={styles.noteBox}>
|
||||||
<Text>{footerNote}</Text>
|
<Text>{footerNote}</Text>
|
||||||
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tiny footer with credit-note number on every page */}
|
|
||||||
<Text style={styles.footer} fixed>
|
<Text style={styles.footer} fixed>
|
||||||
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -548,6 +464,5 @@ export async function renderCreditNotePdf(
|
|||||||
invoice: Invoice
|
invoice: Invoice
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
||||||
// @react-pdf/renderer's renderToBuffer returns a Node Buffer.
|
|
||||||
return renderToBuffer(doc) as unknown as Buffer;
|
return renderToBuffer(doc) as unknown as Buffer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,6 +578,36 @@ const MIGRATION_SQL = `
|
|||||||
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
|
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
|
||||||
ON invoice_refunds(invoice_id);
|
ON invoice_refunds(invoice_id);
|
||||||
|
|
||||||
|
-- Phase 7 fix: the credit_notes.invoice_id and
|
||||||
|
-- invoice_refunds.invoice_id FKs were originally created without
|
||||||
|
-- ON DELETE CASCADE, which made admin "delete invoice" fail with
|
||||||
|
-- a FK violation when the invoice had any voids/refunds attached.
|
||||||
|
-- The production policy is to never delete an invoice that's been
|
||||||
|
-- refunded (the credit notes are part of the customer's records),
|
||||||
|
-- but the schema should allow the admin tool to clean up for test
|
||||||
|
-- data. We drop and re-add the FKs with CASCADE so a delete tears
|
||||||
|
-- down everything related. The DROP/ADD pair is idempotent — on a
|
||||||
|
-- DB that already has the CASCADE variant it's a no-op (we drop
|
||||||
|
-- by name and re-add identically).
|
||||||
|
DO $cnfk$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE credit_notes
|
||||||
|
DROP CONSTRAINT IF EXISTS credit_notes_invoice_id_fkey;
|
||||||
|
ALTER TABLE credit_notes
|
||||||
|
ADD CONSTRAINT credit_notes_invoice_id_fkey
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
|
||||||
|
END
|
||||||
|
$cnfk$;
|
||||||
|
DO $irfk$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE invoice_refunds
|
||||||
|
DROP CONSTRAINT IF EXISTS invoice_refunds_invoice_id_fkey;
|
||||||
|
ALTER TABLE invoice_refunds
|
||||||
|
ADD CONSTRAINT invoice_refunds_invoice_id_fkey
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
|
||||||
|
END
|
||||||
|
$irfk$;
|
||||||
|
|
||||||
-- Invoice line items. The kind column lets the PDF renderer
|
-- Invoice line items. The kind column lets the PDF renderer
|
||||||
-- group lines (all monthly fees together, all AI usage together,
|
-- group lines (all monthly fees together, all AI usage together,
|
||||||
-- etc.) and the admin UI filter by category.
|
-- etc.) and the admin UI filter by category.
|
||||||
|
|||||||
126
src/lib/pdf-brand.tsx
Normal file
126
src/lib/pdf-brand.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Shared brand constants and Logo component for all PDF documents
|
||||||
|
* (invoices, credit notes, future quotes / reminders).
|
||||||
|
*
|
||||||
|
* Phase 7 fix: previously each PDF generator carried its own copy
|
||||||
|
* of BRAND and its own Logo. When Cedric customized the invoice
|
||||||
|
* issuer block in his deployment (real Strasse Nr., PLZ, etc.),
|
||||||
|
* the credit note PDF kept the original placeholders because it
|
||||||
|
* had its own duplicate. Hoisting both here means every PDF reads
|
||||||
|
* the same source of truth.
|
||||||
|
*
|
||||||
|
* To change the brand: edit BRAND below. To change the logo:
|
||||||
|
* edit Logo below. To change the issuer info Cedric ships: edit
|
||||||
|
* BRAND.issuer — both billing-pdf.tsx and credit-note-pdf.tsx pick
|
||||||
|
* it up automatically.
|
||||||
|
*
|
||||||
|
* The Logo component accepts a `color` prop so the credit-note
|
||||||
|
* variant can render the SAME shape tinted red (the document
|
||||||
|
* family is visually consistent; only the accent colour signals
|
||||||
|
* "this is a credit, not an invoice").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Svg, Polygon, Polyline } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Brand constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const BRAND = {
|
||||||
|
name: "PieCed IT",
|
||||||
|
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||||
|
primary: "#10B981",
|
||||||
|
// Slightly darker emerald for headings.
|
||||||
|
primaryDark: "#0a8060",
|
||||||
|
textColor: "#1a1a1a",
|
||||||
|
mutedColor: "#666",
|
||||||
|
borderColor: "#d4d4d4",
|
||||||
|
// Issuer block — change these to your real legal info.
|
||||||
|
// Both billing-pdf.tsx and credit-note-pdf.tsx read from here.
|
||||||
|
issuer: {
|
||||||
|
legalName: "PieCed IT",
|
||||||
|
addressLine1: "Cedric Mosimann",
|
||||||
|
addressLine2: "[Strasse Nr.]",
|
||||||
|
postalCity: "[PLZ] Basel",
|
||||||
|
country: "Switzerland",
|
||||||
|
email: "billing@pieced.ch",
|
||||||
|
web: "pieced.ch",
|
||||||
|
// Show "MWST-Nr. ..." on PDF when set.
|
||||||
|
vatNumber: null as string | null,
|
||||||
|
// Bank instructions — used by invoice PDF, ignored on credit
|
||||||
|
// notes (refunds flow back via the original payment method).
|
||||||
|
bankName: "[Bank name]",
|
||||||
|
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||||
|
bankBic: "[BIC]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accent colours for document variants — credit notes are red so
|
||||||
|
// customers can tell them apart from invoices at a glance.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ACCENT_CREDIT_NOTE = {
|
||||||
|
primary: "#DC2626",
|
||||||
|
primaryDark: "#991B1B",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere;
|
||||||
|
// only the colour changes per document type.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
size?: number;
|
||||||
|
/** Defaults to BRAND.primary (emerald). Pass ACCENT_CREDIT_NOTE.primary
|
||||||
|
* on credit notes for the red variant. */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Logo = ({ size = 60, color = BRAND.primary }: LogoProps) => (
|
||||||
|
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||||
|
{/* H1 solid */}
|
||||||
|
<Polygon
|
||||||
|
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||||
|
fill={color}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
|
{/* H2 outline */}
|
||||||
|
<Polygon
|
||||||
|
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H3 outline */}
|
||||||
|
<Polygon
|
||||||
|
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H4 solid */}
|
||||||
|
<Polygon
|
||||||
|
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||||
|
fill={color}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
|
{/* H5 partial */}
|
||||||
|
<Polyline
|
||||||
|
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H6 partial */}
|
||||||
|
<Polyline
|
||||||
|
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
@@ -699,7 +699,10 @@
|
|||||||
"creditNotePdfHeader": "PDF",
|
"creditNotePdfHeader": "PDF",
|
||||||
"creditNoteKind_void": "Storno",
|
"creditNoteKind_void": "Storno",
|
||||||
"creditNoteKind_refund": "Rückerstattung",
|
"creditNoteKind_refund": "Rückerstattung",
|
||||||
"creditNoteNoPdf": "—"
|
"creditNoteNoPdf": "—",
|
||||||
|
"refundAmountLabel": "Betrag",
|
||||||
|
"refundReasonLabel": "Grund",
|
||||||
|
"refundAmountInclVatHint": "inkl. MWST"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Aktivierungskosten bestätigen",
|
"title": "Aktivierungskosten bestätigen",
|
||||||
@@ -772,7 +775,9 @@
|
|||||||
"paid": "Bezahlt",
|
"paid": "Bezahlt",
|
||||||
"overdue": "Überfällig",
|
"overdue": "Überfällig",
|
||||||
"void": "Storniert",
|
"void": "Storniert",
|
||||||
"uncollectible": "Uneinbringlich"
|
"uncollectible": "Uneinbringlich",
|
||||||
|
"partially_refunded": "Teilrückerstattung",
|
||||||
|
"fully_refunded": "Vollständig rückerstattet"
|
||||||
},
|
},
|
||||||
"payWithCard": "Mit Karte bezahlen",
|
"payWithCard": "Mit Karte bezahlen",
|
||||||
"redirectingToStripe": "Weiterleitung…",
|
"redirectingToStripe": "Weiterleitung…",
|
||||||
|
|||||||
@@ -699,7 +699,10 @@
|
|||||||
"creditNotePdfHeader": "PDF",
|
"creditNotePdfHeader": "PDF",
|
||||||
"creditNoteKind_void": "Void",
|
"creditNoteKind_void": "Void",
|
||||||
"creditNoteKind_refund": "Refund",
|
"creditNoteKind_refund": "Refund",
|
||||||
"creditNoteNoPdf": "—"
|
"creditNoteNoPdf": "—",
|
||||||
|
"refundAmountLabel": "Amount",
|
||||||
|
"refundReasonLabel": "Reason",
|
||||||
|
"refundAmountInclVatHint": "incl. VAT"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Confirm activation cost",
|
"title": "Confirm activation cost",
|
||||||
@@ -772,7 +775,9 @@
|
|||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"overdue": "Overdue",
|
"overdue": "Overdue",
|
||||||
"void": "Void",
|
"void": "Void",
|
||||||
"uncollectible": "Uncollectible"
|
"uncollectible": "Uncollectible",
|
||||||
|
"partially_refunded": "Partially refunded",
|
||||||
|
"fully_refunded": "Fully refunded"
|
||||||
},
|
},
|
||||||
"payWithCard": "Pay with card",
|
"payWithCard": "Pay with card",
|
||||||
"redirectingToStripe": "Redirecting…",
|
"redirectingToStripe": "Redirecting…",
|
||||||
|
|||||||
@@ -699,7 +699,10 @@
|
|||||||
"creditNotePdfHeader": "PDF",
|
"creditNotePdfHeader": "PDF",
|
||||||
"creditNoteKind_void": "Annulation",
|
"creditNoteKind_void": "Annulation",
|
||||||
"creditNoteKind_refund": "Remboursement",
|
"creditNoteKind_refund": "Remboursement",
|
||||||
"creditNoteNoPdf": "—"
|
"creditNoteNoPdf": "—",
|
||||||
|
"refundAmountLabel": "Montant",
|
||||||
|
"refundReasonLabel": "Motif",
|
||||||
|
"refundAmountInclVatHint": "TVA incluse"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Confirmer le coût d'activation",
|
"title": "Confirmer le coût d'activation",
|
||||||
@@ -772,7 +775,9 @@
|
|||||||
"paid": "Payée",
|
"paid": "Payée",
|
||||||
"overdue": "En retard",
|
"overdue": "En retard",
|
||||||
"void": "Annulée",
|
"void": "Annulée",
|
||||||
"uncollectible": "Irrécouvrable"
|
"uncollectible": "Irrécouvrable",
|
||||||
|
"partially_refunded": "Partiellement remboursée",
|
||||||
|
"fully_refunded": "Entièrement remboursée"
|
||||||
},
|
},
|
||||||
"payWithCard": "Payer par carte",
|
"payWithCard": "Payer par carte",
|
||||||
"redirectingToStripe": "Redirection…",
|
"redirectingToStripe": "Redirection…",
|
||||||
|
|||||||
@@ -699,7 +699,10 @@
|
|||||||
"creditNotePdfHeader": "PDF",
|
"creditNotePdfHeader": "PDF",
|
||||||
"creditNoteKind_void": "Annullamento",
|
"creditNoteKind_void": "Annullamento",
|
||||||
"creditNoteKind_refund": "Rimborso",
|
"creditNoteKind_refund": "Rimborso",
|
||||||
"creditNoteNoPdf": "—"
|
"creditNoteNoPdf": "—",
|
||||||
|
"refundAmountLabel": "Importo",
|
||||||
|
"refundReasonLabel": "Motivo",
|
||||||
|
"refundAmountInclVatHint": "IVA inclusa"
|
||||||
},
|
},
|
||||||
"skillCostDialog": {
|
"skillCostDialog": {
|
||||||
"title": "Conferma costi di attivazione",
|
"title": "Conferma costi di attivazione",
|
||||||
@@ -772,7 +775,9 @@
|
|||||||
"paid": "Pagata",
|
"paid": "Pagata",
|
||||||
"overdue": "In ritardo",
|
"overdue": "In ritardo",
|
||||||
"void": "Annullata",
|
"void": "Annullata",
|
||||||
"uncollectible": "Inesigibile"
|
"uncollectible": "Inesigibile",
|
||||||
|
"partially_refunded": "Rimborsata parzialmente",
|
||||||
|
"fully_refunded": "Rimborsata integralmente"
|
||||||
},
|
},
|
||||||
"payWithCard": "Paga con carta",
|
"payWithCard": "Paga con carta",
|
||||||
"redirectingToStripe": "Reindirizzamento…",
|
"redirectingToStripe": "Reindirizzamento…",
|
||||||
|
|||||||
Reference in New Issue
Block a user