initial commit
This commit is contained in:
commit
10dc0401be
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
yarn-error.log
|
||||||
|
.parcel-cache/
|
||||||
|
dist/
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"devDependencies": {
|
||||||
|
"@parcel/config-webextension": "^2.8.3",
|
||||||
|
"@parcel/transformer-graphql": "2.8.3",
|
||||||
|
"@parcel/transformer-inline-string": "2.8.3",
|
||||||
|
"@types/firefox-webext-browser": "^111.0.0",
|
||||||
|
"@types/level-ttl": "^3.1.2",
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"events": "^3.1.0",
|
||||||
|
"parcel": "^2.8.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
|
||||||
|
"build": "parcel build src/manifest.json --no-content-hash --config @parcel/config-webextension",
|
||||||
|
"clean": "rm -rf dist/ .parcel-cache/"
|
||||||
|
},
|
||||||
|
"@parcel/bundler-default": {
|
||||||
|
"minBundles": 10000000,
|
||||||
|
"minBundleSize": 3000,
|
||||||
|
"maxParallelRequests": 20
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": "^2.29.3",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
|
"graphql-request": "^5.2.0",
|
||||||
|
"level": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] };
|
||||||
|
|
||||||
|
import { fetchAccounts } from './landsbankinn';
|
||||||
|
import { PartialStateUpdate, State } from './state';
|
||||||
|
|
||||||
|
type HttpHeaders = browser.webRequest.HttpHeaders;
|
||||||
|
|
||||||
|
function extractFromHeaders(headers: HttpHeaders): PartialStateUpdate {
|
||||||
|
let apiKey = '';
|
||||||
|
let gqlAuthToken = '';
|
||||||
|
let rsaFingerprint = '';
|
||||||
|
|
||||||
|
for (const header of headers) {
|
||||||
|
switch (header.name.toLowerCase()) {
|
||||||
|
case "authorization":
|
||||||
|
gqlAuthToken = header.value || '';
|
||||||
|
break;
|
||||||
|
case "apikey":
|
||||||
|
apiKey = header.value || '';
|
||||||
|
break;
|
||||||
|
case "rsa-fingerprint":
|
||||||
|
rsaFingerprint = header.value || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return { apiKey, gqlAuthToken, rsaFingerprint };
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.webRequest.onBeforeSendHeaders.addListener(e => {
|
||||||
|
// TODO if request errors, then clear state and try again.
|
||||||
|
(async function() {
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
State.update(newState)
|
||||||
|
.then(state => !state.accounts ? fetchAccounts() : state.accounts)
|
||||||
|
.then(accounts => State.update({ accounts }))
|
||||||
|
.then(() => console.log('done'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, matchGraphQL, ["requestHeaders"]);
|
||||||
|
|
||||||
|
export { }
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { setDefaultOptions, parse, format } from 'date-fns';
|
||||||
|
import { enUS } from 'date-fns/locale';
|
||||||
|
import { fetchAccounts, OutputFormat, DownloadTransactionsRequest, downloadTransactions } from './landsbankinn';
|
||||||
|
|
||||||
|
setDefaultOptions({ locale: enUS });
|
||||||
|
|
||||||
|
|
||||||
|
//First attempt hardcoded data
|
||||||
|
async function doIt() {
|
||||||
|
console.log('doing it');
|
||||||
|
const from = parse('01 Jan 2023', 'dd MMM yyyy', new Date());
|
||||||
|
console.log(from);
|
||||||
|
const to = parse('31 Jan 2023', 'dd MMM yyyy', new Date());
|
||||||
|
const req: DownloadTransactionsRequest = {
|
||||||
|
accountNumber: '010126013792',
|
||||||
|
dateFrom: from,
|
||||||
|
dateTo: to,
|
||||||
|
outputFormat: OutputFormat.CSV,
|
||||||
|
};
|
||||||
|
|
||||||
|
const txns = await downloadTransactions(req);
|
||||||
|
console.log(txns);
|
||||||
|
}
|
||||||
|
|
||||||
|
//doIt().then(() => alert('did it'));
|
||||||
|
|
||||||
|
async function fetchIt() {
|
||||||
|
console.log('fetching it');
|
||||||
|
const data = fetchAccounts();
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
//fetchIt().then(() => alert('fetched it'));
|
|
@ -0,0 +1,30 @@
|
||||||
|
export interface FetchAccountsResponse {
|
||||||
|
viewer: Viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Viewer {
|
||||||
|
id: string;
|
||||||
|
accounts: Accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Accounts {
|
||||||
|
nodes: Array<Node>;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
id: string;
|
||||||
|
accountNumber: string;
|
||||||
|
name: string;
|
||||||
|
kennitala: string;
|
||||||
|
owner: string;
|
||||||
|
state: string;
|
||||||
|
isVaxtareikningur30: boolean;
|
||||||
|
isNoticeAccount: boolean;
|
||||||
|
currency: string;
|
||||||
|
isCurrencyAccount: boolean;
|
||||||
|
availableAmount: number;
|
||||||
|
balance: number;
|
||||||
|
orderedAmountAvailable: number;
|
||||||
|
orderedAmountTotal: number;
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
`
|
|
@ -0,0 +1,140 @@
|
||||||
|
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<Array<RawTransaction>> {
|
||||||
|
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<FetchAccountsResponse> {
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Sample Web Extension",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"permissions": [
|
||||||
|
"https://netbanki.landsbankinn.is/",
|
||||||
|
"https://graphql.landsbankinn.is/*",
|
||||||
|
"webRequest",
|
||||||
|
"webRequestBlocking"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": [ "background.ts" ],
|
||||||
|
"persistent": false
|
||||||
|
},
|
||||||
|
"content_scripts": [{
|
||||||
|
"matches": ["https://netbanki.landsbankinn.is/*", "https://graphql.landsbankinn.is/v2"],
|
||||||
|
"js": ["content-script.ts"]
|
||||||
|
}]
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module 'bundle-text:*' {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Level } from 'level';
|
||||||
|
import { FetchAccountsResponse } from '~graphql/models';
|
||||||
|
|
||||||
|
export interface ExporterState {
|
||||||
|
apiKey: string;
|
||||||
|
gqlAuthToken: string;
|
||||||
|
rsaFingerprint: string;
|
||||||
|
accounts: FetchAccountsResponse;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialStateUpdate {
|
||||||
|
apiKey?: string;
|
||||||
|
gqlAuthToken?: string;
|
||||||
|
rsaFingerprint?: string;
|
||||||
|
accounts?: FetchAccountsResponse;
|
||||||
|
ready?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
type LevelError = Error | null | undefined;
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
const errorNotFound = (err: LevelError) => err.code && err.code == 'LEVEL_NOT_FOUND';
|
||||||
|
|
||||||
|
class LevelState {
|
||||||
|
private _db = new Level('landsbankinn-exporter', { valueEncoding: 'json' });
|
||||||
|
private currentTTLHandle: number | undefined = -1;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
function creator(err: Error | null | undefined) {
|
||||||
|
if (err) {
|
||||||
|
if (errorNotFound(err)) {
|
||||||
|
console.info('Creating initial empty state');
|
||||||
|
//@ts-ignore
|
||||||
|
this._db.put('state', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
this.prepTTL().then(() => self._db.get('state', {}, creator.bind(self)));
|
||||||
|
}
|
||||||
|
|
||||||
|
get current(): Promise<ExporterState> {
|
||||||
|
return this._db.get('state') as unknown as Promise<ExporterState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
console.info('Cleared state');
|
||||||
|
return await this._db.put('state', {} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearTTL() {
|
||||||
|
if (this.currentTTLHandle) {
|
||||||
|
window.clearTimeout(this.currentTTLHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepTTL() {
|
||||||
|
console.info("Will clear state in 5 minutes");
|
||||||
|
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(stateUpdate: ExporterState | PartialStateUpdate) {
|
||||||
|
this.clearTTL();
|
||||||
|
const currentState: ExporterState = await this.current;
|
||||||
|
let newState = { ...currentState, ...stateUpdate };
|
||||||
|
await this._db.put('state', newState as any);
|
||||||
|
this.prepTTL();
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const State: LevelState = new LevelState();
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"target": "es6",
|
||||||
|
"module": "es6",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"lib": [ "dom", "es6" ],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsxImportSource": "jsx-dom",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
How to get it working:
|
||||||
|
- [X] Landsbankinn API client for arbitrary transaction lists
|
||||||
|
- [ ] Transform raw transactions into friendlier ones (i.e. date objects)
|
||||||
|
- [ ] Content script that can run all the time and puts a button somewhere (statements tab, and extension icon)
|
||||||
|
- [ ] Background page that downloads all the shit and combines into CSV, then 'downloads' file.
|
||||||
|
- [ ] Graphql API client to get information about user, like listing accounts.
|
||||||
|
- [ ] Get working client
|
||||||
|
- [ ] Set API key
|
||||||
|
- [ ] Set bearer token
|
||||||
|
- [ ] Intercept bearer token by yoinking it out of a request
|
Loading…
Reference in New Issue