@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/app/assets/invoices.css +332 -0
- package/app/components/InvoicesAdminEmptyState.vue +28 -0
- package/app/components/InvoicesAdminInvoices.vue +950 -0
- package/app/components/InvoicesAdminListToolbar.vue +19 -0
- package/app/components/InvoicesAdminPaginatedList.vue +95 -0
- package/app/components/InvoicesClientAccessPanel.vue +63 -0
- package/app/components/InvoicesClientSettingsPanel.vue +150 -0
- package/app/components/InvoicesDashboardCard.vue +29 -0
- package/app/components/InvoicesDashboardReceivedInvoices.vue +1 -0
- package/app/components/InvoicesDashboardSalesInvoices.vue +1 -0
- package/app/components/InvoicesInvoiceDetail.vue +1109 -0
- package/app/components/InvoicesInvoiceEmailModal.vue +223 -0
- package/app/components/InvoicesPageShell.vue +7 -0
- package/app/components/InvoicesSenderDetailsForm.vue +229 -0
- package/app/composables/useInvoiceSources.ts +9 -0
- package/app/composables/useInvoices.ts +68 -0
- package/app/composables/useInvoicesAdminList.ts +113 -0
- package/app/pages/admin/invoices/[id].vue +36 -0
- package/app/pages/admin/invoices/index.vue +61 -0
- package/app/pages/admin/invoices/new.vue +18 -0
- package/app/pages/admin/invoices/settings.vue +86 -0
- package/app/pages/invoices/[id].vue +37 -0
- package/app/pages/invoices/index.vue +131 -0
- package/app/pages/invoices/viewers.vue +115 -0
- package/app/plugins/invoices-feature.ts +60 -0
- package/i18n/locales/en.json +306 -0
- package/i18n/locales/nl.json +306 -0
- package/migrations/0000_baseline.sql +235 -0
- package/migrations/meta/0000_snapshot.json +1318 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +15 -0
- package/package.json +60 -0
- package/portal.manifest.mjs +9 -0
- package/server/api/invoices/admin/bootstrap.get.ts +17 -0
- package/server/api/invoices/admin/clients/[clientId]/access.get.ts +11 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/[id].delete.ts +8 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/[id].patch.ts +13 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/index.get.ts +8 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/index.post.ts +12 -0
- package/server/api/invoices/admin/email-domain.get.ts +17 -0
- package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].delete.ts +23 -0
- package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].get.ts +30 -0
- package/server/api/invoices/admin/invoices/[id]/attachments.post.ts +27 -0
- package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +22 -0
- package/server/api/invoices/admin/invoices/[id]/email-status.post.ts +18 -0
- package/server/api/invoices/admin/invoices/[id]/email.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/index.get.ts +17 -0
- package/server/api/invoices/admin/invoices/[id]/issue.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/payments.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/pdf.get.ts +29 -0
- package/server/api/invoices/admin/invoices/[id]/reminder-preview.get.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/reminder.post.ts +25 -0
- package/server/api/invoices/admin/invoices/[id].patch.ts +24 -0
- package/server/api/invoices/admin/invoices/index.get.ts +20 -0
- package/server/api/invoices/admin/invoices/index.post.ts +19 -0
- package/server/api/invoices/admin/invoices/next-number.get.ts +16 -0
- package/server/api/invoices/admin/settings.put.ts +9 -0
- package/server/api/invoices/capabilities.get.ts +25 -0
- package/server/api/invoices/client/[accessId]/viewers.get.ts +10 -0
- package/server/api/invoices/client/[accessId]/viewers.put.ts +17 -0
- package/server/api/invoices/client/invoices/[id]/attachments/[attachmentId].get.ts +20 -0
- package/server/api/invoices/client/invoices/[id]/index.get.ts +12 -0
- package/server/api/invoices/client/invoices/[id]/pdf.get.ts +23 -0
- package/server/api/invoices/client/invoices/index.get.ts +14 -0
- package/server/api/invoices/client/suppliers.get.ts +8 -0
- package/server/db/schema/invoices.ts +251 -0
- package/server/plugins/openapi-contracts.ts +55 -0
- package/server/utils/invoice-email-status.ts +64 -0
- package/server/utils/invoice-email.ts +240 -0
- package/server/utils/invoice-listing.ts +71 -0
- package/server/utils/invoice-pdf.ts +358 -0
- package/server/utils/invoice-repository.ts +821 -0
- package/server/utils/invoice-validation.ts +112 -0
- package/shared/email-delivery-status.ts +23 -0
- package/shared/feature.ts +148 -0
- package/shared/invoice-introduction.ts +21 -0
- package/shared/invoice-number.ts +24 -0
- package/shared/types/invoice.ts +251 -0
- 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
|
+
}
|