@nuxt-customer-portal/invoices 0.8.3 → 0.8.5

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,28 @@
1
1
  # @nuxt-customer-portal/invoices
2
2
 
3
+ ## 0.8.5
4
+
5
+ ### Patch Changes
6
+
7
+ - ddc08fb: Open customer-facing order details from order cards and link directly to each order's invoice. Preserve order list search, filters, sorting, pagination and scroll position through both back links, and disable the Invoice action when no invoice exists.
8
+ - ddc08fb: Avoid automatic provider lookups for resolved invoice email deliveries. Cache failed attempts, back off checks for older unresolved emails, and prevent duplicate concurrent lookups. Keep manual refresh available for later engagement and complaint events with a short cooldown.
9
+ - @nuxt-customer-portal/clients@0.8.5
10
+ - @nuxt-customer-portal/core@0.8.5
11
+ - @nuxt-customer-portal/organizations@0.8.5
12
+ - @nuxt-customer-portal/ui@0.8.5
13
+
14
+ ## 0.8.4
15
+
16
+ ### Patch Changes
17
+
18
+ - Bind capability fetch types to their concrete route to avoid excessive type inference in host applications.
19
+
20
+ - Updated dependencies [170c58f]
21
+ - @nuxt-customer-portal/core@0.8.4
22
+ - @nuxt-customer-portal/clients@0.8.4
23
+ - @nuxt-customer-portal/organizations@0.8.4
24
+ - @nuxt-customer-portal/ui@0.8.4
25
+
3
26
  ## 0.8.3
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -2,3 +2,7 @@
2
2
 
3
3
  Standalone sales and received invoicing for Nuxt Customer Portal. Add the optional
4
4
  `@nuxt-customer-portal/invoice-timesheets` layer to create invoices from approved time.
5
+
6
+ Invoice email status is refreshed on opening an invoice only when an unresolved delivery is due for a check. Delivered, opened, clicked, bounced, failed, suppressed and complained messages are excluded from automatic checks. Unresolved messages are checked at most every five minutes during the first day, hourly after one day, and daily after a week. Failed lookup attempts observe the same cooldown while preserving the last known status. Concurrent requests claim each attempt in the database before contacting the provider.
7
+
8
+ Manual refresh can check resolved deliveries for later engagement or complaints, with a one-minute cooldown per message. These events are not updated automatically after delivery; receiving them continuously would require a Resend webhook integration.
@@ -6,7 +6,10 @@ import type {
6
6
  InvoiceDto,
7
7
  InvoiceHistoryDto
8
8
  } from '@nuxt-customer-portal/invoices/shared/types/invoice'
9
- import { isKnownEmailProviderEvent } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
9
+ import {
10
+ isKnownEmailProviderEvent,
11
+ shouldRefreshInvoiceEmailStatus
12
+ } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
10
13
 
11
14
  const props = withDefaults(
12
15
  defineProps<{
@@ -377,7 +380,10 @@ const attachmentUrl = (attachmentId: string) =>
377
380
  ? `/api/invoices/client/invoices/${props.invoice.id}/attachments/${attachmentId}`
378
381
  : `/api/invoices/admin/invoices/${props.invoice.id}/attachments/${attachmentId}`
379
382
  const refreshEmailStatuses = async (forceRefresh = false) => {
380
- if (!emailDeliveries.value.some((delivery) => delivery.providerMessageId)) {
383
+ if (
384
+ refreshingEmailStatuses.value ||
385
+ !emailDeliveries.value.some((delivery) => shouldRefreshInvoiceEmailStatus(delivery, forceRefresh))
386
+ ) {
381
387
  return
382
388
  }
383
389
  refreshingEmailStatuses.value = true
@@ -2,6 +2,12 @@
2
2
  defineOptions({ name: 'AdminInvoiceDetailPage' })
3
3
 
4
4
  const route = useRoute()
5
+ const ordersReturnTo = computed(() =>
6
+ typeof route.query.ordersReturnTo === 'string' &&
7
+ /^\/admin\/products\/orders(?:\?|$)/.test(route.query.ordersReturnTo)
8
+ ? route.query.ordersReturnTo
9
+ : undefined
10
+ )
5
11
  const { t } = useI18n()
6
12
  const api = useInvoices()
7
13
  const invoiceId = computed(() => String(route.params.id))
@@ -31,6 +37,12 @@ useSeoMeta({
31
37
  icon="i-lucide-circle-alert"
32
38
  :title="t('features.invoices.admin.invoiceNotFound')"
33
39
  />
34
- <InvoicesInvoiceDetail v-else-if="invoice" :invoice="invoice" :refresh="refresh" />
40
+ <InvoicesInvoiceDetail
41
+ v-else-if="invoice"
42
+ :invoice="invoice"
43
+ :refresh="refresh"
44
+ :back-to="ordersReturnTo"
45
+ :back-label="ordersReturnTo ? t('features.invoices.admin.backToOrderList') : undefined"
46
+ />
35
47
  </section>
36
48
  </template>
@@ -41,7 +41,7 @@ export default defineNuxtPlugin(() => {
41
41
  return register(empty)
42
42
  }
43
43
  try {
44
- register(await $fetch<InvoiceCapabilitiesDto>('/api/invoices/capabilities'))
44
+ register(await $fetch<InvoiceCapabilitiesDto, '/api/invoices/capabilities'>('/api/invoices/capabilities'))
45
45
  } catch {
46
46
  register(empty)
47
47
  }
@@ -51,7 +51,7 @@ export default defineNuxtPlugin(() => {
51
51
  if (import.meta.client) {
52
52
  window.addEventListener('invoices:capabilities-refresh', async () => {
53
53
  try {
54
- register(await $fetch<InvoiceCapabilitiesDto>('/api/invoices/capabilities'))
54
+ register(await $fetch<InvoiceCapabilitiesDto, '/api/invoices/capabilities'>('/api/invoices/capabilities'))
55
55
  } catch {
56
56
  register(empty)
57
57
  }
@@ -153,6 +153,7 @@
153
153
  "paid": "Paid",
154
154
  "backToInvoices": "Back to invoices",
155
155
  "backToOrders": "Back to purchases",
156
+ "backToOrderList": "Back to orders",
156
157
  "invoiceTitle": "Invoice {number}",
157
158
  "invoiceDocumentTitle": "INVOICE",
158
159
  "print": "Print",
@@ -153,6 +153,7 @@
153
153
  "paid": "Betaald",
154
154
  "backToInvoices": "Terug naar facturen",
155
155
  "backToOrders": "Terug naar aankopen",
156
+ "backToOrderList": "Terug naar bestellingen",
156
157
  "invoiceTitle": "Factuur {number}",
157
158
  "invoiceDocumentTitle": "FACTUUR",
158
159
  "print": "Afdrukken",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/invoices",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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.8.3",
48
- "@nuxt-customer-portal/clients": "^0.8.3",
49
- "@nuxt-customer-portal/organizations": "^0.8.3",
50
- "@nuxt-customer-portal/ui": "^0.8.3"
47
+ "@nuxt-customer-portal/core": "^0.8.5",
48
+ "@nuxt-customer-portal/clients": "^0.8.5",
49
+ "@nuxt-customer-portal/organizations": "^0.8.5",
50
+ "@nuxt-customer-portal/ui": "^0.8.5"
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.8.3',
3
+ version: '0.8.5',
4
4
  source: '@nuxt-customer-portal/invoices',
5
5
  clientModuleId: 'invoices',
6
6
  dependsOn: ['core', 'ui', 'organizations', 'clients'],
@@ -1,13 +1,14 @@
1
- import { and, eq, isNotNull } from 'drizzle-orm'
1
+ import { and, eq, isNotNull, isNull } from 'drizzle-orm'
2
2
  import { db } from '@nuxt-customer-portal/core/server/portal'
3
3
  import { retrievePortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
4
- import { normalizeEmailProviderEvent } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
4
+ import {
5
+ normalizeEmailProviderEvent,
6
+ shouldRefreshInvoiceEmailStatus
7
+ } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
5
8
  import type { InvoiceEmailStatusRefreshDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
6
9
  import { invoice, invoiceEmailDelivery } from '@nuxt-customer-portal/invoices/server/db/schema/invoices'
7
10
  import { getInvoice } from './invoice-repository'
8
11
 
9
- const STATUS_CACHE_MS = 60_000
10
-
11
12
  export const refreshInvoiceEmailStatuses = async (
12
13
  organizationId: string,
13
14
  invoiceId: string,
@@ -26,38 +27,47 @@ export const refreshInvoiceEmailStatuses = async (
26
27
  )
27
28
  )
28
29
  const failures: InvoiceEmailStatusRefreshDto['failures'] = []
29
- const staleBefore = Date.now() - STATUS_CACHE_MS
30
-
31
- await Promise.all(
32
- deliveries.map(async ({ delivery }) => {
33
- if (
34
- !forceRefresh &&
35
- delivery.providerStatusCheckedAt &&
36
- delivery.providerStatusCheckedAt.getTime() > staleBefore
37
- ) {
38
- return
39
- }
40
- try {
41
- const providerEmail = await retrievePortalEmail(delivery.providerMessageId!)
42
- const checkedAt = new Date()
43
- await db
44
- .update(invoiceEmailDelivery)
45
- .set({
46
- providerLastEvent: providerEmail.last_event ? normalizeEmailProviderEvent(providerEmail.last_event) : null,
47
- providerStatusCheckedAt: checkedAt
48
- })
49
- .where(eq(invoiceEmailDelivery.id, delivery.id))
50
- } catch (error) {
51
- failures.push({
52
- deliveryId: delivery.id,
53
- code:
54
- error instanceof Error && error.message.includes('not configured')
55
- ? 'PROVIDER_NOT_CONFIGURED'
56
- : 'PROVIDER_LOOKUP_FAILED'
30
+ for (const { delivery } of deliveries) {
31
+ if (!shouldRefreshInvoiceEmailStatus(delivery, forceRefresh)) {
32
+ continue
33
+ }
34
+ // Claim the attempt before calling the provider. Concurrent requests skip it,
35
+ // and failures also observe the cooldown without changing the known status.
36
+ const [claimed] = await db
37
+ .update(invoiceEmailDelivery)
38
+ .set({ providerStatusCheckedAt: new Date() })
39
+ .where(
40
+ and(
41
+ eq(invoiceEmailDelivery.id, delivery.id),
42
+ delivery.providerStatusCheckedAt
43
+ ? eq(invoiceEmailDelivery.providerStatusCheckedAt, delivery.providerStatusCheckedAt)
44
+ : isNull(invoiceEmailDelivery.providerStatusCheckedAt)
45
+ )
46
+ )
47
+ .returning({ id: invoiceEmailDelivery.id })
48
+ if (!claimed) {
49
+ continue
50
+ }
51
+ try {
52
+ const providerEmail = await retrievePortalEmail(delivery.providerMessageId!)
53
+ await db
54
+ .update(invoiceEmailDelivery)
55
+ .set({
56
+ providerLastEvent: providerEmail.last_event
57
+ ? normalizeEmailProviderEvent(providerEmail.last_event)
58
+ : delivery.providerLastEvent
57
59
  })
58
- }
59
- })
60
- )
60
+ .where(eq(invoiceEmailDelivery.id, delivery.id))
61
+ } catch (error) {
62
+ failures.push({
63
+ deliveryId: delivery.id,
64
+ code:
65
+ error instanceof Error && error.message.includes('not configured')
66
+ ? 'PROVIDER_NOT_CONFIGURED'
67
+ : 'PROVIDER_LOOKUP_FAILED'
68
+ })
69
+ }
70
+ }
61
71
 
62
72
  const refreshed = await getInvoice(organizationId, invoiceId)
63
73
  return { deliveries: refreshed.emailDeliveries ?? [], failures }
@@ -21,3 +21,46 @@ export const normalizeEmailProviderEvent = (value: string): string =>
21
21
 
22
22
  export const isKnownEmailProviderEvent = (value: string): value is KnownEmailProviderEvent =>
23
23
  knownEmailProviderEvents.includes(value as KnownEmailProviderEvent)
24
+
25
+ type EmailStatusRefreshState = {
26
+ providerMessageId: string | null
27
+ providerLastEvent: string | null
28
+ providerStatusCheckedAt: Date | string | null
29
+ sentAt: Date | string | null
30
+ createdAt: Date | string
31
+ }
32
+
33
+ const MINUTE_MS = 60_000
34
+ const DAY_MS = 24 * 60 * MINUTE_MS
35
+ const completedDeliveryEvents = new Set([
36
+ 'delivered',
37
+ 'opened',
38
+ 'clicked',
39
+ 'bounced',
40
+ 'failed',
41
+ 'suppressed',
42
+ 'complained'
43
+ ])
44
+
45
+ export const shouldRefreshInvoiceEmailStatus = (
46
+ delivery: EmailStatusRefreshState,
47
+ forceRefresh = false,
48
+ now = Date.now()
49
+ ): boolean => {
50
+ if (!delivery.providerMessageId) {
51
+ return false
52
+ }
53
+ // Delivery is resolved; later engagement/complaint events can still be requested manually.
54
+ if (!forceRefresh && completedDeliveryEvents.has(normalizeEmailProviderEvent(delivery.providerLastEvent || ''))) {
55
+ return false
56
+ }
57
+ const age = now - new Date(delivery.sentAt || delivery.createdAt).getTime()
58
+ const interval = forceRefresh
59
+ ? MINUTE_MS
60
+ : age >= 7 * DAY_MS
61
+ ? DAY_MS
62
+ : age >= DAY_MS
63
+ ? 60 * MINUTE_MS
64
+ : 5 * MINUTE_MS
65
+ return !delivery.providerStatusCheckedAt || now - new Date(delivery.providerStatusCheckedAt).getTime() >= interval
66
+ }