@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,17 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { setClientInvoiceViewer } from '@nuxt-customer-portal/invoices/server/utils/invoice-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ if (!['owner', 'admin'].includes(role)) {
7
+ throw createError({ statusCode: 403, message: 'Client administrator access required' })
8
+ }
9
+ const body = await readBody<{ userId: string; assigned: boolean }>(event)
10
+ return setClientInvoiceViewer(
11
+ getRouterParam(event, 'accessId')!,
12
+ organizationId,
13
+ session.user.id,
14
+ body.userId,
15
+ body.assigned
16
+ )
17
+ })
@@ -0,0 +1,20 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { getClientInvoiceAttachment } from '@nuxt-customer-portal/invoices/server/utils/invoice-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ const attachment = await getClientInvoiceAttachment(
7
+ organizationId,
8
+ session.user.id,
9
+ role === 'owner' || role === 'admin' || session.user.role === 'admin',
10
+ getRouterParam(event, 'id')!,
11
+ getRouterParam(event, 'attachmentId')!
12
+ )
13
+ setResponseHeaders(event, {
14
+ 'Content-Type': attachment.contentType,
15
+ 'Content-Length': attachment.size,
16
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`,
17
+ 'Cache-Control': 'private, no-store'
18
+ })
19
+ return Buffer.from(attachment.contentBase64, 'base64')
20
+ })
@@ -0,0 +1,12 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { getClientInvoice } from '@nuxt-customer-portal/invoices/server/utils/invoice-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ return getClientInvoice(
7
+ organizationId,
8
+ session.user.id,
9
+ role === 'owner' || role === 'admin' || session.user.role === 'admin',
10
+ getRouterParam(event, 'id')!
11
+ )
12
+ })
@@ -0,0 +1,23 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { generateInvoicePdf } from '@nuxt-customer-portal/invoices/server/utils/invoice-pdf'
3
+ import { getClientInvoice } from '@nuxt-customer-portal/invoices/server/utils/invoice-repository'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
7
+ const selected = await getClientInvoice(
8
+ organizationId,
9
+ session.user.id,
10
+ role === 'owner' || role === 'admin' || session.user.role === 'admin',
11
+ getRouterParam(event, 'id')!
12
+ )
13
+ const query = getQuery(event)
14
+ const pdf = await generateInvoicePdf(selected, typeof query.locale === 'string' ? query.locale : undefined)
15
+ const disposition = query.download === '1' ? 'attachment' : 'inline'
16
+ const fileName = `invoice-${selected.number.replace(/[^a-z0-9._-]+/gi, '-')}.pdf`
17
+ setResponseHeaders(event, {
18
+ 'Content-Type': 'application/pdf',
19
+ 'Content-Disposition': `${disposition}; filename="${fileName}"`,
20
+ 'Cache-Control': 'private, no-store'
21
+ })
22
+ return Buffer.from(pdf)
23
+ })
@@ -0,0 +1,14 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { clientInvoiceListQuerySchema } from '@nuxt-customer-portal/invoices/server/utils/invoice-validation'
3
+ import { listClientInvoicesPage } from '@nuxt-customer-portal/invoices/server/utils/invoice-listing'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
7
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
8
+ return listClientInvoicesPage(
9
+ organizationId,
10
+ session.user.id,
11
+ isAdmin,
12
+ clientInvoiceListQuerySchema.parse(getQuery(event))
13
+ )
14
+ })
@@ -0,0 +1,8 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientInvoiceSuppliers } from '@nuxt-customer-portal/invoices/server/utils/invoice-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
7
+ return listClientInvoiceSuppliers(organizationId, session.user.id, isAdmin)
8
+ })
@@ -0,0 +1,251 @@
1
+ import { boolean, date, index, integer, pgSchema, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
2
+ import { sql } from 'drizzle-orm'
3
+ import { organization, user } from '@nuxt-customer-portal/core/schema'
4
+
5
+ export const invoicesSchema = pgSchema('invoices')
6
+ export const invoiceStatus = invoicesSchema.enum('invoice_status', ['DRAFT', 'ISSUED', 'PAID', 'VOID'])
7
+ export const invoiceEmailDeliveryStatus = invoicesSchema.enum('invoice_email_delivery_status', [
8
+ 'PENDING',
9
+ 'SENT',
10
+ 'FAILED'
11
+ ])
12
+ export const invoiceEmailPurpose = invoicesSchema.enum('invoice_email_purpose', ['INVOICE', 'REMINDER'])
13
+ const audit = {
14
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
15
+ updatedAt: timestamp('updated_at', { mode: 'date' })
16
+ .defaultNow()
17
+ .$onUpdate(() => new Date())
18
+ .notNull()
19
+ }
20
+
21
+ export const invoiceSettings = invoicesSchema.table('settings', {
22
+ organizationId: text('organization_id')
23
+ .primaryKey()
24
+ .references(() => organization.id, { onDelete: 'cascade' }),
25
+ enabled: boolean('enabled').default(false).notNull(),
26
+ currency: text('currency').default('EUR').notNull(),
27
+ defaultVatRateBasisPoints: integer('default_vat_rate_basis_points').default(2100).notNull(),
28
+ address: text('address').default('').notNull(),
29
+ registrationNumber: text('registration_number'),
30
+ vatNumber: text('vat_number'),
31
+ iban: text('iban'),
32
+ bic: text('bic'),
33
+ invoiceEmail: text('invoice_email'),
34
+ // Retained for one compatibility release. The portal-wide email template is now used instead.
35
+ invoiceEmailTemplate: text('invoice_email_template'),
36
+ preferredLocale: text('preferred_locale').default('nl').notNull(),
37
+ ...audit
38
+ })
39
+
40
+ export const billingContact = invoicesSchema.table(
41
+ 'billing_contact',
42
+ {
43
+ id: text('id').primaryKey(),
44
+ organizationId: text('organization_id')
45
+ .notNull()
46
+ .references(() => organization.id, { onDelete: 'cascade' }),
47
+ userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
48
+ name: text('name').notNull(),
49
+ email: text('email').notNull(),
50
+ phone: text('phone'),
51
+ jobTitle: text('job_title'),
52
+ ...audit
53
+ },
54
+ (table) => [
55
+ uniqueIndex('billing_contact_org_email_uidx').on(table.organizationId, table.email),
56
+ index('billing_contact_org_idx').on(table.organizationId)
57
+ ]
58
+ )
59
+
60
+ export const invoiceClientAccess = invoicesSchema.table(
61
+ 'client_access',
62
+ {
63
+ id: text('id').primaryKey(),
64
+ providerOrganizationId: text('provider_organization_id')
65
+ .notNull()
66
+ .references(() => organization.id, { onDelete: 'cascade' }),
67
+ clientOrganizationId: text('client_organization_id')
68
+ .notNull()
69
+ .references(() => organization.id, { onDelete: 'restrict' }),
70
+ enabled: boolean('enabled').default(true).notNull(),
71
+ ...audit
72
+ },
73
+ (table) => [
74
+ uniqueIndex('invoice_client_access_provider_client_uidx').on(
75
+ table.providerOrganizationId,
76
+ table.clientOrganizationId
77
+ ),
78
+ index('invoice_client_access_client_idx').on(table.clientOrganizationId)
79
+ ]
80
+ )
81
+
82
+ export const invoiceClientViewer = invoicesSchema.table(
83
+ 'client_viewer',
84
+ {
85
+ id: text('id').primaryKey(),
86
+ accessId: text('access_id')
87
+ .notNull()
88
+ .references(() => invoiceClientAccess.id, { onDelete: 'cascade' }),
89
+ userId: text('user_id')
90
+ .notNull()
91
+ .references(() => user.id, { onDelete: 'cascade' }),
92
+ createdById: text('created_by_id')
93
+ .notNull()
94
+ .references(() => user.id, { onDelete: 'restrict' }),
95
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
96
+ },
97
+ (table) => [
98
+ uniqueIndex('invoice_client_viewer_access_user_uidx').on(table.accessId, table.userId),
99
+ index('invoice_client_viewer_user_idx').on(table.userId)
100
+ ]
101
+ )
102
+
103
+ export const invoice = invoicesSchema.table(
104
+ 'invoice',
105
+ {
106
+ id: text('id').primaryKey(),
107
+ organizationId: text('organization_id')
108
+ .notNull()
109
+ .references(() => organization.id, { onDelete: 'cascade' }),
110
+ clientOrganizationId: text('client_organization_id').references(() => organization.id, { onDelete: 'restrict' }),
111
+ number: text('number').notNull(),
112
+ status: invoiceStatus('status').default('DRAFT').notNull(),
113
+ currency: text('currency').notNull(),
114
+ issueDate: date('issue_date', { mode: 'string' }).notNull(),
115
+ dueDate: date('due_date', { mode: 'string' }).notNull(),
116
+ subject: text('subject'),
117
+ notes: text('notes'),
118
+ senderName: text('sender_name').notNull(),
119
+ senderLogo: text('sender_logo'),
120
+ senderAddress: text('sender_address').notNull(),
121
+ senderRegistration: text('sender_registration'),
122
+ senderVatNumber: text('sender_vat_number'),
123
+ senderIban: text('sender_iban'),
124
+ senderBic: text('sender_bic'),
125
+ recipientName: text('recipient_name').notNull(),
126
+ recipientAddress: text('recipient_address').notNull(),
127
+ recipientContactName: text('recipient_contact_name'),
128
+ recipientEmail: text('recipient_email'),
129
+ recipientLocale: text('recipient_locale').default('nl').notNull(),
130
+ createdById: text('created_by_id')
131
+ .notNull()
132
+ .references(() => user.id, { onDelete: 'restrict' }),
133
+ issuedAt: timestamp('issued_at', { mode: 'date' }),
134
+ ...audit
135
+ },
136
+ (table) => [
137
+ uniqueIndex('invoice_org_number_uidx').on(table.organizationId, table.number),
138
+ index('invoice_org_status_idx').on(table.organizationId, table.status),
139
+ index('invoice_client_idx').on(table.clientOrganizationId)
140
+ ]
141
+ )
142
+
143
+ export const invoiceLine = invoicesSchema.table(
144
+ 'invoice_line',
145
+ {
146
+ id: text('id').primaryKey(),
147
+ invoiceId: text('invoice_id')
148
+ .notNull()
149
+ .references(() => invoice.id, { onDelete: 'cascade' }),
150
+ position: integer('position').notNull(),
151
+ description: text('description').notNull(),
152
+ quantityMilli: integer('quantity_milli').notNull(),
153
+ unit: text('unit').default('item').notNull(),
154
+ unitPriceMinor: integer('unit_price_minor').notNull(),
155
+ vatRateBasisPoints: integer('vat_rate_basis_points').default(2100).notNull()
156
+ },
157
+ (table) => [index('invoice_line_invoice_idx').on(table.invoiceId)]
158
+ )
159
+
160
+ export const invoicePayment = invoicesSchema.table(
161
+ 'invoice_payment',
162
+ {
163
+ id: text('id').primaryKey(),
164
+ invoiceId: text('invoice_id')
165
+ .notNull()
166
+ .references(() => invoice.id, { onDelete: 'cascade' }),
167
+ paidOn: date('paid_on', { mode: 'string' }).notNull(),
168
+ amountMinor: integer('amount_minor').notNull(),
169
+ reference: text('reference'),
170
+ note: text('note'),
171
+ createdById: text('created_by_id')
172
+ .notNull()
173
+ .references(() => user.id, { onDelete: 'restrict' }),
174
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
175
+ },
176
+ (table) => [index('invoice_payment_invoice_idx').on(table.invoiceId)]
177
+ )
178
+
179
+ export const invoiceHistory = invoicesSchema.table(
180
+ 'invoice_history',
181
+ {
182
+ id: text('id').primaryKey(),
183
+ invoiceId: text('invoice_id')
184
+ .notNull()
185
+ .references(() => invoice.id, { onDelete: 'cascade' }),
186
+ action: text('action').notNull(),
187
+ actorUserId: text('actor_user_id')
188
+ .notNull()
189
+ .references(() => user.id, { onDelete: 'restrict' }),
190
+ amountMinor: integer('amount_minor'),
191
+ attachmentName: text('attachment_name'),
192
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
193
+ },
194
+ (table) => [index('invoice_history_invoice_idx').on(table.invoiceId)]
195
+ )
196
+
197
+ export const invoiceAttachment = invoicesSchema.table(
198
+ 'invoice_attachment',
199
+ {
200
+ id: text('id').primaryKey(),
201
+ invoiceId: text('invoice_id')
202
+ .notNull()
203
+ .references(() => invoice.id, { onDelete: 'cascade' }),
204
+ fileName: text('file_name').notNull(),
205
+ contentType: text('content_type').notNull(),
206
+ size: integer('size').notNull(),
207
+ contentBase64: text('content_base64').notNull(),
208
+ uploadedById: text('uploaded_by_id')
209
+ .notNull()
210
+ .references(() => user.id, { onDelete: 'restrict' }),
211
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
212
+ },
213
+ (table) => [index('invoice_attachment_invoice_idx').on(table.invoiceId)]
214
+ )
215
+
216
+ export const invoiceEmailDelivery = invoicesSchema.table(
217
+ 'invoice_email_delivery',
218
+ {
219
+ id: text('id').primaryKey(),
220
+ invoiceId: text('invoice_id')
221
+ .notNull()
222
+ .references(() => invoice.id, { onDelete: 'cascade' }),
223
+ status: invoiceEmailDeliveryStatus('status').default('PENDING').notNull(),
224
+ purpose: invoiceEmailPurpose('purpose').default('INVOICE').notNull(),
225
+ recipientEmail: text('recipient_email').notNull(),
226
+ ccEmails: text('cc_emails').default('[]').notNull(),
227
+ locale: text('locale').notNull(),
228
+ subject: text('subject').notNull(),
229
+ body: text('body').notNull(),
230
+ actorUserId: text('actor_user_id')
231
+ .notNull()
232
+ .references(() => user.id, { onDelete: 'restrict' }),
233
+ providerMessageId: text('provider_message_id'),
234
+ providerLastEvent: text('provider_last_event'),
235
+ providerStatusCheckedAt: timestamp('provider_status_checked_at', { mode: 'date' }),
236
+ errorMessage: text('error_message'),
237
+ payloadHash: text('payload_hash').notNull(),
238
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
239
+ updatedAt: timestamp('updated_at', { mode: 'date' })
240
+ .defaultNow()
241
+ .$onUpdate(() => new Date())
242
+ .notNull(),
243
+ sentAt: timestamp('sent_at', { mode: 'date' })
244
+ },
245
+ (table) => [
246
+ index('invoice_email_delivery_invoice_idx').on(table.invoiceId, table.createdAt),
247
+ uniqueIndex('invoice_email_delivery_pending_invoice_uidx')
248
+ .on(table.invoiceId)
249
+ .where(sql`${table.status} = 'PENDING'`)
250
+ ]
251
+ )
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+ import { registerPortalOpenApiContracts } from '@nuxt-customer-portal/core/server/utils/openapi-contracts'
3
+ import {
4
+ billingContactCreateSchema,
5
+ billingContactUpdateSchema,
6
+ invoiceCreateSchema,
7
+ invoiceEmailDeliverySchema,
8
+ invoiceIssueSchema,
9
+ invoiceListQuerySchema,
10
+ invoicePaymentSchema,
11
+ invoiceSettingsSchema,
12
+ invoiceUpdateSchema
13
+ } from '../utils/invoice-validation'
14
+
15
+ const refresh = z.object({ refresh: z.enum(['1']).optional() })
16
+ const locale = z.object({ locale: z.enum(['nl', 'en']).optional() })
17
+
18
+ export default defineNitroPlugin(() =>
19
+ registerPortalOpenApiContracts({
20
+ owner: 'invoices',
21
+ query: {
22
+ invoicesAdminInvoicesGet: invoiceListQuerySchema,
23
+ invoicesAdminEmailDomainGet: refresh,
24
+ invoicesAdminInvoicesByIdEmailStatusPost: refresh,
25
+ invoicesAdminInvoicesByIdEmailPreviewGet: locale,
26
+ invoicesAdminInvoicesByIdReminderPreviewGet: locale,
27
+ invoicesAdminInvoicesByIdPdfGet: locale
28
+ },
29
+ body: {
30
+ invoicesAdminSettingsPut: invoiceSettingsSchema,
31
+ invoicesAdminInvoicesPost: invoiceCreateSchema,
32
+ invoicesAdminInvoicesByIdPatch: z.union([invoiceUpdateSchema, invoiceIssueSchema]),
33
+ invoicesAdminInvoicesByIdPaymentsPost: invoicePaymentSchema,
34
+ invoicesAdminInvoicesByIdIssuePost: invoiceEmailDeliverySchema,
35
+ invoicesAdminInvoicesByIdEmailPost: invoiceEmailDeliverySchema,
36
+ invoicesAdminInvoicesByIdReminderPost: invoiceEmailDeliverySchema,
37
+ invoicesAdminClientsByClientIdContactsPost: billingContactCreateSchema,
38
+ invoicesAdminClientsByClientIdContactsByIdPatch: billingContactUpdateSchema
39
+ },
40
+ requestBody: {
41
+ invoicesAdminInvoicesByIdAttachmentsPost: {
42
+ required: true,
43
+ content: {
44
+ 'multipart/form-data': {
45
+ schema: {
46
+ type: 'object',
47
+ required: ['file'],
48
+ properties: { file: { type: 'string', format: 'binary', description: 'Invoice attachment file.' } }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ })
55
+ )
@@ -0,0 +1,64 @@
1
+ import { and, eq, isNotNull } from 'drizzle-orm'
2
+ import { db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { retrievePortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
4
+ import { normalizeEmailProviderEvent } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
5
+ import type { InvoiceEmailStatusRefreshDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
6
+ import { invoice, invoiceEmailDelivery } from '@nuxt-customer-portal/invoices/server/db/schema/invoices'
7
+ import { getInvoice } from './invoice-repository'
8
+
9
+ const STATUS_CACHE_MS = 60_000
10
+
11
+ export const refreshInvoiceEmailStatuses = async (
12
+ organizationId: string,
13
+ invoiceId: string,
14
+ forceRefresh = false
15
+ ): Promise<InvoiceEmailStatusRefreshDto> => {
16
+ await getInvoice(organizationId, invoiceId)
17
+ const deliveries = await db
18
+ .select({ delivery: invoiceEmailDelivery })
19
+ .from(invoiceEmailDelivery)
20
+ .innerJoin(invoice, eq(invoice.id, invoiceEmailDelivery.invoiceId))
21
+ .where(
22
+ and(
23
+ eq(invoice.organizationId, organizationId),
24
+ eq(invoiceEmailDelivery.invoiceId, invoiceId),
25
+ isNotNull(invoiceEmailDelivery.providerMessageId)
26
+ )
27
+ )
28
+ const failures: InvoiceEmailStatusRefreshDto['failures'] = []
29
+ const staleBefore = Date.now() - STATUS_CACHE_MS
30
+
31
+ await Promise.all(
32
+ deliveries.map(async ({ delivery }) => {
33
+ if (
34
+ !forceRefresh &&
35
+ delivery.providerStatusCheckedAt &&
36
+ delivery.providerStatusCheckedAt.getTime() > staleBefore
37
+ ) {
38
+ return
39
+ }
40
+ try {
41
+ const providerEmail = await retrievePortalEmail(delivery.providerMessageId!)
42
+ const checkedAt = new Date()
43
+ await db
44
+ .update(invoiceEmailDelivery)
45
+ .set({
46
+ providerLastEvent: providerEmail.last_event ? normalizeEmailProviderEvent(providerEmail.last_event) : null,
47
+ providerStatusCheckedAt: checkedAt
48
+ })
49
+ .where(eq(invoiceEmailDelivery.id, delivery.id))
50
+ } catch (error) {
51
+ failures.push({
52
+ deliveryId: delivery.id,
53
+ code:
54
+ error instanceof Error && error.message.includes('not configured')
55
+ ? 'PROVIDER_NOT_CONFIGURED'
56
+ : 'PROVIDER_LOOKUP_FAILED'
57
+ })
58
+ }
59
+ })
60
+ )
61
+
62
+ const refreshed = await getInvoice(organizationId, invoiceId)
63
+ return { deliveries: refreshed.emailDeliveries ?? [], failures }
64
+ }