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