feat: implement viewer role permissions for bank transaction imports and connections (#234)
This commit is contained in:
@@ -4,8 +4,8 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import { generateExternalId } from '@/lib/import/bank-file/parser'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { IngestOptions } from '@/types'
|
||||
import { getCompanyRole } from '@/lib/auth/require-write'
|
||||
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
@@ -36,10 +36,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const roleCheck = await getCompanyRole(supabase, user.id)
|
||||
if (!roleCheck.ok) return roleCheck.response
|
||||
const { role, companyId } = roleCheck
|
||||
|
||||
const body: ExecuteRequest = await request.json()
|
||||
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true, settlement_account } = body
|
||||
@@ -84,8 +83,11 @@ export async function POST(request: Request) {
|
||||
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
|
||||
}))
|
||||
|
||||
// Run ingestion pipeline
|
||||
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, settlement_account ? { settlementAccount: settlement_account } : undefined)
|
||||
// Run ingestion pipeline — viewers get rawInsertOnly (no categorization, no matching)
|
||||
const ingestOptions: IngestOptions = {}
|
||||
if (settlement_account) ingestOptions.settlementAccount = settlement_account
|
||||
if (role === 'viewer') ingestOptions.rawInsertOnly = true
|
||||
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions)
|
||||
|
||||
// Update import record with results
|
||||
await supabase
|
||||
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
FileText,
|
||||
Link2,
|
||||
Calendar,
|
||||
Lock,
|
||||
Landmark,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
|
||||
|
||||
@@ -39,7 +37,6 @@ export default function BankFileConfirmStep({
|
||||
onBack,
|
||||
isLoading,
|
||||
}: BankFileConfirmStepProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { transactions, stats, date_from, date_to } = parseResult
|
||||
const refsCount = transactions.filter((t) => t.reference).length
|
||||
|
||||
@@ -188,19 +185,13 @@ export default function BankFileConfirmStep({
|
||||
auto_categorize: false,
|
||||
settlement_account: selectedAccount !== '1930' ? selectedAccount : undefined,
|
||||
})}
|
||||
disabled={isLoading || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importerar...
|
||||
</>
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Importera {stats.parsed_rows} transaktioner
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -234,6 +234,7 @@ export const enableBankingExtension: Extension = {
|
||||
|
||||
// Use ctx.services.ingestTransactions when available
|
||||
const ingestFn = ctx?.services.ingestTransactions
|
||||
const companyId = ctx?.companyId ?? user.id
|
||||
|
||||
// Detect SIE overlap — skip auto-categorization if the sync range
|
||||
// overlaps with a completed SIE import to prevent double-booking.
|
||||
@@ -241,15 +242,25 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: sieOverlap } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id')
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'completed')
|
||||
.gte('fiscal_year_end', fromDate)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
const syncOptions = sieOverlap
|
||||
? { skipAutoCategorization: true }
|
||||
: undefined
|
||||
// Check if user is a viewer — viewers get rawInsertOnly (no categorization)
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
const isViewer = membership?.role === 'viewer'
|
||||
|
||||
const syncOptions = {
|
||||
...(sieOverlap ? { skipAutoCategorization: true } : {}),
|
||||
...(isViewer ? { rawInsertOnly: true } : {}),
|
||||
}
|
||||
|
||||
if (sieOverlap) {
|
||||
log.info('SIE import overlap detected — suppressing auto-categorization', {
|
||||
@@ -258,8 +269,6 @@ export const enableBankingExtension: Extension = {
|
||||
toDate,
|
||||
})
|
||||
}
|
||||
|
||||
const companyId = ctx?.companyId ?? user.id
|
||||
const results = await Promise.all(
|
||||
accounts.map(account => syncAccountTransactions(
|
||||
supabase,
|
||||
@@ -281,7 +290,8 @@ export const enableBankingExtension: Extension = {
|
||||
// The greedy algorithm considers all candidates globally (highest-
|
||||
// confidence first) and catches matches the inline per-transaction
|
||||
// pass may have missed due to processing order.
|
||||
if (sieOverlap && totalImported > 0) {
|
||||
// Skip for viewers — reconciliation updates transactions which viewers cannot do.
|
||||
if (sieOverlap && totalImported > 0 && !isViewer) {
|
||||
try {
|
||||
const reconResult = await runReconciliation(supabase, ctx?.companyId ?? user.id, {
|
||||
dateFrom: fromDate,
|
||||
|
||||
@@ -17,6 +17,8 @@ export type IngestFn = (
|
||||
export interface SyncOptions {
|
||||
/** Skip auto-categorization during ingestion (e.g. SIE overlap) */
|
||||
skipAutoCategorization?: boolean
|
||||
/** Only INSERT + dedup, no matching/categorization (viewer imports) */
|
||||
rawInsertOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
@@ -84,9 +86,9 @@ export async function syncAccountTransactions(
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
|
||||
const ingestOptions: IngestOptions | undefined = syncOptions?.skipAutoCategorization
|
||||
? { skipAutoCategorization: true }
|
||||
: undefined
|
||||
const ingestOptions: IngestOptions = {}
|
||||
if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true
|
||||
if (syncOptions?.rawInsertOnly) ingestOptions.rawInsertOnly = true
|
||||
const ingestResult = await ingest(supabase, companyId, userId, rawTransactions, ingestOptions)
|
||||
|
||||
console.log('[enable-banking] Ingest result', {
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { requireWritePermission } from '../require-write'
|
||||
import { requireWritePermission, getCompanyRole } from '../require-write'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
describe('requireWritePermission', () => {
|
||||
@@ -79,3 +79,60 @@ describe('requireWritePermission', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyRole', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns role and companyId for owner', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'owner' } })
|
||||
|
||||
const result = await getCompanyRole(supabase, 'user-1')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.role).toBe('owner')
|
||||
expect(result.companyId).toBe('company-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns role for viewer (does not block)', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'viewer' } })
|
||||
|
||||
const result = await getCompanyRole(supabase, 'user-1')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.role).toBe('viewer')
|
||||
expect(result.companyId).toBe('company-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 403 when user has no membership', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: null })
|
||||
|
||||
const result = await getCompanyRole(supabase, 'user-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.response.status).toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 403 when there is no active company', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue(null)
|
||||
|
||||
const result = await getCompanyRole(supabase, 'user-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.response.status).toBe(403)
|
||||
const body = await result.response.json()
|
||||
expect(body.error).toContain('aktivt företag')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import type { CompanyRole } from '@/types'
|
||||
|
||||
/**
|
||||
* Write-permission guard for API routes.
|
||||
@@ -61,3 +62,54 @@ export async function requireWritePermission(
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the caller's role in the active company without blocking viewers.
|
||||
*
|
||||
* Unlike `requireWritePermission()` (which returns 403 for viewers), this
|
||||
* returns the actual role so the caller can make conditional decisions —
|
||||
* e.g. allowing viewers to import raw bank transactions but nothing else.
|
||||
*
|
||||
* Use `requireWritePermission()` for routes that are fully off-limits to
|
||||
* viewers. Use `getCompanyRole()` only when the route needs viewer-
|
||||
* conditional behavior.
|
||||
*/
|
||||
export type CompanyRoleResult =
|
||||
| { ok: true; role: CompanyRole; companyId: string }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
export async function getCompanyRole(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
): Promise<CompanyRoleResult> {
|
||||
const companyId = await getActiveCompanyId(supabase, userId)
|
||||
|
||||
if (!companyId) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Inget aktivt företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!membership) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Du har ingen roll i detta företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, role: membership.role as CompanyRole, companyId }
|
||||
}
|
||||
|
||||
@@ -701,6 +701,64 @@ describe('ingestTransactions', () => {
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// rawInsertOnly: skips reconciliation, matching, and auto-categorization
|
||||
// -----------------------------------------------------------------------
|
||||
it('skips reconciliation, matching, and categorization when rawInsertOnly is set', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: 5000, description: 'Payment received' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-raw',
|
||||
amount: 5000,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Unbooked bank-synced transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// No supplier invoices fetch (skipped by rawInsertOnly)
|
||||
// Batch external_id dedup query (no matches)
|
||||
enqueue({ data: [], error: null })
|
||||
// Insert returns the new transaction
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
const result = await ingestTransactions(
|
||||
supabase as never, COMPANY_ID, USER_ID, [raw],
|
||||
{ rawInsertOnly: true }
|
||||
)
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.reconciled).toBe(0)
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(result.auto_matched_invoices).toBe(0)
|
||||
// Should NOT have attempted any post-insert operations
|
||||
expect(mockFetchUnlinkedGLLines).not.toHaveBeenCalled()
|
||||
expect(mockTryReconcileTransaction).not.toHaveBeenCalled()
|
||||
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
|
||||
expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still deduplicates when rawInsertOnly is set', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ external_id: 'ext-dup-raw' })
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Unbooked bank-synced transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Batch external_id dedup query — already exists
|
||||
enqueue({ data: [{ external_id: 'ext-dup-raw' }], error: null })
|
||||
|
||||
const result = await ingestTransactions(
|
||||
supabase as never, COMPANY_ID, USER_ID, [raw],
|
||||
{ rawInsertOnly: true }
|
||||
)
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Content-based dedup: cross-source duplicate detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+23
-13
@@ -119,8 +119,14 @@ export async function ingestTransactions(
|
||||
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
|
||||
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
|
||||
|
||||
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
|
||||
// When rawInsertOnly is set (viewer imports), skip pre-fetching GL lines,
|
||||
// supplier invoices, and exchange rates — they are not used.
|
||||
let glLinePool: UnlinkedGLLine[] = []
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
let exchangeRates = new Map<Currency, ExchangeRate>()
|
||||
|
||||
if (!options?.rawInsertOnly) {
|
||||
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
|
||||
try {
|
||||
glLinePool = await fetchUnlinkedGLLines(supabase, companyId, undefined, undefined, options?.settlementAccount)
|
||||
} catch {
|
||||
@@ -128,7 +134,6 @@ export async function ingestTransactions(
|
||||
}
|
||||
|
||||
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('supplier_invoices')
|
||||
@@ -141,20 +146,22 @@ export async function ingestTransactions(
|
||||
} catch {
|
||||
// Non-critical — supplier invoice matching will be skipped
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fetch exchange rates for non-SEK currencies (non-critical)
|
||||
let exchangeRates = new Map<Currency, ExchangeRate>()
|
||||
try {
|
||||
const uniqueCurrencies = [...new Set(
|
||||
rawTransactions
|
||||
.map(t => t.currency)
|
||||
.filter((c): c is Currency => c != null && c !== 'SEK')
|
||||
)]
|
||||
if (uniqueCurrencies.length > 0) {
|
||||
exchangeRates = await fetchMultipleRates(uniqueCurrencies)
|
||||
if (!options?.rawInsertOnly) {
|
||||
try {
|
||||
const uniqueCurrencies = [...new Set(
|
||||
rawTransactions
|
||||
.map(t => t.currency)
|
||||
.filter((c): c is Currency => c != null && c !== 'SEK')
|
||||
)]
|
||||
if (uniqueCurrencies.length > 0) {
|
||||
exchangeRates = await fetchMultipleRates(uniqueCurrencies)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — amount_sek fields will stay null
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — amount_sek fields will stay null
|
||||
}
|
||||
|
||||
// Pre-fetch existing external_ids in batches for dedup (avoids N+1 queries)
|
||||
@@ -244,6 +251,9 @@ export async function ingestTransactions(
|
||||
result.imported++
|
||||
result.transaction_ids.push(newTransaction.id)
|
||||
|
||||
// rawInsertOnly: skip reconciliation, invoice matching, and auto-categorization
|
||||
if (options?.rawInsertOnly) continue
|
||||
|
||||
// 2.5. Try reconciliation against pre-fetched unlinked GL lines
|
||||
if (glLinePool.length > 0) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- =============================================================================
|
||||
-- Viewer role: allow bank transaction import and bank connection
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Viewers are read-only by default (enforced by `current_user_can_write()`).
|
||||
-- This migration adds additive policies that let viewers:
|
||||
-- 1. Import bank files (INSERT transactions + bank_file_imports)
|
||||
-- 2. Connect banks via PSD2 (INSERT + UPDATE bank_connections)
|
||||
--
|
||||
-- No other write operation is opened — viewers still cannot categorize,
|
||||
-- book, edit, or delete transactions, create invoices, etc.
|
||||
--
|
||||
-- RLS is OR-based: existing policies (which require `current_user_can_write()`)
|
||||
-- remain unchanged. These new policies provide an alternative path for
|
||||
-- viewers on just these tables.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─── Transactions ────────────────────────────────────────────────────────────
|
||||
-- Viewer can INSERT (not UPDATE/DELETE) — raw uncategorized transactions only
|
||||
CREATE POLICY "transactions_viewer_insert" ON public.transactions
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role = 'viewer'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─── Bank file imports ───────────────────────────────────────────────────────
|
||||
-- Viewer can INSERT (create import record) + UPDATE (status tracking)
|
||||
CREATE POLICY "bank_file_imports_viewer_insert" ON public.bank_file_imports
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role = 'viewer'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "bank_file_imports_viewer_update" ON public.bank_file_imports
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role = 'viewer'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─── Bank connections ────────────────────────────────────────────────────────
|
||||
-- Viewer can INSERT (initiate PSD2 connection) + UPDATE (status changes, sync)
|
||||
-- No DELETE — disconnecting sets status='revoked' via UPDATE, not DELETE
|
||||
CREATE POLICY "bank_connections_viewer_insert" ON public.bank_connections
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role = 'viewer'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "bank_connections_viewer_update" ON public.bank_connections
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role = 'viewer'
|
||||
)
|
||||
);
|
||||
@@ -2197,6 +2197,9 @@ export interface IngestOptions {
|
||||
/** Override the default settlement account (1930) for bank transactions.
|
||||
* Used when importing to a secondary bank account (e.g., 1931). */
|
||||
settlementAccount?: string
|
||||
/** Only INSERT transactions + dedup. Skip reconciliation, invoice matching,
|
||||
* supplier matching, and auto-categorization. For viewer imports. */
|
||||
rawInsertOnly?: boolean
|
||||
}
|
||||
|
||||
/** Result of the transaction ingestion pipeline */
|
||||
|
||||
Reference in New Issue
Block a user