Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 |
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
@@ -9,8 +9,12 @@ import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-vie
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||
* download) is a client component for the interactive bits.
|
||||
*
|
||||
* Phase 7: also passes any linked credit notes so the detail view
|
||||
* can show the "this invoice was voided / partially refunded" panel
|
||||
* without an extra round-trip.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
@@ -25,11 +29,12 @@ export default async function AdminInvoiceDetailPage({
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
const creditNotes = await listCreditNotesForInvoice(id);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import {
|
||||
listCreditNotesForOrg,
|
||||
listInvoices,
|
||||
syncOverdueInvoices,
|
||||
} from "@/lib/db";
|
||||
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
|
||||
/**
|
||||
* /billing — customer's billing home.
|
||||
*
|
||||
* Shows two things:
|
||||
* Shows three things:
|
||||
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||
* (or the already-issued invoice for the current month, if
|
||||
* that ran early).
|
||||
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||
* newest first. Status is reflected with a colored badge.
|
||||
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||
* refunds) for this org, with PDF download links. Hidden
|
||||
* entirely when there are none (the common case).
|
||||
*
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view. Phase 4 will add a
|
||||
* "settings.payByInvoice" toggle visibility-gated to owners only.
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
@@ -31,10 +38,11 @@ export default async function CustomerBillingPage() {
|
||||
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||
}
|
||||
|
||||
const invoices = await listInvoices({
|
||||
zitadelOrgId: user.orgId,
|
||||
limit: 200,
|
||||
});
|
||||
// Parallel fetch — invoices + credit notes are independent.
|
||||
const [invoices, creditNotes] = await Promise.all([
|
||||
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||
listCreditNotesForOrg(user.orgId, 200),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
@@ -54,12 +62,24 @@ export default async function CustomerBillingPage() {
|
||||
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<section className="animate-in animate-in-delay-2 mb-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("historyHeading")}
|
||||
</h2>
|
||||
<CustomerInvoiceList invoices={invoices} />
|
||||
</section>
|
||||
|
||||
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||
returns null when there are no credit notes, so this whole
|
||||
section disappears for orgs in normal operation. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<section className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("creditNotesHeading")}
|
||||
</h2>
|
||||
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export default async function SettingsPage() {
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
// Build the list of settings cards. Each entry has a stable key, a
|
||||
// route, and a visibility predicate. Currently only billing; this
|
||||
// shape leaves headroom for adding more without restructuring.
|
||||
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||
// visible to every signed-in user (it's their own identity).
|
||||
// Billing stays gated behind canMutate.
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
href: string;
|
||||
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
|
||||
description: string;
|
||||
visible: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: "profile",
|
||||
href: "/settings/profile",
|
||||
title: t("profileTitle"),
|
||||
description: t("profileDescription"),
|
||||
// Every signed-in user can edit their own first/last name.
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
href: "/settings/billing",
|
||||
|
||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||
|
||||
/**
|
||||
* /settings/profile — every authenticated user can edit their own
|
||||
* first + last name. Email is shown read-only; changing it requires
|
||||
* verification and is left to ZITADEL's own self-service flow.
|
||||
*
|
||||
* Personal vs company accounts:
|
||||
* - Both can edit their first/last name in ZITADEL.
|
||||
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||
* does NOT change how the customer's name appears on invoices.
|
||||
* Invoice identity is in org_billing.company_name (the "Full
|
||||
* name" field on /settings/billing) and is intentionally
|
||||
* editable separately, because legal/billing identity may not
|
||||
* match preferred display identity.
|
||||
* - Company accounts see an org-membership hint instead.
|
||||
*
|
||||
* Server-fetches the current profile from ZITADEL via the
|
||||
* service-account PAT so the form starts with the canonical values
|
||||
* rather than whatever happens to be in the JWT (the JWT name might
|
||||
* be stale if the user updated their name in ZITADEL Console).
|
||||
*/
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("settingsProfile");
|
||||
|
||||
let initial = { firstName: "", lastName: "", email: user.email };
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
initial = {
|
||||
firstName: profile.givenName,
|
||||
lastName: profile.familyName,
|
||||
email: profile.email || user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
// Identity provider unreachable: render the form with whatever
|
||||
// we know from the session. The session has a combined `name`,
|
||||
// not split parts, so we leave first/last empty and let the user
|
||||
// re-enter. Server logs catch the underlying failure.
|
||||
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">
|
||||
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
<ProfileSettingsForm
|
||||
initial={initial}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/refund
|
||||
*
|
||||
* Phase 7. Refunds a paid invoice (full or partial) and issues a
|
||||
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
|
||||
* before any local recording. For invoice-paid customers (bank
|
||||
* transfer), records the refund locally and assumes the admin
|
||||
* handled the actual money movement out-of-band.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* amountChf: number, // positive, <= remaining refundable
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — refund issued, credit note returned
|
||||
* 400 — bad request (zero/negative amount, etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a refundable state, or amount exceeds remaining
|
||||
* 500 — Stripe call failed or another internal error
|
||||
*
|
||||
* Idempotency caveats: this endpoint is NOT idempotent against
|
||||
* client retries. Issuing two refunds quickly will result in two
|
||||
* Stripe refund calls (and two credit notes). The admin UI should
|
||||
* disable the submit button while the request is in flight to
|
||||
* prevent accidental double-clicks. The Stripe charge.refunded
|
||||
* webhook is idempotent and will not double-count if it fires
|
||||
* after this endpoint already recorded the refund.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
amountChf: z.number().positive().multipleOf(0.01),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await refundInvoice({
|
||||
invoiceId: id,
|
||||
amountChf: parsed.data.amountChf,
|
||||
reason: parsed.data.reason,
|
||||
refundedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Refund failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/billing/invoices/[id]/void
|
||||
*
|
||||
* Phase 7. Voids an unpaid invoice and issues a credit note.
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* reason: string // required, free-text, max 500
|
||||
* }
|
||||
*
|
||||
* Authorization: platform admin (same as mark-paid, generate, etc.).
|
||||
* The acting user's ID lands in invoices.voided_by and on the
|
||||
* credit_notes.issued_by audit columns.
|
||||
*
|
||||
* Status codes:
|
||||
* 200 — voided, credit note returned in body
|
||||
* 400 — bad request (missing reason etc.)
|
||||
* 401 / 403 — not authenticated / not platform admin
|
||||
* 409 — invoice not in a voidable state
|
||||
* 500 — anything else (Stripe shouldn't apply here, but if PDF
|
||||
* render fails the void still went through — see body
|
||||
* payload for the credit-note number to re-render later)
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
let user;
|
||||
try {
|
||||
await requirePlatformRole();
|
||||
user = await getSessionUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const creditNote = await voidInvoice({
|
||||
invoiceId: id,
|
||||
reason: parsed.data.reason,
|
||||
voidedBy: user.id,
|
||||
});
|
||||
return NextResponse.json({ creditNote });
|
||||
} catch (e) {
|
||||
if (e instanceof VoidNotAllowedError) {
|
||||
return NextResponse.json(
|
||||
{ error: e.message, currentStatus: e.currentStatus },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: safeError(e, "Void failed") },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getCreditNoteByNumber,
|
||||
getCreditNoteByNumberForOrg,
|
||||
getCreditNotePdf,
|
||||
} from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/credit-notes/[number]/pdf
|
||||
*
|
||||
* Phase 7. Customer-facing PDF download for a credit note. Returns
|
||||
* the binary PDF with Content-Disposition: inline so the browser
|
||||
* renders it in-tab (matching the invoice download behaviour). The
|
||||
* customer's email links here.
|
||||
*
|
||||
* Authorization:
|
||||
* - The caller must be authenticated.
|
||||
* - For customer-org callers, the credit note must belong to their
|
||||
* org (orgId-scoped lookup).
|
||||
* - Platform admins can fetch any credit note (cross-org lookup).
|
||||
*
|
||||
* Returns 404 in both "doesn't exist" and "exists but not yours"
|
||||
* cases — leak-safe identical to invoice lookup.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { number } = await params;
|
||||
// URL-decoded number — the route param comes URL-encoded.
|
||||
const decodedNumber = decodeURIComponent(number);
|
||||
const cn = user.isPlatform
|
||||
? await getCreditNoteByNumber(decodedNumber)
|
||||
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
|
||||
if (!cn) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const pdf = await getCreditNotePdf(cn.id);
|
||||
if (!pdf) {
|
||||
// The credit note exists but the PDF was never attached. Most
|
||||
// likely a render failure during issuance — the credit note
|
||||
// row is still authoritative, the PDF needs re-rendering.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Credit note exists but its PDF has not been rendered. Please contact support.",
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
return new NextResponse(new Uint8Array(pdf.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||
"Cache-Control": "private, no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import {
|
||||
getHumanUserDetail,
|
||||
updateHumanUserProfile,
|
||||
} from "@/lib/zitadel";
|
||||
|
||||
/**
|
||||
* GET /api/settings/profile — read the caller's ZITADEL profile.
|
||||
* Returns first/last/display name and email. Used by the settings
|
||||
* page server component to populate the form.
|
||||
*
|
||||
* PUT /api/settings/profile — update first + last name. Email is
|
||||
* NOT mutable here — changing email needs verification flow that
|
||||
* ZITADEL's own self-service UI already provides; we don't
|
||||
* duplicate that.
|
||||
*
|
||||
* Authorization: any authenticated user can edit their own profile.
|
||||
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
|
||||
* service, but only against the caller's own userId. There is no
|
||||
* userId field on the request — it's always derived from the
|
||||
* session, so the route can't be abused to edit other users.
|
||||
*/
|
||||
|
||||
const updateSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const profile = await getHumanUserDetail(user.id);
|
||||
return NextResponse.json({ profile });
|
||||
} catch (e: any) {
|
||||
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
|
||||
// as 502 — the portal couldn't reach its identity provider, which
|
||||
// is operationally different from a 4xx on the caller's input.
|
||||
console.error("getHumanUserDetail failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not load profile from identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await updateHumanUserProfile({
|
||||
userId: user.id,
|
||||
givenName: parsed.data.firstName,
|
||||
familyName: parsed.data.lastName,
|
||||
});
|
||||
return NextResponse.json({
|
||||
displayName: result.displayName,
|
||||
changeDate: result.changeDate,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("updateHumanUserProfile failed:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not update profile in identity provider" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
isStripeRefundRecorded,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
|
||||
/**
|
||||
* POST /api/stripe/webhook
|
||||
@@ -209,13 +212,103 @@ async function handleCheckoutCompleted(
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// v1 scope: log only. Refunds always go through Stripe → admin
|
||||
// initiates them in the dashboard. Updating our invoice status
|
||||
// to 'void' or partial-credit needs more product thinking
|
||||
// (partial refunds? credit notes? VAT corrections?). Phase 7.
|
||||
console.log(
|
||||
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
|
||||
);
|
||||
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||
// no-op (each refund's stripe_refund_id is already recorded
|
||||
// before the webhook lands — refundInvoice records it
|
||||
// synchronously after the Stripe API call).
|
||||
//
|
||||
// A charge can have multiple refund objects (multiple partial
|
||||
// refunds against the same charge accumulate here). We iterate
|
||||
// and process any that aren't yet recorded in our DB.
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string"
|
||||
? charge.payment_intent
|
||||
: charge.payment_intent?.id;
|
||||
if (!paymentIntentId) {
|
||||
console.error(
|
||||
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
|
||||
if (!invoice) {
|
||||
console.error(
|
||||
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const refundsList = charge.refunds?.data ?? [];
|
||||
if (refundsList.length === 0) {
|
||||
// Some charge.refunded events fire with the refunds list
|
||||
// collapsed (the object includes the aggregated amount_refunded
|
||||
// but the data array can be omitted depending on Stripe's
|
||||
// expansion choices). In that case there's nothing for us to
|
||||
// iterate over here; the actual `refund.created` /
|
||||
// `refund.updated` events carry per-refund detail and we'd need
|
||||
// to enable those in Stripe to handle them. For v1 we log and
|
||||
// rely on the in-portal admin path (refundInvoice) being the
|
||||
// only refund initiator.
|
||||
console.log(
|
||||
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const refund of refundsList) {
|
||||
try {
|
||||
// Idempotency: skip refunds we already recorded (either via
|
||||
// portal admin action or a prior webhook delivery).
|
||||
if (await isStripeRefundRecorded(refund.id)) {
|
||||
continue;
|
||||
}
|
||||
const amountChf = (refund.amount ?? 0) / 100;
|
||||
if (amountChf <= 0) continue;
|
||||
// Map Stripe refund status to ours. Anything other than the
|
||||
// canonical four falls through to 'pending' so we don't lose
|
||||
// the record entirely.
|
||||
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
|
||||
if (refund.status === "succeeded") status = "succeeded";
|
||||
else if (refund.status === "failed") status = "failed";
|
||||
else if (refund.status === "canceled") status = "canceled";
|
||||
// For refunds that originated in Stripe Dashboard we don't
|
||||
// have a reason to display. Use a sentinel string so the
|
||||
// credit note PDF has something to print. Admin can edit
|
||||
// post-hoc if needed (no UI for that today, but the DB row
|
||||
// is reachable).
|
||||
const reason = refund.reason
|
||||
? `Stripe Dashboard: ${refund.reason}`
|
||||
: "Refund issued via Stripe Dashboard";
|
||||
// refundInvoice with existingStripeRefund: don't call Stripe
|
||||
// again (we'd error since the refund already exists), just
|
||||
// mirror the record into our DB and issue the credit note.
|
||||
await refundInvoice({
|
||||
invoiceId: invoice.id,
|
||||
amountChf,
|
||||
reason,
|
||||
refundedBy: "stripe-webhook",
|
||||
existingStripeRefund: { id: refund.id, status },
|
||||
});
|
||||
console.log(
|
||||
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof RefundNotAllowedError) {
|
||||
// The invoice was already fully refunded by an earlier
|
||||
// webhook delivery or by an in-portal action. That's fine.
|
||||
console.log(
|
||||
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// For any other error, log but continue to the next refund —
|
||||
// we don't want one bad refund to block the rest.
|
||||
console.error(
|
||||
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(
|
||||
|
||||
@@ -4,33 +4,61 @@ import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
detail: InvoiceDetail;
|
||||
/**
|
||||
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
||||
* Empty array when none. Passed from the server page; client
|
||||
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
||||
*/
|
||||
creditNotes?: CreditNote[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the invoice header (status, totals, action bar) then
|
||||
* line items grouped by tenant, then billing snapshot. Actions are
|
||||
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
||||
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
||||
* PDF download (link to /pdf).
|
||||
*
|
||||
* Phase 7 adds void + refund. The action bar shows:
|
||||
* - status open/overdue → Mark paid, Void, Delete
|
||||
* - status paid → Refund, Delete
|
||||
* - status partially_refunded → Refund (for remainder), Delete
|
||||
* - status fully_refunded / void → Delete only (read-only otherwise)
|
||||
*
|
||||
* On successful action we router.refresh() — the server-side page
|
||||
* re-renders against the new DB state. For delete we navigate
|
||||
* away first.
|
||||
* re-renders against the new DB state, including any new credit
|
||||
* notes.
|
||||
*/
|
||||
export function InvoiceDetailView({ detail }: Props) {
|
||||
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const { invoice, lines } = detail;
|
||||
|
||||
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
||||
null
|
||||
);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
null | "mark-paid" | "delete" | "void" | "refund"
|
||||
>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [noteInput, setNoteInput] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
|
||||
// Phase 7 — void modal state
|
||||
const [voidOpen, setVoidOpen] = useState(false);
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
// Phase 7 — refund modal state. Amount defaults to the full
|
||||
// remaining refundable on open.
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
|
||||
const remainingRefundable =
|
||||
Math.round(
|
||||
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
||||
) / 100;
|
||||
|
||||
const markPaid = async () => {
|
||||
setActionError("");
|
||||
setBusyAction("mark-paid");
|
||||
@@ -75,6 +103,84 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
||||
// a credit note. Backend rejects if the invoice is paid (use
|
||||
// refund) or already voided/refunded.
|
||||
const voidInvoice = async () => {
|
||||
if (!voidReason.trim()) {
|
||||
setActionError(t("voidReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("void");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/void`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: voidReason }),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
||||
// backend caps at remaining refundable.
|
||||
const refundInvoice = async () => {
|
||||
const amt = parseFloat(refundAmount);
|
||||
if (!isFinite(amt) || amt <= 0) {
|
||||
setActionError(t("refundAmountInvalid"));
|
||||
return;
|
||||
}
|
||||
if (amt - remainingRefundable > 0.005) {
|
||||
setActionError(
|
||||
t("refundAmountExceeds", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!refundReason.trim()) {
|
||||
setActionError(t("refundReasonRequired"));
|
||||
return;
|
||||
}
|
||||
setActionError("");
|
||||
setBusyAction("refund");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
amountChf: Math.round(amt * 100) / 100,
|
||||
reason: refundReason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
setRefundOpen(false);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Group lines by tenant for display (matches PDF layout).
|
||||
const linesByTenant = new Map<string | null, typeof lines>();
|
||||
for (const ln of lines) {
|
||||
@@ -171,6 +277,129 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
||||
Same gating as Mark Paid but mutually exclusive with it
|
||||
via the chosen action. Opens a small inline form so
|
||||
the admin can enter a reason; reason is required and
|
||||
lands on the credit-note PDF. */}
|
||||
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||
<>
|
||||
{!voidOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(true);
|
||||
setNoteOpen(false);
|
||||
setRefundOpen(false);
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("voidBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("voidReasonPlaceholder")}
|
||||
value={voidReason}
|
||||
onChange={(e) => setVoidReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={voidInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setVoidOpen(false);
|
||||
setVoidReason("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Phase 7 — Refund: paid invoices, including ones already
|
||||
partially refunded (as long as some refundable amount
|
||||
remains). Opens an inline form with amount + reason.
|
||||
The remaining-refundable hint helps admin pick the
|
||||
right number. */}
|
||||
{(invoice.status === "paid" ||
|
||||
invoice.status === "partially_refunded") &&
|
||||
remainingRefundable > 0 && (
|
||||
<>
|
||||
{!refundOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefundOpen(true);
|
||||
setNoteOpen(false);
|
||||
setVoidOpen(false);
|
||||
setRefundAmount(remainingRefundable.toFixed(2));
|
||||
}}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||
>
|
||||
{t("refundBtn")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 flex-grow">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("refundRemainingHint", {
|
||||
max: remainingRefundable.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingRefundable}
|
||||
placeholder="CHF"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("refundReasonPlaceholder")}
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
maxLength={500}
|
||||
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={refundInvoice}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={deleteInvoice}
|
||||
disabled={busyAction !== null}
|
||||
@@ -189,8 +418,90 @@ export function InvoiceDetailView({ detail }: Props) {
|
||||
{invoice.paidMethodDetail}
|
||||
</div>
|
||||
)}
|
||||
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
||||
Surfaces the auditing context that the columns alone don't
|
||||
(who voided, what the reason was, how much has been
|
||||
refunded vs how much remains). */}
|
||||
{invoice.voidedAt && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
||||
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{invoice.refundedTotalChf > 0 && (
|
||||
<div className="mt-3 text-xs text-text-muted">
|
||||
{t("refundedTotalLabel")}: CHF{" "}
|
||||
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
||||
{t("refundedRemainingLabel")}: CHF{" "}
|
||||
{remainingRefundable.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
||||
none (most invoices). When present, lists each credit note
|
||||
with kind, amount, reason, issued date, and PDF download. */}
|
||||
{creditNotes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("creditNoteNumberHeader")}</th>
|
||||
<th className="pb-2">{t("creditNoteKindHeader")}</th>
|
||||
<th className="pb-2 text-right">
|
||||
{t("creditNoteAmountHeader")}
|
||||
</th>
|
||||
<th className="pb-2">{t("creditNoteReasonHeader")}</th>
|
||||
<th className="pb-2">{t("creditNoteIssuedHeader")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creditNotes.map((cn) => (
|
||||
<tr key={cn.id} className="border-t border-border">
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
|
||||
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-text-secondary text-xs">
|
||||
{cn.reason ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-muted">
|
||||
{cn.issuedAt.slice(0, 10)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{cn.hasPdf ? (
|
||||
<a
|
||||
href={`/api/credit-notes/${encodeURIComponent(
|
||||
cn.creditNoteNumber
|
||||
)}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline text-xs"
|
||||
>
|
||||
{t("downloadPdfBtn")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs italic">
|
||||
{t("creditNoteNoPdf")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
@@ -296,7 +607,9 @@ function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||
? "bg-error/15 text-error"
|
||||
: status === "void" || status === "uncollectible"
|
||||
? "bg-text-muted/15 text-text-muted"
|
||||
: "bg-accent/15 text-accent";
|
||||
: status === "partially_refunded" || status === "fully_refunded"
|
||||
? "bg-error/15 text-error"
|
||||
: "bg-accent/15 text-accent";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||
|
||||
101
src/components/billing/customer-credit-note-list.tsx
Normal file
101
src/components/billing/customer-credit-note-list.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { CreditNote } from "@/types";
|
||||
|
||||
interface Props {
|
||||
creditNotes: CreditNote[];
|
||||
}
|
||||
|
||||
const kindColors: Record<string, string> = {
|
||||
// Voids = the invoice was cancelled; gentle red.
|
||||
void: "text-error bg-error/10",
|
||||
// Refunds = money returned; also red but slightly differentiated.
|
||||
refund: "text-error bg-error/10",
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 7 — customer's credit-note history below the invoice list.
|
||||
*
|
||||
* Hidden entirely when the org has zero credit notes (most orgs in
|
||||
* normal operation). When present, each row shows the credit-note
|
||||
* number, the invoice it relates to, kind (void / refund), amount,
|
||||
* and a download link to the PDF.
|
||||
*
|
||||
* No detail page — clicking the PDF link opens the document inline
|
||||
* (browser PDF viewer), which IS the credit-note detail view. A
|
||||
* separate per-credit-note web page would duplicate what's in the
|
||||
* PDF and add no value.
|
||||
*/
|
||||
export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
|
||||
if (creditNotes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("creditNoteNumberCol")}</th>
|
||||
<th className="pb-2">{t("creditNoteInvoiceCol")}</th>
|
||||
<th className="pb-2">{t("creditNoteIssuedCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNoteAmountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNoteKindCol")}</th>
|
||||
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creditNotes.map((cn) => (
|
||||
<tr
|
||||
key={cn.id}
|
||||
className="border-t border-border align-middle"
|
||||
>
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{cn.creditNoteNumber}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-text-secondary">
|
||||
{cn.invoiceNumber}
|
||||
</td>
|
||||
<td className="py-2 text-text-secondary">
|
||||
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
CHF {cn.amountChf.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
kindColors[cn.kind] ?? ""
|
||||
}`}
|
||||
>
|
||||
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{cn.hasPdf ? (
|
||||
<a
|
||||
href={`/api/credit-notes/${encodeURIComponent(
|
||||
cn.creditNoteNumber
|
||||
)}/pdf`}
|
||||
className="text-accent hover:underline text-xs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("downloadPdf")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs italic">
|
||||
{t("creditNoteNoPdf")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,13 @@ const statusColors: Record<string, string> = {
|
||||
paid: "text-success bg-success/10",
|
||||
overdue: "text-error bg-error/10",
|
||||
void: "text-text-muted bg-surface-3 line-through",
|
||||
// Phase 7: refund states. Red tinting matches the credit-note
|
||||
// PDF accent so customers reading the table get a visual cue
|
||||
// that something was credited back. partially_refunded reads
|
||||
// as a partial state (mixed colour), fully_refunded reads as
|
||||
// closed (line-through like void).
|
||||
partially_refunded: "text-error bg-error/10",
|
||||
fully_refunded: "text-text-muted bg-error/10 line-through",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
187
src/components/settings/profile-form.tsx
Normal file
187
src/components/settings/profile-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
initial: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
/**
|
||||
* Personal-account flag. Drives a small hint about how the ZITADEL
|
||||
* name relates (or doesn't) to invoice identity — see the page
|
||||
* server component for the long explanation.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/**
|
||||
* For company accounts: the display org name. Shown in a small
|
||||
* read-only "Member of <org>" hint so the user understands which
|
||||
* identity they're editing. Ignored for personals (orgName is an
|
||||
* opaque "personal-XXXX" string in that case).
|
||||
*/
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits first/last name in ZITADEL via PUT /api/settings/profile.
|
||||
* Email is shown read-only — changing email requires verification
|
||||
* flow that ZITADEL's own self-service UI handles.
|
||||
*
|
||||
* On save, we trigger NextAuth's `update()` from useSession() with
|
||||
* the new display name. That routes through our jwt callback
|
||||
* (trigger='update' branch) which overlays token.name without a
|
||||
* logout/login. After the cookie is updated we trigger a full page
|
||||
* reload — every server-rendered surface (nav-shell, dashboard
|
||||
* welcome, instance cards) re-reads the cookie on the next request
|
||||
* and renders with the new name. router.refresh() alone wasn't
|
||||
* enough: it re-runs only the current route's server components,
|
||||
* leaving outer-tree segments stale until the user navigates.
|
||||
*/
|
||||
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||
const t = useTranslations("settingsProfile");
|
||||
const { update } = useSession();
|
||||
const [form, setForm] = useState({
|
||||
firstName: initial.firstName,
|
||||
lastName: initial.lastName,
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
setSavedFlash(false);
|
||||
if (!form.firstName.trim() || !form.lastName.trim()) {
|
||||
setError(t("missingRequired"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
// Phase 6 fix5: push the new display name into the session
|
||||
// token. The jwt callback handles trigger='update' and overlays
|
||||
// token.name; the next session callback maps token.name back
|
||||
// to session.user.name. No re-login needed.
|
||||
await update({ name: data.displayName });
|
||||
setSavedFlash(true);
|
||||
// Force a full reload so EVERY server-rendered component picks
|
||||
// up the new session cookie immediately — router.refresh() only
|
||||
// re-runs the current route's server components, leaving the
|
||||
// nav-shell (rendered higher in the tree) and other cached
|
||||
// segments showing the old name until the user navigates.
|
||||
// The 800ms delay lets the "Saved" flash render briefly before
|
||||
// the page reloads, so the user gets visible feedback.
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label={t("firstNameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, firstName: e.target.value }))
|
||||
}
|
||||
maxLength={100}
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("lastNameLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, lastName: e.target.value }))
|
||||
}
|
||||
maxLength={100}
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
|
||||
<input
|
||||
type="email"
|
||||
value={initial.email}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
|
||||
/>
|
||||
</Field>
|
||||
{/* Personal vs company hint. Personals get the
|
||||
"this won't change your invoice name" warning since their
|
||||
ZITADEL name and their invoice identity are intentionally
|
||||
decoupled. Company accounts get a benign "member of"
|
||||
context line so they know which org's identity they're
|
||||
editing. */}
|
||||
{isPersonal ? (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("personalAccountHint")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("companyAccountHint", { orgName })}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-error">{error}</p>}
|
||||
{savedFlash && <p className="text-sm text-success">{t("saved")}</p>}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : t("saveChanges")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||
{label}
|
||||
{required && <span className="text-error ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-text-muted mt-1 italic">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
|
||||
},
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
async jwt({ token, account, profile, trigger, session }) {
|
||||
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||
// route through this branch. We trust the new value because the
|
||||
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||
// and re-fetched the canonical displayName before returning.
|
||||
// The session callback reads token.name directly (see below) so
|
||||
// the update propagates without depending on auth.js's implicit
|
||||
// token→session.user mapping, which is flaky for the name claim
|
||||
// in the v5 OIDC provider configuration.
|
||||
//
|
||||
// Defensive: only the `name` field is accepted from the update
|
||||
// payload, even if the client passes additional keys. Other
|
||||
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||
// sign-in time and are not user-mutable from a settings page.
|
||||
//
|
||||
// Returns a NEW token object (spread) rather than mutating, so
|
||||
// there is no ambiguity for auth.js about whether the token
|
||||
// changed and needs re-encoding into the session cookie.
|
||||
if (trigger === "update" && session) {
|
||||
const update = session as { name?: unknown };
|
||||
if (typeof update.name === "string") {
|
||||
return { ...token, name: update.name };
|
||||
}
|
||||
return token;
|
||||
}
|
||||
if (account && profile) {
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
||||
claims["urn:zitadel:iam:org:project:roles"]
|
||||
);
|
||||
token.accessToken = account.access_token;
|
||||
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||
// onto the token from the OIDC profile. Previously these came
|
||||
// through auth.js's implicit mapping, which works on first
|
||||
// sign-in but isn't reliable after update() — once the update
|
||||
// path overrides token.name, the read-back path needs token
|
||||
// to be the authoritative source. Setting them explicitly
|
||||
// here keeps sign-in and update on the same path.
|
||||
if (typeof profile.name === "string") {
|
||||
token.name = profile.name;
|
||||
}
|
||||
if (typeof profile.email === "string") {
|
||||
token.email = profile.email;
|
||||
}
|
||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||
// freshly generated UUID in token.sub on initial sign-in,
|
||||
// ignoring what profile() returns for `id`. That UUID then
|
||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as Role[]) ?? [];
|
||||
const orgName = (token.orgName as string) ?? "";
|
||||
// Phase 6 fix5: read name and email directly from the token.
|
||||
// Previously this code relied on `session.user?.name`, expecting
|
||||
// auth.js to map token.name → session.user.name automatically.
|
||||
// That mapping is brittle: it works on first sign-in (because
|
||||
// OIDC profile() populates session.user) but not after update()
|
||||
// overrides token.name. Reading from token is the canonical
|
||||
// path regardless of how the token was last written.
|
||||
const tokenName = (token.name as string | undefined) ?? "";
|
||||
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||
const sessionUser: SessionUser = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
email: session.user?.email ?? "",
|
||||
name: tokenName || session.user?.name || "",
|
||||
email: tokenEmail || session.user?.email || "",
|
||||
orgId: token.orgId as string,
|
||||
orgName,
|
||||
roles,
|
||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
||||
isPersonal: isPersonalOrgName(orgName),
|
||||
};
|
||||
(session as any).platformUser = sessionUser;
|
||||
// Also overwrite session.user so any client-side code that uses
|
||||
// the standard NextAuth shape (session.user.name) sees the new
|
||||
// value. Pre-fix5 code paths read from session.user.name; this
|
||||
// keeps them working without per-component changes.
|
||||
if (session.user) {
|
||||
session.user.name = sessionUser.name;
|
||||
session.user.email = sessionUser.email;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreditNote,
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDraft,
|
||||
@@ -44,6 +45,8 @@ import type {
|
||||
TenantSuspensionEvent,
|
||||
} from "@/types";
|
||||
import {
|
||||
attachCreditNotePdf,
|
||||
createCreditNote,
|
||||
createInvoice,
|
||||
getInvoiceById,
|
||||
getOrgBilling,
|
||||
@@ -53,6 +56,8 @@ import {
|
||||
listSkillEventsForTenant,
|
||||
listSkillPricing,
|
||||
listSuspensionEventsForTenant,
|
||||
markInvoiceVoided,
|
||||
recordInvoiceRefund,
|
||||
tenantHasSetupFeeBilled,
|
||||
tenantSkillHasBeenBilled,
|
||||
updateInvoicePdf,
|
||||
@@ -61,7 +66,9 @@ import { listTenants } from "./k8s";
|
||||
import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { sendInvoiceIssuedEmail } from "./email";
|
||||
import { renderCreditNotePdf } from "./credit-note-pdf";
|
||||
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
|
||||
import { createInvoiceRefund } from "./stripe";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -836,3 +843,362 @@ export async function generateInvoice(opts: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7 — void and refund orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoidNotAllowedError extends Error {
|
||||
constructor(message: string, public readonly currentStatus: string) {
|
||||
super(message);
|
||||
this.name = "VoidNotAllowedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RefundNotAllowedError extends Error {
|
||||
constructor(message: string, public readonly currentStatus: string) {
|
||||
super(message);
|
||||
this.name = "RefundNotAllowedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a locale string to the supported four. Used when picking
|
||||
* which translation block to render emails/PDFs with. We never
|
||||
* fall back to admin's locale here — the credit note inherits the
|
||||
* invoice's locale so both documents read consistently to the
|
||||
* customer.
|
||||
*/
|
||||
function pickSupportedLocale(
|
||||
locale: string | null | undefined
|
||||
): "de" | "en" | "fr" | "it" {
|
||||
const supported = ["de", "en", "fr", "it"] as const;
|
||||
return (supported as readonly string[]).includes(locale ?? "")
|
||||
? (locale as "de" | "en" | "fr" | "it")
|
||||
: "de";
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a CHF amount to 2 decimal places. Used when proportionally
|
||||
* splitting VAT between subtotal and refund amount — avoids
|
||||
* accumulating fractional rappen across operations.
|
||||
*/
|
||||
function roundChf(amount: number): number {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an unpaid invoice. State transition: open/overdue → void.
|
||||
*
|
||||
* Side effects, in order:
|
||||
* 1. Mark the invoice voided (status, void_reason, voided_at, voided_by)
|
||||
* 2. Insert credit_notes row (kind='void', amount=full invoice total)
|
||||
* 3. Render the credit-note PDF and attach it to the row
|
||||
* 4. Best-effort email to the billing contact
|
||||
*
|
||||
* Not allowed:
|
||||
* - status='paid' (use refundInvoice instead — voiding paid
|
||||
* invoices would create a record mismatch with the payment
|
||||
* processor)
|
||||
* - status='void' (already voided)
|
||||
* - status='draft' (drafts aren't issued; nothing to void)
|
||||
* - status='partially_refunded' / 'fully_refunded' (use refund
|
||||
* for the remaining amount instead)
|
||||
*
|
||||
* Throws VoidNotAllowedError if the invoice is in a non-voidable
|
||||
* state. Caller surfaces this as 409 Conflict to the admin.
|
||||
*/
|
||||
export async function voidInvoice(params: {
|
||||
invoiceId: string;
|
||||
reason: string;
|
||||
voidedBy: string;
|
||||
}): Promise<CreditNote> {
|
||||
const invoice = await getInvoiceById(params.invoiceId);
|
||||
if (!invoice) {
|
||||
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||||
}
|
||||
// Only unpaid invoices can be voided. The state machine puts
|
||||
// paid invoices on the refund path; voiding them would skip the
|
||||
// payment reversal and leave the customer's money in our account
|
||||
// with no obligation showing in the portal.
|
||||
if (!["open", "overdue"].includes(invoice.status)) {
|
||||
throw new VoidNotAllowedError(
|
||||
`Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`,
|
||||
invoice.status
|
||||
);
|
||||
}
|
||||
const locale = pickSupportedLocale(invoice.locale);
|
||||
|
||||
// The credit note matches the invoice 1:1 in amount and VAT.
|
||||
// We carry the same VAT breakdown so the PDF can render
|
||||
// "subtotal + VAT" the same way the original invoice did.
|
||||
const creditNote = await createCreditNote({
|
||||
invoiceId: invoice.id,
|
||||
zitadelOrgId: invoice.zitadelOrgId,
|
||||
kind: "void",
|
||||
amountChf: invoice.totalChf,
|
||||
vatAmountChf: invoice.vatAmountChf,
|
||||
reason: params.reason || null,
|
||||
issuedBy: params.voidedBy,
|
||||
locale,
|
||||
billingSnapshot: invoice.billingSnapshot,
|
||||
});
|
||||
|
||||
// Mark invoice voided AFTER the credit note row exists, so the
|
||||
// status change has a credit note to point at. If anything below
|
||||
// here fails (PDF render, email), the invoice is still correctly
|
||||
// voided and the credit note row exists — just without a PDF
|
||||
// until manually re-rendered.
|
||||
await markInvoiceVoided({
|
||||
invoiceId: invoice.id,
|
||||
reason: params.reason,
|
||||
voidedBy: params.voidedBy,
|
||||
});
|
||||
|
||||
// Render PDF + attach. PDF failure here doesn't undo the void —
|
||||
// the customer can be told their invoice is voided and the PDF
|
||||
// can be re-issued later. We surface the error in the response
|
||||
// so admin knows to retry, but the void itself stands.
|
||||
try {
|
||||
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||||
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||||
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort email. Same fail-soft pattern as invoice issuance.
|
||||
try {
|
||||
const snap = invoice.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendCreditNoteEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
creditNoteNumber: creditNote.creditNoteNumber,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amountChf: creditNote.amountChf,
|
||||
currency: "CHF",
|
||||
kind: "void",
|
||||
reason: params.reason || null,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return creditNote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a paid invoice (in part or in full). State transition:
|
||||
* paid → partially_refunded (if amount < remaining)
|
||||
* paid → fully_refunded (if amount >= remaining)
|
||||
* partially_refunded → fully_refunded (if cumulative >= total)
|
||||
*
|
||||
* Side effects, in order:
|
||||
* 1. If the invoice was Stripe-paid (payment_method='card' with a
|
||||
* stripe_payment_intent_id) AND no `existingStripeRefund` was
|
||||
* passed, call Stripe to issue the refund. Stripe is the source
|
||||
* of truth for actual money movement; we mirror its outcome
|
||||
* locally.
|
||||
* 2. Insert credit_notes row (kind='refund', amount=refund amount,
|
||||
* VAT proportional)
|
||||
* 3. Insert invoice_refunds row, linking to the credit note and to
|
||||
* the Stripe refund (if any). recordInvoiceRefund updates the
|
||||
* invoice's status atomically based on the new running total.
|
||||
* 4. Render PDF + attach
|
||||
* 5. Best-effort email
|
||||
*
|
||||
* `existingStripeRefund` is for the webhook path: when Stripe fires
|
||||
* `charge.refunded` for a refund that was initiated directly in the
|
||||
* Stripe Dashboard (not via this portal), the webhook needs to
|
||||
* mirror the refund into the DB and issue a credit note WITHOUT
|
||||
* calling Stripe again. Pass the refund id and status to skip the
|
||||
* Stripe call.
|
||||
*
|
||||
* Not allowed:
|
||||
* - status not in {paid, partially_refunded} — full refunds are
|
||||
* only meaningful against actual payment
|
||||
* - amount <= 0 or > remaining refundable
|
||||
*
|
||||
* For invoice-paid (non-Stripe) customers the Stripe step is
|
||||
* skipped; refund settlement happens out-of-band (bank transfer)
|
||||
* and admin records the action in the portal.
|
||||
*/
|
||||
export async function refundInvoice(params: {
|
||||
invoiceId: string;
|
||||
amountChf: number;
|
||||
reason: string;
|
||||
refundedBy: string;
|
||||
/**
|
||||
* Webhook path: a Stripe refund that has already been created
|
||||
* (in the Stripe Dashboard or via a prior API call) and now needs
|
||||
* to be mirrored into the portal. When set, the Stripe API call
|
||||
* is skipped and the provided id/status are recorded as-is.
|
||||
*/
|
||||
existingStripeRefund?: {
|
||||
id: string;
|
||||
status: "pending" | "succeeded" | "failed" | "canceled";
|
||||
};
|
||||
}): Promise<CreditNote> {
|
||||
const invoice = await getInvoiceById(params.invoiceId);
|
||||
if (!invoice) {
|
||||
throw new Error(`Invoice not found: ${params.invoiceId}`);
|
||||
}
|
||||
if (!["paid", "partially_refunded"].includes(invoice.status)) {
|
||||
throw new RefundNotAllowedError(
|
||||
`Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`,
|
||||
invoice.status
|
||||
);
|
||||
}
|
||||
if (params.amountChf <= 0) {
|
||||
throw new RefundNotAllowedError(
|
||||
"Refund amount must be greater than zero.",
|
||||
invoice.status
|
||||
);
|
||||
}
|
||||
const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf);
|
||||
if (params.amountChf - remaining > 0.005) {
|
||||
// Allow a 0.005 tolerance to account for floating-point dust;
|
||||
// anything genuinely larger is a real over-refund attempt.
|
||||
throw new RefundNotAllowedError(
|
||||
`Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`,
|
||||
invoice.status
|
||||
);
|
||||
}
|
||||
const locale = pickSupportedLocale(invoice.locale);
|
||||
|
||||
// Proportional VAT split: refunded VAT / total VAT = refunded
|
||||
// amount / total amount. Keep the proportion explicit so the
|
||||
// credit note's "subtotal + VAT" lines reconcile to the same
|
||||
// VAT rate as the original invoice.
|
||||
const vatPortion =
|
||||
invoice.totalChf > 0
|
||||
? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf)
|
||||
: 0;
|
||||
|
||||
// Step 1: Stripe (only for card-paid invoices, and only when the
|
||||
// caller hasn't already created the refund). We do this BEFORE
|
||||
// any local DB writes for refund tracking — Stripe is the source
|
||||
// of truth for money movement, and if the Stripe call fails we
|
||||
// must NOT have recorded the refund locally (the customer would
|
||||
// see a credit note for money they never received).
|
||||
//
|
||||
// The charge.refunded webhook will also fire later, but we record
|
||||
// the refund here too so the admin gets immediate confirmation
|
||||
// and the credit note can be issued without waiting for the
|
||||
// webhook round-trip. The webhook is idempotent (dedups by
|
||||
// stripe_refund_id) so it's safe to do both.
|
||||
let stripeRefundId: string | null = null;
|
||||
let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" =
|
||||
"succeeded";
|
||||
const isStripePaid =
|
||||
invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId;
|
||||
if (params.existingStripeRefund) {
|
||||
// Webhook path: don't call Stripe again; trust the provided id.
|
||||
stripeRefundId = params.existingStripeRefund.id;
|
||||
stripeStatus = params.existingStripeRefund.status;
|
||||
} else if (isStripePaid) {
|
||||
try {
|
||||
const refund = await createInvoiceRefund({
|
||||
paymentIntentId: invoice.stripePaymentIntentId!,
|
||||
amountChf: params.amountChf,
|
||||
reason: "requested_by_customer",
|
||||
metadata: {
|
||||
invoice_number: invoice.invoiceNumber,
|
||||
refunded_by: params.refundedBy,
|
||||
},
|
||||
});
|
||||
stripeRefundId = refund.id;
|
||||
// Map Stripe statuses to our enum. Anything other than
|
||||
// 'succeeded' or 'pending' is treated as a failure — we
|
||||
// don't record the credit note in that case (see below).
|
||||
if (refund.status === "succeeded") stripeStatus = "succeeded";
|
||||
else if (refund.status === "pending") stripeStatus = "pending";
|
||||
else if (refund.status === "canceled") stripeStatus = "canceled";
|
||||
else stripeStatus = "failed";
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Stripe refund failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
if (stripeStatus === "failed" || stripeStatus === "canceled") {
|
||||
throw new Error(
|
||||
`Stripe refund returned non-success status: ${stripeStatus}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: insert credit note (PDF still null at this point).
|
||||
const creditNote = await createCreditNote({
|
||||
invoiceId: invoice.id,
|
||||
zitadelOrgId: invoice.zitadelOrgId,
|
||||
kind: "refund",
|
||||
amountChf: params.amountChf,
|
||||
vatAmountChf: vatPortion,
|
||||
reason: params.reason || null,
|
||||
issuedBy: params.refundedBy,
|
||||
locale,
|
||||
billingSnapshot: invoice.billingSnapshot,
|
||||
});
|
||||
|
||||
// Step 3: record the refund event and bump invoice status.
|
||||
// recordInvoiceRefund handles status transitions and idempotency.
|
||||
await recordInvoiceRefund({
|
||||
invoiceId: invoice.id,
|
||||
stripeRefundId,
|
||||
amountChf: params.amountChf,
|
||||
reason: params.reason || null,
|
||||
refundedBy: params.refundedBy,
|
||||
creditNoteId: creditNote.id,
|
||||
status: stripeStatus,
|
||||
});
|
||||
|
||||
// Step 4: render + attach PDF. As with voidInvoice, a PDF failure
|
||||
// here doesn't undo the refund — the refund happened (in Stripe
|
||||
// and the DB), only the document is missing. Admin can re-render.
|
||||
try {
|
||||
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
|
||||
const filename = `${creditNote.creditNoteNumber}.pdf`;
|
||||
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: best-effort email.
|
||||
try {
|
||||
const snap = invoice.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendCreditNoteEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
creditNoteNumber: creditNote.creditNoteNumber,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amountChf: creditNote.amountChf,
|
||||
currency: "CHF",
|
||||
kind: "refund",
|
||||
reason: params.reason || null,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return creditNote;
|
||||
}
|
||||
|
||||
553
src/lib/credit-note-pdf.tsx
Normal file
553
src/lib/credit-note-pdf.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Credit-note PDF rendering via @react-pdf/renderer.
|
||||
*
|
||||
* Phase 7. Mirrors billing-pdf.tsx in layout but with:
|
||||
* - Title "Gutschrift" / "Credit note" / "Note de crédit" / "Nota di credito"
|
||||
* - Red accent (vs invoice's emerald) so the document is visually
|
||||
* unmistakable from an invoice
|
||||
* - References the original invoice number prominently
|
||||
* - One amount line ("Refund for invoice 2026-00042" or
|
||||
* "Voided invoice 2026-00042") with VAT broken out
|
||||
* - 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
|
||||
* from billing-pdf.tsx for now. A future refactor can hoist them
|
||||
* into lib/pdf-brand.ts; doing so today is out of scope.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Svg,
|
||||
Polygon,
|
||||
Polyline,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { CreditNote, Invoice } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants — keep in sync with billing-pdf.tsx until the
|
||||
// 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 {
|
||||
/** Document title at the top — "Gutschrift" etc. */
|
||||
creditNote: string;
|
||||
/** "Credit note no." label */
|
||||
creditNoteNumber: string;
|
||||
/** "Issue date" label */
|
||||
issueDate: string;
|
||||
/** "Bill to" label, same as invoice */
|
||||
billTo: string;
|
||||
/** "Attn:" / "z.Hd." prefix */
|
||||
attentionPrefix: string;
|
||||
/** "Reference invoice" — links the credit note back to the original */
|
||||
referenceInvoice: string;
|
||||
/** "Reason" label for the free-text reason block */
|
||||
reason: string;
|
||||
/** Body text describing what this credit note is for. Takes the
|
||||
* invoice number as a `{number}` placeholder. */
|
||||
voidLineLabel: string;
|
||||
refundLineLabel: string;
|
||||
/** Totals labels — same words as invoice but separated for clarity */
|
||||
subtotal: string;
|
||||
vatLabel: string;
|
||||
totalCredited: string;
|
||||
/** Footer note explaining the document */
|
||||
footerVoidNote: string;
|
||||
footerRefundNote: string;
|
||||
/** VAT note (reverse-charge or normal) — same logic as invoice */
|
||||
vatNoteSwiss: string;
|
||||
vatNoteReverseCharge: string;
|
||||
vatNoteOutOfScope: string;
|
||||
}
|
||||
|
||||
const MESSAGES: Record<string, CreditNoteStrings> = {
|
||||
de: {
|
||||
creditNote: "Gutschrift",
|
||||
creditNoteNumber: "Gutschrift-Nr.",
|
||||
issueDate: "Ausstellungsdatum",
|
||||
billTo: "Empfänger",
|
||||
attentionPrefix: "z.Hd.",
|
||||
referenceInvoice: "Bezug Rechnung",
|
||||
reason: "Begründung",
|
||||
voidLineLabel: "Stornierung Rechnung {number}",
|
||||
refundLineLabel: "Rückerstattung Rechnung {number}",
|
||||
subtotal: "Zwischensumme",
|
||||
vatLabel: "MWST",
|
||||
totalCredited: "Gesamtbetrag Gutschrift",
|
||||
footerVoidNote:
|
||||
"Diese Gutschrift storniert die oben referenzierte Rechnung. Ein Zahlungsausgleich ist nicht erforderlich.",
|
||||
footerRefundNote:
|
||||
"Diese Gutschrift dokumentiert die Rückerstattung des oben genannten Betrags. Die Auszahlung erfolgt über den ursprünglichen Zahlungsweg.",
|
||||
vatNoteSwiss:
|
||||
"MWST gemäss schweizerischem Mehrwertsteuergesetz (MWSTG).",
|
||||
vatNoteReverseCharge:
|
||||
"Reverse Charge: Steuerschuldnerschaft des Leistungsempfängers nach Art. 196 EU-MwStSyst-RL bzw. nationaler Umsetzung.",
|
||||
vatNoteOutOfScope:
|
||||
"Leistung ausserhalb des Geltungsbereichs der schweizerischen MWST.",
|
||||
},
|
||||
en: {
|
||||
creditNote: "Credit note",
|
||||
creditNoteNumber: "Credit note no.",
|
||||
issueDate: "Issue date",
|
||||
billTo: "Bill to",
|
||||
attentionPrefix: "Attn:",
|
||||
referenceInvoice: "Reference invoice",
|
||||
reason: "Reason",
|
||||
voidLineLabel: "Void of invoice {number}",
|
||||
refundLineLabel: "Refund for invoice {number}",
|
||||
subtotal: "Subtotal",
|
||||
vatLabel: "VAT",
|
||||
totalCredited: "Total credited",
|
||||
footerVoidNote:
|
||||
"This credit note voids the referenced invoice. No payment is required.",
|
||||
footerRefundNote:
|
||||
"This credit note documents the refund of the amount above. Settlement occurs via the original payment method.",
|
||||
vatNoteSwiss:
|
||||
"VAT charged in accordance with Swiss VAT law (MWSTG).",
|
||||
vatNoteReverseCharge:
|
||||
"Reverse charge: VAT to be accounted for by the recipient per Art. 196 EU VAT Directive or national implementation.",
|
||||
vatNoteOutOfScope:
|
||||
"Service supplied outside the scope of Swiss VAT.",
|
||||
},
|
||||
fr: {
|
||||
creditNote: "Note de crédit",
|
||||
creditNoteNumber: "N° de note de crédit",
|
||||
issueDate: "Date d'émission",
|
||||
billTo: "Destinataire",
|
||||
attentionPrefix: "À l'attention de",
|
||||
referenceInvoice: "Facture de référence",
|
||||
reason: "Motif",
|
||||
voidLineLabel: "Annulation de la facture {number}",
|
||||
refundLineLabel: "Remboursement de la facture {number}",
|
||||
subtotal: "Sous-total",
|
||||
vatLabel: "TVA",
|
||||
totalCredited: "Total du crédit",
|
||||
footerVoidNote:
|
||||
"Cette note de crédit annule la facture référencée ci-dessus. Aucun paiement n'est requis.",
|
||||
footerRefundNote:
|
||||
"Cette note de crédit documente le remboursement du montant ci-dessus. Le règlement s'effectue via le moyen de paiement initial.",
|
||||
vatNoteSwiss:
|
||||
"TVA facturée conformément à la loi suisse sur la TVA (LTVA).",
|
||||
vatNoteReverseCharge:
|
||||
"Autoliquidation : TVA à acquitter par le destinataire selon l'art. 196 de la directive TVA UE ou sa mise en œuvre nationale.",
|
||||
vatNoteOutOfScope:
|
||||
"Prestation hors du champ d'application de la TVA suisse.",
|
||||
},
|
||||
it: {
|
||||
creditNote: "Nota di credito",
|
||||
creditNoteNumber: "N. nota di credito",
|
||||
issueDate: "Data di emissione",
|
||||
billTo: "Destinatario",
|
||||
attentionPrefix: "c.a.",
|
||||
referenceInvoice: "Fattura di riferimento",
|
||||
reason: "Motivo",
|
||||
voidLineLabel: "Annullamento della fattura {number}",
|
||||
refundLineLabel: "Rimborso della fattura {number}",
|
||||
subtotal: "Subtotale",
|
||||
vatLabel: "IVA",
|
||||
totalCredited: "Totale accreditato",
|
||||
footerVoidNote:
|
||||
"Questa nota di credito annulla la fattura sopra indicata. Non è richiesto alcun pagamento.",
|
||||
footerRefundNote:
|
||||
"Questa nota di credito documenta il rimborso dell'importo sopra indicato. Il regolamento avviene tramite il metodo di pagamento originale.",
|
||||
vatNoteSwiss:
|
||||
"IVA addebitata in conformità alla legge svizzera sull'IVA (LIVA).",
|
||||
vatNoteReverseCharge:
|
||||
"Inversione contabile: IVA dovuta dal destinatario ai sensi dell'art. 196 della direttiva IVA UE o della sua attuazione nazionale.",
|
||||
vatNoteOutOfScope:
|
||||
"Prestazione fuori dal campo di applicazione dell'IVA svizzera.",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickStrings(locale: string): CreditNoteStrings {
|
||||
return MESSAGES[locale] ?? MESSAGES.de;
|
||||
}
|
||||
|
||||
function fmtChf(n: number): string {
|
||||
return n.toLocaleString("de-CH", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string, locale: string): string {
|
||||
const d = new Date(iso);
|
||||
const localeMap: Record<string, string> = {
|
||||
de: "de-CH",
|
||||
en: "en-GB",
|
||||
fr: "fr-CH",
|
||||
it: "it-CH",
|
||||
};
|
||||
return d.toLocaleDateString(localeMap[locale] ?? "de-CH", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function pickVatNote(
|
||||
invoice: Invoice,
|
||||
strings: CreditNoteStrings
|
||||
): 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 hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
||||
if (country === "CH" || country === "LI") {
|
||||
return strings.vatNoteSwiss;
|
||||
}
|
||||
if (hasVat) {
|
||||
return strings.vatNoteReverseCharge;
|
||||
}
|
||||
return strings.vatNoteOutOfScope;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 36,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 50,
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica",
|
||||
color: BRAND.textColor,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
},
|
||||
logoBlock: { flexDirection: "row", alignItems: "center" },
|
||||
brandName: {
|
||||
fontSize: 16,
|
||||
color: BRAND.primaryDark,
|
||||
marginLeft: 8,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||
docTitle: {
|
||||
fontSize: 22,
|
||||
color: BRAND.primaryDark,
|
||||
marginBottom: 8,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
metaTable: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 20,
|
||||
},
|
||||
metaCol: { flexDirection: "column", minWidth: 140 },
|
||||
metaLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 2 },
|
||||
metaValue: { fontSize: 10 },
|
||||
billTo: {
|
||||
marginBottom: 24,
|
||||
padding: 8,
|
||||
backgroundColor: "#fdf2f2",
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: BRAND.primary,
|
||||
},
|
||||
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||
billToName: { fontSize: 11, marginBottom: 2 },
|
||||
amountTable: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BRAND.borderColor,
|
||||
marginBottom: 16,
|
||||
},
|
||||
amountHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: BRAND.primaryDark,
|
||||
color: "#ffffff",
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
amountRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
amountDesc: { flex: 1 },
|
||||
amountValue: { width: 90, textAlign: "right" },
|
||||
totals: {
|
||||
marginLeft: "auto",
|
||||
width: 220,
|
||||
marginBottom: 20,
|
||||
},
|
||||
totalsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 3,
|
||||
},
|
||||
totalsLabel: { color: BRAND.mutedColor, fontSize: 10 },
|
||||
totalsValue: { fontSize: 10 },
|
||||
totalsGrand: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BRAND.primaryDark,
|
||||
paddingTop: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
totalsGrandLabel: {
|
||||
color: BRAND.primaryDark,
|
||||
fontSize: 11,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
totalsGrandValue: {
|
||||
color: BRAND.primaryDark,
|
||||
fontSize: 11,
|
||||
textAlign: "right",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
reasonBox: {
|
||||
marginTop: 4,
|
||||
marginBottom: 18,
|
||||
padding: 8,
|
||||
backgroundColor: "#fafafa",
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: BRAND.borderColor,
|
||||
},
|
||||
reasonLabel: {
|
||||
fontSize: 8,
|
||||
color: BRAND.mutedColor,
|
||||
marginBottom: 2,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
reasonText: { fontSize: 9.5, color: BRAND.textColor },
|
||||
noteBox: {
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
fontSize: 8.5,
|
||||
color: BRAND.mutedColor,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
left: 50,
|
||||
right: 50,
|
||||
fontSize: 7.5,
|
||||
color: BRAND.mutedColor,
|
||||
textAlign: "center",
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
paddingTop: 6,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 {
|
||||
creditNote: CreditNote;
|
||||
invoice: Invoice;
|
||||
}
|
||||
|
||||
function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
||||
const strings = pickStrings(creditNote.locale);
|
||||
const snap = creditNote.billingSnapshot;
|
||||
const vatNote = pickVatNote(invoice, strings);
|
||||
const amountLabelTemplate =
|
||||
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
||||
const amountLabel = amountLabelTemplate.replace("{number}", invoice.invoiceNumber);
|
||||
const footerNote =
|
||||
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
||||
// Subtotal + VAT breakdown. We carry the VAT proportion from the
|
||||
// credit note row itself (set by billing.ts based on the invoice's
|
||||
// VAT rate × the refund/void amount).
|
||||
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header: logo+brand left, issuer block right */}
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.logoBlock}>
|
||||
<Logo />
|
||||
<Text style={styles.brandName}>{BRAND.name}</Text>
|
||||
</View>
|
||||
<View style={styles.issuerBlock}>
|
||||
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
|
||||
<Text>{BRAND.issuer.addressLine1}</Text>
|
||||
<Text>{BRAND.issuer.addressLine2}</Text>
|
||||
<Text>{BRAND.issuer.postalCity}</Text>
|
||||
<Text>{BRAND.issuer.country}</Text>
|
||||
<Text>{BRAND.issuer.email}</Text>
|
||||
<Text>{BRAND.issuer.web}</Text>
|
||||
{BRAND.issuer.vatNumber && (
|
||||
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
||||
|
||||
{/* Meta row: number, issue date, reference invoice */}
|
||||
<View style={styles.metaTable}>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
||||
<Text style={styles.metaValue}>{creditNote.creditNoteNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{strings.issueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(creditNote.issuedAt, creditNote.locale)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{strings.referenceInvoice}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bill-to (mirrors invoice block) */}
|
||||
<View style={styles.billTo}>
|
||||
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||
{snap.contactName && snap.contactName.trim().length > 0 && (
|
||||
<Text>
|
||||
{strings.attentionPrefix} {snap.contactName}
|
||||
</Text>
|
||||
)}
|
||||
<Text>{snap.streetAddress}</Text>
|
||||
<Text>
|
||||
{snap.postalCode} {snap.city}
|
||||
</Text>
|
||||
<Text>{snap.country}</Text>
|
||||
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Amount line — single row, the credit note isn't itemized */}
|
||||
<View style={styles.amountTable}>
|
||||
<View style={styles.amountHeader}>
|
||||
<Text style={styles.amountDesc}> </Text>
|
||||
<Text style={styles.amountValue}>CHF</Text>
|
||||
</View>
|
||||
<View style={styles.amountRow}>
|
||||
<Text style={styles.amountDesc}>{amountLabel}</Text>
|
||||
<Text style={styles.amountValue}>{fmtChf(subtotal)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Totals */}
|
||||
<View style={styles.totals}>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
||||
<Text style={styles.totalsValue}>CHF {fmtChf(subtotal)}</Text>
|
||||
</View>
|
||||
{creditNote.vatAmountChf > 0 && (
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>
|
||||
{strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%)
|
||||
</Text>
|
||||
<Text style={styles.totalsValue}>
|
||||
CHF {fmtChf(creditNote.vatAmountChf)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.totalsGrand}>
|
||||
<Text style={styles.totalsGrandLabel}>{strings.totalCredited}</Text>
|
||||
<Text style={styles.totalsGrandValue}>
|
||||
CHF {fmtChf(creditNote.amountChf)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Reason block — only if the admin provided one */}
|
||||
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
||||
<View style={styles.reasonBox}>
|
||||
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
||||
<Text style={styles.reasonText}>{creditNote.reason}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer note explaining what this document is */}
|
||||
<View style={styles.noteBox}>
|
||||
<Text>{footerNote}</Text>
|
||||
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Tiny footer with credit-note number on every page */}
|
||||
<Text style={styles.footer} fixed>
|
||||
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
||||
</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderCreditNotePdf(
|
||||
creditNote: CreditNote,
|
||||
invoice: Invoice
|
||||
): Promise<Buffer> {
|
||||
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
||||
// @react-pdf/renderer's renderToBuffer returns a Node Buffer.
|
||||
return renderToBuffer(doc) as unknown as Buffer;
|
||||
}
|
||||
551
src/lib/db.ts
551
src/lib/db.ts
@@ -492,6 +492,32 @@ const MIGRATION_SQL = `
|
||||
-- admin override at issue time.
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de';
|
||||
-- Phase 7 schema: void + refund tracking on the existing invoices
|
||||
-- table, plus the credit-note and refund-event sub-entities. These
|
||||
-- ALTERs are idempotent (IF NOT EXISTS) so re-running ensureSchema
|
||||
-- on an already-migrated DB is a no-op.
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS void_reason TEXT;
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ;
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS voided_by TEXT;
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS refunded_total_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
|
||||
-- Extend the status CHECK to allow partially/fully_refunded. We
|
||||
-- drop-then-add because there's no "ALTER CONSTRAINT ... ADD
|
||||
-- VALUE" in stock Postgres. Drop is conditional on the constraint
|
||||
-- name; the constraint is auto-named after the table+column.
|
||||
DO $constraints$
|
||||
BEGIN
|
||||
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_check;
|
||||
ALTER TABLE invoices ADD CONSTRAINT invoices_status_check CHECK (
|
||||
status IN ('draft','open','paid','overdue','void','uncollectible',
|
||||
'partially_refunded','fully_refunded')
|
||||
);
|
||||
END
|
||||
$constraints$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_org
|
||||
ON invoices(zitadel_org_id, issued_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status
|
||||
@@ -501,6 +527,57 @@ const MIGRATION_SQL = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
|
||||
ON invoices(zitadel_org_id, period_start);
|
||||
|
||||
-- Phase 7: credit notes. One row per void or refund event. The
|
||||
-- credit_note_number follows CN-YYYY-NNNNN allocated from the
|
||||
-- per-year counter below.
|
||||
CREATE TABLE IF NOT EXISTS credit_note_counters (
|
||||
year INTEGER PRIMARY KEY,
|
||||
last_number INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS credit_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
credit_note_number TEXT NOT NULL UNIQUE,
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('void','refund')),
|
||||
amount_chf NUMERIC(10,2) NOT NULL,
|
||||
vat_amount_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
reason TEXT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
issued_by TEXT NOT NULL,
|
||||
locale TEXT NOT NULL DEFAULT 'de',
|
||||
pdf_data BYTEA,
|
||||
pdf_filename TEXT,
|
||||
-- Frozen snapshot of the customer's billing address at the time
|
||||
-- the credit note is issued. Mirrors invoices.billing_snapshot
|
||||
-- so the PDF is self-contained and reproducible.
|
||||
billing_snapshot JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice
|
||||
ON credit_notes(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_notes_org
|
||||
ON credit_notes(zitadel_org_id, issued_at DESC);
|
||||
|
||||
-- Phase 7: per-refund-event log. Each row is one Stripe Refund
|
||||
-- object (stripe_refund_id non-null) OR one manual admin action
|
||||
-- for invoice-paid customers (stripe_refund_id null). The
|
||||
-- credit_note_id links the refund to the credit note PDF it
|
||||
-- generated.
|
||||
CREATE TABLE IF NOT EXISTS invoice_refunds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||
stripe_refund_id TEXT UNIQUE,
|
||||
amount_chf NUMERIC(10,2) NOT NULL,
|
||||
reason TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'succeeded'
|
||||
CHECK (status IN ('pending','succeeded','failed','canceled')),
|
||||
refunded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
refunded_by TEXT NOT NULL,
|
||||
credit_note_id UUID REFERENCES credit_notes(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
|
||||
ON invoice_refunds(invoice_id);
|
||||
|
||||
-- Invoice line items. The kind column lets the PDF renderer
|
||||
-- group lines (all monthly fees together, all AI usage together,
|
||||
-- etc.) and the admin UI filter by category.
|
||||
@@ -2262,6 +2339,9 @@ import type {
|
||||
InvoiceDraft,
|
||||
InvoiceLine,
|
||||
InvoiceStatus,
|
||||
CreditNote,
|
||||
CreditNoteKind,
|
||||
InvoiceRefund,
|
||||
} from "@/types";
|
||||
|
||||
function rowToInvoice(row: any): Invoice {
|
||||
@@ -2294,6 +2374,12 @@ function rowToInvoice(row: any): Invoice {
|
||||
paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null,
|
||||
paidBy: row.paid_by ?? null,
|
||||
paidMethodDetail: row.paid_method_detail ?? null,
|
||||
voidReason: row.void_reason ?? null,
|
||||
voidedAt: row.voided_at?.toISOString?.() ?? row.voided_at ?? null,
|
||||
voidedBy: row.voided_by ?? null,
|
||||
refundedTotalChf: row.refunded_total_chf != null
|
||||
? Number(row.refunded_total_chf)
|
||||
: 0,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
};
|
||||
}
|
||||
@@ -2323,7 +2409,8 @@ const INVOICE_LIST_COLUMNS = `
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
|
||||
paid_by, paid_method_detail, created_at,
|
||||
paid_by, paid_method_detail, void_reason, voided_at, voided_by,
|
||||
refunded_total_chf, created_at,
|
||||
(pdf_data IS NOT NULL) AS has_pdf
|
||||
`;
|
||||
|
||||
@@ -3188,3 +3275,465 @@ export async function recordReminderSent(params: {
|
||||
);
|
||||
return result.rowCount === 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7 — credit notes and refunds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToCreditNote(row: any): CreditNote {
|
||||
return {
|
||||
id: row.id,
|
||||
creditNoteNumber: row.credit_note_number,
|
||||
invoiceId: row.invoice_id,
|
||||
invoiceNumber: row.invoice_number, // joined column when available
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
kind: row.kind as CreditNoteKind,
|
||||
amountChf: Number(row.amount_chf),
|
||||
vatAmountChf: Number(row.vat_amount_chf),
|
||||
reason: row.reason ?? null,
|
||||
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
|
||||
issuedBy: row.issued_by,
|
||||
locale: row.locale ?? "de",
|
||||
pdfFilename: row.pdf_filename ?? null,
|
||||
hasPdf: row.has_pdf ?? row.pdf_data !== null,
|
||||
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToInvoiceRefund(row: any): InvoiceRefund {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceId: row.invoice_id,
|
||||
stripeRefundId: row.stripe_refund_id ?? null,
|
||||
amountChf: Number(row.amount_chf),
|
||||
reason: row.reason ?? null,
|
||||
status: row.status,
|
||||
refundedAt: row.refunded_at?.toISOString?.() ?? row.refunded_at,
|
||||
refundedBy: row.refunded_by,
|
||||
creditNoteId: row.credit_note_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const CREDIT_NOTE_COLUMNS = `
|
||||
cn.id, cn.credit_note_number, cn.invoice_id, cn.zitadel_org_id,
|
||||
cn.kind, cn.amount_chf, cn.vat_amount_chf, cn.reason, cn.issued_at,
|
||||
cn.issued_by, cn.locale, cn.pdf_filename, cn.billing_snapshot,
|
||||
(cn.pdf_data IS NOT NULL) AS has_pdf,
|
||||
inv.invoice_number AS invoice_number
|
||||
`;
|
||||
|
||||
/**
|
||||
* Allocate the next credit-note number for the given year. Uses the
|
||||
* per-year counter table with a row-level lock, same approach as
|
||||
* invoice numbering. Format: CN-2026-00001 (5-digit padding, matches
|
||||
* invoice padding for visual consistency even though Cedric agreed
|
||||
* to "CN-YYYY-NNNN" originally — the extra digit is harmless headroom
|
||||
* and keeps eyes from misreading "CN-2026-1" next to "2026-00001").
|
||||
*
|
||||
* Must be called inside a transaction; the caller passes the same
|
||||
* client so the allocation and the INSERT roll back together if
|
||||
* anything downstream fails.
|
||||
*/
|
||||
async function allocateCreditNoteNumber(
|
||||
client: any,
|
||||
year: number
|
||||
): Promise<string> {
|
||||
const r = await client.query(
|
||||
`INSERT INTO credit_note_counters (year, last_number)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (year) DO UPDATE SET
|
||||
last_number = credit_note_counters.last_number + 1
|
||||
RETURNING last_number`,
|
||||
[year]
|
||||
);
|
||||
const seq = r.rows[0].last_number;
|
||||
return `CN-${year}-${String(seq).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new credit note (without its PDF — that's attached later
|
||||
* via attachCreditNotePdf so the PDF render can read the just-inserted
|
||||
* row, including its credit_note_number, for self-referential rendering).
|
||||
*
|
||||
* Snapshots the invoice's billing block at issue time. Returns the
|
||||
* inserted row (with PDF still null).
|
||||
*/
|
||||
export async function createCreditNote(params: {
|
||||
invoiceId: string;
|
||||
zitadelOrgId: string;
|
||||
kind: CreditNoteKind;
|
||||
amountChf: number;
|
||||
vatAmountChf: number;
|
||||
reason: string | null;
|
||||
issuedBy: string;
|
||||
locale: string;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
}): Promise<CreditNote> {
|
||||
await ensureSchema();
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// Allocate from the year of NOW — credit notes are issued
|
||||
// "today", not retroactively, so the year is current.
|
||||
const year = new Date().getUTCFullYear();
|
||||
const creditNoteNumber = await allocateCreditNoteNumber(client, year);
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO credit_notes (
|
||||
credit_note_number, invoice_id, zitadel_org_id, kind,
|
||||
amount_chf, vat_amount_chf, reason, issued_by, locale,
|
||||
billing_snapshot
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
RETURNING *`,
|
||||
[
|
||||
creditNoteNumber,
|
||||
params.invoiceId,
|
||||
params.zitadelOrgId,
|
||||
params.kind,
|
||||
params.amountChf,
|
||||
params.vatAmountChf,
|
||||
params.reason,
|
||||
params.issuedBy,
|
||||
params.locale,
|
||||
JSON.stringify(params.billingSnapshot),
|
||||
]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
// Re-query with the invoice_number join so the returned row has
|
||||
// it populated (matches what list/get methods return).
|
||||
const detail = await pool.query(
|
||||
`SELECT ${CREDIT_NOTE_COLUMNS}
|
||||
FROM credit_notes cn
|
||||
JOIN invoices inv ON inv.id = cn.invoice_id
|
||||
WHERE cn.id = $1`,
|
||||
[inserted.rows[0].id]
|
||||
);
|
||||
return rowToCreditNote(detail.rows[0]);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a freshly-rendered PDF to an existing credit note row.
|
||||
* Two-phase issue (insert row, render PDF, attach PDF) mirrors the
|
||||
* invoice flow, where the PDF generation needs the number on the
|
||||
* row to render itself.
|
||||
*/
|
||||
export async function attachCreditNotePdf(
|
||||
creditNoteId: string,
|
||||
pdfBuffer: Buffer,
|
||||
pdfFilename: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
`UPDATE credit_notes
|
||||
SET pdf_data = $1, pdf_filename = $2
|
||||
WHERE id = $3`,
|
||||
[pdfBuffer, pdfFilename, creditNoteId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a credit note by its number, scoped to an org. Returns null
|
||||
* if the row doesn't exist OR exists but belongs to a different org
|
||||
* (same 404-not-403 leak-protection as the invoice equivalent).
|
||||
*/
|
||||
export async function getCreditNoteByNumberForOrg(
|
||||
creditNoteNumber: string,
|
||||
zitadelOrgId: string
|
||||
): Promise<CreditNote | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${CREDIT_NOTE_COLUMNS}
|
||||
FROM credit_notes cn
|
||||
JOIN invoices inv ON inv.id = cn.invoice_id
|
||||
WHERE cn.credit_note_number = $1 AND cn.zitadel_org_id = $2
|
||||
LIMIT 1`,
|
||||
[creditNoteNumber, zitadelOrgId]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Platform-admin variant: look up by number regardless of org. */
|
||||
export async function getCreditNoteByNumber(
|
||||
creditNoteNumber: string
|
||||
): Promise<CreditNote | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${CREDIT_NOTE_COLUMNS}
|
||||
FROM credit_notes cn
|
||||
JOIN invoices inv ON inv.id = cn.invoice_id
|
||||
WHERE cn.credit_note_number = $1
|
||||
LIMIT 1`,
|
||||
[creditNoteNumber]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List credit notes for an org, newest first. Used by /billing to
|
||||
* render the credit-note list alongside the invoice list.
|
||||
*/
|
||||
export async function listCreditNotesForOrg(
|
||||
zitadelOrgId: string,
|
||||
limit = 50
|
||||
): Promise<CreditNote[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${CREDIT_NOTE_COLUMNS}
|
||||
FROM credit_notes cn
|
||||
JOIN invoices inv ON inv.id = cn.invoice_id
|
||||
WHERE cn.zitadel_org_id = $1
|
||||
ORDER BY cn.issued_at DESC
|
||||
LIMIT $2`,
|
||||
[zitadelOrgId, limit]
|
||||
);
|
||||
return result.rows.map(rowToCreditNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* All credit notes linked to a specific invoice. Used by the invoice
|
||||
* detail page to surface "this invoice was voided / partially
|
||||
* refunded by these credit notes".
|
||||
*/
|
||||
export async function listCreditNotesForInvoice(
|
||||
invoiceId: string
|
||||
): Promise<CreditNote[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${CREDIT_NOTE_COLUMNS}
|
||||
FROM credit_notes cn
|
||||
JOIN invoices inv ON inv.id = cn.invoice_id
|
||||
WHERE cn.invoice_id = $1
|
||||
ORDER BY cn.issued_at ASC`,
|
||||
[invoiceId]
|
||||
);
|
||||
return result.rows.map(rowToCreditNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the PDF bytes for a credit note. Returns null if no PDF was
|
||||
* ever attached (which would be a bug — every credit note should
|
||||
* have one) or if the credit note doesn't exist.
|
||||
*/
|
||||
export async function getCreditNotePdf(
|
||||
creditNoteId: string
|
||||
): Promise<{ data: Buffer; filename: string } | null> {
|
||||
const result = await getPool().query(
|
||||
`SELECT pdf_data, pdf_filename, credit_note_number
|
||||
FROM credit_notes WHERE id = $1`,
|
||||
[creditNoteId]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
if (!row.pdf_data) return null;
|
||||
return {
|
||||
data: row.pdf_data,
|
||||
filename: row.pdf_filename ?? `${row.credit_note_number}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice as voided and bump the status. Caller is
|
||||
* responsible for ensuring the invoice is in a void-eligible state
|
||||
* (status='open' or 'overdue'); this helper doesn't enforce that —
|
||||
* billing.voidInvoice() does.
|
||||
*/
|
||||
export async function markInvoiceVoided(params: {
|
||||
invoiceId: string;
|
||||
reason: string;
|
||||
voidedBy: string;
|
||||
}): Promise<void> {
|
||||
await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET status = 'void',
|
||||
void_reason = $2,
|
||||
voided_at = now(),
|
||||
voided_by = $3
|
||||
WHERE id = $1`,
|
||||
[params.invoiceId, params.reason, params.voidedBy]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a refund event and (optionally) bump the invoice status.
|
||||
* The status transition is:
|
||||
* refunded_total + amount >= total_chf → fully_refunded
|
||||
* otherwise → partially_refunded
|
||||
*
|
||||
* Idempotent against Stripe webhook replays via stripe_refund_id:
|
||||
* if a row with the same Stripe refund id already exists, returns
|
||||
* the existing row without double-counting. Manual (non-Stripe)
|
||||
* refunds have stripe_refund_id=null and can't be deduped — caller
|
||||
* must guard against double-submit at the UI/API layer.
|
||||
*
|
||||
* Returns the recorded refund, the updated invoice's new total
|
||||
* refunded amount, and the resulting invoice status.
|
||||
*/
|
||||
export interface RecordRefundResult {
|
||||
refund: InvoiceRefund;
|
||||
alreadyExisted: boolean;
|
||||
newRefundedTotalChf: number;
|
||||
newInvoiceStatus: InvoiceStatus;
|
||||
}
|
||||
|
||||
export async function recordInvoiceRefund(params: {
|
||||
invoiceId: string;
|
||||
stripeRefundId: string | null;
|
||||
amountChf: number;
|
||||
reason: string | null;
|
||||
refundedBy: string;
|
||||
creditNoteId: string | null;
|
||||
status?: "pending" | "succeeded" | "failed" | "canceled";
|
||||
}): Promise<RecordRefundResult> {
|
||||
await ensureSchema();
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// Webhook idempotency: if the Stripe refund id is already
|
||||
// recorded, return the existing row without re-adding to the
|
||||
// running total. The invoice status row reflects the cumulative
|
||||
// state independent of how many times this function is called.
|
||||
if (params.stripeRefundId) {
|
||||
const existing = await client.query(
|
||||
`SELECT * FROM invoice_refunds WHERE stripe_refund_id = $1`,
|
||||
[params.stripeRefundId]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
const inv = await client.query(
|
||||
`SELECT refunded_total_chf, status
|
||||
FROM invoices WHERE id = $1`,
|
||||
[params.invoiceId]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return {
|
||||
refund: rowToInvoiceRefund(existing.rows[0]),
|
||||
alreadyExisted: true,
|
||||
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
|
||||
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Insert the refund event.
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO invoice_refunds (
|
||||
invoice_id, stripe_refund_id, amount_chf, reason,
|
||||
status, refunded_by, credit_note_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.invoiceId,
|
||||
params.stripeRefundId,
|
||||
params.amountChf,
|
||||
params.reason,
|
||||
params.status ?? "succeeded",
|
||||
params.refundedBy,
|
||||
params.creditNoteId,
|
||||
]
|
||||
);
|
||||
const recordedStatus = inserted.rows[0].status;
|
||||
// Only succeeded refunds count toward the invoice's
|
||||
// refunded_total. Pending/failed refunds are tracked for audit
|
||||
// but don't change the customer-visible state.
|
||||
if (recordedStatus !== "succeeded") {
|
||||
const inv = await client.query(
|
||||
`SELECT refunded_total_chf, status FROM invoices WHERE id = $1`,
|
||||
[params.invoiceId]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return {
|
||||
refund: rowToInvoiceRefund(inserted.rows[0]),
|
||||
alreadyExisted: false,
|
||||
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
|
||||
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
|
||||
};
|
||||
}
|
||||
// Update aggregate + status atomically based on the new total.
|
||||
const updated = await client.query(
|
||||
`UPDATE invoices
|
||||
SET refunded_total_chf = refunded_total_chf + $2,
|
||||
status = CASE
|
||||
WHEN refunded_total_chf + $2 >= total_chf
|
||||
THEN 'fully_refunded'
|
||||
ELSE 'partially_refunded'
|
||||
END
|
||||
WHERE id = $1
|
||||
RETURNING refunded_total_chf, status`,
|
||||
[params.invoiceId, params.amountChf]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return {
|
||||
refund: rowToInvoiceRefund(inserted.rows[0]),
|
||||
alreadyExisted: false,
|
||||
newRefundedTotalChf: Number(updated.rows[0].refunded_total_chf),
|
||||
newInvoiceStatus: updated.rows[0].status as InvoiceStatus,
|
||||
};
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** All refund events for an invoice, ordered oldest first. */
|
||||
export async function listRefundsForInvoice(
|
||||
invoiceId: string
|
||||
): Promise<InvoiceRefund[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM invoice_refunds
|
||||
WHERE invoice_id = $1
|
||||
ORDER BY refunded_at ASC`,
|
||||
[invoiceId]
|
||||
);
|
||||
return result.rows.map(rowToInvoiceRefund);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7: find an invoice by its Stripe PaymentIntent id. Used by
|
||||
* the charge.refunded webhook to locate the invoice when a refund
|
||||
* is initiated outside the portal (e.g. directly in Stripe
|
||||
* Dashboard).
|
||||
*
|
||||
* Returns null if no invoice has that payment intent recorded. That
|
||||
* shouldn't happen in normal flow — every Stripe-paid invoice gets
|
||||
* its intent stored at checkout.session.completed time — but a
|
||||
* refund event for an unknown intent should be logged and ignored
|
||||
* rather than throwing.
|
||||
*/
|
||||
export async function getInvoiceByStripePaymentIntent(
|
||||
paymentIntentId: string
|
||||
): Promise<Invoice | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
|
||||
WHERE stripe_payment_intent_id = $1
|
||||
LIMIT 1`,
|
||||
[paymentIntentId]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7: check if a particular Stripe refund id is already
|
||||
* recorded in invoice_refunds. Used by the charge.refunded webhook
|
||||
* to skip refunds that were initiated via /api/admin/.../refund
|
||||
* (which records them immediately) — the webhook would otherwise
|
||||
* try to double-create the credit note.
|
||||
*/
|
||||
export async function isStripeRefundRecorded(
|
||||
stripeRefundId: string
|
||||
): Promise<boolean> {
|
||||
const result = await getPool().query(
|
||||
`SELECT 1 FROM invoice_refunds WHERE stripe_refund_id = $1 LIMIT 1`,
|
||||
[stripeRefundId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
153
src/lib/email.ts
153
src/lib/email.ts
@@ -1158,3 +1158,156 @@ export async function sendInvoiceReminderEmail(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credit note emails — Phase 7
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a credit-note notification to the customer's billing email.
|
||||
*
|
||||
* Covers both kinds (void and refund). The subject and body adapt
|
||||
* based on `kind` — voids ("we've cancelled invoice X, no payment
|
||||
* needed") read very differently from refunds ("we've refunded CHF
|
||||
* X, expect to see it on your card statement within 5-10 days").
|
||||
*
|
||||
* Link-only — the PDF is not attached. The customer downloads it
|
||||
* from /api/credit-notes/<number>/pdf when they click through, which
|
||||
* also gives them a permanent in-portal record next to their
|
||||
* invoices. Same approach as invoice emails.
|
||||
*
|
||||
* Best-effort: failures are logged and swallowed. A mail-server
|
||||
* hiccup must never roll back a credit-note issuance.
|
||||
*/
|
||||
export async function sendCreditNoteEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
creditNoteNumber: string;
|
||||
invoiceNumber: string;
|
||||
amountChf: number;
|
||||
currency: string;
|
||||
kind: "void" | "refund";
|
||||
reason: string | null;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
const L = params.locale;
|
||||
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
|
||||
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
|
||||
params.creditNoteNumber
|
||||
)}`;
|
||||
|
||||
// Subject lines diverge between void and refund — different
|
||||
// mental models for the recipient. Void: "your charge is
|
||||
// cancelled". Refund: "your money is on the way back".
|
||||
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||
en: {
|
||||
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
|
||||
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
|
||||
},
|
||||
de: {
|
||||
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
|
||||
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
|
||||
},
|
||||
fr: {
|
||||
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
|
||||
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
|
||||
},
|
||||
it: {
|
||||
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
|
||||
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
|
||||
},
|
||||
};
|
||||
|
||||
const greetingsByLocale: Record<typeof L, string> = {
|
||||
en: `Hello ${params.contactName},`,
|
||||
de: `Sehr geehrte/r ${params.contactName},`,
|
||||
fr: `Bonjour ${params.contactName},`,
|
||||
it: `Gentile ${params.contactName},`,
|
||||
};
|
||||
|
||||
// Intro: distinct phrasing per kind in each locale.
|
||||
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||
en: {
|
||||
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
|
||||
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 5–10 business days, depending on your bank.`,
|
||||
},
|
||||
de: {
|
||||
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
|
||||
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 5–10 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
|
||||
},
|
||||
fr: {
|
||||
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
|
||||
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
|
||||
},
|
||||
it: {
|
||||
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
|
||||
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 5–10 giorni lavorativi, a seconda della banca.`,
|
||||
},
|
||||
};
|
||||
|
||||
const labels: Record<typeof L, Record<string, string>> = {
|
||||
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||
};
|
||||
const l = labels[L];
|
||||
|
||||
const subject = subjectsByLocale[L][params.kind];
|
||||
const intro = introsByLocale[L][params.kind];
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeNumberCN = escapeHtml(params.creditNoteNumber);
|
||||
const safeNumberINV = escapeHtml(params.invoiceNumber);
|
||||
const safeReason = params.reason ? escapeHtml(params.reason) : null;
|
||||
|
||||
// Red accent (#DC2626) for the credit-note emails, mirroring the
|
||||
// PDF accent so the document family reads visually consistent.
|
||||
// The invoice email uses emerald; the credit note uses red.
|
||||
const ACCENT = "#DC2626";
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
greetingsByLocale[L],
|
||||
"",
|
||||
intro,
|
||||
"",
|
||||
`${l.creditNote}: ${params.creditNoteNumber}`,
|
||||
`${l.invoice}: ${params.invoiceNumber}`,
|
||||
`${l.amount}: ${totalFmt}`,
|
||||
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
|
||||
"",
|
||||
`${l.cta}:`,
|
||||
link,
|
||||
"",
|
||||
`${l.signoff},`,
|
||||
l.brand,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: ${ACCENT};">${escapeHtml(intro)}</h2>
|
||||
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
|
||||
</table>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
${l.cta}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send credit note email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,3 +258,57 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
}
|
||||
return { url: session.url, sessionId: session.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7 — refunds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Stripe Refund against an invoice's PaymentIntent.
|
||||
*
|
||||
* The amount is in CHF; we convert to rappen for Stripe's smallest-
|
||||
* currency-unit API. Pass 0 or undefined for `amountChf` to refund
|
||||
* the full charge.
|
||||
*
|
||||
* Returns the Stripe refund object so the caller can record the
|
||||
* refund id and final status. Stripe processes refunds asynchronously
|
||||
* for some payment methods, so the initial status may be 'pending'
|
||||
* — the charge.refunded webhook delivers the eventual succeeded /
|
||||
* failed transition.
|
||||
*
|
||||
* Throws on Stripe API errors (no charge, insufficient balance,
|
||||
* etc.). The caller surfaces these to the admin via the API
|
||||
* response — we don't swallow them because partial-refund logic
|
||||
* shouldn't be guessing about server state.
|
||||
*/
|
||||
export async function createInvoiceRefund(params: {
|
||||
paymentIntentId: string;
|
||||
amountChf?: number;
|
||||
reason?: "duplicate" | "fraudulent" | "requested_by_customer";
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
amountChf: number;
|
||||
status: string;
|
||||
}> {
|
||||
const stripe = getStripeClient();
|
||||
const refundParams: Parameters<typeof stripe.refunds.create>[0] = {
|
||||
payment_intent: params.paymentIntentId,
|
||||
metadata: params.metadata,
|
||||
};
|
||||
if (params.amountChf && params.amountChf > 0) {
|
||||
refundParams.amount = chfToRappen(params.amountChf);
|
||||
}
|
||||
if (params.reason) {
|
||||
refundParams.reason = params.reason;
|
||||
}
|
||||
const refund = await stripe.refunds.create(refundParams);
|
||||
// The amount on the response is in rappen; convert back. If no
|
||||
// amount was passed, Stripe defaults to the full remaining
|
||||
// charge, which is what we read back.
|
||||
return {
|
||||
id: refund.id,
|
||||
amountChf: refund.amount != null ? refund.amount / 100 : 0,
|
||||
status: refund.status ?? "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 User API — profile updates (Phase 6 fix5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a human user's profile (first name + last name + display
|
||||
* name). Returns the new `details.changeDate` from ZITADEL so the
|
||||
* caller can confirm the write landed.
|
||||
*
|
||||
* The v2 user service endpoint is technically a PUT but accepts
|
||||
* partial bodies — only the `profile` block is sent. ZITADEL
|
||||
* preserves email, password, and other fields across the call
|
||||
* (verified empirically in zitadel-server#7786 and documented in
|
||||
* v2.63+ of zitadel-server).
|
||||
*
|
||||
* `displayName` IS sent explicitly, set to "givenName familyName".
|
||||
* Empirically (and contra what some docs suggest), ZITADEL does
|
||||
* NOT recompute displayName when only the name parts change — it
|
||||
* keeps whatever displayName was previously stored, including the
|
||||
* one set at user creation time. That stale displayName is what
|
||||
* ZITADEL surfaces in the OIDC `name` claim, so without this
|
||||
* explicit write the portal session would never see the updated
|
||||
* name (even after sign-out / sign-in).
|
||||
*
|
||||
* Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
|
||||
* must have user-write permission in the user's resource org.
|
||||
* Today portal-zitadel-sa-pat already has user-write for
|
||||
* createHumanUser etc. — same scope covers this.
|
||||
*/
|
||||
export interface UpdateHumanUserProfileResult {
|
||||
changeDate: string;
|
||||
/** The displayName ZITADEL stored, which the OIDC `name` claim will
|
||||
* carry on the user's next session. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export async function updateHumanUserProfile(params: {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
}): Promise<UpdateHumanUserProfileResult> {
|
||||
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
|
||||
// Compose the displayName ourselves so ZITADEL stores something
|
||||
// sensible. Empty-string fallback only triggers if both name parts
|
||||
// are blank, which the API zod schema prevents anyway.
|
||||
const displayName =
|
||||
`${params.givenName.trim()} ${params.familyName.trim()}`.trim();
|
||||
type ZitadelUpdateResponse = {
|
||||
details?: { changeDate?: string };
|
||||
};
|
||||
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
|
||||
profile: {
|
||||
givenName: params.givenName,
|
||||
familyName: params.familyName,
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
// Re-fetch the user to read back the canonical displayName ZITADEL
|
||||
// committed. Should match what we sent, but reading from the source
|
||||
// of truth catches any sanitization ZITADEL might apply.
|
||||
const detail = await getHumanUserDetail(params.userId);
|
||||
return {
|
||||
changeDate: new Date().toISOString(),
|
||||
displayName: detail.displayName || displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a human user's current profile (given/family/display name +
|
||||
* email). Used by the settings page to populate the form and by the
|
||||
* update helper above to read back the computed displayName.
|
||||
*/
|
||||
export interface HumanUserDetail {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function getHumanUserDetail(
|
||||
userId: string
|
||||
): Promise<HumanUserDetail> {
|
||||
type ZitadelGetUserResponse = {
|
||||
user?: {
|
||||
userId?: string;
|
||||
human?: {
|
||||
profile?: {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
email?: { email?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
const response = await zitadelFetch<ZitadelGetUserResponse>(
|
||||
`/v2/users/${encodeURIComponent(userId)}`,
|
||||
"GET"
|
||||
);
|
||||
const human = response.user?.human;
|
||||
return {
|
||||
userId: response.user?.userId ?? userId,
|
||||
givenName: human?.profile?.givenName ?? "",
|
||||
familyName: human?.profile?.familyName ?? "",
|
||||
displayName: human?.profile?.displayName ?? "",
|
||||
email: human?.email?.email ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -480,7 +480,9 @@
|
||||
"billingTitle": "Abrechnung",
|
||||
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
|
||||
"nothingForYou": "Für Ihre Rolle gibt es hier noch nichts. Inhaber können Organisationseinstellungen verwalten.",
|
||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
|
||||
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants.",
|
||||
"profileTitle": "Profil",
|
||||
"profileDescription": "Bearbeiten Sie Ihren Vor- und Nachnamen, wie er im Portal erscheint."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Rechnungsdaten",
|
||||
@@ -671,7 +673,33 @@
|
||||
"lineItemsTitle": "Positionen",
|
||||
"billToSnapshotTitle": "Rechnungsempfänger",
|
||||
"setupFeeCol": "Einrichtungsgebühr",
|
||||
"skillSetupFeeLabel": "Einrichtungsgebühr"
|
||||
"skillSetupFeeLabel": "Einrichtungsgebühr",
|
||||
"status_partially_refunded": "Teilrückerstattung",
|
||||
"status_fully_refunded": "Vollständig rückerstattet",
|
||||
"voidBtn": "Stornieren",
|
||||
"voidReasonPlaceholder": "Stornierungsgrund (auf Gutschrift gedruckt)",
|
||||
"voidReasonRequired": "Bitte einen Grund für die Stornierung angeben.",
|
||||
"confirmVoid": "Stornierung bestätigen",
|
||||
"voidedOnLabel": "Storniert",
|
||||
"refundBtn": "Rückerstatten",
|
||||
"refundReasonPlaceholder": "Grund der Rückerstattung (auf Gutschrift gedruckt)",
|
||||
"refundReasonRequired": "Bitte einen Grund für die Rückerstattung angeben.",
|
||||
"refundAmountInvalid": "Rückerstattungsbetrag muss eine positive Zahl sein.",
|
||||
"refundAmountExceeds": "Rückerstattungsbetrag überschreitet den verbleibenden Betrag von CHF {max}.",
|
||||
"refundRemainingHint": "Verbleibend erstattbar: CHF {max}",
|
||||
"confirmRefund": "Rückerstattung bestätigen",
|
||||
"refundedTotalLabel": "Rückerstattet",
|
||||
"refundedRemainingLabel": "Verbleibend erstattbar",
|
||||
"creditNotesPanelTitle": "Gutschriften",
|
||||
"creditNoteNumberHeader": "Nummer",
|
||||
"creditNoteKindHeader": "Typ",
|
||||
"creditNoteAmountHeader": "Betrag",
|
||||
"creditNoteReasonHeader": "Grund",
|
||||
"creditNoteIssuedHeader": "Ausgestellt",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Storno",
|
||||
"creditNoteKind_refund": "Rückerstattung",
|
||||
"creditNoteNoPdf": "—"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Aktivierungskosten bestätigen",
|
||||
@@ -751,7 +779,17 @@
|
||||
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
||||
"paymentCancelled": "Zahlung abgebrochen.",
|
||||
"configureBillingCta": "Rechnungsdaten einrichten",
|
||||
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
|
||||
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen.",
|
||||
"creditNotesHeading": "Gutschriften",
|
||||
"creditNoteNumberCol": "Gutschrift",
|
||||
"creditNoteInvoiceCol": "Rechnung",
|
||||
"creditNoteIssuedCol": "Ausgestellt",
|
||||
"creditNoteAmountCol": "Betrag",
|
||||
"creditNoteKindCol": "Typ",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Storno",
|
||||
"creditNoteKind_refund": "Rückerstattung",
|
||||
"creditNoteNoPdf": "PDF nicht verfügbar"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Abrechnungsautomatisierung",
|
||||
@@ -784,5 +822,20 @@
|
||||
},
|
||||
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
|
||||
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Ihr Anzeigename, der im Portal, in Tenant-Anfragen und in Support-Tickets erscheint.",
|
||||
"subtitlePersonal": "Ihr Anzeigename, der im Portal erscheint. Um Ihren Namen auf Rechnungen zu ändern, bearbeiten Sie ihn unter Rechnungsdaten.",
|
||||
"firstNameLabel": "Vorname",
|
||||
"lastNameLabel": "Nachname",
|
||||
"emailLabel": "E-Mail",
|
||||
"emailReadOnlyHint": "Die E-Mail-Adresse kann hier nicht geändert werden. Verwenden Sie die Selbstbedienungseinstellungen Ihres Identitätsanbieters.",
|
||||
"personalAccountHint": "Dies ist ein persönliches Konto. Eine Änderung Ihres Namens hier ändert NICHT, wie Ihr Name auf Rechnungen erscheint — bearbeiten Sie diesen separat unter Rechnungsdaten.",
|
||||
"companyAccountHint": "Sie sind als Mitglied von {orgName} angemeldet.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Vor- und Nachname sind erforderlich."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,9 @@
|
||||
"billingTitle": "Billing",
|
||||
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
|
||||
"nothingForYou": "There's nothing here for your role yet. Owners can manage org settings.",
|
||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
|
||||
"billingDescriptionPersonal": "Address and invoice email used for all your tenants.",
|
||||
"profileTitle": "Profile",
|
||||
"profileDescription": "Edit your first and last name as shown across the portal."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Billing details",
|
||||
@@ -671,7 +673,33 @@
|
||||
"lineItemsTitle": "Line items",
|
||||
"billToSnapshotTitle": "Billed to",
|
||||
"setupFeeCol": "Setup fee",
|
||||
"skillSetupFeeLabel": "Setup fee"
|
||||
"skillSetupFeeLabel": "Setup fee",
|
||||
"status_partially_refunded": "Partially refunded",
|
||||
"status_fully_refunded": "Fully refunded",
|
||||
"voidBtn": "Void",
|
||||
"voidReasonPlaceholder": "Reason for voiding (printed on credit note)",
|
||||
"voidReasonRequired": "Please provide a reason for voiding.",
|
||||
"confirmVoid": "Confirm void",
|
||||
"voidedOnLabel": "Voided",
|
||||
"refundBtn": "Refund",
|
||||
"refundReasonPlaceholder": "Reason for refund (printed on credit note)",
|
||||
"refundReasonRequired": "Please provide a reason for the refund.",
|
||||
"refundAmountInvalid": "Refund amount must be a positive number.",
|
||||
"refundAmountExceeds": "Refund amount exceeds remaining refundable CHF {max}.",
|
||||
"refundRemainingHint": "Remaining refundable: CHF {max}",
|
||||
"confirmRefund": "Confirm refund",
|
||||
"refundedTotalLabel": "Refunded total",
|
||||
"refundedRemainingLabel": "Remaining refundable",
|
||||
"creditNotesPanelTitle": "Credit notes",
|
||||
"creditNoteNumberHeader": "Number",
|
||||
"creditNoteKindHeader": "Type",
|
||||
"creditNoteAmountHeader": "Amount",
|
||||
"creditNoteReasonHeader": "Reason",
|
||||
"creditNoteIssuedHeader": "Issued",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Void",
|
||||
"creditNoteKind_refund": "Refund",
|
||||
"creditNoteNoPdf": "—"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirm activation cost",
|
||||
@@ -751,7 +779,17 @@
|
||||
"paymentReceived": "Payment received — thank you!",
|
||||
"paymentCancelled": "Payment cancelled.",
|
||||
"configureBillingCta": "Configure billing details",
|
||||
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
|
||||
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step.",
|
||||
"creditNotesHeading": "Credit notes",
|
||||
"creditNoteNumberCol": "Credit note",
|
||||
"creditNoteInvoiceCol": "Invoice",
|
||||
"creditNoteIssuedCol": "Issued",
|
||||
"creditNoteAmountCol": "Amount",
|
||||
"creditNoteKindCol": "Type",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Void",
|
||||
"creditNoteKind_refund": "Refund",
|
||||
"creditNoteNoPdf": "PDF unavailable"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Billing automation",
|
||||
@@ -784,5 +822,20 @@
|
||||
},
|
||||
"failureBannerTitle": "Recent automation failures detected",
|
||||
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profile",
|
||||
"subtitle": "Your display name as shown across the portal, in tenant requests, and in support tickets.",
|
||||
"subtitlePersonal": "Your display name as shown across the portal. To change how your name appears on invoices, edit it in Billing details.",
|
||||
"firstNameLabel": "First name",
|
||||
"lastNameLabel": "Last name",
|
||||
"emailLabel": "Email",
|
||||
"emailReadOnlyHint": "Email can't be changed here. Use your identity provider's self-service settings to change your email.",
|
||||
"personalAccountHint": "This is a personal account. Changing your name here does NOT update how your name appears on invoices — edit that separately in Billing details.",
|
||||
"companyAccountHint": "You're signed in as a member of {orgName}.",
|
||||
"saveChanges": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"missingRequired": "First and last name are required."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,9 @@
|
||||
"billingTitle": "Facturation",
|
||||
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
|
||||
"nothingForYou": "Il n'y a rien ici pour votre rôle pour le moment. Les propriétaires peuvent gérer les paramètres de l'organisation.",
|
||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
|
||||
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires.",
|
||||
"profileTitle": "Profil",
|
||||
"profileDescription": "Modifiez votre prénom et nom tels qu'ils apparaissent dans le portail."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Informations de facturation",
|
||||
@@ -671,7 +673,33 @@
|
||||
"lineItemsTitle": "Lignes",
|
||||
"billToSnapshotTitle": "Destinataire",
|
||||
"setupFeeCol": "Frais de configuration",
|
||||
"skillSetupFeeLabel": "Frais de configuration"
|
||||
"skillSetupFeeLabel": "Frais de configuration",
|
||||
"status_partially_refunded": "Partiellement remboursée",
|
||||
"status_fully_refunded": "Entièrement remboursée",
|
||||
"voidBtn": "Annuler",
|
||||
"voidReasonPlaceholder": "Motif de l'annulation (imprimé sur la note de crédit)",
|
||||
"voidReasonRequired": "Veuillez indiquer un motif d'annulation.",
|
||||
"confirmVoid": "Confirmer l'annulation",
|
||||
"voidedOnLabel": "Annulée",
|
||||
"refundBtn": "Rembourser",
|
||||
"refundReasonPlaceholder": "Motif du remboursement (imprimé sur la note de crédit)",
|
||||
"refundReasonRequired": "Veuillez indiquer un motif de remboursement.",
|
||||
"refundAmountInvalid": "Le montant du remboursement doit être un nombre positif.",
|
||||
"refundAmountExceeds": "Le montant dépasse le restant remboursable de CHF {max}.",
|
||||
"refundRemainingHint": "Restant remboursable : CHF {max}",
|
||||
"confirmRefund": "Confirmer le remboursement",
|
||||
"refundedTotalLabel": "Remboursé",
|
||||
"refundedRemainingLabel": "Restant remboursable",
|
||||
"creditNotesPanelTitle": "Notes de crédit",
|
||||
"creditNoteNumberHeader": "Numéro",
|
||||
"creditNoteKindHeader": "Type",
|
||||
"creditNoteAmountHeader": "Montant",
|
||||
"creditNoteReasonHeader": "Motif",
|
||||
"creditNoteIssuedHeader": "Émise",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Annulation",
|
||||
"creditNoteKind_refund": "Remboursement",
|
||||
"creditNoteNoPdf": "—"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Confirmer le coût d'activation",
|
||||
@@ -737,7 +765,7 @@
|
||||
"subtotalLabel": "Sous-total",
|
||||
"vatLabel": "TVA ({rate}%)",
|
||||
"totalLabel": "Total",
|
||||
"downloadPdf": "Télécharger le PDF",
|
||||
"downloadPdf": "Télécharger PDF",
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"open": "Ouverte",
|
||||
@@ -751,7 +779,17 @@
|
||||
"paymentReceived": "Paiement reçu — merci !",
|
||||
"paymentCancelled": "Paiement annulé.",
|
||||
"configureBillingCta": "Configurer les informations de facturation",
|
||||
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape."
|
||||
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape.",
|
||||
"creditNotesHeading": "Notes de crédit",
|
||||
"creditNoteNumberCol": "Note de crédit",
|
||||
"creditNoteInvoiceCol": "Facture",
|
||||
"creditNoteIssuedCol": "Émise",
|
||||
"creditNoteAmountCol": "Montant",
|
||||
"creditNoteKindCol": "Type",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Annulation",
|
||||
"creditNoteKind_refund": "Remboursement",
|
||||
"creditNoteNoPdf": "PDF indisponible"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automatisation de la facturation",
|
||||
@@ -784,5 +822,20 @@
|
||||
},
|
||||
"failureBannerTitle": "Échecs récents détectés",
|
||||
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Votre nom d'affichage tel qu'il apparaît dans le portail, les demandes de tenant et les tickets d'assistance.",
|
||||
"subtitlePersonal": "Votre nom d'affichage tel qu'il apparaît dans le portail. Pour modifier votre nom sur les factures, modifiez-le dans Informations de facturation.",
|
||||
"firstNameLabel": "Prénom",
|
||||
"lastNameLabel": "Nom",
|
||||
"emailLabel": "E-mail",
|
||||
"emailReadOnlyHint": "L'e-mail ne peut pas être modifié ici. Utilisez les paramètres en libre-service de votre fournisseur d'identité.",
|
||||
"personalAccountHint": "Ceci est un compte personnel. Modifier votre nom ici ne change PAS la façon dont votre nom apparaît sur les factures — modifiez-le séparément dans Informations de facturation.",
|
||||
"companyAccountHint": "Vous êtes connecté en tant que membre de {orgName}.",
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"missingRequired": "Le prénom et le nom sont obligatoires."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,9 @@
|
||||
"billingTitle": "Fatturazione",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"profileTitle": "Profilo",
|
||||
"profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Dati di fatturazione",
|
||||
@@ -671,7 +673,33 @@
|
||||
"lineItemsTitle": "Righe",
|
||||
"billToSnapshotTitle": "Destinatario",
|
||||
"setupFeeCol": "Spese di attivazione",
|
||||
"skillSetupFeeLabel": "Spese di attivazione"
|
||||
"skillSetupFeeLabel": "Spese di attivazione",
|
||||
"status_partially_refunded": "Rimborsata parzialmente",
|
||||
"status_fully_refunded": "Rimborsata integralmente",
|
||||
"voidBtn": "Annulla",
|
||||
"voidReasonPlaceholder": "Motivo dell'annullamento (stampato sulla nota di credito)",
|
||||
"voidReasonRequired": "Indicare un motivo per l'annullamento.",
|
||||
"confirmVoid": "Conferma annullamento",
|
||||
"voidedOnLabel": "Annullata",
|
||||
"refundBtn": "Rimborsa",
|
||||
"refundReasonPlaceholder": "Motivo del rimborso (stampato sulla nota di credito)",
|
||||
"refundReasonRequired": "Indicare un motivo per il rimborso.",
|
||||
"refundAmountInvalid": "L'importo del rimborso deve essere un numero positivo.",
|
||||
"refundAmountExceeds": "L'importo supera il residuo rimborsabile di CHF {max}.",
|
||||
"refundRemainingHint": "Residuo rimborsabile: CHF {max}",
|
||||
"confirmRefund": "Conferma rimborso",
|
||||
"refundedTotalLabel": "Rimborsato",
|
||||
"refundedRemainingLabel": "Residuo rimborsabile",
|
||||
"creditNotesPanelTitle": "Note di credito",
|
||||
"creditNoteNumberHeader": "Numero",
|
||||
"creditNoteKindHeader": "Tipo",
|
||||
"creditNoteAmountHeader": "Importo",
|
||||
"creditNoteReasonHeader": "Motivo",
|
||||
"creditNoteIssuedHeader": "Emessa",
|
||||
"creditNotePdfHeader": "PDF",
|
||||
"creditNoteKind_void": "Annullamento",
|
||||
"creditNoteKind_refund": "Rimborso",
|
||||
"creditNoteNoPdf": "—"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Conferma costi di attivazione",
|
||||
@@ -751,7 +779,17 @@
|
||||
"paymentReceived": "Pagamento ricevuto — grazie!",
|
||||
"paymentCancelled": "Pagamento annullato.",
|
||||
"configureBillingCta": "Configura dati di fatturazione",
|
||||
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio."
|
||||
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio.",
|
||||
"creditNotesHeading": "Note di credito",
|
||||
"creditNoteNumberCol": "Nota di credito",
|
||||
"creditNoteInvoiceCol": "Fattura",
|
||||
"creditNoteIssuedCol": "Emessa",
|
||||
"creditNoteAmountCol": "Importo",
|
||||
"creditNoteKindCol": "Tipo",
|
||||
"creditNotePdfCol": "PDF",
|
||||
"creditNoteKind_void": "Annullamento",
|
||||
"creditNoteKind_refund": "Rimborso",
|
||||
"creditNoteNoPdf": "PDF non disponibile"
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automazione fatturazione",
|
||||
@@ -784,5 +822,20 @@
|
||||
},
|
||||
"failureBannerTitle": "Fallimenti recenti rilevati",
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profilo",
|
||||
"subtitle": "Il tuo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
|
||||
"subtitlePersonal": "Il tuo nome visualizzato come appare nel portale. Per modificare il tuo nome in fattura, modificalo in Dati di fatturazione.",
|
||||
"firstNameLabel": "Nome",
|
||||
"lastNameLabel": "Cognome",
|
||||
"emailLabel": "E-mail",
|
||||
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usa le impostazioni self-service del tuo provider di identità.",
|
||||
"personalAccountHint": "Questo è un account personale. Modificare il tuo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
|
||||
"companyAccountHint": "Sei connesso come membro di {orgName}.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Nome e cognome sono obbligatori."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,10 +544,57 @@ export type InvoiceStatus =
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "void"
|
||||
| "uncollectible";
|
||||
| "uncollectible"
|
||||
// Phase 7: refund states. partially_refunded = at least one refund
|
||||
// recorded but sum < total. fully_refunded = sum >= total. Voiding
|
||||
// applies to unpaid invoices; refunding applies to paid invoices —
|
||||
// the two states are mutually exclusive transitions from 'paid'
|
||||
// versus 'open'.
|
||||
| "partially_refunded"
|
||||
| "fully_refunded";
|
||||
|
||||
export type InvoicePaymentMethod = "invoice" | "card";
|
||||
|
||||
// Phase 7 — credit notes are independent documents (separate
|
||||
// numbering, separate PDF) that record a void or refund against an
|
||||
// original invoice. Issued as part of voidInvoice() or
|
||||
// refundInvoice() flows; the customer downloads them from
|
||||
// /api/credit-notes/<number>/pdf.
|
||||
export type CreditNoteKind = "void" | "refund";
|
||||
|
||||
export interface CreditNote {
|
||||
id: string;
|
||||
creditNoteNumber: string;
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
zitadelOrgId: string;
|
||||
kind: CreditNoteKind;
|
||||
amountChf: number;
|
||||
vatAmountChf: number;
|
||||
reason: string | null;
|
||||
issuedAt: string;
|
||||
issuedBy: string;
|
||||
locale: string;
|
||||
pdfFilename: string | null;
|
||||
hasPdf: boolean;
|
||||
billingSnapshot: InvoiceBillingSnapshot;
|
||||
}
|
||||
|
||||
// Phase 7 — per-refund-event record (one row per Stripe Refund
|
||||
// object, or per admin-initiated refund for invoice-paid customers).
|
||||
// Aggregated into invoices.refunded_total_chf for query convenience.
|
||||
export interface InvoiceRefund {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
stripeRefundId: string | null;
|
||||
amountChf: number;
|
||||
reason: string | null;
|
||||
status: "pending" | "succeeded" | "failed" | "canceled";
|
||||
refundedAt: string;
|
||||
refundedBy: string;
|
||||
creditNoteId: string | null;
|
||||
}
|
||||
|
||||
// Phase 5 — Cron run history rows for the admin /admin/cron page.
|
||||
export type CronRunKind = "monthly_issue" | "reminders";
|
||||
export interface CronRun {
|
||||
@@ -641,6 +688,16 @@ export interface Invoice {
|
||||
paidAt: string | null;
|
||||
paidBy: string | null;
|
||||
paidMethodDetail: string | null;
|
||||
// Phase 7 — void tracking. Populated when status='void'. The reason
|
||||
// free-text is rendered on the credit note PDF.
|
||||
voidReason: string | null;
|
||||
voidedAt: string | null;
|
||||
voidedBy: string | null;
|
||||
// Phase 7 — running sum of refunds applied to this invoice. Zero
|
||||
// for invoices that have never been refunded. Drives status
|
||||
// transitions (partially_refunded vs fully_refunded) and the
|
||||
// running-total widget on /billing.
|
||||
refundedTotalChf: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user