| import { |
| type AccountingProvider, |
| type AccountingProviderConfig, |
| getAccountingProvider, |
| getOrgId, |
| type MappedTransaction, |
| } from "@midday/accounting"; |
| import type { Database } from "@midday/db/client"; |
| import { getAppByAppId } from "@midday/db/queries"; |
| import { resolveTaxValues } from "@midday/utils/tax"; |
| import { |
| ensureValidToken, |
| getProviderCredentials, |
| validateProviderCredentials, |
| } from "../../utils/accounting-auth"; |
| import { getDb } from "../../utils/db"; |
| import { BaseProcessor } from "../base"; |
|
|
| |
| |
| |
| export interface InitializedProvider { |
| provider: AccountingProvider; |
| config: AccountingProviderConfig; |
| db: Database; |
| } |
|
|
| |
| |
| |
| export interface TransactionForMapping { |
| id: string; |
| date: string; |
| name: string; |
| description: string | null; |
| amount: number; |
| currency: string; |
| categorySlug: string | null; |
| categoryReportingCode: string | null; |
| counterpartyName: string | null; |
| |
| taxAmount: number | null; |
| |
| taxRate: number | null; |
| |
| taxType: string | null; |
| |
| categoryTaxRate: number | null; |
| |
| categoryTaxType: string | null; |
| |
| note: string | null; |
| attachments: Array<{ |
| id: string; |
| name: string | null; |
| path: string[] | null; |
| type: string | null; |
| size: number | null; |
| }>; |
| } |
|
|
| |
| |
| |
| export type AccountingProviderId = "xero" | "quickbooks" | "fortnox"; |
|
|
| |
| |
| |
| |
| function hasRequiredConfigFields(config: AccountingProviderConfig): boolean { |
| if (!config.accessToken || !config.refreshToken || !config.provider) { |
| return false; |
| } |
| |
| switch (config.provider) { |
| case "xero": |
| return !!config.tenantId; |
| case "quickbooks": |
| return !!config.realmId; |
| case "fortnox": |
| |
| return true; |
| default: { |
| |
| const _exhaustive: never = config; |
| return false; |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export abstract class AccountingProcessorBase< |
| TData = unknown, |
| > extends BaseProcessor<TData> { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| protected async initializeProvider( |
| teamId: string, |
| providerId: string, |
| ): Promise<InitializedProvider> { |
| const db = getDb(); |
|
|
| |
| const app = await getAppByAppId(db, { appId: providerId, teamId }); |
|
|
| if (!app || !app.config) { |
| throw new Error(`${providerId} is not connected for this team`); |
| } |
|
|
| let config = app.config as AccountingProviderConfig; |
|
|
| |
| if (!hasRequiredConfigFields(config)) { |
| throw new Error( |
| `Invalid ${providerId} configuration - missing tokens or org ID`, |
| ); |
| } |
|
|
| |
| const credentials = getProviderCredentials(providerId); |
| validateProviderCredentials(providerId, credentials); |
|
|
| const provider = getAccountingProvider( |
| providerId as AccountingProviderId, |
| config, |
| ); |
|
|
| |
| try { |
| config = await ensureValidToken(db, provider, config, teamId, providerId); |
| this.logger.info("Token validated/refreshed successfully", { |
| teamId, |
| providerId, |
| }); |
| } catch (error) { |
| this.logger.error("Failed to validate/refresh token", { |
| teamId, |
| providerId, |
| error: error instanceof Error ? error.message : "Unknown error", |
| }); |
| throw new Error("Failed to validate authentication token"); |
| } |
|
|
| return { provider, config, db }; |
| } |
|
|
| |
| |
| |
| protected getOrgIdFromConfig(config: AccountingProviderConfig): string { |
| return getOrgId(config); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| protected mapTransactionsToProvider( |
| transactions: TransactionForMapping[], |
| ): MappedTransaction[] { |
| return transactions.map((tx) => { |
| |
| |
| |
| |
| const { taxAmount, taxRate, taxType } = resolveTaxValues({ |
| transactionAmount: tx.amount, |
| transactionTaxAmount: tx.taxAmount, |
| transactionTaxRate: tx.taxRate, |
| transactionTaxType: tx.taxType, |
| categoryTaxRate: tx.categoryTaxRate, |
| categoryTaxType: tx.categoryTaxType, |
| }); |
|
|
| return { |
| id: tx.id, |
| date: tx.date, |
| amount: tx.amount, |
| currency: tx.currency, |
| description: tx.name || tx.description || "Transaction", |
| reference: tx.id.slice(0, 8), |
| counterpartyName: tx.name ?? undefined, |
| category: tx.categorySlug ?? undefined, |
| categoryReportingCode: tx.categoryReportingCode ?? undefined, |
| |
| taxAmount: taxAmount ?? undefined, |
| taxRate: taxRate ?? undefined, |
| taxType: taxType ?? undefined, |
| |
| note: tx.note ?? undefined, |
| attachments: |
| tx.attachments |
| ?.filter( |
| ( |
| att, |
| ): att is typeof att & { |
| name: string; |
| path: string[]; |
| type: string; |
| size: number; |
| } => |
| att.name !== null && |
| att.path !== null && |
| att.type !== null && |
| att.size !== null, |
| ) |
| .map((att) => ({ |
| id: att.id, |
| name: att.name, |
| path: att.path, |
| mimeType: att.type, |
| size: att.size, |
| })) ?? [], |
| }; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| protected async getTargetAccount( |
| provider: AccountingProvider, |
| orgId: string, |
| config?: AccountingProviderConfig, |
| ): Promise<{ id: string; name: string }> { |
| const accounts = await provider.getAccounts(orgId); |
|
|
| |
| if (config?.defaultBankAccountId) { |
| const defaultAccount = accounts.find( |
| (a) => a.id === config.defaultBankAccountId && a.status === "active", |
| ); |
|
|
| if (defaultAccount) { |
| this.logger.info("Using configured default account", { |
| accountId: defaultAccount.id, |
| accountName: defaultAccount.name, |
| }); |
| return defaultAccount; |
| } |
|
|
| this.logger.warn( |
| "Configured default account not found or inactive, falling back", |
| { |
| configuredAccountId: config.defaultBankAccountId, |
| }, |
| ); |
| } |
|
|
| |
| const targetAccount = accounts.find( |
| (a: { status: string }) => a.status === "active", |
| ); |
|
|
| if (!targetAccount) { |
| throw new Error("No active bank account found in accounting provider"); |
| } |
|
|
| this.logger.info("Using target account", { |
| accountId: targetAccount.id, |
| accountName: targetAccount.name, |
| }); |
|
|
| return targetAccount; |
| } |
| } |
|
|