Files
accounted/extensions/general/arcim-migration/lib/provider-client.ts
T
e42da5c32b Staging (#181)
* refactor: remove unnecessary secondary action from EmptyInvoices component

* feat: add direct provider layer and provider_consents migration

Replace Arcim Sync gateway dependency with direct provider clients
for Fortnox, Visma, Briox, Bokio, and Björn Lundén. Adds OAuth
config, rate limiting, retry logic, data fetching, and consent
storage via new provider_consents/tokens/otc tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: migrate arcim extension to direct provider APIs

Replace Arcim Sync gateway calls with direct provider API access.
Use FortnoxClient.getText() for SIE endpoints that return plain text
instead of JSON. OAuth callback now returns HTML with postMessage
to communicate with the opener window instead of redirecting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use OAuth popup window instead of new tab

Open provider login in a centered popup that auto-closes on
completion via postMessage, keeping the user on a single tab.
Falls back to redirect flow if popup is blocked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add connection status, consent reuse, and SIE duplicate detection

- Add listConsents() to query active consents by company
- Add GET /status route returning consents, SIE import history, and
  entity counts
- /connect reuses existing accepted consent instead of creating
  duplicates, and cleans up abandoned (status 0) consents
- /status only returns accepted (status 1) consents
- /sie-data checks each file's SHA-256 hash against sie_imports to
  report per-file import status (alreadyImported, importedAt)
- /sie-data blocks on SIE validation failure (mirrors manual upload)
- /import-sie validates unmapped accounts and auto-activates missing
  BAS accounts in chart_of_accounts (mirrors manual upload)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show active connections, SIE file status, and smart re-sync UI

- ProviderStep shows active connections with last import date, entity
  counts, "Synka igen" button, and disconnect option
- Already-connected providers greyed out in selection grid
- OptionsStep shows per-fiscal-year import status (imported vs new)
- SIE toggle disabled with explanation when all files already imported
- handleStartMigration skips already-imported SIE files
- Auto-skip mapping step and disable SIE on re-sync when up to date
- Result step hides empty "0 importerade" rows and shows
  "Allt är uppdaterat" when nothing new was fetched
- OptionRow supports disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: adjust COMING_SOON_PROVIDERS based on NODE_ENV for development and production

* Removed duplicate

* Removed duplicate

* refactor: redesign reports page navigation from grid boxes to bordered card layout

Replace the 4-column grid of uneven TabsList boxes with a CSS grid card
using auto-sized columns separated by 1px border dividers. All sections
now share equal height via items-stretch, with clear visual separation
between groups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: trigger Vercel deployment

* Update supabase/migrations/20260402010000_provider_consents.sql

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update lib/providers/rate-limiter.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: re-trigger checks after migration sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-07 10:30:20 +02:00

263 lines
7.3 KiB
TypeScript

/**
* Direct provider client — replaces arcim-client.ts.
*
* Instead of making HTTP calls to the Arcim Sync gateway, this module
* performs consent/OTC operations directly against Supabase and delegates
* data fetching to the provider clients in lib/providers/.
*/
import { createServiceClient } from '@/lib/supabase/server'
import type { ProviderName } from '@/lib/providers/types'
import { getOAuthConfig } from '@/lib/providers/oauth-config'
import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth'
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import type { ConsentRecord, OtcResponse } from '../types'
// Re-export data fetching functions from the provider layer
export { resolveConsent } from '@/lib/providers/resolve-consent'
export {
fetchCompanyInfoDirect,
fetchCustomersDirect,
fetchSuppliersDirect,
fetchSalesInvoicesDirect,
fetchSupplierInvoicesDirect,
} from '@/lib/providers/provider-data-fetcher'
// ── Consent lifecycle (direct Supabase) ─────────────────────────────
export async function createConsent(
companyId: string,
provider: ProviderName,
name: string,
orgNumber?: string,
companyName?: string,
): Promise<ConsentRecord> {
const supabase = createServiceClient()
const { data, error } = await supabase
.from('provider_consents')
.insert({
company_id: companyId,
name,
provider,
org_number: orgNumber,
company_name: companyName,
status: 0, // Created
})
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create consent: ${error?.message}`)
}
return {
id: data.id,
name: data.name,
provider: data.provider as ProviderName,
status: data.status,
orgNumber: data.org_number,
companyName: data.company_name,
}
}
export async function listConsents(companyId: string): Promise<ConsentRecord[]> {
const supabase = createServiceClient()
const { data, error } = await supabase
.from('provider_consents')
.select('*')
.eq('company_id', companyId)
.in('status', [0, 1]) // Created or Accepted
.order('created_at', { ascending: false })
if (error) {
throw new Error(`Failed to list consents: ${error.message}`)
}
return (data ?? []).map(d => ({
id: d.id,
name: d.name,
provider: d.provider as ProviderName,
status: d.status,
orgNumber: d.org_number,
companyName: d.company_name,
createdAt: d.created_at,
updatedAt: d.updated_at,
}))
}
export async function getConsent(consentId: string): Promise<ConsentRecord> {
const supabase = createServiceClient()
const { data, error } = await supabase
.from('provider_consents')
.select('*')
.eq('id', consentId)
.single()
if (error || !data) {
throw new Error(`Consent not found: ${error?.message}`)
}
return {
id: data.id,
name: data.name,
provider: data.provider as ProviderName,
status: data.status,
orgNumber: data.org_number,
companyName: data.company_name,
}
}
export async function deleteConsent(consentId: string): Promise<void> {
const supabase = createServiceClient()
// Delete tokens first (cascade should handle this, but be explicit)
await supabase.from('provider_consent_tokens').delete().eq('consent_id', consentId)
await supabase.from('provider_otc').delete().eq('consent_id', consentId)
const { error } = await supabase
.from('provider_consents')
.delete()
.eq('id', consentId)
if (error) {
throw new Error(`Failed to delete consent: ${error.message}`)
}
}
export async function generateOtc(
consentId: string,
expiresInMinutes: number = 60,
): Promise<OtcResponse> {
const supabase = createServiceClient()
const code = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString()
const { error } = await supabase
.from('provider_otc')
.insert({
code,
consent_id: consentId,
expires_at: expiresAt,
})
if (error) {
throw new Error(`Failed to generate OTC: ${error.message}`)
}
return { code, consentId, expiresAt }
}
// ── OAuth helpers (direct provider calls) ───────────────────────────
export async function getAuthUrl(
provider: ProviderName,
state?: string,
redirectUri?: string,
): Promise<{ url: string }> {
const config = getOAuthConfig(provider)
// Override redirect URI if provided (extension callback URL)
const effectiveConfig = redirectUri
? { ...config, redirectUri }
: config
if (provider === 'fortnox') {
const url = buildFortnoxAuthUrl(effectiveConfig, { state })
return { url }
}
if (provider === 'visma') {
const url = buildVismaAuthUrl(effectiveConfig, { state })
return { url }
}
throw new Error(`OAuth is not supported for provider: ${provider}`)
}
export async function exchangeAuthToken(
consentId: string,
provider: ProviderName,
code: string,
redirectUri?: string,
): Promise<{ success: boolean; consentId: string }> {
const config = getOAuthConfig(provider)
const effectiveConfig = redirectUri ? { ...config, redirectUri } : config
const supabase = createServiceClient()
let tokenResponse: { access_token: string; refresh_token: string; expires_in: number }
if (provider === 'fortnox') {
tokenResponse = await exchangeFortnoxCode(effectiveConfig, code)
} else if (provider === 'visma') {
tokenResponse = await exchangeVismaCode(effectiveConfig, code)
} else {
throw new Error(`OAuth exchange not supported for provider: ${provider}`)
}
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
// Store tokens
await supabase
.from('provider_consent_tokens')
.upsert({
consent_id: consentId,
provider,
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
token_expires_at: expiresAt,
})
// Mark consent as accepted
await supabase
.from('provider_consents')
.update({ status: 1 })
.eq('id', consentId)
return { success: true, consentId }
}
export async function submitProviderToken(
consentId: string,
provider: ProviderName,
apiToken: string,
companyId?: string,
): Promise<{ success: boolean; consentId: string }> {
const supabase = createServiceClient()
let accessToken = apiToken
let tokenExpiresAt: string | null = null
// BL uses client credentials — get a real token
if (provider === 'bjornlunden') {
const tokenResponse = await refreshBjornLundenToken()
accessToken = tokenResponse.access_token
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
}
// Store tokens
await supabase
.from('provider_consent_tokens')
.upsert({
consent_id: consentId,
provider,
access_token: accessToken,
refresh_token: null,
token_expires_at: tokenExpiresAt,
provider_company_id: companyId,
})
// Mark consent as accepted
await supabase
.from('provider_consents')
.update({ status: 1 })
.eq('id', consentId)
return { success: true, consentId }
}