Files
carmanagement/apps/api/src/services/invoicePdfService.ts
T
2026-05-09 20:01:51 -04:00

498 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`
}