@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,821 @@
1
+ import { and, asc, desc, eq, inArray } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import {
4
+ db,
5
+ getPortalOrganizationsByIds,
6
+ listPortalOrganizationMembers
7
+ } from '@nuxt-customer-portal/core/server/portal'
8
+ import { organization, user } from '@nuxt-customer-portal/core/schema'
9
+ import { clientModule } from '@nuxt-customer-portal/clients/server/db/schema/clients'
10
+ import { getClient, listSelectableClients } from '@nuxt-customer-portal/clients/server/utils/client-repository'
11
+ import {
12
+ firstInvoiceNumber,
13
+ hasNumericInvoiceSequence,
14
+ incrementInvoiceNumber
15
+ } from '@nuxt-customer-portal/invoices/shared/invoice-number'
16
+ import type { InvoiceCreateInput } from './invoice-validation'
17
+ import type {
18
+ InvoiceDto,
19
+ InvoiceSettingsDto,
20
+ InvoiceClientAccessOverviewDto
21
+ } from '@nuxt-customer-portal/invoices/shared/types/invoice'
22
+ import {
23
+ billingContact,
24
+ invoice,
25
+ invoiceAttachment,
26
+ invoiceClientAccess,
27
+ invoiceClientViewer,
28
+ invoiceEmailDelivery,
29
+ invoiceHistory,
30
+ invoiceLine,
31
+ invoicePayment,
32
+ invoiceSettings
33
+ } from '@nuxt-customer-portal/invoices/server/db/schema/invoices'
34
+
35
+ const overdue = (dueDate: string, status: string, outstandingMinor: number) => {
36
+ if (!['ISSUED'].includes(status) || outstandingMinor <= 0) {
37
+ return { isOverdue: false, daysOverdue: 0 }
38
+ }
39
+ const today = new Date()
40
+ today.setUTCHours(0, 0, 0, 0)
41
+ const due = new Date(`${dueDate}T00:00:00Z`)
42
+ const daysOverdue = Math.max(0, Math.floor((today.getTime() - due.getTime()) / 86_400_000))
43
+ return { isOverdue: daysOverdue > 0, daysOverdue }
44
+ }
45
+
46
+ export const ensureInvoiceSettings = async (organizationId: string) => {
47
+ const [settings] = await db.insert(invoiceSettings).values({ organizationId }).onConflictDoNothing().returning()
48
+ if (settings) {
49
+ return settings
50
+ }
51
+ return (
52
+ await db.select().from(invoiceSettings).where(eq(invoiceSettings.organizationId, organizationId)).limit(1)
53
+ )[0]!
54
+ }
55
+
56
+ export const requireInvoicesEnabled = async (organizationId: string) => {
57
+ const settings = await ensureInvoiceSettings(organizationId)
58
+ if (!settings.enabled) {
59
+ throw createError({ statusCode: 403, message: 'Invoices are not enabled for this organization' })
60
+ }
61
+ return settings
62
+ }
63
+
64
+ export const getInvoiceSettings = async (organizationId: string): Promise<InvoiceSettingsDto> => {
65
+ const row = await ensureInvoiceSettings(organizationId)
66
+ return { ...row, preferredLocale: row.preferredLocale === 'en' ? 'en' : 'nl' }
67
+ }
68
+
69
+ export const updateInvoiceSettings = async (
70
+ organizationId: string,
71
+ input: Omit<InvoiceSettingsDto, 'organizationId'>
72
+ ) => {
73
+ const [updated] = await db
74
+ .insert(invoiceSettings)
75
+ .values({ organizationId, ...input })
76
+ .onConflictDoUpdate({ target: invoiceSettings.organizationId, set: { ...input, updatedAt: new Date() } })
77
+ .returning()
78
+ return updated!
79
+ }
80
+
81
+ export const getOrganizationInvoiceProfile = async (organizationId: string) => {
82
+ const [settings, selected] = await Promise.all([
83
+ getInvoiceSettings(organizationId),
84
+ db.select().from(organization).where(eq(organization.id, organizationId)).limit(1)
85
+ ])
86
+ return { ...settings, name: selected[0]?.name ?? '', logo: selected[0]?.logo ?? null }
87
+ }
88
+
89
+ export const listInvoiceClients = async (_organizationId: string) => {
90
+ const clients = await listSelectableClients()
91
+ const ids = clients.map((item) => item.organizationId)
92
+ const contacts = ids.length
93
+ ? await db.select().from(billingContact).where(inArray(billingContact.organizationId, ids))
94
+ : []
95
+ return clients.map((client) => ({
96
+ ...client,
97
+ contacts: contacts.filter((contact) => contact.organizationId === client.organizationId)
98
+ }))
99
+ }
100
+
101
+ const requireInvoiceClient = async (clientOrganizationId: string) => {
102
+ const client = await getClient(clientOrganizationId)
103
+ if (!client || client.archivedAt) {
104
+ throw createError({ statusCode: 404, message: 'Client not found' })
105
+ }
106
+ return client
107
+ }
108
+
109
+ export const listBillingContacts = async (clientOrganizationId: string) => {
110
+ await requireInvoiceClient(clientOrganizationId)
111
+ return db
112
+ .select()
113
+ .from(billingContact)
114
+ .where(eq(billingContact.organizationId, clientOrganizationId))
115
+ .orderBy(asc(billingContact.name))
116
+ }
117
+
118
+ export const createBillingContact = async (
119
+ clientOrganizationId: string,
120
+ input: { userId?: string | null; name: string; email: string; phone?: string | null; jobTitle?: string | null }
121
+ ) => {
122
+ await requireInvoiceClient(clientOrganizationId)
123
+ try {
124
+ const [created] = await db
125
+ .insert(billingContact)
126
+ .values({ id: nanoid(), organizationId: clientOrganizationId, ...input })
127
+ .returning()
128
+ return created!
129
+ } catch (error) {
130
+ if (
131
+ (error as { code?: string; cause?: { code?: string } }).code === '23505' ||
132
+ (error as { cause?: { code?: string } }).cause?.code === '23505'
133
+ ) {
134
+ throw createError({ statusCode: 409, message: 'A billing contact with this email already exists' })
135
+ }
136
+ throw error
137
+ }
138
+ }
139
+
140
+ export const updateBillingContact = async (
141
+ clientOrganizationId: string,
142
+ id: string,
143
+ input: Record<string, unknown>
144
+ ) => {
145
+ await requireInvoiceClient(clientOrganizationId)
146
+ try {
147
+ const [updated] = await db
148
+ .update(billingContact)
149
+ .set({ ...input, updatedAt: new Date() })
150
+ .where(and(eq(billingContact.id, id), eq(billingContact.organizationId, clientOrganizationId)))
151
+ .returning()
152
+ if (!updated) {
153
+ throw createError({ statusCode: 404, message: 'Billing contact not found' })
154
+ }
155
+ return updated
156
+ } catch (error) {
157
+ if (
158
+ (error as { code?: string; cause?: { code?: string } }).code === '23505' ||
159
+ (error as { cause?: { code?: string } }).cause?.code === '23505'
160
+ ) {
161
+ throw createError({ statusCode: 409, message: 'A billing contact with this email already exists' })
162
+ }
163
+ throw error
164
+ }
165
+ }
166
+
167
+ export const deleteBillingContact = async (clientOrganizationId: string, id: string) => {
168
+ await requireInvoiceClient(clientOrganizationId)
169
+ const [deleted] = await db
170
+ .delete(billingContact)
171
+ .where(and(eq(billingContact.id, id), eq(billingContact.organizationId, clientOrganizationId)))
172
+ .returning()
173
+ if (!deleted) {
174
+ throw createError({ statusCode: 404, message: 'Billing contact not found' })
175
+ }
176
+ return { deleted: true }
177
+ }
178
+
179
+ export const ensureClientAccess = async (providerOrganizationId: string, clientOrganizationId: string) => {
180
+ const [created] = await db
181
+ .insert(invoiceClientAccess)
182
+ .values({ id: nanoid(), providerOrganizationId, clientOrganizationId })
183
+ .onConflictDoUpdate({
184
+ target: [invoiceClientAccess.providerOrganizationId, invoiceClientAccess.clientOrganizationId],
185
+ set: { enabled: true, updatedAt: new Date() }
186
+ })
187
+ .returning()
188
+ return created!
189
+ }
190
+
191
+ const requireClientInvoicesModule = async (clientOrganizationId: string) => {
192
+ const [enabled] = await db
193
+ .select({ id: clientModule.id })
194
+ .from(clientModule)
195
+ .where(
196
+ and(
197
+ eq(clientModule.organizationId, clientOrganizationId),
198
+ eq(clientModule.moduleId, 'invoices'),
199
+ eq(clientModule.enabled, true)
200
+ )
201
+ )
202
+ .limit(1)
203
+ if (!enabled) {
204
+ throw createError({ statusCode: 403, message: 'Invoices are not enabled for this client organization' })
205
+ }
206
+ }
207
+
208
+ export const listClientInvoiceSuppliers = async (clientOrganizationId: string, userId: string, isAdmin: boolean) => {
209
+ await requireClientInvoicesModule(clientOrganizationId)
210
+ const accesses = await db
211
+ .select()
212
+ .from(invoiceClientAccess)
213
+ .where(
214
+ and(eq(invoiceClientAccess.clientOrganizationId, clientOrganizationId), eq(invoiceClientAccess.enabled, true))
215
+ )
216
+ const assignments =
217
+ accesses.length && !isAdmin
218
+ ? await db
219
+ .select()
220
+ .from(invoiceClientViewer)
221
+ .where(
222
+ and(
223
+ inArray(
224
+ invoiceClientViewer.accessId,
225
+ accesses.map((item) => item.id)
226
+ ),
227
+ eq(invoiceClientViewer.userId, userId)
228
+ )
229
+ )
230
+ : []
231
+ const allowed = accesses.filter(
232
+ (item) => isAdmin || assignments.some((assignment) => assignment.accessId === item.id)
233
+ )
234
+ const providers = await getPortalOrganizationsByIds(allowed.map((item) => item.providerOrganizationId))
235
+ return allowed.map((item) => ({
236
+ id: item.id,
237
+ providerOrganizationId: item.providerOrganizationId,
238
+ providerName: providers.find((provider) => provider.id === item.providerOrganizationId)?.name ?? '',
239
+ canViewInvoices: true
240
+ }))
241
+ }
242
+
243
+ const requireClientAccess = async (
244
+ clientOrganizationId: string,
245
+ userId: string,
246
+ isAdmin: boolean,
247
+ invoiceId?: string
248
+ ) => {
249
+ await requireClientInvoicesModule(clientOrganizationId)
250
+ const accesses = await db
251
+ .select()
252
+ .from(invoiceClientAccess)
253
+ .where(
254
+ and(eq(invoiceClientAccess.clientOrganizationId, clientOrganizationId), eq(invoiceClientAccess.enabled, true))
255
+ )
256
+ const assignments =
257
+ accesses.length && !isAdmin
258
+ ? await db
259
+ .select()
260
+ .from(invoiceClientViewer)
261
+ .where(
262
+ and(
263
+ inArray(
264
+ invoiceClientViewer.accessId,
265
+ accesses.map((item) => item.id)
266
+ ),
267
+ eq(invoiceClientViewer.userId, userId)
268
+ )
269
+ )
270
+ : []
271
+ const allowed = accesses.filter(
272
+ (item) => isAdmin || assignments.some((assignment) => assignment.accessId === item.id)
273
+ )
274
+ if (!invoiceId) {
275
+ return allowed
276
+ }
277
+ const [selected] = await db
278
+ .select()
279
+ .from(invoice)
280
+ .where(
281
+ and(
282
+ eq(invoice.id, invoiceId),
283
+ eq(invoice.clientOrganizationId, clientOrganizationId),
284
+ inArray(invoice.status, ['ISSUED', 'PAID'])
285
+ )
286
+ )
287
+ .limit(1)
288
+ const access = selected && allowed.find((item) => item.providerOrganizationId === selected.organizationId)
289
+ if (!access) {
290
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
291
+ }
292
+ return access
293
+ }
294
+
295
+ export const listClientInvoices = async (clientOrganizationId: string, userId: string, isAdmin: boolean) => {
296
+ const accesses = await requireClientAccess(clientOrganizationId, userId, isAdmin)
297
+ if (!Array.isArray(accesses) || !accesses.length) {
298
+ return []
299
+ }
300
+ const providers = await getPortalOrganizationsByIds(accesses.map((item) => item.providerOrganizationId))
301
+ const results = await Promise.all(
302
+ accesses.map(async (access) =>
303
+ (await listInvoices(access.providerOrganizationId))
304
+ .filter(
305
+ (item) => item.clientOrganizationId === clientOrganizationId && ['ISSUED', 'PAID'].includes(item.status)
306
+ )
307
+ .map((item) => ({
308
+ id: item.id,
309
+ number: item.number,
310
+ status: item.status,
311
+ currency: item.currency,
312
+ issueDate: item.issueDate,
313
+ dueDate: item.dueDate,
314
+ subject: item.subject,
315
+ totalMinor: item.totalMinor,
316
+ outstandingMinor: item.outstandingMinor,
317
+ isOverdue: item.isOverdue,
318
+ daysOverdue: item.daysOverdue,
319
+ supplierName:
320
+ providers.find((provider) => provider.id === access.providerOrganizationId)?.name ?? item.senderName,
321
+ accessId: access.id
322
+ }))
323
+ )
324
+ )
325
+ return results.flat()
326
+ }
327
+
328
+ export const getClientInvoice = async (clientOrganizationId: string, userId: string, isAdmin: boolean, id: string) => {
329
+ const access = await requireClientAccess(clientOrganizationId, userId, isAdmin, id)
330
+ if (Array.isArray(access)) {
331
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
332
+ }
333
+ const selected = await getInvoice(access.providerOrganizationId, id)
334
+ const providers = await getPortalOrganizationsByIds([access.providerOrganizationId])
335
+ const {
336
+ payments: _payments,
337
+ reminderCount: _reminders,
338
+ lastReminderSentAt: _lastReminder,
339
+ history: _history,
340
+ emailDeliveries: _deliveries,
341
+ ...safe
342
+ } = selected
343
+ return { ...safe, supplierName: providers[0]?.name ?? selected.senderName, accessId: access.id }
344
+ }
345
+
346
+ export const getClientInvoiceAttachment = async (
347
+ clientOrganizationId: string,
348
+ userId: string,
349
+ isAdmin: boolean,
350
+ invoiceId: string,
351
+ attachmentId: string
352
+ ) => {
353
+ const access = await requireClientAccess(clientOrganizationId, userId, isAdmin, invoiceId)
354
+ if (Array.isArray(access)) {
355
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
356
+ }
357
+ return getInvoiceAttachment(access.providerOrganizationId, invoiceId, attachmentId)
358
+ }
359
+
360
+ export const getClientInvoiceAccessOverview = async (
361
+ providerOrganizationId: string,
362
+ clientOrganizationId: string
363
+ ): Promise<InvoiceClientAccessOverviewDto> => {
364
+ const client = await getClient(clientOrganizationId)
365
+ if (!client) {
366
+ throw createError({ statusCode: 404, message: 'Client not found' })
367
+ }
368
+ const [access] = await db
369
+ .select()
370
+ .from(invoiceClientAccess)
371
+ .where(
372
+ and(
373
+ eq(invoiceClientAccess.providerOrganizationId, providerOrganizationId),
374
+ eq(invoiceClientAccess.clientOrganizationId, clientOrganizationId)
375
+ )
376
+ )
377
+ .limit(1)
378
+ const assignments = access
379
+ ? await db.select().from(invoiceClientViewer).where(eq(invoiceClientViewer.accessId, access.id))
380
+ : []
381
+ const moduleEnabled = client.modules.some((module) => module.moduleId === 'invoices' && module.enabled)
382
+ return {
383
+ configured: Boolean(access),
384
+ enabled: access?.enabled ?? false,
385
+ moduleEnabled,
386
+ members: client.members.map((member) => {
387
+ const fixedAccess = ['owner', 'admin'].includes(member.role)
388
+ const assigned = fixedAccess || assignments.some((item) => item.userId === member.userId)
389
+ return {
390
+ id: member.userId,
391
+ name: member.name,
392
+ email: member.email,
393
+ image: member.image,
394
+ role: member.role,
395
+ fixedAccess,
396
+ assigned,
397
+ canView: moduleEnabled && Boolean(access?.enabled) && assigned
398
+ }
399
+ })
400
+ }
401
+ }
402
+
403
+ export const listClientInvoiceViewers = async (accessId: string, clientOrganizationId: string) => {
404
+ const [access] = await db
405
+ .select()
406
+ .from(invoiceClientAccess)
407
+ .where(
408
+ and(
409
+ eq(invoiceClientAccess.id, accessId),
410
+ eq(invoiceClientAccess.clientOrganizationId, clientOrganizationId),
411
+ eq(invoiceClientAccess.enabled, true)
412
+ )
413
+ )
414
+ .limit(1)
415
+ if (!access) {
416
+ throw createError({ statusCode: 404, message: 'Invoice supplier not found' })
417
+ }
418
+ const [members, assignments] = await Promise.all([
419
+ listPortalOrganizationMembers(clientOrganizationId),
420
+ db.select().from(invoiceClientViewer).where(eq(invoiceClientViewer.accessId, accessId))
421
+ ])
422
+ return members.map((member) => ({
423
+ id: member.id,
424
+ name: member.name,
425
+ email: member.email,
426
+ role: member.organizationRole,
427
+ assigned:
428
+ ['owner', 'admin'].includes(member.organizationRole) || assignments.some((item) => item.userId === member.id),
429
+ fixedAccess: ['owner', 'admin'].includes(member.organizationRole)
430
+ }))
431
+ }
432
+
433
+ export const setClientInvoiceViewer = async (
434
+ accessId: string,
435
+ clientOrganizationId: string,
436
+ actorUserId: string,
437
+ userId: string,
438
+ assigned: boolean
439
+ ) => {
440
+ const viewers = await listClientInvoiceViewers(accessId, clientOrganizationId)
441
+ const selected = viewers.find((item) => item.id === userId)
442
+ if (!selected) {
443
+ throw createError({ statusCode: 400, message: 'User is not a client member' })
444
+ }
445
+ if (selected.fixedAccess) {
446
+ throw createError({ statusCode: 400, message: 'Organization owners and admins always have invoice access' })
447
+ }
448
+ if (assigned) {
449
+ await db
450
+ .insert(invoiceClientViewer)
451
+ .values({ id: nanoid(), accessId, userId, createdById: actorUserId })
452
+ .onConflictDoNothing()
453
+ } else {
454
+ await db
455
+ .delete(invoiceClientViewer)
456
+ .where(and(eq(invoiceClientViewer.accessId, accessId), eq(invoiceClientViewer.userId, userId)))
457
+ }
458
+ return listClientInvoiceViewers(accessId, clientOrganizationId)
459
+ }
460
+
461
+ const totals = (
462
+ lines: Array<{ quantityMilli: number; unitPriceMinor: number; vatRateBasisPoints: number }>,
463
+ payments: Array<{ amountMinor: number }>
464
+ ) => {
465
+ const subtotalMinor = lines.reduce(
466
+ (sum, line) => sum + Math.round((line.quantityMilli * line.unitPriceMinor) / 1000),
467
+ 0
468
+ )
469
+ const vatMinor = lines.reduce((sum, line) => {
470
+ const amount = Math.round((line.quantityMilli * line.unitPriceMinor) / 1000)
471
+ return sum + Math.round((amount * line.vatRateBasisPoints) / 10_000)
472
+ }, 0)
473
+ const totalMinor = subtotalMinor + vatMinor
474
+ const paidMinor = payments.reduce((sum, payment) => sum + payment.amountMinor, 0)
475
+ return { subtotalMinor, vatMinor, totalMinor, paidMinor, outstandingMinor: Math.max(0, totalMinor - paidMinor) }
476
+ }
477
+
478
+ export const listInvoices = async (organizationId: string): Promise<InvoiceDto[]> => {
479
+ await requireInvoicesEnabled(organizationId)
480
+ const rows = await db
481
+ .select()
482
+ .from(invoice)
483
+ .where(eq(invoice.organizationId, organizationId))
484
+ .orderBy(desc(invoice.issueDate), desc(invoice.createdAt))
485
+ if (!rows.length) {
486
+ return []
487
+ }
488
+ const ids = rows.map((item) => item.id)
489
+ const [lines, payments, reminders] = await Promise.all([
490
+ db.select().from(invoiceLine).where(inArray(invoiceLine.invoiceId, ids)).orderBy(asc(invoiceLine.position)),
491
+ db.select().from(invoicePayment).where(inArray(invoicePayment.invoiceId, ids)).orderBy(desc(invoicePayment.paidOn)),
492
+ db
493
+ .select()
494
+ .from(invoiceEmailDelivery)
495
+ .where(
496
+ and(
497
+ inArray(invoiceEmailDelivery.invoiceId, ids),
498
+ eq(invoiceEmailDelivery.purpose, 'REMINDER'),
499
+ eq(invoiceEmailDelivery.status, 'SENT')
500
+ )
501
+ )
502
+ .orderBy(desc(invoiceEmailDelivery.sentAt))
503
+ ])
504
+ return rows.map((row) => {
505
+ const selectedLines = lines.filter((item) => item.invoiceId === row.id)
506
+ const selectedPayments = payments.filter((item) => item.invoiceId === row.id)
507
+ const amounts = totals(selectedLines, selectedPayments)
508
+ const sent = reminders.filter((item) => item.invoiceId === row.id)
509
+ return {
510
+ ...row,
511
+ createdAt: row.createdAt.toISOString(),
512
+ updatedAt: row.updatedAt.toISOString(),
513
+ issuedAt: row.issuedAt?.toISOString() ?? null,
514
+ lines: selectedLines.map((line) => ({
515
+ ...line,
516
+ amountMinor: Math.round((line.quantityMilli * line.unitPriceMinor) / 1000)
517
+ })),
518
+ payments: selectedPayments.map(({ id, paidOn, amountMinor, reference, note }) => ({
519
+ id,
520
+ paidOn,
521
+ amountMinor,
522
+ reference,
523
+ note
524
+ })),
525
+ ...amounts,
526
+ ...overdue(row.dueDate, row.status, amounts.outstandingMinor),
527
+ reminderCount: sent.length,
528
+ lastReminderSentAt: sent[0]?.sentAt?.toISOString() ?? null,
529
+ history: [],
530
+ attachments: [],
531
+ emailDeliveries: []
532
+ }
533
+ })
534
+ }
535
+
536
+ export const getInvoice = async (organizationId: string, id: string): Promise<InvoiceDto> => {
537
+ const selected = (await listInvoices(organizationId)).find((item) => item.id === id)
538
+ if (!selected) {
539
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
540
+ }
541
+ const [histories, attachments, deliveries] = await Promise.all([
542
+ db
543
+ .select({ history: invoiceHistory, actorName: user.name })
544
+ .from(invoiceHistory)
545
+ .innerJoin(user, eq(user.id, invoiceHistory.actorUserId))
546
+ .where(eq(invoiceHistory.invoiceId, id))
547
+ .orderBy(desc(invoiceHistory.createdAt)),
548
+ db
549
+ .select({ attachment: invoiceAttachment, uploaderName: user.name })
550
+ .from(invoiceAttachment)
551
+ .innerJoin(user, eq(user.id, invoiceAttachment.uploadedById))
552
+ .where(eq(invoiceAttachment.invoiceId, id))
553
+ .orderBy(desc(invoiceAttachment.createdAt)),
554
+ db
555
+ .select({ delivery: invoiceEmailDelivery, actorName: user.name })
556
+ .from(invoiceEmailDelivery)
557
+ .innerJoin(user, eq(user.id, invoiceEmailDelivery.actorUserId))
558
+ .where(eq(invoiceEmailDelivery.invoiceId, id))
559
+ .orderBy(desc(invoiceEmailDelivery.createdAt))
560
+ ])
561
+ return {
562
+ ...selected,
563
+ history: histories.map(({ history, actorName }) => ({
564
+ ...history,
565
+ actorName,
566
+ createdAt: history.createdAt.toISOString()
567
+ })),
568
+ attachments: attachments.map(({ attachment, uploaderName }) => ({
569
+ id: attachment.id,
570
+ fileName: attachment.fileName,
571
+ contentType: attachment.contentType,
572
+ size: attachment.size,
573
+ uploadedByName: uploaderName,
574
+ createdAt: attachment.createdAt.toISOString()
575
+ })),
576
+ emailDeliveries: deliveries.map(({ delivery, actorName }) => ({
577
+ ...delivery,
578
+ actorName,
579
+ ccEmails: JSON.parse(delivery.ccEmails),
580
+ providerStatusCheckedAt: delivery.providerStatusCheckedAt?.toISOString() ?? null,
581
+ createdAt: delivery.createdAt.toISOString(),
582
+ sentAt: delivery.sentAt?.toISOString() ?? null
583
+ }))
584
+ }
585
+ }
586
+
587
+ export const getNextInvoiceNumber = async (organizationId: string) => {
588
+ await requireInvoicesEnabled(organizationId)
589
+ const existing = await db
590
+ .select({ number: invoice.number })
591
+ .from(invoice)
592
+ .where(eq(invoice.organizationId, organizationId))
593
+ .orderBy(desc(invoice.createdAt))
594
+ const used = new Set(existing.map((item) => item.number))
595
+ const latest = existing.find((item) => hasNumericInvoiceSequence(item.number))
596
+ let candidate = latest ? incrementInvoiceNumber(latest.number) : firstInvoiceNumber()
597
+ while (used.has(candidate)) {
598
+ candidate = incrementInvoiceNumber(candidate)
599
+ }
600
+ return candidate
601
+ }
602
+
603
+ export const createInvoiceInTransaction = async (
604
+ tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
605
+ organizationId: string,
606
+ actorUserId: string,
607
+ input: InvoiceCreateInput
608
+ ) => {
609
+ const [duplicate] = await tx
610
+ .select({ id: invoice.id })
611
+ .from(invoice)
612
+ .where(and(eq(invoice.organizationId, organizationId), eq(invoice.number, input.number)))
613
+ .limit(1)
614
+ if (duplicate) {
615
+ throw createError({ statusCode: 409, message: 'Invoice number already exists' })
616
+ }
617
+ const client = await getClient(input.clientOrganizationId)
618
+ if (!client || client.archivedAt) {
619
+ throw createError({ statusCode: 400, message: 'Client is unavailable' })
620
+ }
621
+ const sender = await getOrganizationInvoiceProfile(organizationId)
622
+ if (
623
+ ![sender.address, sender.registrationNumber, sender.vatNumber, sender.iban, sender.bic, sender.invoiceEmail].every(
624
+ (value) => value?.trim()
625
+ )
626
+ ) {
627
+ throw createError({
628
+ statusCode: 409,
629
+ message: 'Sender invoice details must be completed before creating an invoice'
630
+ })
631
+ }
632
+ const contacts = await db
633
+ .select()
634
+ .from(billingContact)
635
+ .where(eq(billingContact.organizationId, input.clientOrganizationId))
636
+ const contact = input.contactId ? contacts.find((item) => item.id === input.contactId) : undefined
637
+ if (input.contactId && !contact) {
638
+ throw createError({ statusCode: 400, message: 'Contact does not belong to this client' })
639
+ }
640
+ const invoiceId = nanoid()
641
+ const { lines, contactId: _contactId, ...values } = input
642
+ const [created] = await tx
643
+ .insert(invoice)
644
+ .values({
645
+ id: invoiceId,
646
+ organizationId,
647
+ createdById: actorUserId,
648
+ ...values,
649
+ senderName: sender.name,
650
+ senderLogo: sender.logo,
651
+ senderAddress: sender.address,
652
+ senderRegistration: sender.registrationNumber,
653
+ senderVatNumber: sender.vatNumber,
654
+ senderIban: sender.iban,
655
+ senderBic: sender.bic,
656
+ recipientName: client.officialName || client.name,
657
+ recipientAddress: client.address,
658
+ recipientContactName: contact?.name ?? null,
659
+ recipientEmail: contact?.email ?? null,
660
+ recipientLocale: client.preferredLocale
661
+ })
662
+ .returning()
663
+ await tx.insert(invoiceHistory).values({ id: nanoid(), invoiceId, action: 'CREATED', actorUserId })
664
+ for (const [position, line] of lines.entries()) {
665
+ await tx.insert(invoiceLine).values({ id: nanoid(), invoiceId, position, ...line })
666
+ }
667
+ await tx
668
+ .insert(invoiceClientAccess)
669
+ .values({ id: nanoid(), providerOrganizationId: organizationId, clientOrganizationId: input.clientOrganizationId })
670
+ .onConflictDoUpdate({
671
+ target: [invoiceClientAccess.providerOrganizationId, invoiceClientAccess.clientOrganizationId],
672
+ set: { enabled: true, updatedAt: new Date() }
673
+ })
674
+ return created!
675
+ }
676
+ export const createInvoice = async (organizationId: string, actorUserId: string, input: InvoiceCreateInput) => {
677
+ await requireInvoicesEnabled(organizationId)
678
+ return db.transaction((tx) => createInvoiceInTransaction(tx, organizationId, actorUserId, input))
679
+ }
680
+
681
+ export const changeInvoiceStatus = async (
682
+ organizationId: string,
683
+ actorUserId: string,
684
+ id: string,
685
+ action: 'VOID' | 'UNVOID'
686
+ ) =>
687
+ db.transaction(async (tx) => {
688
+ const [current] = await tx
689
+ .select()
690
+ .from(invoice)
691
+ .where(and(eq(invoice.id, id), eq(invoice.organizationId, organizationId)))
692
+ .limit(1)
693
+ if (!current) {
694
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
695
+ }
696
+ if (action === 'VOID' && !['DRAFT', 'ISSUED'].includes(current.status)) {
697
+ throw createError({ statusCode: 409, message: 'This invoice cannot be voided' })
698
+ }
699
+ if (action === 'UNVOID' && current.status !== 'VOID') {
700
+ throw createError({ statusCode: 409, message: 'Only voided invoices can be unvoided' })
701
+ }
702
+ const status = action === 'VOID' ? 'VOID' : current.issuedAt ? 'ISSUED' : 'DRAFT'
703
+ const [updated] = await tx.update(invoice).set({ status }).where(eq(invoice.id, id)).returning()
704
+ await tx
705
+ .insert(invoiceHistory)
706
+ .values({ id: nanoid(), invoiceId: id, action: action === 'VOID' ? 'VOIDED' : 'UNVOIDED', actorUserId })
707
+ return updated!
708
+ })
709
+ export const updateInvoice = async (
710
+ organizationId: string,
711
+ actorUserId: string,
712
+ id: string,
713
+ input: { number: string; issueDate: string; dueDate: string; subject?: string | null; notes?: string | null }
714
+ ) =>
715
+ db.transaction(async (tx) => {
716
+ const [current] = await tx
717
+ .select()
718
+ .from(invoice)
719
+ .where(and(eq(invoice.id, id), eq(invoice.organizationId, organizationId)))
720
+ .limit(1)
721
+ if (!current) {
722
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
723
+ }
724
+ if (current.status !== 'DRAFT') {
725
+ throw createError({ statusCode: 409, message: 'Only draft invoices can be edited' })
726
+ }
727
+ const [updated] = await tx.update(invoice).set(input).where(eq(invoice.id, id)).returning()
728
+ await tx.insert(invoiceHistory).values({ id: nanoid(), invoiceId: id, action: 'EDITED', actorUserId })
729
+ return updated!
730
+ })
731
+ export const registerInvoicePayment = async (
732
+ organizationId: string,
733
+ actorUserId: string,
734
+ id: string,
735
+ input: { paidOn: string; amountMinor: number; reference?: string | null; note?: string | null }
736
+ ) =>
737
+ db.transaction(async (tx) => {
738
+ const selected = await getInvoice(organizationId, id)
739
+ if (!['ISSUED', 'PAID'].includes(selected.status) || input.amountMinor > selected.outstandingMinor) {
740
+ throw createError({ statusCode: 409, message: 'Payment is not valid for this invoice' })
741
+ }
742
+ const [created] = await tx
743
+ .insert(invoicePayment)
744
+ .values({ id: nanoid(), invoiceId: id, createdById: actorUserId, ...input })
745
+ .returning()
746
+ await tx.insert(invoiceHistory).values({
747
+ id: nanoid(),
748
+ invoiceId: id,
749
+ action: 'PAYMENT_REGISTERED',
750
+ actorUserId,
751
+ amountMinor: input.amountMinor
752
+ })
753
+ if (input.amountMinor === selected.outstandingMinor) {
754
+ await tx.update(invoice).set({ status: 'PAID' }).where(eq(invoice.id, id))
755
+ }
756
+ return created!
757
+ })
758
+ export const addInvoiceAttachment = async (
759
+ organizationId: string,
760
+ actorUserId: string,
761
+ id: string,
762
+ file: { fileName: string; contentType: string; data: Uint8Array }
763
+ ) =>
764
+ db.transaction(async (tx) => {
765
+ const [current] = await tx
766
+ .select({ id: invoice.id })
767
+ .from(invoice)
768
+ .where(and(eq(invoice.id, id), eq(invoice.organizationId, organizationId)))
769
+ .limit(1)
770
+ if (!current) {
771
+ throw createError({ statusCode: 404, message: 'Invoice not found' })
772
+ }
773
+ const [created] = await tx
774
+ .insert(invoiceAttachment)
775
+ .values({
776
+ id: nanoid(),
777
+ invoiceId: id,
778
+ fileName: file.fileName,
779
+ contentType: file.contentType,
780
+ size: file.data.length,
781
+ contentBase64: Buffer.from(file.data).toString('base64'),
782
+ uploadedById: actorUserId
783
+ })
784
+ .returning()
785
+ await tx
786
+ .insert(invoiceHistory)
787
+ .values({ id: nanoid(), invoiceId: id, action: 'ATTACHMENT_ADDED', actorUserId, attachmentName: file.fileName })
788
+ return created!
789
+ })
790
+ export const getInvoiceAttachment = async (organizationId: string, invoiceId: string, attachmentId: string) => {
791
+ const [row] = await db
792
+ .select({ attachment: invoiceAttachment })
793
+ .from(invoiceAttachment)
794
+ .innerJoin(invoice, eq(invoice.id, invoiceAttachment.invoiceId))
795
+ .where(
796
+ and(eq(invoice.organizationId, organizationId), eq(invoice.id, invoiceId), eq(invoiceAttachment.id, attachmentId))
797
+ )
798
+ .limit(1)
799
+ if (!row) {
800
+ throw createError({ statusCode: 404, message: 'Attachment not found' })
801
+ }
802
+ return row.attachment
803
+ }
804
+ export const deleteInvoiceAttachment = async (
805
+ organizationId: string,
806
+ actorUserId: string,
807
+ invoiceId: string,
808
+ attachmentId: string
809
+ ) =>
810
+ db.transaction(async (tx) => {
811
+ const attachment = await getInvoiceAttachment(organizationId, invoiceId, attachmentId)
812
+ await tx.delete(invoiceAttachment).where(eq(invoiceAttachment.id, attachmentId))
813
+ await tx.insert(invoiceHistory).values({
814
+ id: nanoid(),
815
+ invoiceId,
816
+ action: 'ATTACHMENT_REMOVED',
817
+ actorUserId,
818
+ attachmentName: attachment.fileName
819
+ })
820
+ return { deleted: true }
821
+ })