@nuxt-customer-portal/invoices 0.8.4 → 0.8.6
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 +20 -0
- package/README.md +4 -0
- package/app/components/InvoicesInvoiceDetail.vue +8 -2
- package/app/pages/admin/invoices/[id].vue +13 -1
- package/i18n/locales/en.json +1 -0
- package/i18n/locales/nl.json +1 -0
- package/package.json +5 -5
- package/portal.manifest.mjs +1 -1
- package/server/utils/invoice-email-status.ts +45 -35
- package/shared/email-delivery-status.ts +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @nuxt-customer-portal/invoices
|
|
2
2
|
|
|
3
|
+
## 0.8.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @nuxt-customer-portal/clients@0.8.6
|
|
8
|
+
- @nuxt-customer-portal/core@0.8.6
|
|
9
|
+
- @nuxt-customer-portal/organizations@0.8.6
|
|
10
|
+
- @nuxt-customer-portal/ui@0.8.6
|
|
11
|
+
|
|
12
|
+
## 0.8.5
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- @nuxt-customer-portal/clients@0.8.5
|
|
19
|
+
- @nuxt-customer-portal/core@0.8.5
|
|
20
|
+
- @nuxt-customer-portal/organizations@0.8.5
|
|
21
|
+
- @nuxt-customer-portal/ui@0.8.5
|
|
22
|
+
|
|
3
23
|
## 0.8.4
|
|
4
24
|
|
|
5
25
|
### 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 {
|
|
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 (
|
|
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
|
|
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>
|
package/i18n/locales/en.json
CHANGED
package/i18n/locales/nl.json
CHANGED
|
@@ -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
|
+
"version": "0.8.6",
|
|
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.
|
|
48
|
-
"@nuxt-customer-portal/
|
|
49
|
-
"@nuxt-customer-portal/
|
|
50
|
-
"@nuxt-customer-portal/
|
|
47
|
+
"@nuxt-customer-portal/core": "^0.8.6",
|
|
48
|
+
"@nuxt-customer-portal/ui": "^0.8.6",
|
|
49
|
+
"@nuxt-customer-portal/clients": "^0.8.6",
|
|
50
|
+
"@nuxt-customer-portal/organizations": "^0.8.6"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"nuxt": "^4.5.1",
|
package/portal.manifest.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|