Merge pull request #72 from ciphera-net/staging
Invoice list with VAT breakdown and PDF download
This commit is contained in:
@@ -3,9 +3,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Button, toast, Spinner, Modal } from '@ciphera-net/ui'
|
import { Button, toast, Spinner, Modal } from '@ciphera-net/ui'
|
||||||
import { CreditCard, ArrowSquareOut } from '@phosphor-icons/react'
|
import { CreditCard, ArrowSquareOut, DownloadSimple } from '@phosphor-icons/react'
|
||||||
import { useSubscription } from '@/lib/swr/dashboard'
|
import { useSubscription } from '@/lib/swr/dashboard'
|
||||||
import { updatePaymentMethod, cancelSubscription, resumeSubscription, getOrders, type Order } from '@/lib/api/billing'
|
import { updatePaymentMethod, cancelSubscription, resumeSubscription, getInvoices, downloadInvoicePDF, type Invoice } from '@/lib/api/billing'
|
||||||
import { formatDateLong, formatDate } from '@/lib/utils/formatDate'
|
import { formatDateLong, formatDate } from '@/lib/utils/formatDate'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
|
|
||||||
@@ -13,16 +13,12 @@ export default function WorkspaceBillingTab() {
|
|||||||
const { data: subscription, isLoading, mutate } = useSubscription()
|
const { data: subscription, isLoading, mutate } = useSubscription()
|
||||||
const [cancelling, setCancelling] = useState(false)
|
const [cancelling, setCancelling] = useState(false)
|
||||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||||
const [orders, setOrders] = useState<Order[]>([])
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOrders().then(setOrders).catch(() => {})
|
getInvoices().then(setInvoices).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const formatAmount = (amount: string, currency: string) => {
|
|
||||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'EUR' }).format(parseFloat(amount))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleManageBilling = async () => {
|
const handleManageBilling = async () => {
|
||||||
try {
|
try {
|
||||||
const { url } = await updatePaymentMethod()
|
const { url } = await updatePaymentMethod()
|
||||||
@@ -197,19 +193,34 @@ export default function WorkspaceBillingTab() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Recent Invoices */}
|
{/* Recent Invoices */}
|
||||||
{orders.length > 0 && (
|
{invoices.length > 0 && (
|
||||||
<div className="space-y-2 pt-6 border-t border-neutral-800">
|
<div className="space-y-2 pt-6 border-t border-neutral-800">
|
||||||
<h4 className="text-sm font-medium text-neutral-300">Recent Invoices</h4>
|
<h4 className="text-sm font-medium text-neutral-300">Recent Invoices</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{orders.map(order => (
|
{invoices.map(invoice => (
|
||||||
<div key={order.id} className="flex items-center justify-between p-3 rounded-lg border border-neutral-800 text-sm">
|
<div key={invoice.id} className="flex items-center justify-between p-3 rounded-lg border border-neutral-800 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-neutral-300">{formatDate(new Date(order.created_at))}</span>
|
<span className="text-neutral-400 font-mono text-xs">{invoice.invoice_number ?? '—'}</span>
|
||||||
<span className="text-white font-medium">{formatAmount(order.amount, order.currency)}</span>
|
<span className="text-neutral-300">{formatDate(new Date(invoice.created_at))}</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{new Intl.NumberFormat('en-GB', { style: 'currency', currency: invoice.currency || 'EUR' }).format(invoice.total_cents / 100)}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-500 text-xs">
|
||||||
|
(incl. {new Intl.NumberFormat('en-GB', { style: 'currency', currency: invoice.currency || 'EUR' }).format(invoice.vat_cents / 100)} VAT)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${invoice.status === 'sent' ? 'bg-green-900/30 text-green-400' : 'bg-neutral-800 text-neutral-400'}`}>
|
||||||
|
{invoice.status === 'sent' ? 'Paid' : invoice.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadInvoicePDF(invoice.id).catch(() => toast.error('PDF not available yet'))}
|
||||||
|
className="p-1.5 rounded-md hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
<DownloadSimple size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${order.status === 'paid' ? 'bg-green-900/30 text-green-400' : 'bg-neutral-800 text-neutral-400'}`}>
|
|
||||||
{order.status === 'paid' ? 'Paid' : order.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,16 +91,36 @@ export async function updatePaymentMethod(): Promise<{ url: string }> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Invoice {
|
||||||
id: string
|
id: string
|
||||||
amount: string
|
invoice_number: string | null
|
||||||
|
amount_cents: number
|
||||||
|
vat_cents: number
|
||||||
|
total_cents: number
|
||||||
currency: string
|
currency: string
|
||||||
|
description: string
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrders(): Promise<Order[]> {
|
export async function getInvoices(): Promise<Invoice[]> {
|
||||||
return apiRequest<Order[]>('/api/billing/invoices')
|
const res = await apiRequest<{ invoices: Invoice[] }>('/api/billing/invoices')
|
||||||
|
return res.invoices ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadInvoicePDF(invoiceId: string): Promise<void> {
|
||||||
|
const { API_URL } = await import('./client')
|
||||||
|
const res = await fetch(API_URL + '/api/billing/invoices/' + invoiceId + '/pdf', {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to download invoice PDF')
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'invoice.pdf'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VATResult {
|
export interface VATResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user