From 4001f1d067c7da62d343b794acc554ecc77cf127 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 11 Jan 2026 18:27:19 -0600 Subject: [PATCH] feat(billing): implement invoice display and retrieval functionality - Added `ShowInvoices` component to display user invoices with status and actions. - Integrated Stripe API to fetch invoices for the authenticated user. - Updated `ShowBilling` component to conditionally render invoices if the user has a Stripe customer ID. --- .../settings/billing/show-billing.tsx | 7 +- .../settings/billing/show-invoices.tsx | 159 ++++++++++++++++++ apps/dokploy/server/api/routers/stripe.ts | 35 ++++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index ac211a1c5..faa5bcdcc 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -24,6 +24,7 @@ import { Progress } from "@/components/ui/progress"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { ShowInvoices } from "./show-invoices"; const stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, @@ -75,8 +76,8 @@ export const ShowBilling = () => { const safePercentage = Math.min(percentage, 100); return ( -
- +
+
@@ -319,6 +320,8 @@ export const ShowBilling = () => {
+ + {admin?.user.stripeCustomerId && }
); }; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx new file mode 100644 index 000000000..513bb3dc8 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx @@ -0,0 +1,159 @@ +import { Download, ExternalLink, FileText, Loader2 } from "lucide-react"; +import type Stripe from "stripe"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +const formatDate = (timestamp: number | null) => { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +const formatAmount = (amount: number, currency: string) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); +}; + +const getStatusBadge = (status: Stripe.Invoice.Status | null) => { + const statusConfig: Record< + Stripe.Invoice.Status, + { label: string; variant: "default" | "secondary" | "destructive" } + > = { + paid: { label: "Paid", variant: "default" }, + open: { label: "Open", variant: "secondary" }, + draft: { label: "Draft", variant: "secondary" }, + void: { label: "Void", variant: "destructive" }, + uncollectible: { label: "Uncollectible", variant: "destructive" }, + }; + + if (!status) { + return Unknown; + } + + const config = statusConfig[status] || { + label: status, + variant: "secondary" as const, + }; + + return {config.label}; +}; + +export const ShowInvoices = () => { + const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery(); + + return ( + +
+ + + + Invoices + + + View and download your billing invoices + + + + {isLoading ? ( +
+ + Loading invoices... + + +
+ ) : invoices && invoices.length > 0 ? ( +
+ + + + Invoice + Date + Due Date + Amount + Status + Actions + + + + {invoices.map((invoice) => ( + + + {invoice.number || invoice.id.slice(0, 12)} + + {formatDate(invoice.created)} + {formatDate(invoice.dueDate)} + + {formatAmount(invoice.amountDue, invoice.currency)} + + {getStatusBadge(invoice.status)} + +
+ {invoice.hostedInvoiceUrl && ( + + )} + {invoice.invoicePdf && ( + + )} +
+
+
+ ))} +
+
+
+ ) : ( +
+ +

+ No invoices found +

+

+ Your invoices will appear here once you have a subscription +

+
+ )} +
+
+
+ ); +}; diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 2d1556a27..3354c3311 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -129,4 +129,39 @@ export const stripeRouter = createTRPCRouter({ return servers.length < user.serversQuantity; }), + + getInvoices: adminProcedure.query(async ({ ctx }) => { + const user = await findUserById(ctx.user.ownerId); + const stripeCustomerId = user.stripeCustomerId; + + if (!stripeCustomerId) { + return []; + } + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2024-09-30.acacia", + }); + + try { + const invoices = await stripe.invoices.list({ + customer: stripeCustomerId, + limit: 100, + }); + + return invoices.data.map((invoice) => ({ + id: invoice.id, + number: invoice.number, + status: invoice.status, + amountDue: invoice.amount_due, + amountPaid: invoice.amount_paid, + currency: invoice.currency, + created: invoice.created, + dueDate: invoice.due_date, + hostedInvoiceUrl: invoice.hosted_invoice_url, + invoicePdf: invoice.invoice_pdf, + })); + } catch (_) { + return []; + } + }), });