@nuxt-customer-portal/invoices 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +4 -0
  4. package/app/assets/invoices.css +332 -0
  5. package/app/components/InvoicesAdminEmptyState.vue +28 -0
  6. package/app/components/InvoicesAdminInvoices.vue +950 -0
  7. package/app/components/InvoicesAdminListToolbar.vue +19 -0
  8. package/app/components/InvoicesAdminPaginatedList.vue +95 -0
  9. package/app/components/InvoicesClientAccessPanel.vue +63 -0
  10. package/app/components/InvoicesClientSettingsPanel.vue +150 -0
  11. package/app/components/InvoicesDashboardCard.vue +29 -0
  12. package/app/components/InvoicesDashboardReceivedInvoices.vue +1 -0
  13. package/app/components/InvoicesDashboardSalesInvoices.vue +1 -0
  14. package/app/components/InvoicesInvoiceDetail.vue +1109 -0
  15. package/app/components/InvoicesInvoiceEmailModal.vue +223 -0
  16. package/app/components/InvoicesPageShell.vue +7 -0
  17. package/app/components/InvoicesSenderDetailsForm.vue +229 -0
  18. package/app/composables/useInvoiceSources.ts +9 -0
  19. package/app/composables/useInvoices.ts +68 -0
  20. package/app/composables/useInvoicesAdminList.ts +113 -0
  21. package/app/pages/admin/invoices/[id].vue +36 -0
  22. package/app/pages/admin/invoices/index.vue +61 -0
  23. package/app/pages/admin/invoices/new.vue +18 -0
  24. package/app/pages/admin/invoices/settings.vue +86 -0
  25. package/app/pages/invoices/[id].vue +37 -0
  26. package/app/pages/invoices/index.vue +131 -0
  27. package/app/pages/invoices/viewers.vue +115 -0
  28. package/app/plugins/invoices-feature.ts +60 -0
  29. package/i18n/locales/en.json +306 -0
  30. package/i18n/locales/nl.json +306 -0
  31. package/migrations/0000_baseline.sql +235 -0
  32. package/migrations/meta/0000_snapshot.json +1318 -0
  33. package/migrations/meta/_journal.json +13 -0
  34. package/nuxt.config.ts +15 -0
  35. package/package.json +60 -0
  36. package/portal.manifest.mjs +9 -0
  37. package/server/api/invoices/admin/bootstrap.get.ts +17 -0
  38. package/server/api/invoices/admin/clients/[clientId]/access.get.ts +11 -0
  39. package/server/api/invoices/admin/clients/[clientId]/contacts/[id].delete.ts +8 -0
  40. package/server/api/invoices/admin/clients/[clientId]/contacts/[id].patch.ts +13 -0
  41. package/server/api/invoices/admin/clients/[clientId]/contacts/index.get.ts +8 -0
  42. package/server/api/invoices/admin/clients/[clientId]/contacts/index.post.ts +12 -0
  43. package/server/api/invoices/admin/email-domain.get.ts +17 -0
  44. package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].delete.ts +23 -0
  45. package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].get.ts +30 -0
  46. package/server/api/invoices/admin/invoices/[id]/attachments.post.ts +27 -0
  47. package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +22 -0
  48. package/server/api/invoices/admin/invoices/[id]/email-status.post.ts +18 -0
  49. package/server/api/invoices/admin/invoices/[id]/email.post.ts +24 -0
  50. package/server/api/invoices/admin/invoices/[id]/index.get.ts +17 -0
  51. package/server/api/invoices/admin/invoices/[id]/issue.post.ts +24 -0
  52. package/server/api/invoices/admin/invoices/[id]/payments.post.ts +24 -0
  53. package/server/api/invoices/admin/invoices/[id]/pdf.get.ts +29 -0
  54. package/server/api/invoices/admin/invoices/[id]/reminder-preview.get.ts +24 -0
  55. package/server/api/invoices/admin/invoices/[id]/reminder.post.ts +25 -0
  56. package/server/api/invoices/admin/invoices/[id].patch.ts +24 -0
  57. package/server/api/invoices/admin/invoices/index.get.ts +20 -0
  58. package/server/api/invoices/admin/invoices/index.post.ts +19 -0
  59. package/server/api/invoices/admin/invoices/next-number.get.ts +16 -0
  60. package/server/api/invoices/admin/settings.put.ts +9 -0
  61. package/server/api/invoices/capabilities.get.ts +25 -0
  62. package/server/api/invoices/client/[accessId]/viewers.get.ts +10 -0
  63. package/server/api/invoices/client/[accessId]/viewers.put.ts +17 -0
  64. package/server/api/invoices/client/invoices/[id]/attachments/[attachmentId].get.ts +20 -0
  65. package/server/api/invoices/client/invoices/[id]/index.get.ts +12 -0
  66. package/server/api/invoices/client/invoices/[id]/pdf.get.ts +23 -0
  67. package/server/api/invoices/client/invoices/index.get.ts +14 -0
  68. package/server/api/invoices/client/suppliers.get.ts +8 -0
  69. package/server/db/schema/invoices.ts +251 -0
  70. package/server/plugins/openapi-contracts.ts +55 -0
  71. package/server/utils/invoice-email-status.ts +64 -0
  72. package/server/utils/invoice-email.ts +240 -0
  73. package/server/utils/invoice-listing.ts +71 -0
  74. package/server/utils/invoice-pdf.ts +358 -0
  75. package/server/utils/invoice-repository.ts +821 -0
  76. package/server/utils/invoice-validation.ts +112 -0
  77. package/shared/email-delivery-status.ts +23 -0
  78. package/shared/feature.ts +148 -0
  79. package/shared/invoice-introduction.ts +21 -0
  80. package/shared/invoice-number.ts +24 -0
  81. package/shared/types/invoice.ts +251 -0
  82. package/tokens.css +25 -0
@@ -0,0 +1,240 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { getClientEmailLocale } from '@nuxt-customer-portal/clients/server/utils/client-email-locale'
3
+ import { and, desc, eq } from 'drizzle-orm'
4
+ import { nanoid } from 'nanoid'
5
+ import { db } from '@nuxt-customer-portal/core/server/portal'
6
+ import {
7
+ getPortalEmailProviderStatus,
8
+ renderPortalEmail,
9
+ sendPortalEmail
10
+ } from '@nuxt-customer-portal/core/server/utils/portal-email'
11
+ import {
12
+ invoice,
13
+ invoiceAttachment,
14
+ invoiceEmailDelivery,
15
+ invoiceHistory
16
+ } from '@nuxt-customer-portal/invoices/server/db/schema/invoices'
17
+ import type { InvoiceEmailPreviewDto, InvoiceEmailPurpose } from '@nuxt-customer-portal/invoices/shared/types/invoice'
18
+ import { invoicesFeature } from '@nuxt-customer-portal/invoices/shared/feature'
19
+ import { generateInvoicePdf } from './invoice-pdf'
20
+ import { getInvoice, getOrganizationInvoiceProfile } from './invoice-repository'
21
+
22
+ export const MAX_EMAIL_ATTACHMENT_SIZE = 40 * 1024 * 1024
23
+ const domainFor = (email: string) => email.split('@')[1]?.toLowerCase() ?? ''
24
+ const emailDefinition = (purpose: InvoiceEmailPurpose) => {
25
+ const id = purpose === 'REMINDER' ? 'payment-reminder' : 'invoice'
26
+ const definition = invoicesFeature.emails?.find((item) => item.id === id)
27
+ if (!definition) {
28
+ throw new Error(`Missing invoice email definition: ${id}`)
29
+ }
30
+ return definition
31
+ }
32
+ const htmlToPlainText = (value: string) =>
33
+ value
34
+ .replace(/<br\s*\/?\s*>/gi, '\n')
35
+ .replace(/<[^>]+>/g, '')
36
+ .trim()
37
+
38
+ export const getInvoiceEmailPreview = async (
39
+ organizationId: string,
40
+ id: string,
41
+ _localeOverride?: string,
42
+ purpose: InvoiceEmailPurpose = 'INVOICE'
43
+ ): Promise<InvoiceEmailPreviewDto> => {
44
+ const [selected, sender, files, providerStatus] = await Promise.all([
45
+ getInvoice(organizationId, id),
46
+ getOrganizationInvoiceProfile(organizationId),
47
+ db.select().from(invoiceAttachment).where(eq(invoiceAttachment.invoiceId, id)),
48
+ getPortalEmailProviderStatus()
49
+ ])
50
+ if (!sender.invoiceEmail) {
51
+ throw createError({ statusCode: 409, message: 'Configure the company invoice email first' })
52
+ }
53
+ if (purpose === 'REMINDER' && !selected.isOverdue) {
54
+ throw createError({ statusCode: 409, message: 'Only overdue invoices can receive payment reminders' })
55
+ }
56
+ const locale = await getClientEmailLocale(
57
+ selected.clientOrganizationId,
58
+ selected.recipientLocale === 'en' ? 'en' : 'nl'
59
+ )
60
+ const pdf = await generateInvoicePdf(selected, locale)
61
+ const pdfName = `${locale === 'nl' ? 'factuur' : 'invoice'}-${selected.number.replace(/[^a-z0-9._-]+/gi, '-')}.pdf`
62
+ const senderDomain = domainFor(sender.invoiceEmail)
63
+ const date = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
64
+ new Date(`${selected.dueDate}T12:00:00Z`)
65
+ )
66
+ const outstanding = new Intl.NumberFormat(locale, { style: 'currency', currency: selected.currency }).format(
67
+ selected.outstandingMinor / 100
68
+ )
69
+ const values = {
70
+ invoice_number: selected.number,
71
+ sender_name: selected.senderName,
72
+ recipient_name: selected.recipientName,
73
+ due_date: date,
74
+ outstanding_amount: outstanding
75
+ }
76
+ const rendered = await renderPortalEmail({
77
+ moduleId: invoicesFeature.id,
78
+ definition: emailDefinition(purpose),
79
+ locale,
80
+ values
81
+ })
82
+ return {
83
+ to: selected.recipientEmail ?? '',
84
+ cc: [],
85
+ locale,
86
+ subject: rendered.subject,
87
+ body: htmlToPlainText(rendered.body),
88
+ senderEmail: sender.invoiceEmail,
89
+ senderDomain,
90
+ emailProviderConfigured: providerStatus.configured,
91
+ senderDomainVerified: providerStatus.verifiedDomains.includes(senderDomain),
92
+ attachments: [
93
+ { fileName: pdfName, size: pdf.length },
94
+ ...files.map((file) => ({ fileName: file.fileName, size: file.size }))
95
+ ],
96
+ totalAttachmentSize: pdf.length + files.reduce((sum, file) => sum + file.size, 0),
97
+ maximumAttachmentSize: MAX_EMAIL_ATTACHMENT_SIZE
98
+ }
99
+ }
100
+
101
+ export const deliverInvoiceEmail = async (
102
+ organizationId: string,
103
+ actorUserId: string,
104
+ id: string,
105
+ input: { to: string; cc: string[]; locale: 'nl' | 'en'; subject: string; body: string },
106
+ issue: boolean,
107
+ purpose: InvoiceEmailPurpose = 'INVOICE'
108
+ ) => {
109
+ const selected = await getInvoice(organizationId, id)
110
+ if (purpose === 'REMINDER' && !selected.isOverdue) {
111
+ throw createError({ statusCode: 409, message: 'Only overdue invoices can receive payment reminders' })
112
+ }
113
+ if (issue ? selected.status !== 'DRAFT' : !['ISSUED', 'PAID'].includes(selected.status)) {
114
+ throw createError({
115
+ statusCode: 409,
116
+ message: issue ? 'Only draft invoices can be issued' : 'Only issued invoices can be resent'
117
+ })
118
+ }
119
+ const preview = await getInvoiceEmailPreview(organizationId, id, input.locale, purpose)
120
+ input = { ...input, locale: preview.locale === 'en' ? 'en' : 'nl' }
121
+ if (!preview.emailProviderConfigured) {
122
+ throw createError({ statusCode: 409, message: 'The organization email provider is not configured' })
123
+ }
124
+ if (!preview.senderDomainVerified) {
125
+ throw createError({ statusCode: 409, message: `The sending domain ${preview.senderDomain} is not verified` })
126
+ }
127
+ if (preview.totalAttachmentSize > MAX_EMAIL_ATTACHMENT_SIZE) {
128
+ throw createError({
129
+ statusCode: 413,
130
+ message: `Email attachments exceed 40 MB: ${preview.attachments.map((file) => file.fileName).join(', ')}`
131
+ })
132
+ }
133
+ const payloadHash = createHash('sha256').update(JSON.stringify({ purpose, input })).digest('hex')
134
+ const currentInvoice = await getInvoice(organizationId, id)
135
+ if (purpose === 'REMINDER' && !currentInvoice.isOverdue) {
136
+ throw createError({ statusCode: 409, message: 'Only overdue invoices can receive payment reminders' })
137
+ }
138
+ const [pending] = await db
139
+ .select()
140
+ .from(invoiceEmailDelivery)
141
+ .where(and(eq(invoiceEmailDelivery.invoiceId, id), eq(invoiceEmailDelivery.status, 'PENDING')))
142
+ .orderBy(desc(invoiceEmailDelivery.createdAt))
143
+ .limit(1)
144
+ if (pending && pending.payloadHash !== payloadHash) {
145
+ throw createError({ statusCode: 409, message: 'A different email delivery is already pending' })
146
+ }
147
+ const delivery =
148
+ pending ??
149
+ (
150
+ await db
151
+ .insert(invoiceEmailDelivery)
152
+ .values({
153
+ id: nanoid(),
154
+ invoiceId: id,
155
+ actorUserId,
156
+ purpose,
157
+ status: 'PENDING',
158
+ recipientEmail: input.to,
159
+ ccEmails: JSON.stringify(input.cc),
160
+ locale: input.locale,
161
+ subject: input.subject,
162
+ body: input.body,
163
+ payloadHash
164
+ })
165
+ .returning()
166
+ )[0]!
167
+ const pdf = await generateInvoicePdf(currentInvoice, input.locale)
168
+ const storedFiles = await db.select().from(invoiceAttachment).where(eq(invoiceAttachment.invoiceId, id))
169
+ let result: { id: string }
170
+ try {
171
+ const locale = input.locale
172
+ const dueDate = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
173
+ new Date(`${currentInvoice.dueDate}T12:00:00Z`)
174
+ )
175
+ const outstandingAmount = new Intl.NumberFormat(locale, {
176
+ style: 'currency',
177
+ currency: currentInvoice.currency
178
+ }).format(currentInvoice.outstandingMinor / 100)
179
+ result = await sendPortalEmail({
180
+ moduleId: invoicesFeature.id,
181
+ definition: emailDefinition(purpose),
182
+ locale,
183
+ values: {
184
+ invoice_number: currentInvoice.number,
185
+ sender_name: currentInvoice.senderName,
186
+ recipient_name: currentInvoice.recipientName,
187
+ due_date: dueDate,
188
+ outstanding_amount: outstandingAmount
189
+ },
190
+ fromEmail: preview.senderEmail,
191
+ fromName: currentInvoice.senderName,
192
+ to: input.to,
193
+ cc: input.cc,
194
+ text: { subject: input.subject, body: input.body.replaceAll('\n', '<br>') },
195
+ attachments: [
196
+ { filename: preview.attachments[0]!.fileName, content: Buffer.from(pdf), contentType: 'application/pdf' },
197
+ ...storedFiles.map((file) => ({
198
+ filename: file.fileName,
199
+ content: Buffer.from(file.contentBase64, 'base64'),
200
+ contentType: file.contentType
201
+ }))
202
+ ],
203
+ idempotencyKey: delivery.id
204
+ })
205
+ } catch (error) {
206
+ await db
207
+ .update(invoiceEmailDelivery)
208
+ .set({
209
+ status: 'FAILED',
210
+ errorMessage: error instanceof Error ? error.message : String(error)
211
+ })
212
+ .where(eq(invoiceEmailDelivery.id, delivery.id))
213
+ throw createError({ statusCode: 502, message: 'The invoice email could not be sent', cause: error })
214
+ }
215
+ await db.transaction(async (tx) => {
216
+ const finalized = await tx
217
+ .update(invoiceEmailDelivery)
218
+ .set({ status: 'SENT', providerMessageId: result.id, sentAt: new Date(), errorMessage: null })
219
+ .where(and(eq(invoiceEmailDelivery.id, delivery.id), eq(invoiceEmailDelivery.status, 'PENDING')))
220
+ .returning({ id: invoiceEmailDelivery.id })
221
+ if (!finalized.length) {
222
+ return
223
+ }
224
+ if (issue) {
225
+ await tx
226
+ .update(invoice)
227
+ .set({ status: 'ISSUED', issuedAt: new Date() })
228
+ .where(and(eq(invoice.id, id), eq(invoice.status, 'DRAFT')))
229
+ await tx.insert(invoiceHistory).values({ id: nanoid(), invoiceId: id, action: 'ISSUED', actorUserId })
230
+ }
231
+ await tx.insert(invoiceHistory).values({
232
+ id: nanoid(),
233
+ invoiceId: id,
234
+ action: purpose === 'REMINDER' ? 'REMINDER_SENT' : 'EMAIL_SENT',
235
+ actorUserId,
236
+ attachmentName: input.to
237
+ })
238
+ })
239
+ return { deliveryId: delivery.id, status: 'SENT' as const, providerMessageId: result.id, to: input.to, cc: input.cc }
240
+ }
@@ -0,0 +1,71 @@
1
+ import type { ClientInvoiceSummaryDto, InvoiceDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
2
+ import type { ClientInvoiceListQuery, InvoiceListQuery } from './invoice-validation'
3
+ import { listClientInvoices, listInvoices } from './invoice-repository'
4
+
5
+ const compare = (a: string | null | undefined, b: string | null | undefined) =>
6
+ (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base', numeric: true })
7
+ const direction = (value: number, dir: 'asc' | 'desc') => (dir === 'asc' ? value : -value)
8
+ const paginate = <T>(items: T[], page: number, pageSize: number) => {
9
+ const total = items.length
10
+ const pageCount = Math.ceil(total / pageSize)
11
+ const safePage = pageCount ? Math.min(page, pageCount) : 1
12
+ return {
13
+ items: items.slice((safePage - 1) * pageSize, safePage * pageSize),
14
+ pagination: { total, page: safePage, pageSize, pageCount }
15
+ }
16
+ }
17
+ const includes = (value: string | null | undefined, search: string) =>
18
+ (value ?? '').toLocaleLowerCase().includes(search)
19
+ export const listInvoicesPage = async (organizationId: string, query: InvoiceListQuery) => {
20
+ const search = query.search?.toLocaleLowerCase() ?? ''
21
+ const rows = (await listInvoices(organizationId))
22
+ .filter(
23
+ (item) => !search || [item.number, item.recipientName, item.subject].some((value) => includes(value, search))
24
+ )
25
+ .filter((item) => !query.status || item.status === query.status)
26
+ .filter((item) => !query.clientOrganizationId || item.clientOrganizationId === query.clientOrganizationId)
27
+ .filter((item) => query.overdue === undefined || item.isOverdue === query.overdue)
28
+ .sort(
29
+ (a, b) =>
30
+ direction(
31
+ query.sortBy === 'number'
32
+ ? compare(a.number, b.number)
33
+ : query.sortBy === 'dueDate'
34
+ ? compare(a.dueDate, b.dueDate)
35
+ : query.sortBy === 'totalMinor'
36
+ ? a.totalMinor - b.totalMinor
37
+ : compare(a.issueDate, b.issueDate),
38
+ query.sortDir
39
+ ) || compare(a.id, b.id)
40
+ )
41
+ return paginate<InvoiceDto>(rows, query.page, query.pageSize)
42
+ }
43
+ export const listClientInvoicesPage = async (
44
+ organizationId: string,
45
+ userId: string,
46
+ isAdmin: boolean,
47
+ query: ClientInvoiceListQuery
48
+ ) => {
49
+ const search = query.search?.toLocaleLowerCase() ?? ''
50
+ const rows = (await listClientInvoices(organizationId, userId, isAdmin))
51
+ .filter(
52
+ (item) => !search || [item.number, item.supplierName, item.subject].some((value) => includes(value, search))
53
+ )
54
+ .filter((item) => !query.status || item.status === query.status)
55
+ .filter((item) => !query.accessId || item.accessId === query.accessId)
56
+ .filter((item) => query.overdue === undefined || item.isOverdue === query.overdue)
57
+ .sort(
58
+ (a, b) =>
59
+ direction(
60
+ query.sortBy === 'number'
61
+ ? compare(a.number, b.number)
62
+ : query.sortBy === 'dueDate'
63
+ ? compare(a.dueDate, b.dueDate)
64
+ : query.sortBy === 'totalMinor'
65
+ ? a.totalMinor - b.totalMinor
66
+ : compare(a.issueDate, b.issueDate),
67
+ query.sortDir
68
+ ) || compare(a.id, b.id)
69
+ )
70
+ return paginate<ClientInvoiceSummaryDto>(rows, query.page, query.pageSize)
71
+ }
@@ -0,0 +1,358 @@
1
+ import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from 'pdf-lib'
2
+ import type { ClientInvoiceDto, InvoiceDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
3
+
4
+ type InvoicePdfLocale = 'en' | 'nl'
5
+
6
+ const labels = {
7
+ en: {
8
+ title: 'INVOICE',
9
+ from: 'From',
10
+ to: 'Invoice to',
11
+ number: 'Invoice number',
12
+ issueDate: 'Invoice date',
13
+ dueDate: 'Due date',
14
+ description: 'Description',
15
+ quantity: 'Quantity',
16
+ unitPrice: 'Unit price',
17
+ vat: 'VAT',
18
+ amount: 'Amount',
19
+ subtotal: 'Subtotal',
20
+ paid: 'Paid',
21
+ total: 'Total payable',
22
+ notes: 'Notes',
23
+ page: 'Page'
24
+ },
25
+ nl: {
26
+ title: 'FACTUUR',
27
+ from: 'Van',
28
+ to: 'Factuur aan',
29
+ number: 'Factuurnummer',
30
+ issueDate: 'Factuurdatum',
31
+ dueDate: 'Vervaldatum',
32
+ description: 'Omschrijving',
33
+ quantity: 'Aantal',
34
+ unitPrice: 'Prijs / eenheid',
35
+ vat: 'Btw',
36
+ amount: 'Bedrag',
37
+ subtotal: 'Subtotaal',
38
+ paid: 'Betaald',
39
+ total: 'Totaal te betalen',
40
+ notes: 'Opmerkingen',
41
+ page: 'Pagina'
42
+ }
43
+ } as const
44
+
45
+ const width = 595.28
46
+ const height = 841.89
47
+ const margin = 48
48
+ const ink = rgb(0.12, 0.14, 0.17)
49
+ const muted = rgb(0.42, 0.45, 0.5)
50
+ const rule = rgb(0.86, 0.87, 0.89)
51
+ const accent = rgb(0.12, 0.45, 0.68)
52
+
53
+ const normalize = (value: string) =>
54
+ value
55
+ .replace(/\r/g, '')
56
+ .replace(/[\u2010-\u2015]/g, '-')
57
+ .replace(/\u2212/g, '-')
58
+ .split('')
59
+ .map((character) => {
60
+ const code = character.codePointAt(0) ?? 0
61
+ return character === '\n' || character === '\t' || (code >= 32 && code <= 255) || code === 8364 ? character : '?'
62
+ })
63
+ .join('')
64
+
65
+ const wrap = (font: PDFFont, text: string, size: number, maxWidth: number) => {
66
+ const lines: string[] = []
67
+ for (const paragraph of normalize(text).split('\n')) {
68
+ const words = paragraph.split(/\s+/).filter(Boolean)
69
+ if (!words.length) {
70
+ lines.push('')
71
+ continue
72
+ }
73
+ let line = words.shift()!
74
+ for (const word of words) {
75
+ if (font.widthOfTextAtSize(`${line} ${word}`, size) <= maxWidth) {
76
+ line += ` ${word}`
77
+ } else {
78
+ lines.push(line)
79
+ line = word
80
+ }
81
+ }
82
+ lines.push(line)
83
+ }
84
+ return lines
85
+ }
86
+
87
+ export async function generateInvoicePdf(invoice: InvoiceDto | ClientInvoiceDto, requestedLocale?: string) {
88
+ const locale: InvoicePdfLocale = (requestedLocale || invoice.recipientLocale)?.toLowerCase().startsWith('nl')
89
+ ? 'nl'
90
+ : 'en'
91
+ const l = labels[locale]
92
+ const pdf = await PDFDocument.create()
93
+ const regular = await pdf.embedFont(StandardFonts.Helvetica)
94
+ const bold = await pdf.embedFont(StandardFonts.HelveticaBold)
95
+ const money = (minor: number) =>
96
+ new Intl.NumberFormat(locale === 'nl' ? 'nl-NL' : 'en-GB', {
97
+ style: 'currency',
98
+ currency: invoice.currency
99
+ }).format(minor / 100)
100
+ const number = (milli: number) =>
101
+ new Intl.NumberFormat(locale === 'nl' ? 'nl-NL' : 'en-GB', {
102
+ minimumFractionDigits: 2,
103
+ maximumFractionDigits: 3
104
+ }).format(milli / 1000)
105
+ const date = (value: string) =>
106
+ new Intl.DateTimeFormat(locale === 'nl' ? 'nl-NL' : 'en-GB', { dateStyle: 'medium', timeZone: 'UTC' }).format(
107
+ new Date(`${value}T12:00:00Z`)
108
+ )
109
+ let page!: PDFPage
110
+ let y = 0
111
+
112
+ const addPage = () => {
113
+ page = pdf.addPage([width, height])
114
+ y = height - margin
115
+ page.drawText(invoice.number, { x: margin, y: 25, size: 8, font: regular, color: muted })
116
+ return page
117
+ }
118
+ const text = (value: string, x: number, size = 9, font = regular, color = ink) =>
119
+ page.drawText(normalize(value), { x, y, size, font, color })
120
+ const multiline = (
121
+ value: string,
122
+ x: number,
123
+ maxWidth: number,
124
+ size = 9,
125
+ font = regular,
126
+ lineHeight = 13,
127
+ color = ink
128
+ ) => {
129
+ for (const line of wrap(font, value, size, maxWidth)) {
130
+ if (y < margin + lineHeight) {
131
+ addPage()
132
+ }
133
+ text(line, x, size, font, color)
134
+ y -= lineHeight
135
+ }
136
+ }
137
+ const divider = () =>
138
+ page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.7, color: rule })
139
+
140
+ addPage()
141
+ const headingTop = y
142
+ let logoDrawn = false
143
+ let senderLogo = invoice.senderLogo
144
+ if (!senderLogo) {
145
+ const { resolvePortalEmailBranding } = await import('@nuxt-customer-portal/core/server/utils/portal-email')
146
+ const branding = await resolvePortalEmailBranding()
147
+ if (branding.brandName.trim().toLowerCase() === invoice.senderName.trim().toLowerCase()) {
148
+ senderLogo = branding.brandIconLight || branding.brandIconDark || null
149
+ }
150
+ }
151
+ if (senderLogo?.startsWith('data:image/')) {
152
+ try {
153
+ const [, meta, data] = senderLogo.match(/^data:(image\/(?:png|jpeg|jpg));base64,(.+)$/i) ?? []
154
+ if (meta && data) {
155
+ const bytes = Buffer.from(data, 'base64')
156
+ const image = meta.toLowerCase() === 'image/png' ? await pdf.embedPng(bytes) : await pdf.embedJpg(bytes)
157
+ const scale = Math.min(150 / image.width, 55 / image.height, 1)
158
+ page.drawImage(image, {
159
+ x: margin,
160
+ y: y - image.height * scale + 4,
161
+ width: image.width * scale,
162
+ height: image.height * scale
163
+ })
164
+ logoDrawn = true
165
+ }
166
+ } catch {
167
+ /* A malformed optional logo must not prevent invoice generation. */
168
+ }
169
+ }
170
+ if (!logoDrawn) {
171
+ text(invoice.senderName, margin, 16, bold, accent)
172
+ }
173
+ page.drawText(l.title, {
174
+ x: width - margin - bold.widthOfTextAtSize(l.title, 22),
175
+ y,
176
+ size: 22,
177
+ font: bold,
178
+ color: ink
179
+ })
180
+ y -= 28
181
+ page.drawText(l.from, {
182
+ x: width - margin - regular.widthOfTextAtSize(l.from, 8),
183
+ y,
184
+ size: 8,
185
+ font: regular,
186
+ color: muted
187
+ })
188
+ y -= 14
189
+ for (const line of wrap(regular, `${invoice.senderName}\n${invoice.senderAddress}`, 9, 205)) {
190
+ text(line, width - margin - regular.widthOfTextAtSize(line, 9))
191
+ y -= 12
192
+ }
193
+ const addressBottom = y
194
+ y = headingTop - 78
195
+ const senderDetails = [
196
+ ['IBAN', invoice.senderIban],
197
+ ['BIC', invoice.senderBic],
198
+ [locale === 'nl' ? 'KVK-nr' : 'Registration no.', invoice.senderRegistration],
199
+ [locale === 'nl' ? 'BTW-nr' : 'VAT no.', invoice.senderVatNumber]
200
+ ].filter(([, value]) => Boolean(value))
201
+ const labelWidth = Math.max(0, ...senderDetails.map(([label]) => regular.widthOfTextAtSize(`${label}:`, 8))) + 10
202
+ for (const [label, value] of senderDetails) {
203
+ text(`${label}:`, margin, 8, regular, muted)
204
+ multiline(value!, margin + labelWidth, 245 - labelWidth, 8, regular, 13, muted)
205
+ }
206
+ y = Math.min(y, addressBottom) - 55
207
+
208
+ const partiesY = y
209
+ text(l.to, margin, 8, bold, muted)
210
+ y -= 15
211
+ multiline(
212
+ `${invoice.recipientName}\n${invoice.recipientAddress}${invoice.recipientContactName ? `\n${invoice.recipientContactName}` : ''}${invoice.recipientEmail ? `\n${invoice.recipientEmail}` : ''}`,
213
+ margin,
214
+ 235,
215
+ 9,
216
+ regular,
217
+ 12
218
+ )
219
+ y = partiesY
220
+ const metaX = 355
221
+ for (const [label, value] of [
222
+ [l.number, invoice.number],
223
+ [l.issueDate, date(invoice.issueDate)],
224
+ [l.dueDate, date(invoice.dueDate)]
225
+ ]) {
226
+ text(label!, metaX, 8, regular, muted)
227
+ page.drawText(normalize(value!), {
228
+ x: width - margin - regular.widthOfTextAtSize(normalize(value!), 9),
229
+ y,
230
+ size: 9,
231
+ font: regular,
232
+ color: ink
233
+ })
234
+ y -= 17
235
+ }
236
+ y = Math.min(y, partiesY - 85)
237
+ if (invoice.subject) {
238
+ multiline(invoice.subject, margin, width - margin * 2, 10, regular, 14)
239
+ y -= 20
240
+ }
241
+ y -= 8
242
+
243
+ const cols = [margin, 304, 365, 431, width - margin]
244
+ const tableHeader = () => {
245
+ text(l.description, cols[0]!, 8, bold, muted)
246
+ for (const [label, right] of [
247
+ [l.quantity, cols[2]! - 8],
248
+ [l.unitPrice, cols[3]! - 8],
249
+ [l.vat, cols[4]! - 74],
250
+ [l.amount, cols[4]!]
251
+ ]) {
252
+ page.drawText(label as string, {
253
+ x: (right as number) - bold.widthOfTextAtSize(label as string, 8),
254
+ y,
255
+ size: 8,
256
+ font: bold,
257
+ color: muted
258
+ })
259
+ }
260
+ y -= 10
261
+ divider()
262
+ y -= 13
263
+ }
264
+ tableHeader()
265
+ for (const line of invoice.lines) {
266
+ const description = wrap(regular, line.description, 8.5, cols[1]! - cols[0]! - 12)
267
+ const rowHeight = Math.max(24, description.length * 11 + 8)
268
+ if (y - rowHeight < 155) {
269
+ addPage()
270
+ y -= 22
271
+ tableHeader()
272
+ }
273
+ const rowTop = y
274
+ description.forEach((value, index) =>
275
+ page.drawText(value, { x: cols[0]!, y: rowTop - index * 11, size: 8.5, font: regular, color: ink })
276
+ )
277
+ const values = [
278
+ number(line.quantityMilli),
279
+ money(line.unitPriceMinor),
280
+ new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: 2 }).format(
281
+ line.vatRateBasisPoints / 10000
282
+ ),
283
+ money(line.amountMinor)
284
+ ]
285
+ const rights = [cols[2]! - 8, cols[3]! - 8, cols[4]! - 74, cols[4]!]
286
+ values.forEach((value, index) =>
287
+ page.drawText(normalize(value), {
288
+ x: rights[index]! - regular.widthOfTextAtSize(normalize(value), 8.5),
289
+ y: rowTop,
290
+ size: 8.5,
291
+ font: regular,
292
+ color: ink
293
+ })
294
+ )
295
+ y -= rowHeight
296
+ divider()
297
+ y -= 10
298
+ }
299
+ if (y < 160) {
300
+ addPage()
301
+ }
302
+ const summaryX = 340
303
+ const summary: Array<[string, number]> = [
304
+ [l.subtotal, invoice.subtotalMinor],
305
+ [l.vat, invoice.vatMinor]
306
+ ]
307
+ if (invoice.paidMinor) {
308
+ summary.push([l.paid, -invoice.paidMinor])
309
+ }
310
+ for (const [label, value] of summary) {
311
+ text(label, summaryX, 9, regular, muted)
312
+ const formatted = money(value)
313
+ page.drawText(normalize(formatted), {
314
+ x: width - margin - regular.widthOfTextAtSize(normalize(formatted), 9),
315
+ y,
316
+ size: 9,
317
+ font: regular,
318
+ color: ink
319
+ })
320
+ y -= 17
321
+ }
322
+ page.drawLine({ start: { x: summaryX, y }, end: { x: width - margin, y }, thickness: 0.7, color: rule })
323
+ y -= 18
324
+ text(l.total, summaryX, 10, bold)
325
+ const total = money(invoice.outstandingMinor)
326
+ page.drawText(normalize(total), {
327
+ x: width - margin - bold.widthOfTextAtSize(normalize(total), 10),
328
+ y,
329
+ size: 10,
330
+ font: bold,
331
+ color: ink
332
+ })
333
+ y -= 34
334
+ if (invoice.notes) {
335
+ if (y < 95) {
336
+ addPage()
337
+ }
338
+ text(l.notes, margin, 8, bold, muted)
339
+ y -= 15
340
+ multiline(invoice.notes, margin, width - margin * 2, 8.5, regular, 12)
341
+ }
342
+
343
+ const pages = pdf.getPages()
344
+ pages.forEach((pdfPage, index) => {
345
+ const value = `${l.page} ${index + 1} / ${pages.length}`
346
+ pdfPage.drawText(value, {
347
+ x: width - margin - regular.widthOfTextAtSize(value, 8),
348
+ y: 25,
349
+ size: 8,
350
+ font: regular,
351
+ color: muted
352
+ })
353
+ })
354
+ pdf.setTitle(`${l.title} ${invoice.number}`)
355
+ pdf.setAuthor(invoice.senderName)
356
+ pdf.setCreator('Ludu Customer Portal')
357
+ return pdf.save()
358
+ }