feat: invoice inbox extension — conversion, workspace UI, Gmail UX (#255)
* feat: invoice inbox extension — conversion, workspace UI, Gmail UX Complete the invoice-inbox extension with full end-to-end flow: - Add POST /items/:id/convert route to create supplier invoices from classified inbox items, with accrual journal entry and document linking - Add PATCH /items/:id/reject route to dismiss non-relevant items - Add workspace UI at /e/general/invoice-inbox with items table, status filtering, convert dialog, and match confirmation - Add Gmail connection banner (connect/disconnect/status) in workspace - Add one-click supplier creation from AI-extracted data - Add transaction auto-matching with fuzzy name + currency-aware amount - Add event emission (received, extracted, confirmed) on classification - Redirect OAuth callback to workspace instead of /settings/banking - Fix extension catch-all body clone for POST routes with path params - Fix duplicate Löner nav entry from salary module merge - Remove summary cards from expenses and supplier invoices pages - Fix supplier-invoices/new amount input (valueAsNumber → Controller) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — company_id filters, currency guard, skipAuth clone - Add company_id filter to reject route update (defense in depth) - Add company_id filter to document_attachments journal entry link - Guard sekMatch with tx.currency === 'SEK' to prevent false matches - Clone request in skipAuth branch for consistency with auth branch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Plus, Search, Wallet, Clock, AlertCircle, Lock } from 'lucide-react'
|
||||
import { Plus, Search, Wallet, Lock } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
@@ -105,16 +105,6 @@ export default function ExpensesPage() {
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
|
||||
const unpaidInvoices = invoices.filter((i) => UNPAID_STATUSES.includes(i.status))
|
||||
const overdueInvoices = invoices.filter((i) => i.status === 'overdue')
|
||||
|
||||
const stats = {
|
||||
unpaidAmount: unpaidInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
|
||||
unpaidCount: unpaidInvoices.length,
|
||||
overdueAmount: overdueInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
|
||||
overdueCount: overdueInvoices.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -140,46 +130,6 @@ export default function ExpensesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Att betala</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.unpaidCount} utgifter</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<AlertCircle className={`h-6 w-6 ${stats.overdueCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div>
|
||||
{stats.overdueCount > 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Förfallet</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.overdueCount} utgifter</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function NewSupplierInvoicePage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [watchedSupplierId, suppliers])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [watchedSupplierId, suppliers, watch, setValue, fields.length])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
const res = await fetch('/api/suppliers')
|
||||
@@ -421,11 +422,18 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
{...register(`items.${index}.amount`, { valueAsNumber: true })}
|
||||
<Controller
|
||||
name={`items.${index}.amount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
@@ -507,11 +515,18 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Belopp (exkl.)</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
{...register(`items.${index}.amount`, { valueAsNumber: true })}
|
||||
<Controller
|
||||
name={`items.${index}.amount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
@@ -62,15 +62,6 @@ export default function SupplierInvoicesPage() {
|
||||
}
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const totalUnpaid = invoices
|
||||
.filter((i) => !['paid', 'credited'].includes(i.status))
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueAmount = invoices
|
||||
.filter((i) => i.status === 'overdue')
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueCount = invoices.filter((i) => i.status === 'overdue').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -98,62 +89,6 @@ export default function SupplierInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="h-7 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Totalt obetalt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(totalUnpaid)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Förfallet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatAmount(overdueAmount)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Antal fakturor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
|
||||
@@ -98,10 +98,11 @@ async function handleRequest(
|
||||
for (const [key, value] of Object.entries(extractedParams)) {
|
||||
url.searchParams.set(`_${key}`, value)
|
||||
}
|
||||
const cloned = request.clone()
|
||||
handlerRequest = new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: cloned.method,
|
||||
headers: cloned.headers,
|
||||
body: cloned.body,
|
||||
// @ts-expect-error -- duplex needed for streaming body
|
||||
duplex: 'half',
|
||||
})
|
||||
@@ -126,10 +127,12 @@ async function handleRequest(
|
||||
for (const [key, value] of Object.entries(extractedParams)) {
|
||||
url.searchParams.set(`_${key}`, value)
|
||||
}
|
||||
// Clone first to avoid body stream locking issues when transferring to new Request
|
||||
const cloned = request.clone()
|
||||
handlerRequest = new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: cloned.method,
|
||||
headers: cloned.headers,
|
||||
body: cloned.body,
|
||||
// @ts-expect-error -- duplex needed for streaming body
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
@@ -71,8 +71,6 @@ const navItems: NavItem[] = [
|
||||
// Temporarily hidden pending module rework (see feedback #49)
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
|
||||
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
|
||||
// Personal
|
||||
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'redovisning', modes: ['aktiebolag'] },
|
||||
// General accounting
|
||||
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
makeInvoiceInboxItem,
|
||||
makeSupplier,
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
|
||||
}))
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path
|
||||
)!
|
||||
}
|
||||
|
||||
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
...overrides,
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
const SUPPLIER_UUID = '00000000-0000-4000-8000-000000000001'
|
||||
const ITEM_UUID = '00000000-0000-4000-8000-000000000002'
|
||||
|
||||
const VALID_CONVERT_BODY = {
|
||||
supplier_id: SUPPLIER_UUID,
|
||||
supplier_invoice_number: 'F-2024-001',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
items: [
|
||||
{ description: 'Konsulttjänster', amount: 10000, account_number: '6200', vat_rate: 0.25 },
|
||||
],
|
||||
}
|
||||
|
||||
// ── POST /items/:id/convert ──────────────────────────────────
|
||||
|
||||
describe('POST /items/:id/convert', () => {
|
||||
const route = findRoute('POST', '/items/:id/convert')
|
||||
|
||||
it('returns 401 when no context', async () => {
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, undefined)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when item not found', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'Not found' } }) // fetch inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when item status is not ready', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ status: 'confirmed' }) }) // fetch inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 400 when required fields missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: { items: [] }, // missing required fields
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when supplier not found in company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
|
||||
enqueue({ data: null, error: { message: 'Not found' } }) // fetch supplier
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('successfully converts inbox item to supplier invoice', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const inboxItem = makeInvoiceInboxItem({ status: 'ready', document_id: 'doc-1' })
|
||||
const supplier = makeSupplier({ id: 'supplier-1' })
|
||||
const createdInvoice = {
|
||||
id: 'invoice-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
supplier_id: SUPPLIER_UUID,
|
||||
arrival_number: 42,
|
||||
supplier_invoice_number: 'F-2024-001',
|
||||
total: 12500,
|
||||
status: 'registered',
|
||||
}
|
||||
|
||||
enqueue({ data: inboxItem }) // fetch inbox item
|
||||
enqueue({ data: supplier }) // fetch supplier
|
||||
enqueue({ data: 42 }) // get_next_arrival_number RPC
|
||||
enqueue({ data: createdInvoice }) // insert supplier_invoices
|
||||
enqueue({ data: null, error: null }) // insert supplier_invoice_items
|
||||
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) // company_settings
|
||||
enqueue({ data: null, error: null }) // update inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; inbox_item_id: string } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('invoice-1')
|
||||
expect(body.data.inbox_item_id).toBe('item-1')
|
||||
})
|
||||
|
||||
it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
|
||||
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
||||
enqueue({ data: 42 }) // arrival number
|
||||
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
|
||||
enqueue({ data: null, error: null }) // update inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
await route.handler(request, ctx)
|
||||
|
||||
const emitCalls = (ctx.emit as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(emitCalls.length).toBe(2)
|
||||
expect(emitCalls[0][0].type).toBe('supplier_invoice.registered')
|
||||
expect(emitCalls[1][0].type).toBe('supplier_invoice.confirmed')
|
||||
})
|
||||
|
||||
it('creates registration journal entry when accounting method is accrual', async () => {
|
||||
const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries')
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
|
||||
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
||||
enqueue({ data: 42 }) // arrival number
|
||||
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: makeCompanySettings({ accounting_method: 'accrual' }) })
|
||||
enqueue({ data: null, error: null }) // update registration_journal_entry_id
|
||||
enqueue({ data: null, error: null }) // update inbox item
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/convert', {
|
||||
method: 'POST',
|
||||
body: VALID_CONVERT_BODY,
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status, body } = await parseJsonResponse<{ data: { registration_journal_entry_id: string } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.registration_journal_entry_id).toBe('je-1')
|
||||
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /items/:id/reject ──────────────────────────────────
|
||||
|
||||
describe('PATCH /items/:id/reject', () => {
|
||||
const route = findRoute('PATCH', '/items/:id/reject')
|
||||
|
||||
it('returns 401 when no context', async () => {
|
||||
const request = createMockRequest('/items/item-1/reject', {
|
||||
method: 'PATCH',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, undefined)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when item not found', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/reject', {
|
||||
method: 'PATCH',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when item already confirmed', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'item-1', status: 'confirmed' } })
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/reject', {
|
||||
method: 'PATCH',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(409)
|
||||
})
|
||||
|
||||
it('updates item status to rejected', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'item-1', status: 'ready' } }) // fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
|
||||
const ctx = buildCtx(supabase)
|
||||
const request = createMockRequest('/items/item-1/reject', {
|
||||
method: 'PATCH',
|
||||
searchParams: { _id: 'item-1' },
|
||||
})
|
||||
const res = await route.handler(request, ctx)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('rejected')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,9 @@ import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { classifyDocument } from './lib/classify-document'
|
||||
import { encryptState, decryptState, encryptToken, decryptToken } from './lib/gmail-helpers'
|
||||
import { scanGmailConnection } from './lib/gmail-scanner'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // Match MAX_DOCUMENT_SIZE from document-service
|
||||
|
||||
@@ -28,7 +30,8 @@ async function uploadAndClassify(
|
||||
companyId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type: string },
|
||||
source: 'upload' | 'email',
|
||||
emailMeta?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string }
|
||||
emailMeta?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string },
|
||||
ctx?: ExtensionContext
|
||||
) {
|
||||
// Store in WORM archive
|
||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||
@@ -111,6 +114,25 @@ async function uploadAndClassify(
|
||||
|
||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
|
||||
// Emit events for supplier invoices (non-blocking)
|
||||
if (ctx && inbox.document_type === 'supplier_invoice') {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'supplier_invoice.received',
|
||||
payload: { inboxItem: inbox as unknown as InvoiceInboxItem, userId, companyId },
|
||||
})
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
if (!classificationError && classificationResult?.confidence) {
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: { inboxItem: inbox as unknown as InvoiceInboxItem, confidence: classificationResult.confidence / 100, userId, companyId },
|
||||
})
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: inbox.id,
|
||||
@@ -161,7 +183,9 @@ export const invoiceInboxExtension: Extension = {
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
{ name: file.name, buffer, type: file.type },
|
||||
'upload'
|
||||
'upload',
|
||||
undefined,
|
||||
ctx
|
||||
)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
@@ -188,7 +212,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, email_from, email_subject, error_message')
|
||||
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, document_id, email_from, email_subject, error_message')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -281,18 +305,18 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
if (error) {
|
||||
console.error('[gmail/callback] OAuth error:', error)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_auth_denied`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_auth_denied`)
|
||||
}
|
||||
if (!code || !stateParam) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_missing_params`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_missing_params`)
|
||||
}
|
||||
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_config_error`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_config_error`)
|
||||
}
|
||||
|
||||
const state = decryptState(stateParam) as { companyId: string; userId: string; exp: number } | null
|
||||
if (!state || Date.now() > state.exp) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_invalid_state`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_invalid_state`)
|
||||
}
|
||||
|
||||
const { companyId, userId } = state
|
||||
@@ -314,14 +338,14 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error('[gmail/callback] Token exchange failed:', await tokenResponse.text())
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_token_exchange`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_token_exchange`)
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json() as {
|
||||
access_token: string; refresh_token?: string
|
||||
}
|
||||
if (!tokens.refresh_token) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_no_refresh_token`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_no_refresh_token`)
|
||||
}
|
||||
|
||||
// Get user email
|
||||
@@ -329,7 +353,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
if (!profileResponse.ok) {
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_profile_error`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_profile_error`)
|
||||
}
|
||||
const profile = await profileResponse.json() as { emailAddress: string }
|
||||
|
||||
@@ -390,14 +414,14 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
if (dbError) {
|
||||
console.error('[gmail/callback] DB insert failed:', dbError)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_db_error`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_db_error`)
|
||||
}
|
||||
|
||||
console.log(`[gmail/callback] Gmail connected for ${profile.emailAddress} (company ${companyId})`)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?gmail=connected`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?gmail=connected`)
|
||||
} catch (err) {
|
||||
console.error('[gmail/callback] Unexpected error:', err)
|
||||
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_unexpected`)
|
||||
return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_unexpected`)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -475,5 +499,304 @@ export const invoiceInboxExtension: Extension = {
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// ── Reject inbox item ──────────────────────────────────
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: '/items/:id/reject',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(_request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
const { data: item, error: fetchError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (item.status === 'confirmed') return NextResponse.json({ error: 'Cannot reject a confirmed item' }, { status: 409 })
|
||||
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'rejected' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
return NextResponse.json({ data: { id, status: 'rejected' } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Convert inbox item to supplier invoice ─────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/convert',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
// Fetch inbox item
|
||||
const { data: item, error: fetchError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
if (item.status !== 'ready') return NextResponse.json({ error: 'Item is not in ready status' }, { status: 409 })
|
||||
|
||||
// Validate request body
|
||||
let body: ReturnType<typeof CreateSupplierInvoiceSchema.parse>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = CreateSupplierInvoiceSchema.parse(json)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid request body'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify supplier exists
|
||||
const { data: supplier, error: supplierError } = await ctx.supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('id', body.supplier_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (supplierError || !supplier) {
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get next arrival number
|
||||
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
|
||||
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
|
||||
|
||||
if (arrivalError) {
|
||||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate totals (same logic as app/api/supplier-invoices/route.ts)
|
||||
const items = body.items.map((bodyItem, index) => {
|
||||
const vatRate = bodyItem.vat_rate ?? 0.25
|
||||
const lineTotal = bodyItem.amount != null
|
||||
? Math.round(bodyItem.amount * 100) / 100
|
||||
: Math.round((bodyItem.quantity ?? 1) * (bodyItem.unit_price ?? 0) * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
sort_order: index,
|
||||
description: bodyItem.description,
|
||||
quantity: bodyItem.amount != null ? 1 : (bodyItem.quantity ?? 1),
|
||||
unit: bodyItem.amount != null ? 'st' : (bodyItem.unit || 'st'),
|
||||
unit_price: bodyItem.amount != null ? lineTotal : (bodyItem.unit_price ?? 0),
|
||||
line_total: lineTotal,
|
||||
account_number: bodyItem.account_number,
|
||||
vat_code: bodyItem.vat_code || null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
}
|
||||
})
|
||||
|
||||
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
||||
const totalVat = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
||||
const total = Math.round((subtotal + totalVat) * 100) / 100
|
||||
|
||||
const exchangeRate = body.exchange_rate || null
|
||||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||||
const vatAmountSek = exchangeRate ? Math.round(totalVat * exchangeRate * 100) / 100 : null
|
||||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||||
|
||||
// Insert supplier invoice
|
||||
const { data: invoice, error: invoiceError } = await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId,
|
||||
supplier_id: body.supplier_id,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date || null,
|
||||
status: 'registered',
|
||||
currency: body.currency || 'SEK',
|
||||
exchange_rate: exchangeRate,
|
||||
vat_treatment: body.vat_treatment || 'standard_25',
|
||||
reverse_charge: body.reverse_charge || false,
|
||||
payment_reference: body.payment_reference || null,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: Math.round(totalVat * 100) / 100,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total: Math.round(total * 100) / 100,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: Math.round(total * 100) / 100,
|
||||
document_id: item.document_id || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Insert line items
|
||||
const itemInserts = items.map((lineItem) => ({
|
||||
supplier_invoice_id: invoice.id,
|
||||
...lineItem,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await ctx.supabase
|
||||
.from('supplier_invoice_items')
|
||||
.insert(itemInserts)
|
||||
|
||||
if (itemsError) {
|
||||
await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Accrual method: create registration journal entry
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
let registrationJournalEntryId: string | null = null
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
ctx.userId,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
supplier.supplier_type,
|
||||
supplier.name
|
||||
)
|
||||
if (journalEntry) {
|
||||
registrationJournalEntryId = journalEntry.id
|
||||
await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
|
||||
// Link the document to the journal entry
|
||||
if (item.document_id) {
|
||||
await ctx.supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', item.document_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit supplier_invoice.registered
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'supplier_invoice.registered',
|
||||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId: ctx.companyId, userId: ctx.userId },
|
||||
})
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
// Update inbox item to confirmed
|
||||
await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'confirmed', created_supplier_invoice_id: invoice.id })
|
||||
.eq('id', id)
|
||||
|
||||
// Emit supplier_invoice.confirmed
|
||||
try {
|
||||
await ctx.emit({
|
||||
type: 'supplier_invoice.confirmed',
|
||||
payload: {
|
||||
inboxItem: { ...item, status: 'confirmed', created_supplier_invoice_id: invoice.id } as InvoiceInboxItem,
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId,
|
||||
},
|
||||
})
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
// Suggest matching transaction (don't book — user confirms in UI)
|
||||
let suggestedTransaction: { id: string; description: string; amount: number; currency: string; date: string } | null = null
|
||||
try {
|
||||
const invoiceTotal = Math.round(total * 100) / 100
|
||||
const invoiceTotalSek = totalSek ? Math.round(totalSek * 100) / 100 : null
|
||||
|
||||
const { data: candidates } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id, description, amount, currency, date')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.is('supplier_invoice_id', null)
|
||||
.lt('amount', 0)
|
||||
.order('date', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (candidates?.length) {
|
||||
const supplierWords = supplier.name.toLowerCase().replace(/[,.\-]/g, ' ').split(/\s+/).filter((w: string) => w.length >= 3)
|
||||
|
||||
const match = candidates.find((tx) => {
|
||||
const txAmount = Math.round(Math.abs(tx.amount) * 100) / 100
|
||||
const txDesc = tx.description?.toLowerCase() || ''
|
||||
|
||||
const exactMatch = txAmount === invoiceTotal
|
||||
const sekMatch = invoiceTotalSek != null && tx.currency === 'SEK' && Math.abs(txAmount - invoiceTotalSek) / invoiceTotalSek < 0.05
|
||||
|
||||
const nameMatch = supplierWords.some((word: string) => {
|
||||
if (txDesc.includes(word)) return true
|
||||
const txWords = txDesc.split(/\s+/)
|
||||
return txWords.some((tw: string) => {
|
||||
if (tw.length < 3 || word.length < 3) return false
|
||||
if (Math.abs(tw.length - word.length) > 1) return false
|
||||
let diffs = 0
|
||||
const longer = tw.length >= word.length ? tw : word
|
||||
const shorter = tw.length >= word.length ? word : tw
|
||||
let j = 0
|
||||
for (let i = 0; i < longer.length && diffs <= 1; i++) {
|
||||
if (longer[i] !== shorter[j]) { diffs++; if (longer.length === shorter.length) j++ }
|
||||
else { j++ }
|
||||
}
|
||||
return diffs <= 1
|
||||
})
|
||||
})
|
||||
|
||||
return (exactMatch || sekMatch) && nameMatch
|
||||
})
|
||||
|
||||
if (match) {
|
||||
suggestedTransaction = match as unknown as typeof suggestedTransaction
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/convert] Transaction suggestion failed (non-blocking):', err)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...invoice,
|
||||
items: itemInserts,
|
||||
registration_journal_entry_id: registrationJournalEntryId,
|
||||
inbox_item_id: id,
|
||||
suggested_transaction: suggestedTransaction,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"sector": "general",
|
||||
"exportName": "invoiceInboxExtension",
|
||||
"entryPoint": "@/extensions/general/invoice-inbox",
|
||||
"workspace": null,
|
||||
"workspace": "@/components/extensions/general/InvoiceInboxWorkspace",
|
||||
"requiredEnvVars": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
|
||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GMAIL_TOKEN_ENCRYPTION_KEY"],
|
||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
||||
|
||||
Reference in New Issue
Block a user