diff --git a/src/background.ts b/src/background.ts index 6428f9a..9a80ed0 100644 --- a/src/background.ts +++ b/src/background.ts @@ -5,45 +5,46 @@ import { PartialStateUpdate, State } from './state'; type HttpHeaders = browser.webRequest.HttpHeaders; -function extractFromHeaders(headers: HttpHeaders): PartialStateUpdate { +function extractAuthStateFromHeaders(headers: HttpHeaders): PartialStateUpdate { let apiKey = ''; - let gqlAuthToken = ''; - let rsaFingerprint = ''; + let authToken = ''; + let fingerprintValue = ''; for (const header of headers) { switch (header.name.toLowerCase()) { case "authorization": - gqlAuthToken = header.value || ''; + authToken = header.value || ''; break; case "apikey": apiKey = header.value || ''; break; - case "rsa-fingerprint": - rsaFingerprint = header.value || ''; + case "fingerprintvalue": + fingerprintValue = header.value || ''; break; } } - - return { apiKey, gqlAuthToken, rsaFingerprint }; + return { apiKey, authToken, fingerprintValue }; } browser.webRequest.onBeforeSendHeaders.addListener(e => { // TODO if request errors, then clear state and try again. (async function() { - const state = await State.current; + let state = await State.current; if (!state.ready && e.requestHeaders) { console.info('Will update auth info from headers'); // Lock ready to true so we don't have multiple attempts // at getting account info. await State.update({ ready: true }); - let newState = extractFromHeaders(e.requestHeaders); + let newState = extractAuthStateFromHeaders(e.requestHeaders); + state = await State.update(newState); - State.update(newState) - .then(state => !state.accounts ? fetchAccounts() : state.accounts) - .then(accounts => State.update({ accounts })) - .then(() => console.log('done')); + if (!newState.accounts) { + const accounts = await fetchAccounts(state); + await State.update({ accounts }); + console.info('Acquired account data'); + } } })(); }, matchGraphQL, ["requestHeaders"]); diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts deleted file mode 100644 index 8234751..0000000 --- a/src/graphql/queries.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { gql } from 'graphql-request/build/esm'; - -// Currently not imported due to a bug with Parcel and the bundle-text -// importer, and complications with running the query from the -// imported text. - -// Exports -export const fetchAccounts = gql` -query fetchAccounts($first: Int, $after: String, $filter: AccountFilterArgs, $orderBy: AccountOrderBy) { - viewer { - id - accounts(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - nodes { - ...AccountSelectorAccountParts - __typename - } - totalCount - } - } -} - -fragment AccountSelectorAccountParts on Account { - id - accountNumber - name - kennitala - owner - state - isVaxtareikningur30 - isNoticeAccount - currency - isCurrencyAccount - availableAmount - balance - orderedAmountAvailable - orderedAmountTotal - __typename -} -` diff --git a/src/landsbankinn.ts b/src/landsbankinn.ts deleted file mode 100644 index ebf71d4..0000000 --- a/src/landsbankinn.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { GraphQLClient } from 'graphql-request/build/esm'; -import { setDefaultOptions, format } from 'date-fns'; -import { enUS } from 'date-fns/locale'; -import { RequestConfig } from 'graphql-request/build/esm/types'; -import { State } from './state'; -import * as queries from './graphql/queries'; -import { FetchAccountsResponse } from '~graphql/models'; - -setDefaultOptions({ locale: enUS }); - -const apiEndpoint = 'https://netbanki.landsbankinn.is/services/accounts/$accountID/transactions'; - -export enum TransactionFormat { JSON }; -export enum OutputFormat { CSV }; - -export interface DownloadTransactionsRequest { - accountNumber: string; - dateFrom: Date; - dateTo: Date; - outputFormat: OutputFormat; -} - -export interface RawTransaction { - //txn id? - id: string; - //often null - accountNumber: string | null; - amount: Number; - currencyCode: string; - //ISO date - bookingDate: string; - //ISO date - valueDate: string; - - balanceAfterTransaction: Number; - - displayText: string; - - //how do these relate to id? - reference: string; - referenceNumber: string; - - //how does it relate to the booking/value dates? - //exact time of txn/ - timestamp: string; - - textCodeDescription: string; - payerDescription: string; - - // e.g. Utteky med debitkorti - description: String; - - batchID: string; - - // only for individuals? - recipientKennitala: string; - - recipientName: string; - //probably some kind of txn category (central bank?) - transactionCodekey: string; - - bankBranch: string; - debitCardWarrantyNumber: string | null; - - //currency to ISK exchange rate - exchangeRate: Number; - - // what is displayed to the user in the table - customerDescription: string; - - //don't know what this could be. - details: null; - -} - -//Sat Jan 03 2023 -const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy'); - -function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) { - const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`; - const from = encodeURIComponent(formatDate(dateFrom)); - const to = encodeURIComponent(formatDate(dateTo)); - const params = `?from=${from}&to=${to}` - return url + params; -} - -export async function downloadTransactions(req: DownloadTransactionsRequest): Promise> { - const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo); - const options: RequestInit = { - method: "GET", - credentials: "include", - cache: "no-cache", - referrerPolicy: "strict-origin-when-cross-origin", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - }; - - const result = await fetch(url, options); - if (result.ok) { - return await result.json(); - } else { - //attempt to read json. - const error = await result.json(); - if (error.hasOwnProperty('message')) { - console.error(error['message']); - } - console.error(result); - return new Array(); - } -} - -export async function fetchAccounts(): Promise { - const state = await State.current; - const options: RequestConfig = { - mode: "cors", - credentials: "include", - cache: "no-cache", - referrerPolicy: "strict-origin-when-cross-origin", - headers: { - apikey: state.apiKey, - authorization: state.gqlAuthToken, - "rsa-fingerprint": state.rsaFingerprint - }, - }; - - const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options); - - const variables = { - "first": 100, - "filter": { - "includeClosed": true - }, - }; - - const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables); - //TODO transform janky graphql response into nicer data structure for us. - return data; -} diff --git a/src/landsbankinn/graphql/api.ts b/src/landsbankinn/graphql/api.ts new file mode 100644 index 0000000..a3a7f29 --- /dev/null +++ b/src/landsbankinn/graphql/api.ts @@ -0,0 +1,51 @@ +import { GraphQLClient } from 'graphql-request/build/esm'; +import { RequestConfig } from 'graphql-request/build/esm/types'; +import * as queries from './queries'; +import { FetchAccountsResponse } from './models'; +import { Account } from '../models'; + +// The Context interface separates the required authentication +// information from the actual GraphQL API call. Anything that meets +// the shape (i.e. the application auth state) can be passed in. +export interface Context { + apiKey: string; + authToken: string; + fingerprintValue: string; +} + +function convertToDomain(resp: FetchAccountsResponse): Array { + const accounts = []; + for (const edge of resp.viewer.accounts.edges) { + accounts.push({ + accountNumber: edge.node.accountNumber, + currency: edge.node.currency, + name: edge.node.name + }); + } + + return accounts; +} + +export async function fetchAccounts(ctx: Context): Promise> { + const options: RequestConfig = { + mode: "cors", + credentials: "include", + cache: "no-cache", + referrerPolicy: "strict-origin-when-cross-origin", + headers: { + apikey: ctx.apiKey, + authorization: ctx.authToken, + "rsa-fingerprint": ctx.fingerprintValue, + "onlinebank-type": "personal" //Todo can it be also business? + }, + }; + + const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options); + + const variables = { + "pageSize": 40, + }; + + const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables); + return convertToDomain(data); +} diff --git a/src/graphql/models.ts b/src/landsbankinn/graphql/models.ts similarity index 53% rename from src/graphql/models.ts rename to src/landsbankinn/graphql/models.ts index 2e9e3bf..e08a10a 100644 --- a/src/graphql/models.ts +++ b/src/landsbankinn/graphql/models.ts @@ -8,23 +8,31 @@ export interface Viewer { } export interface Accounts { - nodes: Array; + edges: Array; totalCount: number; } +export interface Edge { + //appears to be an id? + cursor: string; + node: Node; +} + export interface Node { id: string; - accountNumber: string; + idAccount: string; name: string; - kennitala: string; - owner: string; - state: string; + accountNumber: string; + balance: number; + availableAmount: number; + currency: string; + accountProductName: string; + overdraftLimit: number; isVaxtareikningur30: boolean; isNoticeAccount: boolean; - currency: string; - isCurrencyAccount: boolean; - availableAmount: number; - balance: number; - orderedAmountAvailable: number; - orderedAmountTotal: number; + hasDebitCards: boolean; + canBeRegularSavingsAccount: boolean; + isWithdrawalAccount: boolean; + isOverdraftAllowed: boolean; + showOnFrontpage: boolean; } diff --git a/src/landsbankinn/graphql/queries.ts b/src/landsbankinn/graphql/queries.ts new file mode 100644 index 0000000..26ca515 --- /dev/null +++ b/src/landsbankinn/graphql/queries.ts @@ -0,0 +1,47 @@ +import { gql } from 'graphql-request/build/esm'; + +// Currently not imported due to a bug with Parcel and the bundle-text +// importer, and complications with running the query from the +// imported text. + +// Exports +export const fetchAccounts = gql` +query fetchDomesticAccounts($after: String, $pageSize: Int = 20) { + viewer { + id + accounts(first: $pageSize, after: $after, filter: {includedCurrencies: ["ISK"]}) { + edges { + cursor + node { + id + idAccount + name + accountNumber + balance + availableAmount + currency + accountProductName + overdraftLimit + isVaxtareikningur30 + isNoticeAccount + hasDebitCards + canBeRegularSavingsAccount + isWithdrawalAccount + isOverdraftAllowed + showOnFrontpage + __typename + } + __typename + } + totalCount + pageInfo { + hasNextPage + currentPage + __typename + } + __typename + } + __typename + } +} +` diff --git a/src/landsbankinn/index.ts b/src/landsbankinn/index.ts new file mode 100644 index 0000000..edb03d1 --- /dev/null +++ b/src/landsbankinn/index.ts @@ -0,0 +1,5 @@ +// Simply re-exports the organized code into one unified API. +export * from './netbanki/api'; +export * from './netbanki/models'; +export * from './graphql/api'; +export * from './graphql/models'; diff --git a/src/landsbankinn/models.ts b/src/landsbankinn/models.ts new file mode 100644 index 0000000..db9c100 --- /dev/null +++ b/src/landsbankinn/models.ts @@ -0,0 +1,9 @@ +// These models are common, unified, domain-specific types for the +// extension. The models under the specific API clients are specific +// to Landsbankinn's own domain. + +export interface Account { + accountNumber: string; + name: String; + currency: string; +} diff --git a/src/landsbankinn/netbanki/api.ts b/src/landsbankinn/netbanki/api.ts new file mode 100644 index 0000000..f8b8480 --- /dev/null +++ b/src/landsbankinn/netbanki/api.ts @@ -0,0 +1,42 @@ +import { setDefaultOptions, format } from 'date-fns'; +import { enUS } from 'date-fns/locale'; +import { RawTransaction, DownloadTransactionsRequest } from './models'; + +setDefaultOptions({ locale: enUS }); + +const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy'); + +function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) { + const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`; + const from = encodeURIComponent(formatDate(dateFrom)); + const to = encodeURIComponent(formatDate(dateTo)); + const params = `?from=${from}&to=${to}` + return url + params; +} + +export async function downloadTransactions(req: DownloadTransactionsRequest): Promise> { + const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo); + const options: RequestInit = { + method: "GET", + credentials: "include", + cache: "no-cache", + referrerPolicy: "strict-origin-when-cross-origin", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + }; + + const result = await fetch(url, options); + if (result.ok) { + return await result.json(); + } else { + //attempt to read error json. + const error = await result.json(); + if (error.hasOwnProperty('message')) { + console.error(error['message']); + } + console.error(result); + return new Array(); + } +} diff --git a/src/landsbankinn/netbanki/models.ts b/src/landsbankinn/netbanki/models.ts new file mode 100644 index 0000000..e891391 --- /dev/null +++ b/src/landsbankinn/netbanki/models.ts @@ -0,0 +1,61 @@ +export enum TransactionFormat { JSON }; +export enum OutputFormat { CSV }; + +export interface DownloadTransactionsRequest { + accountNumber: string; + dateFrom: Date; + dateTo: Date; + outputFormat: OutputFormat; +} + +export interface RawTransaction { + //txn id? + id: string; + //often null + accountNumber: string | null; + amount: Number; + currencyCode: string; + //ISO date + bookingDate: string; + //ISO date + valueDate: string; + + balanceAfterTransaction: Number; + + displayText: string; + + //how do these relate to id? + reference: string; + referenceNumber: string; + + //how does it relate to the booking/value dates? + //exact time of txn/ + timestamp: string; + + textCodeDescription: string; + payerDescription: string; + + // e.g. Utteky med debitkorti + description: String; + + batchID: string; + + // only for individuals? + recipientKennitala: string; + + recipientName: string; + //probably some kind of txn category (central bank?) + transactionCodekey: string; + + bankBranch: string; + debitCardWarrantyNumber: string | null; + + //currency to ISK exchange rate + exchangeRate: Number; + + // what is displayed to the user in the table + customerDescription: string; + + //don't know what this could be. + details: null; +} diff --git a/src/state.ts b/src/state.ts index c0e3cde..434d715 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,23 +1,23 @@ import { Level } from 'level'; -import { FetchAccountsResponse } from '~graphql/models'; +import { Account } from '~landsbankinn/models'; export interface ExporterState { apiKey: string; - gqlAuthToken: string; - rsaFingerprint: string; - accounts: FetchAccountsResponse; + authToken: string; + fingerprintValue: string; + accounts: Array; ready: boolean; } export interface PartialStateUpdate { apiKey?: string; - gqlAuthToken?: string; - rsaFingerprint?: string; - accounts?: FetchAccountsResponse; + authToken?: string; + fingerprintValue?: string; + accounts?: Array; ready?: boolean; } -const FIVE_MINUTES = 5 * 60 * 1000; +const FIVE_MINUTES = 0.25 * 60 * 1000; type LevelError = Error | null | undefined; @@ -63,7 +63,7 @@ class LevelState { this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES); } - async update(stateUpdate: ExporterState | PartialStateUpdate) { + async update(stateUpdate: ExporterState | PartialStateUpdate): Promise { this.clearTTL(); const currentState: ExporterState = await this.current; let newState = { ...currentState, ...stateUpdate };