e11f70b347
* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
generateFullArchive,
|
|
generateBaseDataArchive,
|
|
ARCHIVE_OVERHEAD_BYTES,
|
|
} from '@/lib/reports/full-archive-export'
|
|
import { buildDriveFolderReadme } from '@/lib/reports/archive-readme'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import {
|
|
getOAuthEnv,
|
|
refreshAccessToken,
|
|
GoogleTokenRefreshError,
|
|
} from './google-oauth'
|
|
import {
|
|
DriveFileGoneError,
|
|
ensureFolder,
|
|
getFileMeta,
|
|
updateFile,
|
|
uploadFile,
|
|
type UploadResult,
|
|
} from './google-drive'
|
|
import { decryptToken } from './crypto'
|
|
import type {
|
|
DriveFileState,
|
|
GoogleDriveConnection,
|
|
GoogleDriveLastSync,
|
|
} from '../types'
|
|
|
|
export const CONNECTION_KEY = 'google_drive_connection'
|
|
export const LAST_SYNC_KEY = 'google_drive_last_sync'
|
|
export const SCHEDULE_KEY = 'google_drive_schedule'
|
|
export const ROOT_FOLDER_NAME = 'gnubok'
|
|
/**
|
|
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
|
|
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
|
|
* bound left is JSZip building each archive in memory on a serverless
|
|
* function, hence 300 MB rather than "unlimited".
|
|
*
|
|
* Memory budget: worst-case transient usage is ~3x this limit (~900 MB):
|
|
* JSZip holds the input blobs while generateAsync accumulates output chunks
|
|
* and then concatenates them into the final ArrayBuffer. The upload path
|
|
* adds no further copies (md5/sha256 hash via zero-copy Buffer views,
|
|
* resumable 8 MB chunk views, no multipart concatenation), and files are
|
|
* generated and uploaded one at a time. That fits the Vercel default
|
|
* function memory (2048 MB) with ~2x headroom; raise this limit only
|
|
* together with an explicit memory bump in vercel.json.
|
|
*/
|
|
export const SIZE_LIMIT_BYTES = 300 * 1024 * 1024
|
|
/** Bump to force a re-upload of every file when the archive format changes. */
|
|
export const ARCHIVE_FORMAT_VERSION = 3
|
|
|
|
export type SyncFailureReason =
|
|
| 'not_connected'
|
|
| 'needs_reauth'
|
|
| 'archive_too_large'
|
|
| 'upload_failed'
|
|
| 'internal'
|
|
|
|
export type PerformSyncResult =
|
|
| {
|
|
ok: true
|
|
lastSync: GoogleDriveLastSync
|
|
/** Link to the company's backup folder in Drive. */
|
|
webViewLink: string
|
|
uploadedCount: number
|
|
skippedCount: number
|
|
}
|
|
| {
|
|
ok: false
|
|
reason: SyncFailureReason
|
|
message: string
|
|
size_bytes?: number
|
|
size_limit_bytes?: number
|
|
}
|
|
|
|
interface PerformSyncParams {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
userId: string
|
|
origin: string
|
|
includeDocuments: boolean
|
|
/**
|
|
* When a single archive file with documents exceeds the size limit, fall
|
|
* back to building THAT file without document blobs instead of failing.
|
|
* The cron and the post-connect sync set this; the manual flow leaves it
|
|
* off and lets the user choose via a dialog.
|
|
*/
|
|
allowDocumentFallback?: boolean
|
|
}
|
|
|
|
interface PlannedFile {
|
|
key: string
|
|
kind: DriveFileState['kind']
|
|
periodId?: string
|
|
name: string
|
|
contentType: string
|
|
fingerprint: string
|
|
includeDocuments: boolean
|
|
generate: () => Promise<ArrayBuffer>
|
|
}
|
|
|
|
interface PeriodRow {
|
|
id: string
|
|
period_start: string
|
|
period_end: string
|
|
}
|
|
|
|
/**
|
|
* Run the end-to-end cloud backup sync against the per-fiscal-year layout:
|
|
*
|
|
* gnubok/<company>/Arkiv <år>.zip one per räkenskapsår
|
|
* gnubok/<company>/Grunddata.zip registers, SIE originals, audit trail
|
|
* gnubok/<company>/LÄSMIG.txt folder map
|
|
*
|
|
* Every file carries a fingerprint (entry/document counts + latest
|
|
* timestamps); only files whose fingerprint changed are regenerated and
|
|
* uploaded, in place (Drive keeps ~30 days of prior versions). State is
|
|
* persisted after every upload, so an interrupted run resumes where it
|
|
* stopped instead of re-uploading finished years.
|
|
*
|
|
* Settings ops use raw `extension_data` queries (not the extension context
|
|
* wrapper) because the cron runs under the service role with no user session.
|
|
*/
|
|
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
|
|
const { supabase, companyId, userId, origin } = params
|
|
|
|
const connection = await loadExtensionData<GoogleDriveConnection>(
|
|
supabase,
|
|
companyId,
|
|
CONNECTION_KEY
|
|
)
|
|
if (!connection) {
|
|
return { ok: false, reason: 'not_connected', message: 'Google Drive not connected' }
|
|
}
|
|
|
|
const env = getOAuthEnv(origin)
|
|
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
|
let accessToken: string
|
|
try {
|
|
const refreshed = await refreshAccessToken(env, refreshToken)
|
|
accessToken = refreshed.access_token
|
|
} catch (err) {
|
|
if (err instanceof GoogleTokenRefreshError && err.isInvalidGrant) {
|
|
// The refresh token is permanently dead (revoked or expired). Flag the
|
|
// connection so the cron stops retrying it and the UI can ask the user
|
|
// to reconnect. Other failures (network, 5xx) stay throwing: they are
|
|
// transient and worth retrying.
|
|
const flagged: GoogleDriveConnection = {
|
|
...connection,
|
|
status: 'needs_reauth',
|
|
needs_reauth_at: new Date().toISOString(),
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, flagged)
|
|
return {
|
|
ok: false,
|
|
reason: 'needs_reauth',
|
|
message: 'Google Drive authorization expired; reconnect required',
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
|
|
const company = await fetchCompanyInfo(supabase, companyId)
|
|
|
|
let rootFolderId = connection.root_folder_id
|
|
let companyFolderId = connection.company_folder_id
|
|
// Revalidate cached folder ids: files created inside a trashed folder are
|
|
// purged with it, so a folder the user trashed or deleted must never
|
|
// receive uploads. `trashed` is inherited from parents, so checking the
|
|
// company folder covers a trashed root too; the root is only re-checked
|
|
// when the company folder needs recreating.
|
|
if (companyFolderId) {
|
|
const meta = await getFileMeta(accessToken, companyFolderId)
|
|
if (!meta || meta.trashed) companyFolderId = null
|
|
}
|
|
if (!companyFolderId && rootFolderId) {
|
|
const rootMeta = await getFileMeta(accessToken, rootFolderId)
|
|
if (!rootMeta || rootMeta.trashed) rootFolderId = null
|
|
}
|
|
if (!rootFolderId) {
|
|
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
|
rootFolderId = root.id
|
|
}
|
|
if (!companyFolderId) {
|
|
const companyFolder = await ensureFolder(accessToken, company.label, rootFolderId)
|
|
companyFolderId = companyFolder.id
|
|
}
|
|
if (
|
|
rootFolderId !== connection.root_folder_id ||
|
|
companyFolderId !== connection.company_folder_id ||
|
|
connection.status === 'needs_reauth'
|
|
) {
|
|
// A successful refresh also clears a stale needs_reauth flag.
|
|
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
|
|
...connection,
|
|
root_folder_id: rootFolderId,
|
|
company_folder_id: companyFolderId,
|
|
status: 'active',
|
|
needs_reauth_at: undefined,
|
|
})
|
|
}
|
|
|
|
// ---- Fingerprint basis: three paged reads + one point read. ----
|
|
const periods = await fetchAllRows<PeriodRow>(({ from, to }) =>
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, period_start, period_end')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: true })
|
|
.range(from, to)
|
|
)
|
|
const entries = await fetchAllRows<{
|
|
id: string
|
|
fiscal_period_id: string
|
|
updated_at: string | null
|
|
}>(({ from, to }) =>
|
|
supabase
|
|
.from('journal_entries')
|
|
.select('id, fiscal_period_id, updated_at')
|
|
.eq('company_id', companyId)
|
|
.in('status', ['posted', 'reversed'])
|
|
.order('id', { ascending: true })
|
|
.range(from, to)
|
|
)
|
|
const docs = await fetchAllRows<{
|
|
id: string
|
|
journal_entry_id: string | null
|
|
file_size_bytes: number | null
|
|
created_at: string | null
|
|
}>(({ from, to }) =>
|
|
supabase
|
|
.from('document_attachments')
|
|
.select('id, journal_entry_id, file_size_bytes, created_at')
|
|
.eq('company_id', companyId)
|
|
.order('id', { ascending: true })
|
|
.range(from, to)
|
|
)
|
|
const latestAuditAt = await fetchLatestAuditAt(supabase, companyId)
|
|
|
|
const entryToPeriod = new Map(entries.map((e) => [e.id, e.fiscal_period_id]))
|
|
|
|
interface Stats {
|
|
entryCount: number
|
|
maxEntryUpdated: string
|
|
docCount: number
|
|
docBytes: number
|
|
maxDocCreated: string
|
|
}
|
|
const emptyStats = (): Stats => ({
|
|
entryCount: 0,
|
|
maxEntryUpdated: '',
|
|
docCount: 0,
|
|
docBytes: 0,
|
|
maxDocCreated: '',
|
|
})
|
|
const statsByPeriod = new Map<string, Stats>()
|
|
for (const period of periods) statsByPeriod.set(period.id, emptyStats())
|
|
for (const entry of entries) {
|
|
const stats = statsByPeriod.get(entry.fiscal_period_id)
|
|
if (!stats) continue
|
|
stats.entryCount++
|
|
if (entry.updated_at && entry.updated_at > stats.maxEntryUpdated) {
|
|
stats.maxEntryUpdated = entry.updated_at
|
|
}
|
|
}
|
|
const baseStats = emptyStats()
|
|
for (const doc of docs) {
|
|
const periodId = doc.journal_entry_id ? entryToPeriod.get(doc.journal_entry_id) : undefined
|
|
const stats = (periodId && statsByPeriod.get(periodId)) || baseStats
|
|
stats.docCount++
|
|
stats.docBytes += Number(doc.file_size_bytes) || 0
|
|
if (doc.created_at && doc.created_at > stats.maxDocCreated) {
|
|
stats.maxDocCreated = doc.created_at
|
|
}
|
|
}
|
|
|
|
// ---- Plan the file set. ----
|
|
const planned: PlannedFile[] = []
|
|
|
|
const decideDocuments = (
|
|
docBytes: number
|
|
): { includeDocuments: boolean } | { tooLargeBytes: number } => {
|
|
let includeDocuments = params.includeDocuments
|
|
if (includeDocuments && ARCHIVE_OVERHEAD_BYTES + docBytes > SIZE_LIMIT_BYTES) {
|
|
if (params.allowDocumentFallback) {
|
|
includeDocuments = false
|
|
} else {
|
|
return { tooLargeBytes: ARCHIVE_OVERHEAD_BYTES + docBytes }
|
|
}
|
|
}
|
|
return { includeDocuments }
|
|
}
|
|
|
|
for (const period of periods) {
|
|
const stats = statsByPeriod.get(period.id)!
|
|
const decision = decideDocuments(stats.docBytes)
|
|
if ('tooLargeBytes' in decision) {
|
|
return {
|
|
ok: false,
|
|
reason: 'archive_too_large',
|
|
message: `Archive for ${period.period_start}..${period.period_end} exceeds size limit`,
|
|
size_bytes: decision.tooLargeBytes,
|
|
size_limit_bytes: SIZE_LIMIT_BYTES,
|
|
}
|
|
}
|
|
const includeDocuments = decision.includeDocuments
|
|
planned.push({
|
|
key: `period:${period.id}`,
|
|
kind: 'period',
|
|
periodId: period.id,
|
|
name: arkivFileName(period),
|
|
contentType: 'application/zip',
|
|
fingerprint: [
|
|
`v${ARCHIVE_FORMAT_VERSION}`,
|
|
stats.entryCount,
|
|
stats.maxEntryUpdated,
|
|
stats.docCount,
|
|
stats.maxDocCreated,
|
|
`docs:${includeDocuments ? 1 : 0}`,
|
|
].join('|'),
|
|
includeDocuments,
|
|
generate: () =>
|
|
generateFullArchive(supabase, companyId, {
|
|
scope: 'period',
|
|
period_id: period.id,
|
|
include_documents: includeDocuments,
|
|
}),
|
|
})
|
|
}
|
|
|
|
const baseDecision = decideDocuments(baseStats.docBytes)
|
|
if ('tooLargeBytes' in baseDecision) {
|
|
return {
|
|
ok: false,
|
|
reason: 'archive_too_large',
|
|
message: 'Grunddata archive exceeds size limit',
|
|
size_bytes: baseDecision.tooLargeBytes,
|
|
size_limit_bytes: SIZE_LIMIT_BYTES,
|
|
}
|
|
}
|
|
planned.push({
|
|
key: 'base',
|
|
kind: 'base',
|
|
name: 'Grunddata.zip',
|
|
contentType: 'application/zip',
|
|
fingerprint: [
|
|
`v${ARCHIVE_FORMAT_VERSION}`,
|
|
// Any data change writes the audit log, so this covers master data.
|
|
latestAuditAt,
|
|
baseStats.docCount,
|
|
baseStats.maxDocCreated,
|
|
`docs:${baseDecision.includeDocuments ? 1 : 0}`,
|
|
].join('|'),
|
|
includeDocuments: baseDecision.includeDocuments,
|
|
generate: () =>
|
|
generateBaseDataArchive(supabase, companyId, {
|
|
include_documents: baseDecision.includeDocuments,
|
|
}),
|
|
})
|
|
|
|
// Folder README: fingerprinted on its content (no timestamp inside), so it
|
|
// uploads once and again only when the text or company name changes.
|
|
const readmeText = buildDriveFolderReadme({
|
|
companyName: company.name,
|
|
orgNumber: company.orgNumber,
|
|
generatedAt: '',
|
|
appName: getBranding().appName,
|
|
})
|
|
planned.push({
|
|
key: 'readme',
|
|
kind: 'readme',
|
|
name: 'LÄSMIG.txt',
|
|
contentType: 'text/plain',
|
|
fingerprint: `v${ARCHIVE_FORMAT_VERSION}|${sha256Hex(textToArrayBuffer(readmeText)).slice(0, 16)}`,
|
|
includeDocuments: true,
|
|
generate: async () => textToArrayBuffer(readmeText),
|
|
})
|
|
|
|
// ---- Execute: regenerate + upload only what changed. ----
|
|
const previous = await loadExtensionData<GoogleDriveLastSync>(
|
|
supabase,
|
|
companyId,
|
|
LAST_SYNC_KEY
|
|
)
|
|
const stateByKey = new Map<string, DriveFileState>()
|
|
for (const file of previous?.files ?? []) {
|
|
stateByKey.set(fileKey(file), file)
|
|
}
|
|
|
|
const persistSnapshot = async (): Promise<GoogleDriveLastSync> => {
|
|
const files = [...stateByKey.values()]
|
|
const lastSync: GoogleDriveLastSync = {
|
|
at: new Date().toISOString(),
|
|
folder_id: companyFolderId!,
|
|
files,
|
|
total_size_bytes: files.reduce((sum, f) => sum + f.size_bytes, 0),
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, LAST_SYNC_KEY, lastSync)
|
|
return lastSync
|
|
}
|
|
|
|
let uploadedCount = 0
|
|
let skippedCount = 0
|
|
for (const plan of planned) {
|
|
const prev = stateByKey.get(plan.key)
|
|
if (prev && prev.fingerprint === plan.fingerprint && prev.file_name === plan.name) {
|
|
skippedCount++
|
|
continue
|
|
}
|
|
|
|
const bytes = await plan.generate()
|
|
let uploaded: UploadResult
|
|
if (prev?.file_id && prev.file_name === plan.name) {
|
|
try {
|
|
uploaded = await updateFile(accessToken, prev.file_id, bytes, plan.contentType)
|
|
} catch (err) {
|
|
if (err instanceof DriveFileGoneError) {
|
|
// The user deleted the file in Drive: recreate it.
|
|
uploaded = await uploadFile(
|
|
accessToken,
|
|
companyFolderId,
|
|
plan.name,
|
|
bytes,
|
|
plan.contentType
|
|
)
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
} else {
|
|
uploaded = await uploadFile(accessToken, companyFolderId, plan.name, bytes, plan.contentType)
|
|
}
|
|
|
|
stateByKey.set(plan.key, {
|
|
kind: plan.kind,
|
|
period_id: plan.periodId,
|
|
file_id: uploaded.id,
|
|
file_name: uploaded.name,
|
|
size_bytes: uploaded.size_bytes,
|
|
fingerprint: plan.fingerprint,
|
|
sha256: sha256Hex(bytes),
|
|
included_documents: plan.includeDocuments,
|
|
uploaded_at: new Date().toISOString(),
|
|
})
|
|
uploadedCount++
|
|
// Persist progressively: an interrupted run (time budget, crash) resumes
|
|
// from the finished files instead of re-uploading them.
|
|
await persistSnapshot()
|
|
}
|
|
|
|
// Drop state for files no longer planned (e.g. a deleted fiscal period).
|
|
const plannedKeys = new Set(planned.map((p) => p.key))
|
|
for (const key of [...stateByKey.keys()]) {
|
|
if (!plannedKeys.has(key)) stateByKey.delete(key)
|
|
}
|
|
|
|
const lastSync = await persistSnapshot()
|
|
|
|
return {
|
|
ok: true,
|
|
lastSync,
|
|
webViewLink: `https://drive.google.com/drive/folders/${companyFolderId}`,
|
|
uploadedCount,
|
|
skippedCount,
|
|
}
|
|
}
|
|
|
|
function fileKey(file: DriveFileState): string {
|
|
return file.kind === 'period' ? `period:${file.period_id}` : file.kind
|
|
}
|
|
|
|
/** `Arkiv 2024.zip` for calendar years, full dates for broken years. */
|
|
export function arkivFileName(period: PeriodRow): string {
|
|
const year = period.period_start.slice(0, 4)
|
|
const isCalendarYear =
|
|
period.period_start === `${year}-01-01` && period.period_end === `${year}-12-31`
|
|
return isCalendarYear
|
|
? `Arkiv ${year}.zip`
|
|
: `Arkiv ${period.period_start}_${period.period_end}.zip`
|
|
}
|
|
|
|
function sha256Hex(data: ArrayBuffer): string {
|
|
return createHash('sha256').update(Buffer.from(data)).digest('hex')
|
|
}
|
|
|
|
function textToArrayBuffer(text: string): ArrayBuffer {
|
|
const bytes = new TextEncoder().encode(text)
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
}
|
|
|
|
async function fetchLatestAuditAt(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<string> {
|
|
const { data, error } = await supabase
|
|
.from('audit_log')
|
|
.select('created_at')
|
|
.eq('company_id', companyId)
|
|
// extension_data is non-portable runtime state and includes this sync's
|
|
// own progress snapshots. Letting those rows advance the watermark makes
|
|
// every completed backup invalidate Grunddata for the next run.
|
|
.neq('table_name', 'extension_data')
|
|
.order('created_at', { ascending: false })
|
|
.order('id', { ascending: false })
|
|
.limit(1)
|
|
if (error) throw new Error(`Failed to fetch backup audit watermark: ${error.message}`)
|
|
const rows = (data as { created_at: string }[] | null) ?? []
|
|
return rows[0]?.created_at ?? ''
|
|
}
|
|
|
|
export async function loadExtensionData<T>(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
key: string
|
|
): Promise<T | null> {
|
|
const { data } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('company_id', companyId)
|
|
.eq('extension_id', 'cloud-backup')
|
|
.eq('key', key)
|
|
.maybeSingle()
|
|
return (data?.value as T) ?? null
|
|
}
|
|
|
|
export async function saveExtensionData<T>(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
key: string,
|
|
value: T
|
|
): Promise<void> {
|
|
const { error } = await supabase.from('extension_data').upsert(
|
|
{
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
extension_id: 'cloud-backup',
|
|
key,
|
|
value,
|
|
},
|
|
{ onConflict: 'company_id,extension_id,key' }
|
|
)
|
|
if (error) throw new Error(`Failed to save extension data: ${error.message}`)
|
|
}
|
|
|
|
async function fetchCompanyInfo(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<{ name: string; orgNumber: string | null; label: string }> {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('company_name, org_number')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
const name = (data?.company_name as string) || 'företag'
|
|
const orgNumber = (data?.org_number as string) || null
|
|
const label = `${name} (${orgNumber || companyId.slice(0, 8)})`.replace(/[\\/]/g, '-')
|
|
return { name, orgNumber, label }
|
|
}
|