498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
import React from 'react'
|
||
import { renderToBuffer, Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
|
||
|
||
interface InvoiceData {
|
||
invoiceNumber: string
|
||
issueDate: string
|
||
dueDate: string | null
|
||
company: {
|
||
name: string
|
||
email: string
|
||
phone?: string | null
|
||
address?: any
|
||
}
|
||
subscription: {
|
||
plan: string
|
||
billingPeriod: string
|
||
currentPeriodStart?: string | null
|
||
currentPeriodEnd?: string | null
|
||
currency: string
|
||
}
|
||
amount: number
|
||
currency: string
|
||
status: string
|
||
paymentProvider: string
|
||
transactionId?: string | null
|
||
paidAt?: string | null
|
||
}
|
||
|
||
const PLAN_LABEL: Record<string, string> = {
|
||
STARTER: 'Starter',
|
||
GROWTH: 'Growth',
|
||
PRO: 'Pro',
|
||
}
|
||
|
||
const PERIOD_LABEL: Record<string, string> = {
|
||
MONTHLY: 'Monthly',
|
||
ANNUAL: 'Annual',
|
||
}
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
PAID: '#10b981',
|
||
PENDING: '#f59e0b',
|
||
FAILED: '#ef4444',
|
||
REFUNDED: '#6b7280',
|
||
}
|
||
|
||
const colors = {
|
||
bg: '#0f0f11',
|
||
surface: '#18181b',
|
||
border: '#27272a',
|
||
textPrimary: '#f4f4f5',
|
||
textSecondary: '#a1a1aa',
|
||
textMuted: '#71717a',
|
||
accent: '#10b981',
|
||
white: '#ffffff',
|
||
}
|
||
|
||
const s = StyleSheet.create({
|
||
page: {
|
||
backgroundColor: colors.bg,
|
||
paddingHorizontal: 48,
|
||
paddingVertical: 48,
|
||
fontFamily: 'Helvetica',
|
||
color: colors.textPrimary,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
marginBottom: 40,
|
||
paddingBottom: 24,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.border,
|
||
},
|
||
brandName: {
|
||
fontSize: 22,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.accent,
|
||
letterSpacing: 0.5,
|
||
},
|
||
brandTagline: {
|
||
fontSize: 9,
|
||
color: colors.textMuted,
|
||
marginTop: 3,
|
||
},
|
||
invoiceMeta: {
|
||
alignItems: 'flex-end',
|
||
},
|
||
invoiceTitle: {
|
||
fontSize: 26,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.white,
|
||
letterSpacing: 1,
|
||
},
|
||
invoiceNumber: {
|
||
fontSize: 11,
|
||
color: colors.textSecondary,
|
||
marginTop: 4,
|
||
},
|
||
section: {
|
||
marginBottom: 24,
|
||
},
|
||
sectionLabel: {
|
||
fontSize: 8,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.textMuted,
|
||
letterSpacing: 1.2,
|
||
textTransform: 'uppercase',
|
||
marginBottom: 8,
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
gap: 24,
|
||
marginBottom: 24,
|
||
},
|
||
col: {
|
||
flex: 1,
|
||
backgroundColor: colors.surface,
|
||
borderRadius: 8,
|
||
padding: 16,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
},
|
||
colLabel: {
|
||
fontSize: 8,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.textMuted,
|
||
letterSpacing: 1,
|
||
textTransform: 'uppercase',
|
||
marginBottom: 10,
|
||
},
|
||
companyName: {
|
||
fontSize: 13,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.textPrimary,
|
||
marginBottom: 4,
|
||
},
|
||
companyDetail: {
|
||
fontSize: 10,
|
||
color: colors.textSecondary,
|
||
marginBottom: 2,
|
||
},
|
||
metaRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 6,
|
||
},
|
||
metaKey: {
|
||
fontSize: 9,
|
||
color: colors.textMuted,
|
||
},
|
||
metaValue: {
|
||
fontSize: 9,
|
||
color: colors.textSecondary,
|
||
fontFamily: 'Helvetica-Bold',
|
||
},
|
||
table: {
|
||
backgroundColor: colors.surface,
|
||
borderRadius: 8,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
overflow: 'hidden',
|
||
marginBottom: 20,
|
||
},
|
||
tableHeader: {
|
||
flexDirection: 'row',
|
||
backgroundColor: '#1c1c1f',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 10,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.border,
|
||
},
|
||
tableHeaderCell: {
|
||
fontSize: 8,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.textMuted,
|
||
letterSpacing: 0.8,
|
||
textTransform: 'uppercase',
|
||
},
|
||
tableRow: {
|
||
flexDirection: 'row',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.border,
|
||
},
|
||
tableCell: {
|
||
fontSize: 10,
|
||
color: colors.textPrimary,
|
||
},
|
||
tableCellMuted: {
|
||
fontSize: 10,
|
||
color: colors.textSecondary,
|
||
},
|
||
col60: { flex: 6 },
|
||
col20: { flex: 2, textAlign: 'right' as const },
|
||
col20Center: { flex: 2, textAlign: 'center' as const },
|
||
totalRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-end',
|
||
marginBottom: 6,
|
||
},
|
||
totalLabel: {
|
||
fontSize: 11,
|
||
color: colors.textSecondary,
|
||
marginRight: 32,
|
||
},
|
||
totalValue: {
|
||
fontSize: 11,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.white,
|
||
minWidth: 100,
|
||
textAlign: 'right' as const,
|
||
},
|
||
grandTotalRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-end',
|
||
marginTop: 8,
|
||
paddingTop: 12,
|
||
borderTopWidth: 1,
|
||
borderTopColor: colors.border,
|
||
},
|
||
grandTotalLabel: {
|
||
fontSize: 13,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.textSecondary,
|
||
marginRight: 32,
|
||
},
|
||
grandTotalValue: {
|
||
fontSize: 15,
|
||
fontFamily: 'Helvetica-Bold',
|
||
color: colors.accent,
|
||
minWidth: 100,
|
||
textAlign: 'right' as const,
|
||
},
|
||
statusBadge: {
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 4,
|
||
alignSelf: 'flex-start',
|
||
marginTop: 6,
|
||
},
|
||
statusText: {
|
||
fontSize: 9,
|
||
fontFamily: 'Helvetica-Bold',
|
||
letterSpacing: 0.5,
|
||
color: colors.white,
|
||
},
|
||
paymentBox: {
|
||
backgroundColor: colors.surface,
|
||
borderRadius: 8,
|
||
padding: 16,
|
||
borderWidth: 1,
|
||
borderColor: colors.border,
|
||
marginBottom: 24,
|
||
},
|
||
paymentRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 8,
|
||
},
|
||
paymentKey: {
|
||
fontSize: 9,
|
||
color: colors.textMuted,
|
||
},
|
||
paymentValue: {
|
||
fontSize: 9,
|
||
color: colors.textSecondary,
|
||
fontFamily: 'Helvetica-Bold',
|
||
maxWidth: 280,
|
||
textAlign: 'right' as const,
|
||
},
|
||
footer: {
|
||
position: 'absolute',
|
||
bottom: 32,
|
||
left: 48,
|
||
right: 48,
|
||
borderTopWidth: 1,
|
||
borderTopColor: colors.border,
|
||
paddingTop: 12,
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
},
|
||
footerText: {
|
||
fontSize: 8,
|
||
color: colors.textMuted,
|
||
},
|
||
})
|
||
|
||
function fmt(amount: number, currency: string) {
|
||
return `${(amount / 100).toFixed(2)} ${currency}`
|
||
}
|
||
|
||
function fmtDate(iso: string | null | undefined) {
|
||
if (!iso) return '—'
|
||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
||
}
|
||
|
||
function formatAddress(address: any): string {
|
||
if (!address) return ''
|
||
if (typeof address === 'string') return address
|
||
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
||
return parts.filter(Boolean).join(', ')
|
||
}
|
||
|
||
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
||
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
||
const addressStr = formatAddress(data.company.address)
|
||
const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan
|
||
const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod
|
||
const description = `${planLabel} Plan — ${periodLabel} Subscription`
|
||
const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd
|
||
? `${fmtDate(data.subscription.currentPeriodStart)} – ${fmtDate(data.subscription.currentPeriodEnd)}`
|
||
: '—'
|
||
|
||
return React.createElement(
|
||
Document,
|
||
{ author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' },
|
||
React.createElement(
|
||
Page,
|
||
{ size: 'A4', style: s.page },
|
||
|
||
// ── Header ──────────────────────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: s.header },
|
||
React.createElement(
|
||
View,
|
||
null,
|
||
React.createElement(Text, { style: s.brandName }, 'RentalDriveGo'),
|
||
React.createElement(Text, { style: s.brandTagline }, 'Car Rental Management Platform'),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.invoiceMeta },
|
||
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
||
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
|
||
React.createElement(
|
||
View,
|
||
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
||
React.createElement(Text, { style: s.statusText }, data.status),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Bill To / Invoice Dates ──────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: s.row },
|
||
React.createElement(
|
||
View,
|
||
{ style: s.col },
|
||
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
||
React.createElement(Text, { style: s.companyName }, data.company.name),
|
||
React.createElement(Text, { style: s.companyDetail }, data.company.email),
|
||
data.company.phone
|
||
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
|
||
: null,
|
||
addressStr
|
||
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
|
||
: null,
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.col },
|
||
React.createElement(Text, { style: s.colLabel }, 'Invoice Details'),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
||
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Issue Date'),
|
||
React.createElement(Text, { style: s.metaValue }, fmtDate(data.issueDate)),
|
||
),
|
||
data.dueDate
|
||
? React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Due Date'),
|
||
React.createElement(Text, { style: s.metaValue }, fmtDate(data.dueDate)),
|
||
)
|
||
: null,
|
||
React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
||
React.createElement(Text, { style: s.metaValue }, data.currency),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
||
React.createElement(Text, { style: s.metaValue }, periodLabel),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.metaRow },
|
||
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
||
React.createElement(Text, { style: s.metaValue }, planLabel),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── Line Items ───────────────────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: s.table },
|
||
React.createElement(
|
||
View,
|
||
{ style: s.tableHeader },
|
||
React.createElement(Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'),
|
||
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
|
||
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.tableRow },
|
||
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
|
||
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
|
||
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(data.amount, data.currency)),
|
||
),
|
||
),
|
||
|
||
// ── Totals ───────────────────────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: s.totalRow },
|
||
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
|
||
React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.grandTotalRow },
|
||
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
|
||
React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)),
|
||
),
|
||
|
||
// ── Payment Info ─────────────────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: [s.paymentBox, { marginTop: 24 }] },
|
||
React.createElement(Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.paymentRow },
|
||
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
||
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
|
||
),
|
||
data.transactionId
|
||
? React.createElement(
|
||
View,
|
||
{ style: s.paymentRow },
|
||
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
||
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
|
||
)
|
||
: null,
|
||
React.createElement(
|
||
View,
|
||
{ style: s.paymentRow },
|
||
React.createElement(Text, { style: s.paymentKey }, 'Payment Date'),
|
||
React.createElement(Text, { style: s.paymentValue }, fmtDate(data.paidAt)),
|
||
),
|
||
React.createElement(
|
||
View,
|
||
{ style: s.paymentRow },
|
||
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
||
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
|
||
),
|
||
),
|
||
|
||
// ── Footer ───────────────────────────────────────────────────
|
||
React.createElement(
|
||
View,
|
||
{ style: s.footer },
|
||
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
|
||
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
||
),
|
||
),
|
||
)
|
||
}
|
||
|
||
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
||
const doc = React.createElement(InvoiceDocument, { data })
|
||
const buffer = await renderToBuffer(doc as any)
|
||
return buffer as unknown as Buffer
|
||
}
|
||
|
||
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
||
const year = createdAt.getFullYear()
|
||
const short = invoiceId.slice(-6).toUpperCase()
|
||
return `INV-${year}-${short}`
|
||
}
|