From 75ad4946d242b51ffc94688da961ff146f1e0247 Mon Sep 17 00:00:00 2001 From: Martin Berg Alstad Date: Sun, 2 Feb 2025 12:37:43 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Allow=20syncing=20multiple=20acc?= =?UTF-8?q?ounts=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - By defining multiple account keys in the .env, will fetch from all and upload to correct account - If transaction id is 0, will not be marked as cleared - Added accountKey to Transaction interface --- httpRequests/sparebank1API.http | 4 +++- src/actual.ts | 5 +++-- src/bank/sparebank1.ts | 8 ++++++-- src/bank/sparebank1Api.ts | 21 ++++++++------------- src/main.ts | 22 +++++++++++----------- src/mappings.ts | 33 +++++++++++++++++++++++++-------- 6 files changed, 56 insertions(+), 37 deletions(-) diff --git a/httpRequests/sparebank1API.http b/httpRequests/sparebank1API.http index 40b9fca..789a611 100644 --- a/httpRequests/sparebank1API.http +++ b/httpRequests/sparebank1API.http @@ -51,6 +51,8 @@ GET {{bankingBaseUrl}}/accounts Authorization: Bearer {{ACCESS_TOKEN}} ### Fetch all transactions of specific days (inclusive) -GET {{bankingBaseUrl}}/transactions?accountKey={{brukskontoAccountKey}}&fromDate=2025-01-20&toDate=2025-01-22 +GET {{bankingBaseUrl}}/transactions?accountKey={{accountKey1}}&accountKey={{accountKey2}}& + fromDate=2025-01-20& + toDate=2025-01-24 Authorization: Bearer {{ACCESS_TOKEN}} Accept: application/vnd.sparebank1.v1+json; charset=utf-8 diff --git a/src/actual.ts b/src/actual.ts index 038d474..275562b 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -12,13 +12,14 @@ import logger from "@/logger.ts" export interface Actual { importTransactions: ( accountId: UUID, - transactions: ReadonlyArray, + transactions: Iterable, ) => Promise shutdown: () => Promise } export interface ActualTransaction extends TransactionEntity { + account: UUID payee_name?: string } @@ -51,7 +52,7 @@ export class ActualImpl implements Actual { async importTransactions( accountId: UUID, - transactions: ReadonlyArray, + transactions: Iterable, ): Promise { return actual.importTransactions(accountId, transactions) } diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index e6beba8..db7fb57 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -27,8 +27,12 @@ export type BookingStatus = "PENDING" | "BOOKED" export interface Transaction { id: string nonUniqueId: string - date: number // Unix time - amount: number // Amount in NOK + // The Id of the account + accountKey: string + // Unix time + date: number + // Amount in NOK + amount: number cleanedDescription: string remoteAccountName: string bookingStatus: BookingStatus diff --git a/src/bank/sparebank1Api.ts b/src/bank/sparebank1Api.ts index eeb818b..b1eedb0 100644 --- a/src/bank/sparebank1Api.ts +++ b/src/bank/sparebank1Api.ts @@ -1,4 +1,4 @@ -import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "../../config.ts" +import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "@/../config.ts" import type { OAuthTokenResponse, TransactionResponse, @@ -6,6 +6,7 @@ import type { import logger from "@/logger.ts" import { type Dayjs } from "dayjs" import { toISODateString } from "@/date.ts" +import * as querystring from "node:querystring" const baseUrl = "https://api.sparebank1.no" @@ -13,13 +14,8 @@ type Success = { status: "success"; data: T } type Failure = { status: "failure"; data: T } type Result = Success | Failure -function success(data: T): Success { - return { status: "success", data: data } -} - -function failure(data: T): Failure { - return { status: "failure", data: data } -} +const success = (data: T): Success => ({ status: "success", data: data }) +const failure = (data: T): Failure => ({ status: "failure", data: data }) export async function transactions( accessToken: string, @@ -29,16 +25,15 @@ export async function transactions( toDate: Dayjs }, ): Promise { - const queries = new URLSearchParams({ - // TODO allow multiple accountKeys - accountKey: typeof accountKeys === "string" ? accountKeys : accountKeys[0], + const queryString = querystring.stringify({ + accountKey: accountKeys, ...(timePeriod && { fromDate: toISODateString(timePeriod.fromDate), toDate: toISODateString(timePeriod.toDate), }), }) - const url = `${baseUrl}/personal/banking/transactions?${queries}` + const url = `${baseUrl}/personal/banking/transactions?${queryString}` logger.debug(`Sending GET request to '${url}'`) const response = await fetch(url, { headers: { @@ -58,7 +53,7 @@ export async function transactions( export async function refreshToken( refreshToken: string, ): Promise> { - const queries = new URLSearchParams({ + const queries = querystring.stringify({ client_id: BANK_OAUTH_CLIENT_ID, client_secret: BANK_OAUTH_CLIENT_SECRET, refresh_token: refreshToken, diff --git a/src/main.ts b/src/main.ts index be60b59..75d6ed1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,6 @@ import { } from "@/bank/sparebank1.ts" import { bankTransactionIntoActualTransaction } from "@/mappings.ts" import { - ACTUAL_ACCOUNT_IDS, ACTUAL_DATA_DIR, BANK_ACCOUNT_IDS, DB_DIRECTORY, @@ -30,22 +29,23 @@ export async function daily(actual: Actual, bank: Bank): Promise { const transactions = await fetchTransactionsFromPastDay(bank) logger.info(`Fetched ${transactions.length} transactions`) - // TODO multiple accounts - const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID const actualTransactions = transactions.map((transaction) => // TODO move to Bank interface? - bankTransactionIntoActualTransaction(transaction, accountId), + bankTransactionIntoActualTransaction(transaction), ) - // TODO Import transactions into Actual - // If multiple accounts, loop over them - // Get account ID from mapper - - const response = await actual.importTransactions( - accountId, + const transactionsGroup = Object.groupBy( actualTransactions, + (transaction) => transaction.account, ) - logger.info(`ImportTransactionsResponse=${JSON.stringify(response)}`) + + const response = await Promise.all( + Object.entries(transactionsGroup).map(([accountId, transactions]) => + actual.importTransactions(accountId as UUID, transactions || []), + ), + ) + + logger.debug(response, "Finished importing transactions") } async function fetchTransactionsFromPastDay( diff --git a/src/mappings.ts b/src/mappings.ts index b5eafde..03108a9 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -3,27 +3,44 @@ import type { UUID } from "node:crypto" import dayjs from "dayjs" import { toISODateString } from "@/date.ts" import { type ActualTransaction } from "@/actual.ts" +import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts" +import logger from "@/logger.ts" export function bankTransactionIntoActualTransaction( transaction: Transaction, - accountId: UUID, ): ActualTransaction { return { id: transaction.id, // Transactions with the same id will be ignored imported_id: transaction.nonUniqueId, - account: accountId, + account: getActualAccountId(transaction), // The value without decimals - amount: transaction.amount * 100, + amount: Math.floor(transaction.amount * 100), date: toISODateString(dayjs(transaction.date)), payee_name: transaction.cleanedDescription, // TODO if not cleared or nonUniqueId is 0, rerun later - cleared: transaction.bookingStatus === "BOOKED", + cleared: isCleared(transaction), } } -// TODO take the account from the bank and match it to the actual account -// Use ENV -export function bankAccountIntoActualAccount(account: string): string { - throw new Error("Not implemented") +export function isCleared(transaction: Transaction): boolean { + const id = Number(transaction.nonUniqueId) + return transaction.bookingStatus === "BOOKED" && !Number.isNaN(id) && id > 0 +} + +function getActualAccountId(transcation: Transaction): UUID { + for ( + let i = 0; + i < Math.min(ACTUAL_ACCOUNT_IDS.length, BANK_ACCOUNT_IDS.length); + i++ + ) { + if (BANK_ACCOUNT_IDS[i] === transcation.accountKey) { + return ACTUAL_ACCOUNT_IDS[i] as UUID + } + } + const error = new Error( + "Failed to find ActualAccountId, length of BANK_ACCOUNT_IDS and ACTUAL_ACCOUNT_IDS must match", + ) + logger.error(error) + throw error }