@nuxt-customer-portal/invoices 0.7.0 → 0.8.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 +15 -0
- package/app/components/InvoicesInvoiceDetail.vue +44 -0
- package/i18n/locales/en.json +3 -1
- package/i18n/locales/nl.json +3 -1
- package/migrations/0004_deferred_invoice_review.sql +2 -0
- package/package.json +5 -5
- package/portal.manifest.mjs +1 -1
- package/server/api/invoices/admin/invoices/[id]/tax-review.post.ts +14 -0
- package/server/db/schema/invoices.ts +2 -0
- package/server/utils/commerce.ts +31 -15
- package/server/utils/deferred-invoice.ts +71 -0
- package/server/utils/deferred-tax.ts +7 -0
- package/server/utils/invoice-email.ts +10 -0
- package/server/utils/invoice-validation.ts +6 -0
- package/shared/types/invoice.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @nuxt-customer-portal/invoices
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e558791: Support store-wide online and deferred invoice payment choices with immediate purchase fulfillment, linked unsent invoice drafts, inclusive tax review, and manual payment progress. Add unlisted products with server-generated unguessable slugs, excluded from catalog discovery while remaining available through individual product pages.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [175854d]
|
|
12
|
+
- Updated dependencies [28bb7af]
|
|
13
|
+
- @nuxt-customer-portal/core@0.8.0
|
|
14
|
+
- @nuxt-customer-portal/ui@0.8.0
|
|
15
|
+
- @nuxt-customer-portal/clients@0.8.0
|
|
16
|
+
- @nuxt-customer-portal/organizations@0.8.0
|
|
17
|
+
|
|
3
18
|
## 0.7.0
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
|
@@ -38,6 +38,33 @@ const senderLogo = computed(() => {
|
|
|
38
38
|
? branding?.markDark || branding?.markLight || ''
|
|
39
39
|
: branding?.markLight || branding?.markDark || ''
|
|
40
40
|
})
|
|
41
|
+
const deferredInvoice = computed(() => 'agreedTotalMinor' in props.invoice && props.invoice.agreedTotalMinor !== null)
|
|
42
|
+
const taxReview = reactive({
|
|
43
|
+
rates: props.invoice.lines.map((line) => ({ lineId: line.id, rate: line.vatRateBasisPoints / 100 }))
|
|
44
|
+
})
|
|
45
|
+
const taxReviewSchema = z.object({
|
|
46
|
+
rates: z.array(z.object({ lineId: z.string(), rate: z.number().min(0).max(100).multipleOf(0.01) })).min(1)
|
|
47
|
+
})
|
|
48
|
+
async function reviewTaxes() {
|
|
49
|
+
busy.value = true
|
|
50
|
+
try {
|
|
51
|
+
await $fetch(`/api/invoices/admin/invoices/${encodeURIComponent(props.invoice.id)}/tax-review`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: {
|
|
54
|
+
rates: taxReview.rates.map((line) => ({ lineId: line.lineId, vatRateBasisPoints: Math.round(line.rate * 100) }))
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
await props.refresh()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
toast.add({
|
|
60
|
+
title: t('features.invoices.messages.saveError'),
|
|
61
|
+
description: (error as { data?: { message?: string } }).data?.message,
|
|
62
|
+
color: 'error'
|
|
63
|
+
})
|
|
64
|
+
} finally {
|
|
65
|
+
busy.value = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
41
68
|
const api = useInvoices()
|
|
42
69
|
const toast = useToast()
|
|
43
70
|
const busy = ref(false)
|
|
@@ -154,6 +181,9 @@ const providerStatusColor = (status: string) => {
|
|
|
154
181
|
const providerStatusLabel = (status: string) =>
|
|
155
182
|
isKnownEmailProviderEvent(status) ? t(`features.invoices.admin.emailProviderStatus.${status}`) : status
|
|
156
183
|
const historyText = (entry: InvoiceHistoryDto) => {
|
|
184
|
+
if (entry.action === 'TAX_REVIEWED') {
|
|
185
|
+
return t('features.invoices.reviewTaxes')
|
|
186
|
+
}
|
|
157
187
|
if (entry.action === 'PAYMENT_REGISTERED') {
|
|
158
188
|
return t('features.invoices.admin.invoiceHistory.payment', { amount: money(entry.amountMinor ?? 0) })
|
|
159
189
|
}
|
|
@@ -381,6 +411,20 @@ if (!isClient.value) {
|
|
|
381
411
|
|
|
382
412
|
<template>
|
|
383
413
|
<div class="invoice-detail space-y-6">
|
|
414
|
+
<UCard v-if="!isClient && deferredInvoice && invoice.status === 'DRAFT'" class="mb-4">
|
|
415
|
+
<UForm :state="taxReview" :schema="taxReviewSchema" novalidate class="space-y-4" @submit="reviewTaxes">
|
|
416
|
+
<p>{{ t('features.invoices.taxReviewHelp') }}</p>
|
|
417
|
+
<UFormField
|
|
418
|
+
v-for="(line, index) in taxReview.rates"
|
|
419
|
+
:key="line.lineId"
|
|
420
|
+
:name="`rates.${index}.rate`"
|
|
421
|
+
:label="invoice.lines[index]?.description"
|
|
422
|
+
>
|
|
423
|
+
<UInputNumber v-model="line.rate" :min="0" :max="100" :step="0.01" />
|
|
424
|
+
</UFormField>
|
|
425
|
+
<UButton type="submit" :loading="busy">{{ t('features.invoices.reviewTaxes') }}</UButton>
|
|
426
|
+
</UForm>
|
|
427
|
+
</UCard>
|
|
384
428
|
<header class="invoice-detail-header">
|
|
385
429
|
<div class="min-w-0">
|
|
386
430
|
<UButton
|
package/i18n/locales/en.json
CHANGED
|
@@ -310,7 +310,9 @@
|
|
|
310
310
|
"saveError": "The change could not be saved",
|
|
311
311
|
"fetchError": "Invoices could not be loaded",
|
|
312
312
|
"senderInvoiceDetailsSaved": "Sender invoice details saved"
|
|
313
|
-
}
|
|
313
|
+
},
|
|
314
|
+
"taxReviewHelp": "Review the tax rate for every line. The agreed purchase total remains unchanged.",
|
|
315
|
+
"reviewTaxes": "Confirm tax breakdown"
|
|
314
316
|
}
|
|
315
317
|
}
|
|
316
318
|
}
|
package/i18n/locales/nl.json
CHANGED
|
@@ -310,7 +310,9 @@
|
|
|
310
310
|
"saveError": "De wijziging kon niet worden opgeslagen",
|
|
311
311
|
"fetchError": "Facturen konden niet worden geladen",
|
|
312
312
|
"senderInvoiceDetailsSaved": "Factuurgegevens afzender opgeslagen"
|
|
313
|
-
}
|
|
313
|
+
},
|
|
314
|
+
"taxReviewHelp": "Controleer het btw-tarief voor elke regel. Het afgesproken aankoopbedrag blijft gelijk.",
|
|
315
|
+
"reviewTaxes": "Btw-verdeling bevestigen"
|
|
314
316
|
}
|
|
315
317
|
}
|
|
316
318
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxt-customer-portal/invoices",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://nuxt-customer-portal.com",
|
|
6
6
|
"repository": {
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"pg": "^8.22.0",
|
|
45
45
|
"resend": "^6.18.1",
|
|
46
46
|
"zod": "^4.4.3",
|
|
47
|
-
"@nuxt-customer-portal/core": "^0.
|
|
48
|
-
"@nuxt-customer-portal/organizations": "^0.
|
|
49
|
-
"@nuxt-customer-portal/clients": "^0.
|
|
50
|
-
"@nuxt-customer-portal/ui": "^0.
|
|
47
|
+
"@nuxt-customer-portal/core": "^0.8.0",
|
|
48
|
+
"@nuxt-customer-portal/organizations": "^0.8.0",
|
|
49
|
+
"@nuxt-customer-portal/clients": "^0.8.0",
|
|
50
|
+
"@nuxt-customer-portal/ui": "^0.8.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"nuxt": "^4.5.1",
|
package/portal.manifest.mjs
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
|
|
2
|
+
import { invoicesFeature } from '@nuxt-customer-portal/invoices/shared/feature'
|
|
3
|
+
import { invoiceTaxReviewSchema } from '@nuxt-customer-portal/invoices/server/utils/invoice-validation'
|
|
4
|
+
import { reviewDeferredInvoiceTax } from '@nuxt-customer-portal/invoices/server/utils/deferred-invoice'
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const { organizationId, session } = await requireFeatureAccess(event, invoicesFeature.policy, 'manage')
|
|
8
|
+
return reviewDeferredInvoiceTax(
|
|
9
|
+
organizationId,
|
|
10
|
+
session.user.id,
|
|
11
|
+
getRouterParam(event, 'id')!,
|
|
12
|
+
invoiceTaxReviewSchema.parse(await readBody(event)).rates
|
|
13
|
+
)
|
|
14
|
+
})
|
|
@@ -114,6 +114,8 @@ export const invoice = invoicesSchema.table(
|
|
|
114
114
|
originalInvoiceId: text('original_invoice_id'),
|
|
115
115
|
externalReference: text('external_reference'),
|
|
116
116
|
importMetadata: jsonb('import_metadata').$type<Record<string, unknown>>(),
|
|
117
|
+
agreedTotalMinor: integer('agreed_total_minor'),
|
|
118
|
+
taxReviewRequired: boolean('tax_review_required').default(false).notNull(),
|
|
117
119
|
automated: boolean('automated').default(false).notNull(),
|
|
118
120
|
status: invoiceStatus('status').default('DRAFT').notNull(),
|
|
119
121
|
currency: text('currency').notNull(),
|
package/server/utils/commerce.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { firstInvoiceNumber, incrementInvoiceNumber } from '../../shared/invoice
|
|
|
6
6
|
import { getOrganizationInvoiceProfile, requireInvoicesEnabled } from './invoice-repository'
|
|
7
7
|
|
|
8
8
|
export interface CommerceDocument {
|
|
9
|
+
deferred?: boolean
|
|
9
10
|
reference: string
|
|
10
11
|
storeId: string
|
|
11
12
|
clientId: string
|
|
@@ -77,7 +78,13 @@ export async function createCommerceDocument(tx: PoolClient, input: CommerceDocu
|
|
|
77
78
|
taxExempt?: string
|
|
78
79
|
} | null
|
|
79
80
|
const notes = [
|
|
80
|
-
input.
|
|
81
|
+
input.deferred
|
|
82
|
+
? input.locale === 'nl'
|
|
83
|
+
? 'Factuur volgt. Controleer de btw-verdeling voor verzending.'
|
|
84
|
+
: 'Invoice to follow. Review the tax breakdown before sending.'
|
|
85
|
+
: input.locale === 'nl'
|
|
86
|
+
? 'Automatisch verwerkt via Stripe.'
|
|
87
|
+
: 'Automatically reconciled through Stripe.',
|
|
81
88
|
input.paymentReference
|
|
82
89
|
]
|
|
83
90
|
if (input.originalInvoiceId) {
|
|
@@ -111,7 +118,7 @@ export async function createCommerceDocument(tx: PoolClient, input: CommerceDocu
|
|
|
111
118
|
today = new Date().toISOString().slice(0, 10)
|
|
112
119
|
await tx.query(
|
|
113
120
|
`INSERT INTO invoices.invoice(id,organization_id,client_organization_id,number,status,currency,issue_date,due_date,subject,notes,sender_name,sender_logo,sender_address,sender_country,sender_registration,sender_vat_number,sender_iban,sender_bic,recipient_name,recipient_address,recipient_email,recipient_locale,created_by_id,issued_at,document_type,original_invoice_id,external_reference,automated)
|
|
114
|
-
VALUES($1,$2,$3,$4
|
|
121
|
+
VALUES($1,$2,$3,$4,$25::invoices.invoice_status,$5,$6,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,CASE WHEN $25::text='DRAFT' THEN NULL ELSE now() END,$22,$23,$24,true)`,
|
|
115
122
|
[
|
|
116
123
|
id,
|
|
117
124
|
input.storeId,
|
|
@@ -136,7 +143,8 @@ export async function createCommerceDocument(tx: PoolClient, input: CommerceDocu
|
|
|
136
143
|
input.actorId,
|
|
137
144
|
input.originalInvoiceId ? 'credit' : 'invoice',
|
|
138
145
|
input.originalInvoiceId || null,
|
|
139
|
-
input.reference
|
|
146
|
+
input.reference,
|
|
147
|
+
input.deferred ? 'DRAFT' : 'PAID'
|
|
140
148
|
]
|
|
141
149
|
)
|
|
142
150
|
for (const [position, line] of input.lines.entries()) {
|
|
@@ -156,19 +164,27 @@ export async function createCommerceDocument(tx: PoolClient, input: CommerceDocu
|
|
|
156
164
|
]
|
|
157
165
|
)
|
|
158
166
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
if (!input.deferred) {
|
|
168
|
+
await tx.query(
|
|
169
|
+
'INSERT INTO invoices.invoice_payment(id,invoice_id,paid_on,amount_minor,reference,note,created_by_id) VALUES($1,$2,$3,$4,$5,$6,$7)',
|
|
170
|
+
[
|
|
171
|
+
randomUUID(),
|
|
172
|
+
id,
|
|
173
|
+
today,
|
|
174
|
+
input.total,
|
|
175
|
+
input.paymentReference,
|
|
176
|
+
'Automatically reconciled provider transaction',
|
|
177
|
+
input.actorId
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (input.deferred) {
|
|
182
|
+
await tx.query('UPDATE invoices.invoice SET agreed_total_minor=$2,tax_review_required=true WHERE id=$1', [
|
|
163
183
|
id,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
input.actorId
|
|
169
|
-
]
|
|
170
|
-
)
|
|
171
|
-
for (const action of ['CREATED', 'ISSUED', 'PAYMENT_REGISTERED']) {
|
|
184
|
+
input.total
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
for (const action of input.deferred ? ['CREATED'] : ['CREATED', 'ISSUED', 'PAYMENT_REGISTERED']) {
|
|
172
188
|
await tx.query(
|
|
173
189
|
'INSERT INTO invoices.invoice_history(id,invoice_id,action,actor_user_id,amount_minor) VALUES($1,$2,$3,$4,$5)',
|
|
174
190
|
[randomUUID(), id, action, input.actorId, input.total]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import { pool } from '@nuxt-customer-portal/core/server/utils/db'
|
|
4
|
+
import { splitInclusiveTax } from './deferred-tax'
|
|
5
|
+
|
|
6
|
+
export async function reviewDeferredInvoiceTax(
|
|
7
|
+
organizationId: string,
|
|
8
|
+
actorId: string,
|
|
9
|
+
id: string,
|
|
10
|
+
rates: Array<{ lineId: string; vatRateBasisPoints: number }>
|
|
11
|
+
) {
|
|
12
|
+
const tx = await pool.connect()
|
|
13
|
+
try {
|
|
14
|
+
await tx.query('BEGIN')
|
|
15
|
+
const {
|
|
16
|
+
rows: [invoice]
|
|
17
|
+
} = await tx.query('SELECT * FROM invoices.invoice WHERE id=$1 AND organization_id=$2 FOR UPDATE', [
|
|
18
|
+
id,
|
|
19
|
+
organizationId
|
|
20
|
+
])
|
|
21
|
+
if (!invoice || invoice.status !== 'DRAFT' || invoice.agreed_total_minor === null) {
|
|
22
|
+
throw createError({ statusCode: 409, message: 'Only deferred invoice drafts can be reviewed' })
|
|
23
|
+
}
|
|
24
|
+
const { rows: lines } = await tx.query(
|
|
25
|
+
'SELECT * FROM invoices.invoice_line WHERE invoice_id=$1 ORDER BY position',
|
|
26
|
+
[id]
|
|
27
|
+
)
|
|
28
|
+
if (
|
|
29
|
+
rates.length !== lines.length ||
|
|
30
|
+
new Set(rates.map((rate) => rate.lineId)).size !== lines.length ||
|
|
31
|
+
lines.some((line) => !rates.some((rate) => rate.lineId === line.id))
|
|
32
|
+
) {
|
|
33
|
+
throw createError({ statusCode: 400, message: 'Review every invoice line exactly once' })
|
|
34
|
+
}
|
|
35
|
+
let total = 0
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
const gross =
|
|
38
|
+
Math.round((line.quantity_milli * line.unit_price_minor) / 1000) +
|
|
39
|
+
(line.exact_tax_minor ??
|
|
40
|
+
Math.round((((line.quantity_milli * line.unit_price_minor) / 1000) * line.vat_rate_basis_points) / 10000))
|
|
41
|
+
const rate = rates.find((rate) => rate.lineId === line.id)!.vatRateBasisPoints
|
|
42
|
+
const split = splitInclusiveTax(gross, rate)
|
|
43
|
+
const unitNet = Math.round((split.net * 1000) / line.quantity_milli)
|
|
44
|
+
if (Math.round((line.quantity_milli * unitNet) / 1000) !== split.net) {
|
|
45
|
+
throw createError({ statusCode: 409, message: 'Invoice quantity cannot represent the agreed total exactly' })
|
|
46
|
+
}
|
|
47
|
+
total += gross
|
|
48
|
+
await tx.query(
|
|
49
|
+
'UPDATE invoices.invoice_line SET unit_price_minor=$2,vat_rate_basis_points=$3,exact_tax_minor=$4,tax_details=$5 WHERE id=$1',
|
|
50
|
+
[line.id, unitNet, rate, split.tax, JSON.stringify({ reviewed: true, inclusiveTotal: gross })]
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
if (total !== invoice.agreed_total_minor) {
|
|
54
|
+
throw createError({ statusCode: 409, message: 'Invoice total must match the agreed purchase total' })
|
|
55
|
+
}
|
|
56
|
+
await tx.query('UPDATE invoices.invoice SET tax_review_required=false WHERE id=$1', [id])
|
|
57
|
+
await tx.query('INSERT INTO invoices.invoice_history(id,invoice_id,action,actor_user_id) VALUES($1,$2,$3,$4)', [
|
|
58
|
+
randomUUID(),
|
|
59
|
+
id,
|
|
60
|
+
'TAX_REVIEWED',
|
|
61
|
+
actorId
|
|
62
|
+
])
|
|
63
|
+
await tx.query('COMMIT')
|
|
64
|
+
return { reviewed: true }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await tx.query('ROLLBACK')
|
|
67
|
+
throw error
|
|
68
|
+
} finally {
|
|
69
|
+
tx.release()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function splitInclusiveTax(total: number, rate: number) {
|
|
2
|
+
if (!Number.isSafeInteger(total) || total < 0 || !Number.isInteger(rate) || rate < 0 || rate > 10000) {
|
|
3
|
+
throw new Error('Invalid inclusive tax amount or rate')
|
|
4
|
+
}
|
|
5
|
+
const net = Math.round((total * 10000) / (10000 + rate))
|
|
6
|
+
return { net, tax: total - net, total }
|
|
7
|
+
}
|
|
@@ -136,6 +136,16 @@ export const deliverInvoiceEmail = async (
|
|
|
136
136
|
message: issue ? 'Only draft invoices can be issued' : 'Only issued invoices can be resent'
|
|
137
137
|
})
|
|
138
138
|
}
|
|
139
|
+
if (
|
|
140
|
+
issue &&
|
|
141
|
+
(selected.taxReviewRequired ||
|
|
142
|
+
(selected.agreedTotalMinor != null && selected.totalMinor !== selected.agreedTotalMinor))
|
|
143
|
+
) {
|
|
144
|
+
throw createError({
|
|
145
|
+
statusCode: 409,
|
|
146
|
+
message: 'Review taxes and preserve the agreed purchase total before issuing this invoice'
|
|
147
|
+
})
|
|
148
|
+
}
|
|
139
149
|
const preview = await getInvoiceEmailPreview(organizationId, id, input.locale, purpose)
|
|
140
150
|
input = { ...input, locale: preview.locale === 'en' ? 'en' : 'nl' }
|
|
141
151
|
if (!preview.emailProviderConfigured) {
|
|
@@ -47,6 +47,12 @@ export const invoiceUpdateSchema = z
|
|
|
47
47
|
path: ['dueDate'],
|
|
48
48
|
message: 'Due date must not precede invoice date'
|
|
49
49
|
})
|
|
50
|
+
export const invoiceTaxReviewSchema = z.object({
|
|
51
|
+
rates: z
|
|
52
|
+
.array(z.object({ lineId: id, vatRateBasisPoints: z.number().int().min(0).max(10000) }))
|
|
53
|
+
.min(1)
|
|
54
|
+
.max(500)
|
|
55
|
+
})
|
|
50
56
|
export const invoiceIssueSchema = z.object({ action: z.enum(['VOID', 'UNVOID']) })
|
|
51
57
|
export const invoicePaymentSchema = z.object({
|
|
52
58
|
paidOn: isoDate,
|
package/shared/types/invoice.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface InvoiceEmailDeliveryDto {
|
|
|
50
50
|
sentAt: string | null
|
|
51
51
|
}
|
|
52
52
|
export interface InvoiceDto {
|
|
53
|
+
agreedTotalMinor: number | null
|
|
54
|
+
taxReviewRequired: boolean
|
|
53
55
|
id: string
|
|
54
56
|
organizationId: string
|
|
55
57
|
clientOrganizationId: string | null
|
|
@@ -122,6 +124,7 @@ export type ClientInvoiceDto = Omit<
|
|
|
122
124
|
emailDeliveries?: never
|
|
123
125
|
}
|
|
124
126
|
export type InvoiceHistoryAction =
|
|
127
|
+
| 'TAX_REVIEWED'
|
|
125
128
|
| 'CREATED'
|
|
126
129
|
| 'EDITED'
|
|
127
130
|
| 'ISSUED'
|