@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,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
|
+
}
|