a752a399c2
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
663 lines
21 KiB
TypeScript
663 lines
21 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
|
|
lineItems?: Array<{
|
|
description: string
|
|
amount: number
|
|
currency: string
|
|
quantity?: number
|
|
unitAmount?: number
|
|
periodStart?: string | null
|
|
periodEnd?: string | null
|
|
}>
|
|
totals?: {
|
|
subtotalAmount: number
|
|
discountAmount: number
|
|
creditAmount: number
|
|
taxAmount: number
|
|
totalAmount: number
|
|
amountPaid: number
|
|
amountDue: number
|
|
}
|
|
}
|
|
|
|
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',
|
|
OPEN: '#f59e0b',
|
|
PAYMENT_PENDING: '#f59e0b',
|
|
PARTIALLY_PAID: '#f59e0b',
|
|
PAST_DUE: '#ef4444',
|
|
FAILED: '#ef4444',
|
|
REFUNDED: '#6b7280',
|
|
PARTIALLY_REFUNDED: '#6b7280',
|
|
VOID: '#6b7280',
|
|
UNCOLLECTIBLE: '#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
|
|
if (typeof address.formatted === 'string') return address.formatted
|
|
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
|
return parts.filter(Boolean).join(', ')
|
|
}
|
|
|
|
function pdfText(value: unknown) {
|
|
return String(value ?? '')
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[\u2013\u2014]/g, '-')
|
|
.replace(/\u00d7/g, 'x')
|
|
.replace(/[^\x20-\x7E]/g, '')
|
|
}
|
|
|
|
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
|
const addressStr = formatAddress(data.company.address)
|
|
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
|
|
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
|
|
const lineItems = data.lineItems?.length
|
|
? data.lineItems
|
|
: [{
|
|
description: `${planLabel} Plan — ${periodLabel} Subscription`,
|
|
amount: data.amount,
|
|
currency: data.currency,
|
|
periodStart: data.subscription?.currentPeriodStart ?? null,
|
|
periodEnd: data.subscription?.currentPeriodEnd ?? null,
|
|
}]
|
|
const totals = data.totals ?? {
|
|
subtotalAmount: data.amount,
|
|
discountAmount: 0,
|
|
creditAmount: 0,
|
|
taxAmount: 0,
|
|
totalAmount: data.amount,
|
|
amountPaid: data.paidAt ? data.amount : 0,
|
|
amountDue: data.paidAt ? 0 : data.amount,
|
|
}
|
|
|
|
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 }, pdfText(data.invoiceNumber)),
|
|
React.createElement(
|
|
View,
|
|
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
|
React.createElement(Text, { style: s.statusText }, pdfText(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 }, pdfText(data.company.name)),
|
|
React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.email)),
|
|
data.company.phone
|
|
? React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.phone))
|
|
: null,
|
|
addressStr
|
|
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, pdfText(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 }, pdfText(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 }, pdfText(data.currency)),
|
|
),
|
|
React.createElement(
|
|
View,
|
|
{ style: s.metaRow },
|
|
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
|
React.createElement(Text, { style: s.metaValue }, pdfText(periodLabel)),
|
|
),
|
|
data.subscription
|
|
? React.createElement(
|
|
View,
|
|
{ style: s.metaRow },
|
|
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
|
React.createElement(Text, { style: s.metaValue }, pdfText(planLabel)),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
|
|
// ── 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'),
|
|
),
|
|
...lineItems.map((item) => {
|
|
const periodStr = item.periodStart && item.periodEnd
|
|
? `${fmtDate(item.periodStart)} - ${fmtDate(item.periodEnd)}`
|
|
: '-'
|
|
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
|
? `${item.description} (${item.quantity} x ${fmt(item.unitAmount, item.currency)})`
|
|
: item.description
|
|
return React.createElement(
|
|
View,
|
|
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
|
|
React.createElement(Text, { style: [s.tableCell, s.col60] }, pdfText(description)),
|
|
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, pdfText(periodStr)),
|
|
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, pdfText(fmt(item.amount, item.currency))),
|
|
)
|
|
}),
|
|
),
|
|
|
|
// ── Totals ───────────────────────────────────────────────────
|
|
React.createElement(
|
|
View,
|
|
{ style: s.totalRow },
|
|
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
|
|
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
|
|
),
|
|
totals.discountAmount > 0
|
|
? React.createElement(
|
|
View,
|
|
{ style: s.totalRow },
|
|
React.createElement(Text, { style: s.totalLabel }, 'Discounts'),
|
|
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.discountAmount, data.currency)}`),
|
|
)
|
|
: null,
|
|
totals.creditAmount > 0
|
|
? React.createElement(
|
|
View,
|
|
{ style: s.totalRow },
|
|
React.createElement(Text, { style: s.totalLabel }, 'Credits'),
|
|
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
|
|
)
|
|
: null,
|
|
totals.taxAmount > 0
|
|
? React.createElement(
|
|
View,
|
|
{ style: s.totalRow },
|
|
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
|
|
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
|
|
)
|
|
: null,
|
|
React.createElement(
|
|
View,
|
|
{ style: s.grandTotalRow },
|
|
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
|
|
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, 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 }, pdfText(data.paymentProvider)),
|
|
),
|
|
data.transactionId
|
|
? React.createElement(
|
|
View,
|
|
{ style: s.paymentRow },
|
|
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
|
React.createElement(Text, { style: s.paymentValue }, pdfText(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 }] }, pdfText(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 })
|
|
try {
|
|
const buffer = await renderToBuffer(doc as any)
|
|
return buffer as unknown as Buffer
|
|
} catch {
|
|
return buildSimpleInvoicePdf(data)
|
|
}
|
|
}
|
|
|
|
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
|
const year = createdAt.getFullYear()
|
|
const short = invoiceId.slice(-6).toUpperCase()
|
|
return `INV-${year}-${short}`
|
|
}
|
|
|
|
function pdfEscape(value: unknown) {
|
|
return pdfText(value).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
|
}
|
|
|
|
function buildSimpleInvoicePdf(data: InvoiceData) {
|
|
const lineItems = data.lineItems?.length
|
|
? data.lineItems
|
|
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
|
|
const totals = data.totals ?? {
|
|
subtotalAmount: data.amount,
|
|
discountAmount: 0,
|
|
creditAmount: 0,
|
|
taxAmount: 0,
|
|
totalAmount: data.amount,
|
|
amountPaid: data.paidAt ? data.amount : 0,
|
|
amountDue: data.paidAt ? 0 : data.amount,
|
|
}
|
|
const address = formatAddress(data.company.address)
|
|
const lines = [
|
|
'RentalDriveGo - Invoice',
|
|
`Invoice Number: ${data.invoiceNumber}`,
|
|
`Status: ${data.status}`,
|
|
`Issue Date: ${fmtDate(data.issueDate)}`,
|
|
data.dueDate ? `Due Date: ${fmtDate(data.dueDate)}` : null,
|
|
`Bill To: ${data.company.name}`,
|
|
`Email: ${data.company.email}`,
|
|
data.company.phone ? `Phone: ${data.company.phone}` : null,
|
|
address ? `Address: ${address}` : null,
|
|
'',
|
|
'Line Items',
|
|
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
|
|
'',
|
|
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
|
|
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
|
|
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
|
|
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
|
|
`Total: ${fmt(totals.totalAmount, data.currency)}`,
|
|
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
|
|
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
|
|
'',
|
|
`Payment Provider: ${data.paymentProvider}`,
|
|
data.transactionId ? `Transaction ID: ${data.transactionId}` : null,
|
|
].filter((line): line is string => line !== null)
|
|
|
|
const content = [
|
|
'BT',
|
|
'/F1 18 Tf',
|
|
'50 790 Td',
|
|
`(${pdfEscape(lines[0])}) Tj`,
|
|
'/F1 10 Tf',
|
|
...lines.slice(1).flatMap((line) => ['0 -18 Td', `(${pdfEscape(line)}) Tj`]),
|
|
'ET',
|
|
].join('\n')
|
|
const objects = [
|
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
|
`<< /Length ${Buffer.byteLength(content, 'utf8')} >>\nstream\n${content}\nendstream`,
|
|
]
|
|
|
|
let pdf = '%PDF-1.4\n'
|
|
const offsets = [0]
|
|
objects.forEach((object, index) => {
|
|
offsets.push(Buffer.byteLength(pdf, 'utf8'))
|
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`
|
|
})
|
|
const xrefOffset = Buffer.byteLength(pdf, 'utf8')
|
|
pdf += `xref\n0 ${objects.length + 1}\n`
|
|
pdf += '0000000000 65535 f \n'
|
|
for (let i = 1; i < offsets.length; i += 1) {
|
|
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`
|
|
}
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
|
|
|
return Buffer.from(pdf, 'utf8')
|
|
}
|