File size: 8,612 Bytes
c09f67c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
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";

/**
 * Result of initializing an accounting provider
 */
export interface InitializedProvider {
  provider: AccountingProvider;
  config: AccountingProviderConfig;
  db: Database;
}

/**
 * Transaction data from DB for mapping to provider format
 */
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;
  /** Tax amount from OCR or manual entry */
  taxAmount: number | null;
  /** Tax rate percentage (e.g., 25 for 25%) */
  taxRate: number | null;
  /** Tax type (e.g., "VAT", "moms", "GST") */
  taxType: string | null;
  /** Category's tax rate (fallback if transaction doesn't have one) */
  categoryTaxRate: number | null;
  /** Category's tax type (fallback if transaction doesn't have one) */
  categoryTaxType: string | null;
  /** User's personal notes about the transaction */
  note: string | null;
  attachments: Array<{
    id: string;
    name: string | null;
    path: string[] | null;
    type: string | null;
    size: number | null;
  }>;
}

/**
 * Supported accounting provider IDs
 */
export type AccountingProviderId = "xero" | "quickbooks" | "fortnox";

/**
 * Check if config has required fields for any accounting provider
 * Uses discriminator-based type narrowing for type safety
 */
function hasRequiredConfigFields(config: AccountingProviderConfig): boolean {
  if (!config.accessToken || !config.refreshToken || !config.provider) {
    return false;
  }
  // Check for organization ID based on provider discriminator
  switch (config.provider) {
    case "xero":
      return !!config.tenantId;
    case "quickbooks":
      return !!config.realmId;
    case "fortnox":
      // Fortnox doesn't require a tenant ID - company context is from token
      return true;
    default: {
      // TypeScript exhaustive check for future providers
      const _exhaustive: never = config;
      return false;
    }
  }
}

/**
 * Base class for accounting processors with shared functionality
 *
 * Provides:
 * - Provider initialization with token validation
 * - Transaction mapping to provider format
 * - Standardized error handling
 */
export abstract class AccountingProcessorBase<
  TData = unknown,
> extends BaseProcessor<TData> {
  /**
   * Initialize an accounting provider with valid tokens
   *
   * Handles:
   * - Fetching app configuration from DB
   * - Validating required config fields
   * - Getting OAuth credentials from environment
   * - Creating provider instance
   * - Refreshing tokens if expired
   *
   * @throws Error if provider is not connected or configuration is invalid
   */
  protected async initializeProvider(
    teamId: string,
    providerId: string,
  ): Promise<InitializedProvider> {
    const db = getDb();

    // Get the app configuration for this provider
    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;

    // Validate required config fields
    if (!hasRequiredConfigFields(config)) {
      throw new Error(
        `Invalid ${providerId} configuration - missing tokens or org ID`,
      );
    }

    // Get and validate OAuth credentials from environment
    const credentials = getProviderCredentials(providerId);
    validateProviderCredentials(providerId, credentials);

    const provider = getAccountingProvider(
      providerId as AccountingProviderId,
      config,
    );

    // Ensure token is valid (refresh if expired)
    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 };
  }

  /**
   * Get the organization/tenant ID from the config in a provider-agnostic way
   */
  protected getOrgIdFromConfig(config: AccountingProviderConfig): string {
    return getOrgId(config);
  }

  /**
   * Map transactions from DB format to accounting provider format
   *
   * Handles:
   * - Type conversions
   * - Null coalescing for optional fields
   * - Attachment filtering and mapping
   */
  protected mapTransactionsToProvider(
    transactions: TransactionForMapping[],
  ): MappedTransaction[] {
    return transactions.map((tx) => {
      // Resolve tax values using priority:
      // 1. Transaction taxAmount (if set)
      // 2. Calculate from transaction taxRate
      // 3. Calculate from category taxRate/taxType
      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,
        // Resolved tax values (from transaction or category)
        taxAmount: taxAmount ?? undefined,
        taxRate: taxRate ?? undefined,
        taxType: taxType ?? undefined,
        // User notes
        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,
            })) ?? [],
      };
    });
  }

  /**
   * Get the target bank account from the provider
   * Uses defaultBankAccountId from config if set, otherwise falls back to first active account
   *
   * @throws Error if no active account is found
   */
  protected async getTargetAccount(
    provider: AccountingProvider,
    orgId: string,
    config?: AccountingProviderConfig,
  ): Promise<{ id: string; name: string }> {
    const accounts = await provider.getAccounts(orgId);

    // Use configured default account if set
    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,
        },
      );
    }

    // Fall back to first active account
    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;
  }
}