@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 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
@@ -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
  }
@@ -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
  }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE invoices.invoice ADD COLUMN agreed_total_minor integer;
2
+ ALTER TABLE invoices.invoice ADD COLUMN tax_review_required boolean NOT NULL DEFAULT false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/invoices",
3
- "version": "0.7.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.7.0",
48
- "@nuxt-customer-portal/organizations": "^0.7.0",
49
- "@nuxt-customer-portal/clients": "^0.7.0",
50
- "@nuxt-customer-portal/ui": "^0.7.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",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'invoices',
3
- version: '0.7.0',
3
+ version: '0.8.0',
4
4
  source: '@nuxt-customer-portal/invoices',
5
5
  clientModuleId: 'invoices',
6
6
  dependsOn: ['core', 'ui', 'organizations', 'clients'],
@@ -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(),
@@ -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.locale === 'nl' ? 'Automatisch verwerkt via Stripe.' : 'Automatically reconciled through Stripe.',
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,'PAID',$5,$6,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,now(),$22,$23,$24,true)`,
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
- await tx.query(
160
- '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)',
161
- [
162
- randomUUID(),
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
- today,
165
- input.total,
166
- input.paymentReference,
167
- 'Automatically reconciled provider transaction',
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,
@@ -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'