since3-portal/modules/shared/actions/invoices.ts
2026-10-01 13:59:20 +02:00

175 lines
7.1 KiB
TypeScript

"use server";
import * as z from "zod";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/supabase/dal";
import { getClientId } from "@/modules/shared/queries/profile";
import { getInvoicesForPdf } from "@/modules/shared/queries/invoices";
import { getMailRecipient } from "@/modules/shared/queries/mail-recipient";
import { getAppUrl } from "@/modules/shared/mail/app-url";
import { sendMail } from "@/modules/shared/mail/send-mail";
import { invoiceMail } from "@/modules/shared/mail/templates";
import { invoiceFileName, renderInvoicePdf } from "@/modules/shared/pdf/render-invoice";
import {
INVOICE_STATUSES,
InvoiceDraftSchema,
firstIssueMessage,
type InvoiceDraft,
type InvoiceStatus,
} from "@/modules/shared/schemas/invoice";
import { invoiceTotals, toIsoDate } from "@/modules/shared/lib/invoice-math";
export type InvoiceActionState = { error: string } | { success: true };
export type CreateInvoiceState = { error: string } | { success: true; mailError?: string };
function revalidateBilling(profileId: string) {
revalidatePath("/client/billing");
revalidatePath(`/admin/clients/${profileId}/billing`);
}
// Versendet eine Rechnung als PDF an den Ansprechpartner. Aufrufer muss Admin-Rechte bereits geprüft haben.
async function deliverInvoiceMail(profileId: string, clientId: string, invoiceId: string): Promise<{ error: string } | null> {
const supabase = await createClient();
const { data: invoice } = await supabase
.from("invoices")
.select("id, status")
.eq("id", invoiceId)
.eq("client_id", clientId)
.maybeSingle();
if (!invoice) return { error: "Rechnung nicht gefunden." };
if (invoice.status === "cancelled") return { error: "Stornierte Rechnungen werden nicht verschickt." };
// Die Rechnung ist beim Aufruf aus createInvoice schon gespeichert: Fehler hier dürfen nicht nach oben durchschlagen.
try {
const [pdfData] = await getInvoicesForPdf([invoiceId]);
const recipient = await getMailRecipient(profileId);
if (!pdfData || !recipient) return { error: "Rechnung oder Empfänger konnte nicht geladen werden." };
const [pdf, appUrl] = await Promise.all([renderInvoicePdf(pdfData), getAppUrl()]);
const mail = invoiceMail({
name: recipient.name,
invoiceNumber: pdfData.invoiceNumber,
invoiceDate: pdfData.invoiceDate,
dueDate: pdfData.dueDate,
grossTotal: invoiceTotals(pdfData.items).gross,
appUrl,
});
const sent = await sendMail({
to: recipient.email,
...mail,
type: "invoice",
attachments: [{ filename: invoiceFileName(pdfData.invoiceNumber), content: new Uint8Array(pdf), contentType: "application/pdf" }],
});
if (!sent.ok) return { error: sent.error };
const { error: updateError } = await supabase
.from("invoices")
.update({ email_sent_at: new Date().toISOString(), email_sent_to: recipient.email })
.eq("id", invoiceId);
if (updateError) {
console.error(`[mail] Rechnung ${invoiceId} wurde versendet, Versandstatus aber nicht gespeichert:`, updateError.message);
}
return null;
} catch (error) {
console.error(`[mail] Rechnung ${invoiceId} konnte nicht versendet werden:`, error);
return { error: "E-Mail konnte nicht gesendet werden." };
}
}
export async function createInvoice(draft: InvoiceDraft, options: { sendEmail?: boolean } = {}): Promise<CreateInvoiceState> {
const session = await verifySession();
if (!session.isAdmin) return { error: "Keine Berechtigung." };
const validated = InvoiceDraftSchema.safeParse(draft);
if (!validated.success) return { error: firstIssueMessage(validated.error) };
const data = validated.data;
const clientId = await getClientId(data.profileId);
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
const supabase = await createClient();
const { data: invoiceId, error } = await supabase.rpc("create_invoice", {
p_client_id: clientId,
p_invoice_date: data.invoiceDate,
p_due_date: data.dueDate,
p_company_name: data.companyName,
p_billing_street: data.billingStreet,
p_billing_postal_code: data.billingPostalCode,
p_billing_city: data.billingCity,
p_billing_country: data.billingCountry.toUpperCase(),
p_items: data.items.map((item) => ({
product_template_id: item.productTemplateId,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
tax_rate: item.taxRate,
})),
});
if (error) return { error: "Rechnung konnte nicht erstellt werden." };
const mailFailure = options.sendEmail ? await deliverInvoiceMail(data.profileId, clientId, invoiceId) : null;
revalidateBilling(data.profileId);
return mailFailure ? { success: true, mailError: mailFailure.error } : { success: true };
}
const SendSchema = z.object({ profileId: z.string().uuid(), invoiceId: z.string().uuid() });
export async function sendInvoiceEmail(profileId: string, invoiceId: string): Promise<InvoiceActionState> {
const session = await verifySession();
if (!session.isAdmin) return { error: "Keine Berechtigung." };
const validated = SendSchema.safeParse({ profileId, invoiceId });
if (!validated.success) return { error: "Ungültige Rechnung." };
const clientId = await getClientId(validated.data.profileId);
if (!clientId) return { error: "Client hat noch keine Stammdaten hinterlegt." };
const failure = await deliverInvoiceMail(validated.data.profileId, clientId, validated.data.invoiceId);
if (failure) return failure;
revalidateBilling(validated.data.profileId);
return { success: true };
}
const StatusSchema = z.object({
profileId: z.string().uuid(),
invoiceId: z.string().uuid(),
status: z.enum(INVOICE_STATUSES),
});
export async function updateInvoiceStatus(
profileId: string,
invoiceId: string,
status: InvoiceStatus
): Promise<InvoiceActionState> {
const session = await verifySession();
if (!session.isAdmin) return { error: "Keine Berechtigung." };
const validated = StatusSchema.safeParse({ profileId, invoiceId, status });
if (!validated.success) return { error: "Ungültiger Status." };
const clientId = await getClientId(validated.data.profileId);
if (!clientId) return { error: "Status konnte nicht geändert werden." };
const supabase = await createClient();
const { data, error } = await supabase
.from("invoices")
.update({
status: validated.data.status,
paid_at: validated.data.status === "paid" ? toIsoDate(new Date()) : null,
})
.eq("id", validated.data.invoiceId)
.eq("client_id", clientId)
.select("id");
if (error || !data?.length) return { error: "Status konnte nicht geändert werden." };
revalidateBilling(validated.data.profileId);
return { success: true };
}