@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,112 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { hasNumericInvoiceSequence } from '@nuxt-customer-portal/invoices/shared/invoice-number'
|
|
3
|
+
|
|
4
|
+
const id = z.string().trim().min(1).max(200)
|
|
5
|
+
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
6
|
+
const moneyMinor = z.number().int().min(0).max(1_000_000_000)
|
|
7
|
+
const invoiceNumber = z
|
|
8
|
+
.string()
|
|
9
|
+
.trim()
|
|
10
|
+
.min(1)
|
|
11
|
+
.max(60)
|
|
12
|
+
.refine(hasNumericInvoiceSequence, 'Invoice number must end with a numeric sequence')
|
|
13
|
+
export const invoiceLineSchema = z.object({
|
|
14
|
+
description: z.string().trim().min(1).max(500),
|
|
15
|
+
quantityMilli: z.number().int().positive().max(100_000_000),
|
|
16
|
+
unit: z.string().trim().min(1).max(30).default('item'),
|
|
17
|
+
unitPriceMinor: moneyMinor,
|
|
18
|
+
vatRateBasisPoints: z.number().int().min(0).max(10_000).default(2100)
|
|
19
|
+
})
|
|
20
|
+
export const invoiceCreateSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
clientOrganizationId: id,
|
|
23
|
+
contactId: id.nullable().optional(),
|
|
24
|
+
number: invoiceNumber,
|
|
25
|
+
currency: z.string().length(3),
|
|
26
|
+
issueDate: isoDate,
|
|
27
|
+
dueDate: isoDate,
|
|
28
|
+
subject: z.string().trim().max(500).nullable().optional(),
|
|
29
|
+
notes: z.string().trim().max(5000).nullable().optional(),
|
|
30
|
+
lines: z.array(invoiceLineSchema).min(1).max(500)
|
|
31
|
+
})
|
|
32
|
+
.refine((value) => value.dueDate >= value.issueDate, {
|
|
33
|
+
path: ['dueDate'],
|
|
34
|
+
message: 'Due date must not precede invoice date'
|
|
35
|
+
})
|
|
36
|
+
export type InvoiceCreateInput = z.infer<typeof invoiceCreateSchema>
|
|
37
|
+
export const invoiceUpdateSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
number: invoiceNumber,
|
|
40
|
+
issueDate: isoDate,
|
|
41
|
+
dueDate: isoDate,
|
|
42
|
+
subject: z.string().trim().max(500).nullable().optional(),
|
|
43
|
+
notes: z.string().trim().max(5000).nullable().optional()
|
|
44
|
+
})
|
|
45
|
+
.refine((value) => value.dueDate >= value.issueDate, {
|
|
46
|
+
path: ['dueDate'],
|
|
47
|
+
message: 'Due date must not precede invoice date'
|
|
48
|
+
})
|
|
49
|
+
export const invoiceIssueSchema = z.object({ action: z.enum(['VOID', 'UNVOID']) })
|
|
50
|
+
export const invoicePaymentSchema = z.object({
|
|
51
|
+
paidOn: isoDate,
|
|
52
|
+
amountMinor: moneyMinor.positive(),
|
|
53
|
+
reference: z.string().trim().max(200).nullable().optional(),
|
|
54
|
+
note: z.string().trim().max(1000).nullable().optional()
|
|
55
|
+
})
|
|
56
|
+
export const invoiceEmailDeliverySchema = z
|
|
57
|
+
.object({
|
|
58
|
+
to: z.string().trim().toLowerCase().email().max(320),
|
|
59
|
+
cc: z.array(z.string().trim().toLowerCase().email().max(320)).max(20).default([]),
|
|
60
|
+
locale: z.enum(['nl', 'en']),
|
|
61
|
+
subject: z.string().trim().min(1).max(500),
|
|
62
|
+
body: z.string().trim().min(1).max(10_000)
|
|
63
|
+
})
|
|
64
|
+
.transform((value) => ({ ...value, cc: [...new Set(value.cc.filter((email) => email !== value.to))] }))
|
|
65
|
+
export const invoiceSettingsSchema = z.object({
|
|
66
|
+
enabled: z.boolean(),
|
|
67
|
+
currency: z.string().trim().toUpperCase().length(3),
|
|
68
|
+
defaultVatRateBasisPoints: z.number().int().min(0).max(10_000),
|
|
69
|
+
address: z.string().trim().max(5000),
|
|
70
|
+
registrationNumber: z.string().trim().max(200).nullable(),
|
|
71
|
+
vatNumber: z.string().trim().max(200).nullable(),
|
|
72
|
+
iban: z.string().trim().max(100).nullable(),
|
|
73
|
+
bic: z.string().trim().max(100).nullable(),
|
|
74
|
+
invoiceEmail: z.string().trim().email().max(320).nullable(),
|
|
75
|
+
preferredLocale: z.enum(['nl', 'en'])
|
|
76
|
+
})
|
|
77
|
+
export const billingContactCreateSchema = z.object({
|
|
78
|
+
userId: id.nullable().optional(),
|
|
79
|
+
name: z.string().trim().min(1).max(200),
|
|
80
|
+
email: z.string().trim().toLowerCase().email().max(320),
|
|
81
|
+
phone: z.string().trim().max(80).nullable().optional(),
|
|
82
|
+
jobTitle: z.string().trim().max(160).nullable().optional()
|
|
83
|
+
})
|
|
84
|
+
export const billingContactUpdateSchema = billingContactCreateSchema
|
|
85
|
+
.partial()
|
|
86
|
+
.refine((value) => Object.keys(value).length > 0, 'At least one field is required')
|
|
87
|
+
const listBase = z.object({
|
|
88
|
+
search: z.string().trim().max(200).optional(),
|
|
89
|
+
page: z.coerce.number().int().positive().default(1),
|
|
90
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
91
|
+
sortDir: z.enum(['asc', 'desc']).default('desc')
|
|
92
|
+
})
|
|
93
|
+
export const invoiceListQuerySchema = listBase.extend({
|
|
94
|
+
status: z.enum(['DRAFT', 'ISSUED', 'PAID', 'VOID']).optional(),
|
|
95
|
+
clientOrganizationId: id.optional(),
|
|
96
|
+
overdue: z
|
|
97
|
+
.enum(['true', 'false'])
|
|
98
|
+
.transform((value) => value === 'true')
|
|
99
|
+
.optional(),
|
|
100
|
+
sortBy: z.enum(['issueDate', 'dueDate', 'number', 'totalMinor']).default('issueDate')
|
|
101
|
+
})
|
|
102
|
+
export const clientInvoiceListQuerySchema = listBase.extend({
|
|
103
|
+
status: z.enum(['ISSUED', 'PAID']).optional(),
|
|
104
|
+
accessId: id.optional(),
|
|
105
|
+
overdue: z
|
|
106
|
+
.enum(['true', 'false'])
|
|
107
|
+
.transform((value) => value === 'true')
|
|
108
|
+
.optional(),
|
|
109
|
+
sortBy: z.enum(['issueDate', 'dueDate', 'number', 'totalMinor']).default('issueDate')
|
|
110
|
+
})
|
|
111
|
+
export type InvoiceListQuery = z.infer<typeof invoiceListQuerySchema>
|
|
112
|
+
export type ClientInvoiceListQuery = z.infer<typeof clientInvoiceListQuerySchema>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const knownEmailProviderEvents = [
|
|
2
|
+
'sent',
|
|
3
|
+
'delivered',
|
|
4
|
+
'delivery_delayed',
|
|
5
|
+
'bounced',
|
|
6
|
+
'failed',
|
|
7
|
+
'suppressed',
|
|
8
|
+
'complained',
|
|
9
|
+
'opened',
|
|
10
|
+
'clicked'
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
export type KnownEmailProviderEvent = (typeof knownEmailProviderEvents)[number]
|
|
14
|
+
|
|
15
|
+
export const normalizeEmailProviderEvent = (value: string): string =>
|
|
16
|
+
value
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/^email\./, '')
|
|
20
|
+
.replaceAll('-', '_')
|
|
21
|
+
|
|
22
|
+
export const isKnownEmailProviderEvent = (value: string): value is KnownEmailProviderEvent =>
|
|
23
|
+
knownEmailProviderEvents.includes(value as KnownEmailProviderEvent)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { PortalFeatureDefinition } from '@nuxt-customer-portal/core/shared/types/feature'
|
|
2
|
+
|
|
3
|
+
export const invoiceActions = ['read', 'create', 'update', 'manage'] as const
|
|
4
|
+
export type InvoiceAction = (typeof invoiceActions)[number]
|
|
5
|
+
|
|
6
|
+
export const invoicesFeature: PortalFeatureDefinition<InvoiceAction> = {
|
|
7
|
+
id: 'invoices',
|
|
8
|
+
modules: [
|
|
9
|
+
{
|
|
10
|
+
id: 'invoices',
|
|
11
|
+
labelKey: 'features.invoices.title',
|
|
12
|
+
icon: 'i-lucide-receipt-text',
|
|
13
|
+
to: '/invoices',
|
|
14
|
+
routePrefixes: ['/invoices', '/admin/invoices'],
|
|
15
|
+
audiences: ['authenticated'],
|
|
16
|
+
order: 31,
|
|
17
|
+
menuItems: [
|
|
18
|
+
{
|
|
19
|
+
id: 'received-invoices',
|
|
20
|
+
labelKey: 'features.invoices.receivedInvoices',
|
|
21
|
+
icon: 'i-lucide-inbox',
|
|
22
|
+
to: '/invoices',
|
|
23
|
+
exact: true,
|
|
24
|
+
audiences: ['authenticated']
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'invoice-viewers',
|
|
28
|
+
labelKey: 'features.invoices.clientInvoices.viewersTitle',
|
|
29
|
+
icon: 'i-lucide-users-round',
|
|
30
|
+
to: '/invoices/viewers',
|
|
31
|
+
audiences: ['clientAdmin']
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'sales-invoices',
|
|
35
|
+
labelKey: 'features.invoices.salesInvoices',
|
|
36
|
+
icon: 'i-lucide-receipt-text',
|
|
37
|
+
to: '/admin/invoices',
|
|
38
|
+
exact: true,
|
|
39
|
+
audiences: ['providerAdmin']
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'invoice-settings',
|
|
43
|
+
labelKey: 'features.invoices.admin.workspaceSettings',
|
|
44
|
+
icon: 'i-lucide-settings-2',
|
|
45
|
+
to: '/admin/invoices/settings',
|
|
46
|
+
audiences: ['providerAdmin']
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
dashboardWidgets: [
|
|
52
|
+
{ id: 'invoices-sales', component: 'InvoicesDashboardSalesInvoices', area: 'main', size: 'half', order: 15 },
|
|
53
|
+
{ id: 'invoices-received', component: 'InvoicesDashboardReceivedInvoices', area: 'main', size: 'half', order: 40 }
|
|
54
|
+
],
|
|
55
|
+
clientIntegration: {
|
|
56
|
+
moduleId: 'invoices',
|
|
57
|
+
labelKey: 'features.invoices.title',
|
|
58
|
+
detailComponent: 'InvoicesClientSettingsPanel'
|
|
59
|
+
},
|
|
60
|
+
emails: [
|
|
61
|
+
{
|
|
62
|
+
id: 'invoice',
|
|
63
|
+
labelKey: 'features.invoices.admin.emailMessages.invoice',
|
|
64
|
+
defaults: {
|
|
65
|
+
en: {
|
|
66
|
+
subject: 'Invoice {{invoice_number}} from {{sender_name}}',
|
|
67
|
+
body: 'Dear Sir or Madam,<br><br>Please find invoice {{invoice_number}} attached.<br><br>Kind regards,<br>{{sender_name}}'
|
|
68
|
+
},
|
|
69
|
+
nl: {
|
|
70
|
+
subject: 'Factuur {{invoice_number}} van {{sender_name}}',
|
|
71
|
+
body: 'Geachte heer/mevrouw,<br><br>In de bijlage vindt u factuur {{invoice_number}}.<br><br>Met vriendelijke groet,<br>{{sender_name}}'
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
placeholders: [
|
|
75
|
+
{
|
|
76
|
+
key: 'invoice_number',
|
|
77
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.invoiceNumber',
|
|
78
|
+
example: '2026-001'
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'sender_name',
|
|
82
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.senderName',
|
|
83
|
+
example: 'Example Company'
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: 'recipient_name',
|
|
87
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.recipientName',
|
|
88
|
+
example: 'Example Client'
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
key: 'due_date',
|
|
92
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.dueDate',
|
|
93
|
+
example: '30 September 2026'
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'outstanding_amount',
|
|
97
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.outstandingAmount',
|
|
98
|
+
example: '€1,250.00'
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'payment-reminder',
|
|
104
|
+
labelKey: 'features.invoices.admin.emailMessages.paymentReminder',
|
|
105
|
+
defaults: {
|
|
106
|
+
en: {
|
|
107
|
+
subject: 'Payment reminder for invoice {{invoice_number}} from {{sender_name}}',
|
|
108
|
+
body: 'Dear Sir or Madam,<br><br>Invoice {{invoice_number}}, due on {{due_date}}, remains outstanding for {{outstanding_amount}}. Please arrange payment.<br><br>Kind regards,<br>{{sender_name}}'
|
|
109
|
+
},
|
|
110
|
+
nl: {
|
|
111
|
+
subject: 'Betalingsherinnering factuur {{invoice_number}} van {{sender_name}}',
|
|
112
|
+
body: 'Geachte heer/mevrouw,<br><br>Factuur {{invoice_number}}, met vervaldatum {{due_date}}, staat nog open voor {{outstanding_amount}}. Wij verzoeken u vriendelijk te betalen.<br><br>Met vriendelijke groet,<br>{{sender_name}}'
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
placeholders: [
|
|
116
|
+
{
|
|
117
|
+
key: 'invoice_number',
|
|
118
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.invoiceNumber',
|
|
119
|
+
example: '2026-001'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: 'sender_name',
|
|
123
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.senderName',
|
|
124
|
+
example: 'Example Company'
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: 'recipient_name',
|
|
128
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.recipientName',
|
|
129
|
+
example: 'Example Client'
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: 'due_date',
|
|
133
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.dueDate',
|
|
134
|
+
example: '30 September 2026'
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
key: 'outstanding_amount',
|
|
138
|
+
labelKey: 'features.invoices.admin.emailPlaceholders.outstandingAmount',
|
|
139
|
+
example: '€1,250.00'
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
policy: {
|
|
145
|
+
PROVIDER: { owner: invoiceActions, admin: invoiceActions, member: [] },
|
|
146
|
+
CLIENT: { owner: ['read', 'manage'], admin: ['read', 'manage'], member: ['read'] }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const createHoursIntroduction = (entries: Array<{ person: string; date: string }>, locale: 'nl' | 'en') => {
|
|
2
|
+
if (!entries.length) {
|
|
3
|
+
return ''
|
|
4
|
+
}
|
|
5
|
+
const names = [...new Set(entries.map((entry) => entry.person))].sort()
|
|
6
|
+
const people = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(names)
|
|
7
|
+
const dates = entries.map((entry) => entry.date).sort()
|
|
8
|
+
const format = (date: string) =>
|
|
9
|
+
new Intl.DateTimeFormat(locale, {
|
|
10
|
+
day: 'numeric',
|
|
11
|
+
month: 'long',
|
|
12
|
+
year: 'numeric',
|
|
13
|
+
timeZone: 'UTC'
|
|
14
|
+
}).format(new Date(`${date}T12:00:00Z`))
|
|
15
|
+
const start = dates[0]!
|
|
16
|
+
const end = dates[dates.length - 1]!
|
|
17
|
+
const period = start === end ? format(start) : `${format(start)} ${locale === 'nl' ? 't/m' : 'to'} ${format(end)}`
|
|
18
|
+
return locale === 'nl'
|
|
19
|
+
? `Hierbij factureren wij de uren van ${people} over de periode ${period}.`
|
|
20
|
+
: `We hereby invoice the hours worked by ${people} for the period ${period}.`
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const trailingSequence = /\d+$/
|
|
2
|
+
|
|
3
|
+
export const hasNumericInvoiceSequence = (number: string) => trailingSequence.test(number)
|
|
4
|
+
|
|
5
|
+
export const incrementInvoiceNumber = (number: string): string => {
|
|
6
|
+
const match = number.match(trailingSequence)
|
|
7
|
+
if (!match || match.index === undefined) {
|
|
8
|
+
throw new Error('Invoice number must end with a numeric sequence')
|
|
9
|
+
}
|
|
10
|
+
const digits = match[0].split('')
|
|
11
|
+
let carry = 1
|
|
12
|
+
for (let index = digits.length - 1; index >= 0 && carry; index -= 1) {
|
|
13
|
+
const next = Number(digits[index]) + carry
|
|
14
|
+
digits[index] = String(next % 10)
|
|
15
|
+
carry = next > 9 ? 1 : 0
|
|
16
|
+
}
|
|
17
|
+
if (carry) {
|
|
18
|
+
digits.unshift('1')
|
|
19
|
+
}
|
|
20
|
+
const next = digits.join('').padStart(match[0].length, '0')
|
|
21
|
+
return `${number.slice(0, match.index)}${next}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const firstInvoiceNumber = (year = new Date().getFullYear()): string => `${year}.0001`
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
export type InvoiceStatus = 'DRAFT' | 'ISSUED' | 'PAID' | 'VOID'
|
|
2
|
+
export interface InvoiceLineDto {
|
|
3
|
+
id: string
|
|
4
|
+
description: string
|
|
5
|
+
quantityMilli: number
|
|
6
|
+
unit: string
|
|
7
|
+
unitPriceMinor: number
|
|
8
|
+
vatRateBasisPoints: number
|
|
9
|
+
amountMinor: number
|
|
10
|
+
}
|
|
11
|
+
export interface InvoicePaymentDto {
|
|
12
|
+
id: string
|
|
13
|
+
paidOn: string
|
|
14
|
+
amountMinor: number
|
|
15
|
+
reference: string | null
|
|
16
|
+
note: string | null
|
|
17
|
+
}
|
|
18
|
+
export interface InvoiceHistoryDto {
|
|
19
|
+
id: string
|
|
20
|
+
action: string
|
|
21
|
+
actorName: string
|
|
22
|
+
amountMinor: number | null
|
|
23
|
+
attachmentName: string | null
|
|
24
|
+
createdAt: string
|
|
25
|
+
}
|
|
26
|
+
export interface InvoiceAttachmentDto {
|
|
27
|
+
id: string
|
|
28
|
+
fileName: string
|
|
29
|
+
contentType: string
|
|
30
|
+
size: number
|
|
31
|
+
uploadedByName: string
|
|
32
|
+
createdAt: string
|
|
33
|
+
}
|
|
34
|
+
export interface InvoiceEmailDeliveryDto {
|
|
35
|
+
id: string
|
|
36
|
+
purpose: 'INVOICE' | 'REMINDER'
|
|
37
|
+
status: 'PENDING' | 'SENT' | 'FAILED'
|
|
38
|
+
recipientEmail: string
|
|
39
|
+
ccEmails: string[]
|
|
40
|
+
locale: string
|
|
41
|
+
subject: string
|
|
42
|
+
actorName: string
|
|
43
|
+
providerMessageId: string | null
|
|
44
|
+
providerLastEvent: string | null
|
|
45
|
+
providerStatusCheckedAt: string | null
|
|
46
|
+
errorMessage: string | null
|
|
47
|
+
createdAt: string
|
|
48
|
+
sentAt: string | null
|
|
49
|
+
}
|
|
50
|
+
export interface InvoiceDto {
|
|
51
|
+
id: string
|
|
52
|
+
organizationId: string
|
|
53
|
+
clientOrganizationId: string | null
|
|
54
|
+
number: string
|
|
55
|
+
status: InvoiceStatus
|
|
56
|
+
currency: string
|
|
57
|
+
issueDate: string
|
|
58
|
+
dueDate: string
|
|
59
|
+
subject: string | null
|
|
60
|
+
notes: string | null
|
|
61
|
+
senderName: string
|
|
62
|
+
senderLogo: string | null
|
|
63
|
+
senderAddress: string
|
|
64
|
+
senderRegistration: string | null
|
|
65
|
+
senderVatNumber: string | null
|
|
66
|
+
senderIban: string | null
|
|
67
|
+
senderBic: string | null
|
|
68
|
+
recipientName: string
|
|
69
|
+
recipientAddress: string
|
|
70
|
+
recipientContactName: string | null
|
|
71
|
+
recipientEmail: string | null
|
|
72
|
+
recipientLocale: string
|
|
73
|
+
issuedAt: string | null
|
|
74
|
+
createdAt: string
|
|
75
|
+
updatedAt: string
|
|
76
|
+
lines: InvoiceLineDto[]
|
|
77
|
+
payments: InvoicePaymentDto[]
|
|
78
|
+
history: InvoiceHistoryDto[]
|
|
79
|
+
attachments: InvoiceAttachmentDto[]
|
|
80
|
+
emailDeliveries: InvoiceEmailDeliveryDto[]
|
|
81
|
+
subtotalMinor: number
|
|
82
|
+
vatMinor: number
|
|
83
|
+
totalMinor: number
|
|
84
|
+
paidMinor: number
|
|
85
|
+
outstandingMinor: number
|
|
86
|
+
isOverdue: boolean
|
|
87
|
+
daysOverdue: number
|
|
88
|
+
reminderCount: number
|
|
89
|
+
lastReminderSentAt: string | null
|
|
90
|
+
}
|
|
91
|
+
export type ClientInvoiceSummaryDto = Pick<
|
|
92
|
+
InvoiceDto,
|
|
93
|
+
| 'id'
|
|
94
|
+
| 'number'
|
|
95
|
+
| 'status'
|
|
96
|
+
| 'currency'
|
|
97
|
+
| 'issueDate'
|
|
98
|
+
| 'dueDate'
|
|
99
|
+
| 'subject'
|
|
100
|
+
| 'totalMinor'
|
|
101
|
+
| 'outstandingMinor'
|
|
102
|
+
| 'isOverdue'
|
|
103
|
+
| 'daysOverdue'
|
|
104
|
+
> & { supplierName: string; accessId: string }
|
|
105
|
+
export type ClientInvoiceDto = Omit<
|
|
106
|
+
InvoiceDto,
|
|
107
|
+
'payments' | 'reminderCount' | 'lastReminderSentAt' | 'history' | 'emailDeliveries'
|
|
108
|
+
> & {
|
|
109
|
+
supplierName: string
|
|
110
|
+
accessId: string
|
|
111
|
+
payments?: never
|
|
112
|
+
reminderCount?: never
|
|
113
|
+
lastReminderSentAt?: never
|
|
114
|
+
history?: never
|
|
115
|
+
emailDeliveries?: never
|
|
116
|
+
}
|
|
117
|
+
export type InvoiceHistoryAction =
|
|
118
|
+
| 'CREATED'
|
|
119
|
+
| 'EDITED'
|
|
120
|
+
| 'ISSUED'
|
|
121
|
+
| 'VOIDED'
|
|
122
|
+
| 'UNVOIDED'
|
|
123
|
+
| 'PAYMENT_REGISTERED'
|
|
124
|
+
| 'ATTACHMENT_ADDED'
|
|
125
|
+
| 'ATTACHMENT_REMOVED'
|
|
126
|
+
| 'EMAIL_SENT'
|
|
127
|
+
| 'REMINDER_SENT'
|
|
128
|
+
export type InvoiceEmailPurpose = 'INVOICE' | 'REMINDER'
|
|
129
|
+
export interface OrganizationInvoiceProfileDto extends InvoiceSettingsDto {
|
|
130
|
+
name: string
|
|
131
|
+
logo: string | null
|
|
132
|
+
}
|
|
133
|
+
export interface InvoiceSettingsDto {
|
|
134
|
+
organizationId: string
|
|
135
|
+
enabled: boolean
|
|
136
|
+
currency: string
|
|
137
|
+
defaultVatRateBasisPoints: number
|
|
138
|
+
address: string
|
|
139
|
+
registrationNumber: string | null
|
|
140
|
+
vatNumber: string | null
|
|
141
|
+
iban: string | null
|
|
142
|
+
bic: string | null
|
|
143
|
+
invoiceEmail: string | null
|
|
144
|
+
preferredLocale: 'nl' | 'en'
|
|
145
|
+
}
|
|
146
|
+
export interface InvoiceCapabilitiesDto {
|
|
147
|
+
canConfigureInvoices: boolean
|
|
148
|
+
canManageInvoices: boolean
|
|
149
|
+
canViewReceivedInvoices: boolean
|
|
150
|
+
canManageViewers: boolean
|
|
151
|
+
invoicesEnabled: boolean
|
|
152
|
+
}
|
|
153
|
+
export interface ClientInvoiceSupplierDto {
|
|
154
|
+
id: string
|
|
155
|
+
providerOrganizationId: string
|
|
156
|
+
providerName: string
|
|
157
|
+
canViewInvoices: boolean
|
|
158
|
+
}
|
|
159
|
+
export interface ClientInvoiceViewerDto {
|
|
160
|
+
id: string
|
|
161
|
+
name: string
|
|
162
|
+
email: string
|
|
163
|
+
role: string
|
|
164
|
+
assigned: boolean
|
|
165
|
+
fixedAccess: boolean
|
|
166
|
+
}
|
|
167
|
+
export interface InvoiceEmailPreviewDto {
|
|
168
|
+
to: string
|
|
169
|
+
cc: string[]
|
|
170
|
+
locale: string
|
|
171
|
+
subject: string
|
|
172
|
+
body: string
|
|
173
|
+
senderEmail: string
|
|
174
|
+
senderDomain: string
|
|
175
|
+
emailProviderConfigured: boolean
|
|
176
|
+
senderDomainVerified: boolean
|
|
177
|
+
attachments: Array<{ fileName: string; size: number }>
|
|
178
|
+
totalAttachmentSize: number
|
|
179
|
+
maximumAttachmentSize: number
|
|
180
|
+
}
|
|
181
|
+
export interface InvoiceEmailStatusRefreshDto {
|
|
182
|
+
deliveries: InvoiceEmailDeliveryDto[]
|
|
183
|
+
failures: Array<{ deliveryId: string; code: 'PROVIDER_NOT_CONFIGURED' | 'PROVIDER_LOOKUP_FAILED' }>
|
|
184
|
+
}
|
|
185
|
+
export interface InvoiceContactDto {
|
|
186
|
+
id: string
|
|
187
|
+
name: string
|
|
188
|
+
email: string
|
|
189
|
+
phone: string | null
|
|
190
|
+
jobTitle: string | null
|
|
191
|
+
}
|
|
192
|
+
export interface InvoiceClientDto {
|
|
193
|
+
id: string
|
|
194
|
+
organizationId: string
|
|
195
|
+
name: string
|
|
196
|
+
officialName: string
|
|
197
|
+
address: string
|
|
198
|
+
registrationNumber: string | null
|
|
199
|
+
vatNumber: string | null
|
|
200
|
+
invoiceEmail: string | null
|
|
201
|
+
preferredLocale: 'nl' | 'en'
|
|
202
|
+
contacts: InvoiceContactDto[]
|
|
203
|
+
}
|
|
204
|
+
export interface InvoiceSourceEntryDto {
|
|
205
|
+
entryId: string
|
|
206
|
+
date: string
|
|
207
|
+
client: string
|
|
208
|
+
clientOrganizationId: string
|
|
209
|
+
project: string
|
|
210
|
+
projectId: string
|
|
211
|
+
person: string
|
|
212
|
+
activity: string
|
|
213
|
+
minutes: number
|
|
214
|
+
hourlyRateMinor: number
|
|
215
|
+
amountMinor: number
|
|
216
|
+
currency: string
|
|
217
|
+
note: string | null
|
|
218
|
+
}
|
|
219
|
+
export type InvoiceableEntryDto = InvoiceSourceEntryDto
|
|
220
|
+
export interface InvoicesListPagination {
|
|
221
|
+
total: number
|
|
222
|
+
page: number
|
|
223
|
+
pageSize: number
|
|
224
|
+
pageCount: number
|
|
225
|
+
}
|
|
226
|
+
export interface InvoicesListResponse<T> {
|
|
227
|
+
items: T[]
|
|
228
|
+
pagination: InvoicesListPagination
|
|
229
|
+
}
|
|
230
|
+
export interface InvoiceSourceProvider {
|
|
231
|
+
id: string
|
|
232
|
+
labelKey: string
|
|
233
|
+
descriptionKey: string
|
|
234
|
+
load: (query: { from: string; to: string }) => Promise<{ enabled: boolean; entries: InvoiceSourceEntryDto[] }>
|
|
235
|
+
create: (input: Record<string, unknown>) => Promise<{ id: string }>
|
|
236
|
+
}
|
|
237
|
+
export interface InvoiceClientAccessOverviewDto {
|
|
238
|
+
configured: boolean
|
|
239
|
+
enabled: boolean
|
|
240
|
+
moduleEnabled: boolean
|
|
241
|
+
members: {
|
|
242
|
+
id: string
|
|
243
|
+
name: string
|
|
244
|
+
email: string
|
|
245
|
+
image: string | null
|
|
246
|
+
role: string
|
|
247
|
+
fixedAccess: boolean
|
|
248
|
+
assigned: boolean
|
|
249
|
+
canView: boolean
|
|
250
|
+
}[]
|
|
251
|
+
}
|
package/tokens.css
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--invoices-color-running: var(--color-success-600);
|
|
3
|
+
--invoices-color-running-soft: var(--color-success-50);
|
|
4
|
+
--invoices-color-weekend: var(--color-neutral-50);
|
|
5
|
+
--invoices-rule: var(--ui-border);
|
|
6
|
+
--invoices-radius: var(--ui-radius);
|
|
7
|
+
--invoices-space-cell: 0.75rem;
|
|
8
|
+
--invoices-duration-fast: 120ms;
|
|
9
|
+
--invoices-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.dark {
|
|
13
|
+
--invoices-color-running: var(--color-success-400);
|
|
14
|
+
--invoices-color-running-soft: var(--color-neutral-800);
|
|
15
|
+
--invoices-color-weekend: var(--color-neutral-900);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html[data-portal-theme='brutal'] {
|
|
19
|
+
--invoices-color-weekend: var(--color-paper-2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html.dark[data-portal-theme='brutal'] {
|
|
23
|
+
--invoices-color-running-soft: var(--color-paper-2);
|
|
24
|
+
--invoices-color-weekend: var(--color-paper-2);
|
|
25
|
+
}
|