You've already forked openaccounting-web
forked from cybercinch/openaccounting-web
initial commit
This commit is contained in:
253
src/app/core/account.service.spec.ts
Normal file
253
src/app/core/account.service.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { AccountService } from './account.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { EmptyObservable } from 'rxjs/observable/EmptyObservable';
|
||||
import { Logger } from '../core/logger';
|
||||
|
||||
import { AccountApi } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Price } from '../shared/price';
|
||||
import { Org } from '../shared/org';
|
||||
|
||||
var rawAccounts = [
|
||||
new AccountApi({
|
||||
id: '1',
|
||||
orgId: '1',
|
||||
name: 'Root',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '2',
|
||||
orgId: '1',
|
||||
name: 'Assets',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '3',
|
||||
orgId: '1',
|
||||
name: 'Liabilities',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: false,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '4',
|
||||
orgId: '1',
|
||||
name: 'Equity',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: false,
|
||||
parent: '1'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '5',
|
||||
orgId: '1',
|
||||
name: 'Bitcoin',
|
||||
currency: 'BTC',
|
||||
precision: 8,
|
||||
debitBalance: true,
|
||||
parent: '2',
|
||||
balance: 1000000,
|
||||
nativeBalance: 7000
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '6',
|
||||
orgId: '1',
|
||||
name: 'Current Assets',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '2'
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '7',
|
||||
orgId: '1',
|
||||
name: 'Checking',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '6',
|
||||
balance: 1000,
|
||||
nativeBalance: 1000
|
||||
}),
|
||||
new AccountApi({
|
||||
id: '8',
|
||||
orgId: '1',
|
||||
name: 'Savings',
|
||||
currency: 'USD',
|
||||
precision: 2,
|
||||
debitBalance: true,
|
||||
parent: '6',
|
||||
balance: 2000,
|
||||
nativeBalance: 2000
|
||||
})
|
||||
];
|
||||
|
||||
class Mock {
|
||||
|
||||
}
|
||||
|
||||
class ApiMock {
|
||||
getAccounts() {
|
||||
return Observable.of(rawAccounts);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionMock {
|
||||
getSessions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
}
|
||||
|
||||
class TransactionMock {
|
||||
getNewTransactions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
|
||||
getDeletedTransactions() {
|
||||
return new EmptyObservable();
|
||||
}
|
||||
|
||||
getRecentTransactions() {
|
||||
let txs = [
|
||||
new Transaction({
|
||||
id: '1',
|
||||
date: new Date('2018-09-24'),
|
||||
splits: [
|
||||
{
|
||||
accountId: '7',
|
||||
amount: -1000,
|
||||
nativeAmount: -1000
|
||||
},
|
||||
{
|
||||
accountId: '4',
|
||||
amount: 1000,
|
||||
nativeAmount: 1000
|
||||
}
|
||||
]
|
||||
})
|
||||
];
|
||||
return Observable.of(txs);
|
||||
}
|
||||
}
|
||||
|
||||
class PriceMock {
|
||||
getPricesNearestInTime() {
|
||||
let prices = [
|
||||
new Price({
|
||||
id: '1',
|
||||
currency: 'BTC',
|
||||
date: new Date('2018-09-24'),
|
||||
price: 10000
|
||||
})
|
||||
];
|
||||
return Observable.of(prices);
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountService', () => {
|
||||
describe('#getAccountTree', () => {
|
||||
it('should correctly create an AccountTree', (done) => {
|
||||
let as = new AccountService(
|
||||
new Logger,
|
||||
new ApiMock() as ApiService,
|
||||
new Mock() as WebSocketService,
|
||||
new TransactionMock() as any,
|
||||
new PriceMock() as any,
|
||||
new SessionMock() as any
|
||||
);
|
||||
|
||||
as['accountWs$'] = Observable.empty();
|
||||
|
||||
as['org'] = new Org({
|
||||
id: '1',
|
||||
currency: 'USD',
|
||||
precision: 2
|
||||
});
|
||||
|
||||
as.getAccountTree().subscribe(tree => {
|
||||
console.log(tree);
|
||||
expect(tree.rootAccount.name).toEqual('Root');
|
||||
expect(tree.rootAccount.depth).toEqual(0);
|
||||
expect(tree.rootAccount.totalBalance).toEqual(3000);
|
||||
expect(tree.rootAccount.totalNativeBalanceCost).toEqual(10000);
|
||||
expect(tree.rootAccount.totalNativeBalancePrice).toEqual(13000);
|
||||
expect(tree.rootAccount.children.length).toEqual(3);
|
||||
expect(tree.rootAccount.children[0].name).toEqual('Assets');
|
||||
expect(tree.rootAccount.children[0].fullName).toEqual('Assets');
|
||||
expect(tree.rootAccount.children[0].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[0].totalBalance).toEqual(3000);
|
||||
expect(tree.rootAccount.children[0].totalNativeBalanceCost).toEqual(10000);
|
||||
expect(tree.rootAccount.children[0].totalNativeBalancePrice).toEqual(13000);
|
||||
expect(tree.rootAccount.children[1].name).toEqual('Equity');
|
||||
expect(tree.rootAccount.children[1].fullName).toEqual('Equity');
|
||||
expect(tree.rootAccount.children[1].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[1].totalBalance).toEqual(0);
|
||||
expect(tree.rootAccount.children[2].name).toEqual('Liabilities');
|
||||
expect(tree.rootAccount.children[2].fullName).toEqual('Liabilities');
|
||||
expect(tree.rootAccount.children[2].depth).toEqual(1);
|
||||
expect(tree.rootAccount.children[2].totalBalance).toEqual(0);
|
||||
let assets = tree.rootAccount.children[0];
|
||||
expect(assets.children.length).toEqual(2);
|
||||
expect(assets.children[0].name).toEqual('Bitcoin');
|
||||
expect(assets.children[0].fullName).toEqual('Assets:Bitcoin');
|
||||
expect(assets.children[0].depth).toEqual(2);
|
||||
expect(assets.children[0].totalBalance).toEqual(1000000);
|
||||
expect(assets.children[0].totalNativeBalanceCost).toEqual(7000);
|
||||
expect(assets.children[0].totalNativeBalancePrice).toEqual(10000);
|
||||
expect(assets.children[1].name).toEqual('Current Assets');
|
||||
expect(assets.children[1].fullName).toEqual('Assets:Current Assets');
|
||||
expect(assets.children[1].depth).toEqual(2);
|
||||
expect(assets.children[1].totalBalance).toEqual(3000);
|
||||
let currentAssets = assets.children[1];
|
||||
expect(currentAssets.children.length).toEqual(2);
|
||||
expect(currentAssets.children[0].name).toEqual('Checking');
|
||||
expect(currentAssets.children[0].fullName).toEqual('Assets:Current Assets:Checking');
|
||||
expect(currentAssets.children[0].depth).toEqual(3);
|
||||
expect(currentAssets.children[0].totalBalance).toEqual(1000);
|
||||
expect(currentAssets.children[1].name).toEqual('Savings');
|
||||
expect(currentAssets.children[1].fullName).toEqual('Assets:Current Assets:Savings');
|
||||
expect(currentAssets.children[1].depth).toEqual(3);
|
||||
expect(currentAssets.children[1].totalBalance).toEqual(2000);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRawAccountMap', () => {
|
||||
it('should correctly create a raw account map', (done) => {
|
||||
let as = new AccountService(
|
||||
new Logger,
|
||||
new ApiMock() as ApiService,
|
||||
new Mock() as WebSocketService,
|
||||
new TransactionMock() as any,
|
||||
new PriceMock() as any,
|
||||
new SessionMock() as any
|
||||
);
|
||||
|
||||
as['accountWs$'] = Observable.empty();
|
||||
|
||||
as.getRawAccountMap().subscribe(accountMap => {
|
||||
expect(Object.keys(accountMap).length).toEqual(rawAccounts.length);
|
||||
expect(accountMap['5'].price).toEqual(10000);
|
||||
expect(accountMap['7'].recentTxCount).toEqual(1);
|
||||
expect(accountMap['8'].recentTxCount).toEqual(0);
|
||||
done();
|
||||
}, (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
649
src/app/core/account.service.ts
Normal file
649
src/app/core/account.service.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { concat } from 'rxjs/observable/concat';
|
||||
import { merge } from 'rxjs/observable/merge';
|
||||
import { Account, AccountApi, AccountTree } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Org } from '../shared/org';
|
||||
import { Price } from '../shared/price';
|
||||
import { Message } from '../shared/message';
|
||||
import 'rxjs/add/observable/combineLatest';
|
||||
import 'rxjs/add/operator/concat';
|
||||
import 'rxjs/add/operator/shareReplay';
|
||||
import 'rxjs/add/observable/empty';
|
||||
import 'rxjs/add/operator/startWith';
|
||||
import 'rxjs/add/operator/filter';
|
||||
import 'rxjs/add/operator/debounceTime';
|
||||
import 'rxjs/add/operator/take';
|
||||
import { Util } from '../shared/util';
|
||||
import { personalAccounts } from '../fixtures/personalAccounts';
|
||||
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
private rawAccountMap$: Observable<{[accountId: string]: AccountApi}>;
|
||||
private rawAccountMaps: any = {};
|
||||
private accountWs$: Observable<Message>;
|
||||
private accountSubscription: Subscription;
|
||||
private org: Org;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private txService: TransactionService,
|
||||
private priceService: PriceService,
|
||||
private sessionService: SessionService) {
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org, options]) => {
|
||||
this.log.debug('accountService new session');
|
||||
|
||||
// cleanup after old session
|
||||
this.rawAccountMap$ = null;
|
||||
this.rawAccountMaps = {};
|
||||
|
||||
if(this.accountWs$ && this.org) {
|
||||
this.wsService.unsubscribe('account', this.org.id);
|
||||
this.accountWs$ = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
// subscribe to web socket
|
||||
this.accountWs$ = this.wsService.subscribe('account', org.id);
|
||||
|
||||
if(options.createDefaultAccounts) {
|
||||
this.getAccountTree().take(1).switchMap(tree => {
|
||||
return this.createDefaultAccounts(tree);
|
||||
}).subscribe(accounts => {
|
||||
log.debug('Created default accounts');
|
||||
log.debug(accounts);
|
||||
}, err => {
|
||||
log.error('Error creating default accounts');
|
||||
log.error(err);
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRawSocketAccounts(): Observable<AccountApi> {
|
||||
return this.accountWs$.filter(message => {
|
||||
return message.action === 'create' || message.action === 'update';
|
||||
}).map(message => {
|
||||
return new AccountApi(message.data);
|
||||
});
|
||||
}
|
||||
|
||||
getRawAccountMap(): Observable<{[accountId: string]: AccountApi}> {
|
||||
this.log.debug('getRawAccountMap()');
|
||||
if(!this.rawAccountMap$) {
|
||||
let emptyTx$ = Observable.of(new Transaction({splits: []}));
|
||||
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
|
||||
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
|
||||
|
||||
this.rawAccountMap$ = this.txService.getRecentTransactions().map(recentTxs => {
|
||||
this.log.debug('recentTxs');
|
||||
return recentTxs.reduce((acc, tx) => {
|
||||
tx.splits.forEach(split => {
|
||||
acc[split.accountId] = (acc[split.accountId] || 0) + 1;
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
})
|
||||
.switchMap(txCounts => {
|
||||
this.log.debug('txCounts');
|
||||
this.log.debug(txCounts);
|
||||
return this.apiService.getAccounts().map(rawAccounts => {
|
||||
let rawAccountMap = {};
|
||||
|
||||
rawAccounts.forEach(rawAccount => {
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
rawAccount.recentTxCount = txCounts[rawAccount.id] || 0;
|
||||
})
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('rawAccountMap');
|
||||
this.log.debug(rawAccountMap);
|
||||
return concat(Observable.of(null), this.accountWs$).map(message => {
|
||||
if(message && message.data) {
|
||||
let rawAccount = new AccountApi(message.data);
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
case 'update':
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
break;
|
||||
case 'delete':
|
||||
delete rawAccountMap[rawAccount.id];
|
||||
}
|
||||
}
|
||||
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
return this.priceService.getPricesNearestInTime(new Date()).map(prices => {
|
||||
this.log.debug(prices);
|
||||
prices.forEach(price => {
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
if(rawAccount.currency === price.currency) {
|
||||
rawAccount.price = price.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rawAccountMap;
|
||||
});
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('newtxs');
|
||||
return newTxs$.map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance += split.amount;
|
||||
rawAccount.nativeBalance += split.nativeAmount;
|
||||
rawAccount.recentTxCount++;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('deletedtxs');
|
||||
return deletedTxs$.map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance -= split.amount;
|
||||
rawAccount.nativeBalance -= split.nativeAmount;
|
||||
rawAccount.recentTxCount--;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.debounceTime(500)
|
||||
.shareReplay(1);
|
||||
}
|
||||
|
||||
return this.rawAccountMap$;
|
||||
}
|
||||
|
||||
getRawAccountMapAtDate(date: Date): Observable<{[accountId: string]: AccountApi}> {
|
||||
if(!this.rawAccountMaps[date.getTime()]) {
|
||||
|
||||
let emptyTx$ = Observable.of(new Transaction({splits: []}));
|
||||
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
|
||||
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
|
||||
|
||||
this.rawAccountMaps[date.getTime()] = this.apiService.getAccounts(date).map(rawAccounts => {
|
||||
let rawAccountMap = {};
|
||||
|
||||
rawAccounts.forEach(rawAccount => {
|
||||
rawAccountMap[rawAccount.id] = rawAccount;
|
||||
})
|
||||
return rawAccountMap;
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
return this.priceService.getPricesNearestInTime(date).map(prices => {
|
||||
this.log.debug(prices);
|
||||
prices.forEach(price => {
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
if(rawAccount.currency === price.currency) {
|
||||
rawAccount.price = price.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rawAccountMap;
|
||||
});
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('newtxs');
|
||||
return newTxs$.filter(tx => {
|
||||
return tx.date < date;
|
||||
}).map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance += split.amount;
|
||||
rawAccount.nativeBalance += split.nativeAmount;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.switchMap(rawAccountMap => {
|
||||
this.log.debug('deletedtxs');
|
||||
return deletedTxs$.filter(tx => {
|
||||
return tx.date < date;
|
||||
}).map(tx => {
|
||||
for(let split of tx.splits) {
|
||||
let rawAccount = rawAccountMap[split.accountId];
|
||||
if(rawAccount) {
|
||||
rawAccount.balance -= split.amount;
|
||||
rawAccount.nativeBalance -= split.nativeAmount;
|
||||
}
|
||||
}
|
||||
return rawAccountMap;
|
||||
})
|
||||
})
|
||||
.debounceTime(500)
|
||||
.shareReplay(1);
|
||||
}
|
||||
|
||||
return this.rawAccountMaps[date.getTime()];
|
||||
}
|
||||
|
||||
getAccountTree(): Observable<AccountTree> {
|
||||
return this.getRawAccountMap()
|
||||
.map(rawAccountMap => {
|
||||
this.log.debug('accountTree: rawAccountMap');
|
||||
this.log.debug(rawAccountMap);
|
||||
let accountMap = {};
|
||||
let rootAccount = null;
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = new Account(rawAccount);
|
||||
account.parent = null;
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
accountMap[account.id] = account;
|
||||
}
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = accountMap[id];
|
||||
|
||||
if(accountMap[rawAccount.parent]) {
|
||||
account.parent = accountMap[rawAccount.parent];
|
||||
account.parent.children.push(account);
|
||||
// sort children alphabetically
|
||||
account.parent.children.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else {
|
||||
rootAccount = account;
|
||||
}
|
||||
}
|
||||
|
||||
this.log.debug('rootAccount', rootAccount);
|
||||
|
||||
// cache account (for transaction consumers)
|
||||
|
||||
return new AccountTree({
|
||||
rootAccount: rootAccount,
|
||||
accountMap: accountMap
|
||||
});
|
||||
})
|
||||
.map(tree => this._addDepths(tree))
|
||||
.map(tree => this._addFullNames(tree))
|
||||
.map(tree => this._updateBalances(tree));
|
||||
}
|
||||
|
||||
getAccountTreeAtDate(date: Date): Observable<AccountTree> {
|
||||
return this.getRawAccountMapAtDate(date).map(rawAccountMap => {
|
||||
this.log.debug('rawAccounts');
|
||||
this.log.debug(rawAccountMap);
|
||||
let accountMap = {};
|
||||
let rootAccount = null;
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
accountMap[account.id] = account;
|
||||
}
|
||||
|
||||
for(let id in rawAccountMap) {
|
||||
let rawAccount = rawAccountMap[id];
|
||||
let account = accountMap[id];
|
||||
|
||||
if(accountMap[rawAccount.parent]) {
|
||||
account.parent = accountMap[rawAccount.parent];
|
||||
account.parent.children.push(account);
|
||||
// sort children alphabetically
|
||||
account.parent.children.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} else {
|
||||
rootAccount = account;
|
||||
}
|
||||
}
|
||||
|
||||
return new AccountTree({
|
||||
rootAccount: rootAccount,
|
||||
accountMap: accountMap
|
||||
});
|
||||
})
|
||||
.map(tree => this._addDepths(tree))
|
||||
.map(tree => this._addFullNames(tree))
|
||||
.map(tree => this._updateBalances(tree));
|
||||
}
|
||||
|
||||
getAccountTreeWithPeriodBalance(startDate: Date, endDate?: Date): Observable<AccountTree> {
|
||||
let startTree$ = this.getAccountTreeAtDate(startDate);
|
||||
let endTree$ = endDate ? this.getAccountTreeAtDate(endDate) : this.getAccountTree();
|
||||
|
||||
return Observable.combineLatest(startTree$, endTree$)
|
||||
.map(([start, end]) => {
|
||||
// function is impure... but convenient
|
||||
// consider making it pure
|
||||
|
||||
for(let accountId in end.accountMap) {
|
||||
let account = end.accountMap[accountId];
|
||||
let startAccount = start.accountMap[accountId];
|
||||
|
||||
this.log.debug(account.name, startAccount ? startAccount.balance : 0, account.balance);
|
||||
|
||||
// TODO maybe there is a better way of dealing with price / balance for non-native currencies
|
||||
let balancePriceDelta = account.balance * account.price - (startAccount ? startAccount.balance * startAccount.price : 0);
|
||||
let balanceDelta = account.balance - (startAccount ? startAccount.balance : 0);
|
||||
|
||||
let weightedPrice = 0;
|
||||
if(balanceDelta) {
|
||||
weightedPrice = balancePriceDelta / balanceDelta;
|
||||
}
|
||||
|
||||
account.balance -= startAccount ? startAccount.balance : 0;
|
||||
account.nativeBalanceCost -= startAccount ? startAccount.nativeBalanceCost : 0;
|
||||
account.nativeBalancePrice -= startAccount ? startAccount.nativeBalancePrice : 0;
|
||||
account.totalBalance -= startAccount ? startAccount.totalBalance : 0;
|
||||
account.totalNativeBalanceCost -= startAccount ? startAccount.totalNativeBalanceCost : 0;
|
||||
account.totalNativeBalancePrice -= startAccount ? startAccount.totalNativeBalancePrice : 0;
|
||||
account.price = weightedPrice;
|
||||
}
|
||||
|
||||
this.log.debug('accountTreeWithPeriodBalance');
|
||||
this.log.debug(end);
|
||||
|
||||
return end;
|
||||
});
|
||||
}
|
||||
|
||||
getFlattenedAccounts(): Observable<any> {
|
||||
return this.getAccountTree().map(tree => {
|
||||
return this._getFlattenedAccounts(tree.rootAccount);
|
||||
});
|
||||
}
|
||||
|
||||
getFlattenedAccountsWithPeriodBalance(startDate: Date, endDate?: Date): Observable<Account[]> {
|
||||
return this.getAccountTreeWithPeriodBalance(startDate, endDate).map(tree => {
|
||||
return this._getFlattenedAccounts(tree.rootAccount);
|
||||
});
|
||||
}
|
||||
|
||||
_getFlattenedAccounts(node: Account): Account[] {
|
||||
let flattened = [];
|
||||
|
||||
for(let account of node.children) {
|
||||
flattened.push(account);
|
||||
flattened = flattened.concat(this._getFlattenedAccounts(account));
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
getAccountByName (accounts: Account[], name: string): Account {
|
||||
for(let account of accounts) {
|
||||
// TODO pass in depth
|
||||
if(account.name === name && account.depth === 1) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
sortAccountsAlphabetically(accounts) {
|
||||
accounts.sort((a, b) => {
|
||||
let nameA = a.name.toLowerCase();
|
||||
let nameB = b.name.toLowerCase();
|
||||
if (nameA < nameB)
|
||||
return -1;
|
||||
if (nameA > nameB)
|
||||
return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
_addDepths(tree: AccountTree): AccountTree {
|
||||
for(let id in tree.accountMap) {
|
||||
let account = tree.accountMap[id];
|
||||
let node = account;
|
||||
|
||||
let depth = 0;
|
||||
while(node.parent) {
|
||||
depth++;
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
account.depth = depth;
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
_addFullNames(tree: AccountTree): AccountTree {
|
||||
for(let id in tree.accountMap) {
|
||||
let account = tree.accountMap[id];
|
||||
let node = account;
|
||||
|
||||
let accountArray = [account.name];
|
||||
|
||||
while(node.parent && node.parent.depth > 0) {
|
||||
node = node.parent;
|
||||
accountArray.unshift(node.name);
|
||||
}
|
||||
|
||||
account.fullName = accountArray.join(':');
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
_updateBalances(tree: AccountTree): AccountTree {
|
||||
// TODO impure function
|
||||
|
||||
// first zero out balances. not necessary if all functions are pure
|
||||
for(let accountId in tree.accountMap) {
|
||||
let account = tree.accountMap[accountId];
|
||||
|
||||
account.totalBalance = account.balance;
|
||||
account.totalNativeBalanceCost = account.nativeBalanceCost;
|
||||
|
||||
if(account.currency === this.org.currency) {
|
||||
account.nativeBalancePrice = account.balance;
|
||||
} else {
|
||||
account.nativeBalancePrice = account.balance * account.price / Math.pow(10, account.precision - this.org.precision);
|
||||
}
|
||||
|
||||
account.totalNativeBalancePrice = account.nativeBalancePrice;
|
||||
}
|
||||
|
||||
// update balances
|
||||
for(let accountId in tree.accountMap) {
|
||||
let account = tree.accountMap[accountId];
|
||||
|
||||
if(!account.children.length) {
|
||||
let parent = account.parent;
|
||||
|
||||
while(parent) {
|
||||
parent.totalNativeBalanceCost += account.totalNativeBalanceCost;
|
||||
parent.totalNativeBalancePrice += account.totalNativeBalancePrice;
|
||||
|
||||
if(parent.currency === account.currency) {
|
||||
parent.totalBalance += account.totalBalance;
|
||||
}
|
||||
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
getAccountTreeFromName(name: string, rootNode: Account) {
|
||||
for(var i = 0; i < rootNode.children.length; i++) {
|
||||
let child = rootNode.children[i];
|
||||
if(child.name === name) {
|
||||
return child;
|
||||
}
|
||||
|
||||
try {
|
||||
let account = this.getAccountTreeFromName(name, child);
|
||||
return account;
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Account not found ' + name);
|
||||
}
|
||||
|
||||
getAccountAtoms(rootNode: Account): Account[] {
|
||||
let accounts = [];
|
||||
|
||||
for(let i = 0; i < rootNode.children.length; i++) {
|
||||
let child = rootNode.children[i];
|
||||
if(!child.children.length) {
|
||||
accounts.push(child);
|
||||
} else {
|
||||
accounts = accounts.concat(this.getAccountAtoms(child));
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
// getSelectBoxAccountAtoms(rootNode: Account): any[] {
|
||||
// var data = [];
|
||||
|
||||
// for(let account of rootNode.children) {
|
||||
// if(!account.children.length) {
|
||||
// data.push({
|
||||
// id: account.id,
|
||||
// name: this.getAccountHierarchyString(account),
|
||||
// debitBalance: account.debitBalance
|
||||
// });
|
||||
// }
|
||||
|
||||
// let childData = this.getSelectBoxAccountAtoms(account);
|
||||
// data = data.concat(childData);
|
||||
// }
|
||||
|
||||
// return data;
|
||||
// }
|
||||
|
||||
accountIsChildOf(account: Account, parent: Account) {
|
||||
for(let child of parent.children) {
|
||||
if(child.id === account.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if(this.accountIsChildOf(account, child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
newAccount(account: AccountApi): Observable<Account> {
|
||||
return this.apiService.postAccount(account)
|
||||
.map(rawAccount => {
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
putAccount(account: AccountApi): Observable<Account> {
|
||||
return this.apiService.putAccount(account)
|
||||
.map(rawAccount => {
|
||||
let account = new Account(rawAccount);
|
||||
account.orgCurrency = this.org.currency;
|
||||
account.orgPrecision = this.org.precision;
|
||||
return account;
|
||||
})
|
||||
}
|
||||
|
||||
deleteAccount(id: string): Observable<any> {
|
||||
return this.apiService.deleteAccount(id);
|
||||
}
|
||||
|
||||
getPeriodStart(): Date {
|
||||
let date = new Date();
|
||||
date.setDate(1);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
createDefaultAccounts(tree: AccountTree): Observable<any> {
|
||||
let assetAccount = tree.getAccountByName('Assets', 1);
|
||||
let equityAccount = tree.getAccountByName('Equity', 1);
|
||||
let liabilityAccount = tree.getAccountByName('Liabilities', 1);
|
||||
let incomeAccount = tree.getAccountByName('Income', 1);
|
||||
let expenseAccount = tree.getAccountByName('Expenses', 1);
|
||||
|
||||
let currency = assetAccount.currency;
|
||||
let precision = assetAccount.precision;
|
||||
|
||||
let accountNameMap = {
|
||||
'Assets': [assetAccount.id, true],
|
||||
'Equity': [equityAccount.id, false],
|
||||
'Liabilities': [liabilityAccount.id, false],
|
||||
'Income': [incomeAccount.id, false],
|
||||
'Expenses': [expenseAccount.id, true]
|
||||
};
|
||||
|
||||
let newAccounts;
|
||||
|
||||
try {
|
||||
newAccounts = personalAccounts.map(data => {
|
||||
let id = Util.newGuid();
|
||||
let [parentId, debitBalance] = accountNameMap[data.parent];
|
||||
|
||||
if(!parentId) {
|
||||
throw new Error('Parent does not exist ' + data.parent);
|
||||
}
|
||||
|
||||
// TODO find a cleaner way of doing this without making assumptions
|
||||
if(['Assets', 'Equity', 'Liabilities', 'Income', 'Expenses'].indexOf(data.parent) > -1) {
|
||||
accountNameMap[data.name] = [id, debitBalance];
|
||||
}
|
||||
|
||||
return new AccountApi({
|
||||
id: id,
|
||||
name: data.name,
|
||||
currency: currency,
|
||||
precision: precision,
|
||||
debitBalance: debitBalance,
|
||||
parent: parentId
|
||||
})
|
||||
});
|
||||
} catch(e) {
|
||||
return new ErrorObservable(e);
|
||||
}
|
||||
|
||||
return this.apiService.postAccounts(newAccounts);
|
||||
}
|
||||
}
|
||||
340
src/app/core/api.service.ts
Normal file
340
src/app/core/api.service.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
||||
import { AccountApi } from '../shared/account';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Org } from '../shared/org';
|
||||
import { User } from '../shared/user';
|
||||
import { Price } from '../shared/price';
|
||||
import { ApiKey } from '../shared/apikey';
|
||||
import { Invite } from '../shared/invite';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
|
||||
import { catchError, retry } from 'rxjs/operators';
|
||||
import { AppError } from '../shared/error';
|
||||
|
||||
let logger;
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
|
||||
private url: string; // URL to web api
|
||||
private httpOptions = {
|
||||
headers: new HttpHeaders({
|
||||
'content-type': 'application/json',
|
||||
'accept-version': '^0.1.8'
|
||||
})
|
||||
};
|
||||
private orgId: string;
|
||||
private sessionId: string;
|
||||
|
||||
constructor(private log: Logger, private http: HttpClient) {
|
||||
logger = log;
|
||||
}
|
||||
|
||||
setUrl(url: string) {
|
||||
this.log.debug('set url', url);
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
verifyUser(code: string): Observable<any> {
|
||||
return this.http.post<any>(this.url + '/user/verify', {code: code}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
resetPassword(email: string): Observable<any> {
|
||||
return this.http.post<any>(this.url + '/user/reset-password', {email: email}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
confirmResetPassword(password: string, code: string): Observable<User> {
|
||||
return this.http.put<User>(this.url + '/user', {password: password, code: code}, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
newSession(email: string, password: string, sessionId: string): Observable<any> {
|
||||
let url = this.url + '/sessions';
|
||||
|
||||
let tempHeaders = new HttpHeaders(this.httpOptions.headers.keys().reduce((acc, current) => {
|
||||
acc[current] = this.httpOptions.headers.get(current);
|
||||
return acc;
|
||||
}, {}));
|
||||
|
||||
tempHeaders = tempHeaders.set('Authorization', 'Basic ' + window.btoa(email + ':' + password));
|
||||
|
||||
return this.http.post<any>(url, {id: sessionId}, {headers: tempHeaders})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
logout() {
|
||||
let url = this.url + '/sessions/' + this.sessionId;
|
||||
this.http.delete<any>(url, this.httpOptions).subscribe(() => {
|
||||
this.removeSessionInfo();
|
||||
});
|
||||
}
|
||||
|
||||
setSession(id: string) {
|
||||
this.sessionId = id;
|
||||
this.httpOptions.headers = this.httpOptions.headers.set('Authorization', 'Basic ' + window.btoa(id + ':'));
|
||||
}
|
||||
|
||||
removeSessionInfo() {
|
||||
this.httpOptions.headers.delete('Authorization');
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
setOrgId(orgId: string) {
|
||||
this.orgId = orgId;
|
||||
}
|
||||
|
||||
getAccounts (date?: Date): Observable<AccountApi[]> {
|
||||
this.log.debug('API getAccounts()');
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts';
|
||||
if(date) {
|
||||
url += '?date=' + date.getTime();
|
||||
}
|
||||
return this.http.get<AccountApi[]>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getTransactionsByAccount (accountId, options: any = {}): Observable<Transaction[]> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + accountId + '/transactions';
|
||||
|
||||
if(Object.keys(options).length) {
|
||||
let optionsArray: string [] = [];
|
||||
|
||||
for(let option in options) {
|
||||
optionsArray.push(option + '=' + options[option]);
|
||||
}
|
||||
|
||||
url += '?' + optionsArray.join('&');
|
||||
}
|
||||
|
||||
return this.http.get<Transaction[]>(url, this.httpOptions)
|
||||
.map(transactions => {
|
||||
return transactions.map(transaction => {
|
||||
// TODO do this on all transactions
|
||||
transaction = new Transaction(transaction);
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getTransactions(options: any = {}): Observable<Transaction[]> {
|
||||
this.log.debug('API getTransactions()');
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions';
|
||||
|
||||
if(Object.keys(options).length) {
|
||||
let optionsArray: string [] = [];
|
||||
|
||||
for(let option in options) {
|
||||
optionsArray.push(option + '=' + options[option]);
|
||||
}
|
||||
|
||||
url += '?' + optionsArray.join('&');
|
||||
}
|
||||
|
||||
return this.http.get<Transaction[]>(url, this.httpOptions)
|
||||
.map(transactions => {
|
||||
return transactions.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postTransaction(transaction: Transaction): Observable<Transaction> {
|
||||
return this.http.post<Transaction>(this.url + '/orgs/' + this.orgId + '/transactions', transaction, this.httpOptions)
|
||||
.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + oldId;
|
||||
return this.http.put<Transaction>(url, transaction, this.httpOptions)
|
||||
.map(transaction => {
|
||||
transaction.date = new Date(transaction.date);
|
||||
transaction.inserted = new Date(transaction.inserted);
|
||||
transaction.updated = new Date(transaction.updated);
|
||||
return transaction;
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postAccount(account: AccountApi): Observable<AccountApi> {
|
||||
return this.http.post<AccountApi>(this.url + '/orgs/' + this.orgId + '/accounts', account, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postAccounts(accounts: AccountApi[]): Observable<AccountApi> {
|
||||
return this.http.post<AccountApi[]>(this.url + '/orgs/' + this.orgId + '/accounts', accounts, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putAccount(account: AccountApi): Observable<AccountApi> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + account.id;
|
||||
return this.http.put<AccountApi>(url, account, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteAccount(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getOrg (orgId): Observable<Org> {
|
||||
return this.http.get<Org>(this.url + '/orgs/' + orgId, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getOrgs (): Observable<Org[]> {
|
||||
return this.http.get<Org[]>(this.url + '/orgs', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getUser (): Observable<User> {
|
||||
return this.http.get<User>(this.url + '/user', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postUser(user: User): Observable<User> {
|
||||
return this.http.post<User>(this.url + '/users', user, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putUser(user: User): Observable<User> {
|
||||
return this.http.put<User>(this.url + '/user', user, this.httpOptions)
|
||||
.pipe(catchError(this.handleError))
|
||||
}
|
||||
|
||||
postOrg(org: Org): Observable<Org> {
|
||||
return this.http.post<Org>(this.url + '/orgs', org, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getPricesNearestInTime(date: Date): Observable<Price[]> {
|
||||
let query = '/orgs/' + this.orgId + '/prices?nearestDate=' + date.getTime();
|
||||
return this.http.get<Price[]>(this.url + query, this.httpOptions)
|
||||
.map(prices => {
|
||||
return prices.map(price => {
|
||||
price.date = new Date(price.date);
|
||||
price.inserted = new Date(price.inserted);
|
||||
price.updated = new Date(price.updated);
|
||||
return price;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getPricesByCurrency(currency: string): Observable<Price[]> {
|
||||
let query = '/orgs/' + this.orgId + '/prices?currency=' + currency;
|
||||
return this.http.get<Price[]>(this.url + query, this.httpOptions)
|
||||
.map(prices => {
|
||||
return prices.map(price => {
|
||||
price.date = new Date(price.date);
|
||||
price.inserted = new Date(price.inserted);
|
||||
price.updated = new Date(price.updated);
|
||||
return price;
|
||||
});
|
||||
})
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postPrice(price: Price): Observable<Price> {
|
||||
return this.http.post<Price>(this.url + '/orgs/' + this.orgId + '/prices', price, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deletePrice(id: string): Observable<any> {
|
||||
let url = this.url + '/orgs/' + this.orgId + '/prices/' + id;
|
||||
return this.http.delete<any>(url, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getApiKeys(): Observable<ApiKey[]> {
|
||||
return this.http.get<ApiKey[]>(this.url + '/apikeys', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.http.post<ApiKey>(this.url + '/apikeys', key, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.http.put<ApiKey>(this.url + '/apikeys/' + key.id, key, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteApiKey(id: string): Observable<any> {
|
||||
return this.http.delete<any>(this.url + '/apikeys/' + id, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
getInvites(): Observable<Invite[]> {
|
||||
return this.http.get<Invite[]>(this.url + '/orgs/' + this.orgId + '/invites', this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
postInvite(invite: Invite): Observable<Invite> {
|
||||
return this.http.post<Invite>(this.url + '/orgs/' + this.orgId + '/invites', invite, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
putInvite(invite: Invite): Observable<Invite> {
|
||||
return this.http.put<Invite>(this.url + '/orgs/' + this.orgId + '/invites/' + invite.id, invite, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
deleteInvite(id: string): Observable<any> {
|
||||
return this.http.delete<any>(this.url + '/orgs/' + this.orgId + '/invites/' + id, this.httpOptions)
|
||||
.pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse) {
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// A client-side or network error occurred. Handle it accordingly.
|
||||
logger.error('An error occurred:', error.error.message);
|
||||
return new ErrorObservable(new AppError(error.error.message));
|
||||
} else {
|
||||
// The backend returned an unsuccessful response code.
|
||||
// The response body may contain clues as to what went wrong,
|
||||
logger.error(
|
||||
`Backend returned code ${error.status}, ` +
|
||||
`body was: ${error.error}`);
|
||||
|
||||
logger.error(error);
|
||||
logger.error(error.error.error);
|
||||
|
||||
let appError: AppError;
|
||||
if(error.error.error) {
|
||||
appError = new AppError(error.error.error, error.status);
|
||||
} else if(error.message) {
|
||||
appError = new AppError(error.message, error.status);
|
||||
} else {
|
||||
appError = new AppError('An unexpected error has occurred');
|
||||
}
|
||||
|
||||
return new ErrorObservable(appError);
|
||||
}
|
||||
};
|
||||
}
|
||||
27
src/app/core/apikey.service.ts
Normal file
27
src/app/core/apikey.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiKey } from '../shared/apikey';
|
||||
import { ApiService } from './api.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(private apiService: ApiService) {
|
||||
|
||||
}
|
||||
|
||||
getApiKeys(): Observable<ApiKey[]> {
|
||||
return this.apiService.getApiKeys();
|
||||
}
|
||||
|
||||
newApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.apiService.postApiKey(key);
|
||||
}
|
||||
|
||||
putApiKey(key: ApiKey): Observable<ApiKey> {
|
||||
return this.apiService.putApiKey(key)
|
||||
}
|
||||
|
||||
deleteApiKey(id: string): Observable<any> {
|
||||
return this.apiService.deleteApiKey(id);
|
||||
}
|
||||
}
|
||||
51
src/app/core/config.service.ts
Normal file
51
src/app/core/config.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Org } from '../shared/org';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/of';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
private config: any;
|
||||
|
||||
constructor() {}
|
||||
|
||||
init(): Observable<any> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
return this.config[key];
|
||||
}
|
||||
|
||||
put(key: string, value: any) {
|
||||
this.config[key] = value;
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.config = {
|
||||
server: this.config.server
|
||||
};
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(): Observable<any> {
|
||||
localStorage.setItem('config', JSON.stringify(this.config));
|
||||
|
||||
return Observable.of(this.config);
|
||||
}
|
||||
|
||||
load(): Observable<any> {
|
||||
try {
|
||||
this.config = JSON.parse(localStorage.getItem('config')) || {};
|
||||
} catch(e) {
|
||||
this.config = {};
|
||||
}
|
||||
|
||||
return Observable.of(null);
|
||||
}
|
||||
|
||||
}
|
||||
52
src/app/core/core.module.ts
Normal file
52
src/app/core/core.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
//import { DataService } from './data.service';
|
||||
import { AccountService } from './account.service';
|
||||
import { ConfigService } from './config.service';
|
||||
import { OrgService } from './org.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { TransactionService } from './transaction.service';
|
||||
import { UserService } from './user.service';
|
||||
import { PriceService } from './price.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { ApiKeyService } from './apikey.service';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, HttpClientModule],
|
||||
declarations: [],
|
||||
exports: [],
|
||||
providers: []
|
||||
})
|
||||
export class CoreModule {
|
||||
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||
if (parentModule) {
|
||||
throw new Error(
|
||||
'CoreModule is already loaded. Import it in the AppModule only');
|
||||
}
|
||||
}
|
||||
|
||||
static forRoot(): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: CoreModule,
|
||||
providers: [
|
||||
Logger,
|
||||
ApiService,
|
||||
AccountService,
|
||||
ConfigService,
|
||||
OrgService,
|
||||
SessionService,
|
||||
TransactionService,
|
||||
UserService,
|
||||
PriceService,
|
||||
WebSocketService,
|
||||
ApiKeyService
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
50
src/app/core/logger.ts
Normal file
50
src/app/core/logger.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class Logger {
|
||||
|
||||
private logLevel: number;
|
||||
|
||||
static FATAL: number = 0;
|
||||
static ERROR: number = 1;
|
||||
static INFO: number = 2;
|
||||
static DEBUG: number = 3;
|
||||
|
||||
constructor() {
|
||||
this.logLevel = Logger.INFO;
|
||||
}
|
||||
|
||||
setLevel(logLevel: number) {
|
||||
this.logLevel = logLevel;
|
||||
}
|
||||
|
||||
fatal(...params: any[]) {
|
||||
if(this.logLevel >= Logger.FATAL) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.error.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
error(...params: any[]) {
|
||||
if(this.logLevel >= Logger.ERROR) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.error.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
info(...params: any[]) {
|
||||
if(this.logLevel >= Logger.INFO) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.log.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
debug(...params: any[]) {
|
||||
if(this.logLevel >= Logger.DEBUG) {
|
||||
params.unshift(new Date().toLocaleString());
|
||||
console.log.apply(null, params);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
82
src/app/core/org.service.ts
Normal file
82
src/app/core/org.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { ConfigService } from './config.service';
|
||||
import { Org } from '../shared/org';
|
||||
import { Invite } from '../shared/invite';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SessionOptions } from '../shared/session-options';
|
||||
|
||||
@Injectable()
|
||||
export class OrgService {
|
||||
private org: Org;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private sessionService: SessionService,
|
||||
private configService: ConfigService) {
|
||||
this.log.debug('orgService constructor');
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('orgService: new session');
|
||||
this.org = org;
|
||||
});
|
||||
}
|
||||
|
||||
getOrg(id: string): Observable<Org> {
|
||||
return this.apiService.getOrg(id);
|
||||
}
|
||||
|
||||
getCurrentOrg(): Org {
|
||||
return this.org;
|
||||
}
|
||||
|
||||
getOrgs(): Observable<Org[]> {
|
||||
return this.apiService.getOrgs();
|
||||
}
|
||||
|
||||
newOrg(org: Org, createDefaultAccounts: boolean): Observable<Org> {
|
||||
let sessionOptions = new SessionOptions({
|
||||
createDefaultAccounts: createDefaultAccounts
|
||||
});
|
||||
|
||||
return this.apiService.postOrg(org)
|
||||
.do(org => {
|
||||
this.org = org;
|
||||
this.configService.put('defaultOrg', this.org.id);
|
||||
this.sessionService.switchOrg(this.org, sessionOptions);
|
||||
});
|
||||
}
|
||||
|
||||
selectOrg(id: string): Observable<Org> {
|
||||
return this.getOrg(id)
|
||||
.do(org => {
|
||||
this.org = org;
|
||||
this.configService.put('defaultOrg', this.org.id);
|
||||
this.sessionService.switchOrg(this.org);
|
||||
});
|
||||
}
|
||||
|
||||
getInvites(): Observable<Invite[]> {
|
||||
return this.apiService.getInvites();
|
||||
}
|
||||
|
||||
newInvite(invite: Invite): Observable<Invite> {
|
||||
return this.apiService.postInvite(invite);
|
||||
}
|
||||
|
||||
acceptInvite(inviteId: string): Observable<Invite> {
|
||||
let invite = new Invite({
|
||||
id: inviteId,
|
||||
accepted: true
|
||||
});
|
||||
|
||||
return this.apiService.putInvite(invite);
|
||||
}
|
||||
|
||||
deleteInvite(inviteId: string): Observable<any> {
|
||||
return this.apiService.deleteInvite(inviteId);
|
||||
}
|
||||
}
|
||||
107
src/app/core/price.service.ts
Normal file
107
src/app/core/price.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { Price } from '../shared/price';
|
||||
import { Org } from '../shared/org';
|
||||
import { Message } from '../shared/message';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import 'rxjs/add/operator/merge';
|
||||
import { Util } from '../shared/util';
|
||||
|
||||
@Injectable()
|
||||
export class PriceService {
|
||||
private org: Org;
|
||||
private priceSubscription: Subscription;
|
||||
private newPrices: Subject<Price>;
|
||||
private deletedPrices: Subject<Price>;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private sessionService: SessionService) {
|
||||
|
||||
this.newPrices = new Subject<Price>();
|
||||
this.deletedPrices = new Subject<Price>();
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('priceService new session');
|
||||
|
||||
// cleanup after old session
|
||||
if(this.priceSubscription) {
|
||||
this.wsService.unsubscribe('price', this.org.id);
|
||||
this.priceSubscription.unsubscribe();
|
||||
this.priceSubscription = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
// subscribe to web socket
|
||||
let priceWs$ = this.wsService.subscribe('price', org.id);
|
||||
|
||||
this.priceSubscription = priceWs$.subscribe(message => {
|
||||
let price = null;
|
||||
|
||||
if(message.data) {
|
||||
price = new Price(message.data);
|
||||
}
|
||||
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
this.newPrices.next(price);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deletedPrices.next(price);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getNewPrices(): Observable<Price> {
|
||||
return this.newPrices.asObservable();
|
||||
}
|
||||
|
||||
getDeletedPrices(): Observable<Price> {
|
||||
return this.deletedPrices.asObservable();
|
||||
}
|
||||
|
||||
getPricesNearestInTime(date: Date): Observable<Price[]> {
|
||||
// TODO make more efficient by mutating state as needed instead of full api call
|
||||
// on every price change
|
||||
let newPrices$ = this.getNewPrices();
|
||||
let deletedPrices$ = this.getDeletedPrices();
|
||||
|
||||
let stream$ = Observable.of(null).concat(newPrices$.merge(deletedPrices$));
|
||||
|
||||
return stream$.switchMap(() => {
|
||||
return this.apiService.getPricesNearestInTime(date);
|
||||
});
|
||||
}
|
||||
|
||||
getPricesByCurrency(currency: string): Observable<Price[]> {
|
||||
return this.apiService.getPricesByCurrency(currency);
|
||||
}
|
||||
|
||||
newPrice(price: Price): Observable<Price> {
|
||||
return this.apiService.postPrice(price);
|
||||
}
|
||||
|
||||
deletePrice(id: string): Observable<any> {
|
||||
return this.apiService.deletePrice(id);
|
||||
}
|
||||
|
||||
updatePrice(price: Price): Observable<Price> {
|
||||
return this.apiService.deletePrice(price.id).switchMap(() => {
|
||||
let newPrice = new Price(price);
|
||||
newPrice.id = Util.newGuid();
|
||||
return this.apiService.postPrice(newPrice);
|
||||
});
|
||||
}
|
||||
}
|
||||
169
src/app/core/session.service.ts
Normal file
169
src/app/core/session.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { User } from '../shared/user';
|
||||
import { Org } from '../shared/org';
|
||||
import { SessionOptions } from '../shared/session-options';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { ConfigService } from './config.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import 'rxjs/add/operator/catch';
|
||||
import 'rxjs/add/operator/switchMap';
|
||||
import 'rxjs/add/operator/map';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
|
||||
private sessions$: Subject<[User, Org, SessionOptions]>;
|
||||
private user: User;
|
||||
private org: Org;
|
||||
private loading: boolean;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private configService: ConfigService,
|
||||
private wsService: WebSocketService) {
|
||||
this.loading = true;
|
||||
|
||||
this.sessions$ = new Subject<[User, Org, SessionOptions]>();
|
||||
}
|
||||
|
||||
getSessions(): Observable<[User, Org, SessionOptions]> {
|
||||
return this.sessions$.asObservable();
|
||||
}
|
||||
|
||||
login(email: string, password: string, sessionId: string): Observable<any> {
|
||||
return this.apiService.newSession(email, password, sessionId).do(() => {
|
||||
this.init(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
init(sessionId?: string) {
|
||||
this.loading = true;
|
||||
let server = this.configService.get('server');
|
||||
|
||||
if(!server) {
|
||||
server = 'https://openaccounting.io:8080/api';
|
||||
this.configService.put('server', server);
|
||||
}
|
||||
|
||||
this.apiService.setUrl(server || 'https://openaccounting.io:8080/api');
|
||||
|
||||
sessionId = sessionId || this.configService.get('sessionId');
|
||||
|
||||
let orgId = this.configService.get('defaultOrg');
|
||||
|
||||
if(!sessionId) {
|
||||
this.loading = false;
|
||||
return this.sessions$.next([null, null, new SessionOptions()]);
|
||||
}
|
||||
|
||||
this.apiService.setSession(sessionId);
|
||||
|
||||
this.apiService.getUser()
|
||||
.catch(err => {
|
||||
this.log.debug('bad session ' + err);
|
||||
this.apiService.removeSessionInfo();
|
||||
this.configService.clear();
|
||||
this.loading = false;
|
||||
return Observable.of(null);
|
||||
})
|
||||
.switchMap(user => {
|
||||
if(!user) {
|
||||
this.loading = false;
|
||||
return Observable.of([null, null, new SessionOptions]);
|
||||
}
|
||||
|
||||
return this.apiService.getOrg(orgId).map(org => {
|
||||
return [user, org];
|
||||
}).catch(err => {
|
||||
this.loading = false;
|
||||
this.log.debug('catching error here');
|
||||
return this.apiService.getOrgs().map(orgs => {
|
||||
if(orgs.length) {
|
||||
let org = orgs[0];
|
||||
this.configService.put('defaultOrg', org.id);
|
||||
return [user, org];
|
||||
}
|
||||
|
||||
return [user, null];
|
||||
})
|
||||
})
|
||||
})
|
||||
.subscribe(([user, org]) => {
|
||||
this.log.debug('new session');
|
||||
this.log.debug(user);
|
||||
this.log.debug(org);
|
||||
this.user = user;
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
this.apiService.setOrgId(org.id);
|
||||
}
|
||||
|
||||
// initialize websocket
|
||||
let matches = server.match(/\/\/(.+?)\//);
|
||||
|
||||
if(matches[1]) {
|
||||
let url = 'wss://' +
|
||||
matches[1] +
|
||||
'/ws';
|
||||
|
||||
this.wsService.init(url, sessionId);
|
||||
|
||||
} else {
|
||||
this.log.debug('Failed to initialize web socket because we can\'t parse server url');
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.sessions$.next([user, org, new SessionOptions()]);
|
||||
})
|
||||
}
|
||||
|
||||
logout() {
|
||||
setTimeout(() => {
|
||||
this.wsService.close();
|
||||
this.apiService.logout();
|
||||
this.log.debug('new session');
|
||||
this.log.debug(null);
|
||||
this.log.debug(null);
|
||||
this.sessions$.next([null, null, new SessionOptions()]);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
switchOrg(org: Org, options?: SessionOptions) {
|
||||
setTimeout(() => {
|
||||
if(!options) {
|
||||
options = new SessionOptions();
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
this.apiService.setOrgId(org.id);
|
||||
this.log.debug('new session');
|
||||
this.log.debug(this.user);
|
||||
this.log.debug(org);
|
||||
this.sessions$.next([this.user, org, options]);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
setTimeout(() => {
|
||||
this.loading = loading;
|
||||
}, 1);
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
getOrg() {
|
||||
return this.org;
|
||||
}
|
||||
}
|
||||
228
src/app/core/transaction.service.ts
Normal file
228
src/app/core/transaction.service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { ApiService } from './api.service';
|
||||
import { WebSocketService } from './websocket.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { Transaction } from '../shared/transaction';
|
||||
import { Account } from '../shared/account';
|
||||
import { Org } from '../shared/org';
|
||||
import { Message } from '../shared/message';
|
||||
import 'rxjs/add/operator/do';
|
||||
import 'rxjs/add/operator/merge';
|
||||
import 'rxjs/add/operator/filter';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionService {
|
||||
private transactionLastUpdated: Date;
|
||||
private cache: any;
|
||||
private recentTransactions: Transaction[] = null;
|
||||
private newTxs: Subject<Transaction>;
|
||||
private deletedTxs: Subject<Transaction>;
|
||||
private org: Org;
|
||||
private txSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private apiService: ApiService,
|
||||
private wsService: WebSocketService,
|
||||
private sessionService: SessionService) {
|
||||
this.newTxs = new Subject<Transaction>();
|
||||
this.deletedTxs = new Subject<Transaction>();
|
||||
this.transactionLastUpdated = new Date(0);
|
||||
|
||||
this.sessionService.getSessions().subscribe(([user, org]) => {
|
||||
this.log.debug('transactionService new session');
|
||||
|
||||
// cleanup from old session
|
||||
if(this.txSubscription) {
|
||||
this.wsService.unsubscribe('transaction', this.org.id);
|
||||
this.txSubscription.unsubscribe();
|
||||
this.txSubscription = null;
|
||||
}
|
||||
|
||||
this.org = org;
|
||||
|
||||
if(org) {
|
||||
this.recentTransactions = null;
|
||||
|
||||
let txMessages$ = this.wsService.subscribe('transaction', org.id);
|
||||
|
||||
this.txSubscription = txMessages$.subscribe(message => {
|
||||
let tx = null;
|
||||
if(message.data) {
|
||||
tx = new Transaction(message.data);
|
||||
}
|
||||
|
||||
if(tx && tx.updated) {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
}
|
||||
|
||||
switch(message.action) {
|
||||
case 'create':
|
||||
if(this.recentTransactions) {
|
||||
this.recentTransactions.push(tx);
|
||||
}
|
||||
|
||||
this.newTxs.next(tx);
|
||||
break;
|
||||
case 'update':
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions[i] = tx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.deletedTxs.next(tx);
|
||||
this.newTxs.next(tx);
|
||||
break;
|
||||
case 'delete':
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.deletedTxs.next(tx);
|
||||
break;
|
||||
case 'reconnect':
|
||||
this.log.debug('Resyncing transactions');
|
||||
this.log.debug('Fetching transactions since ' + this.transactionLastUpdated);
|
||||
let options = {sinceUpdated: this.transactionLastUpdated.getTime(), sort: 'updated-asc', includeDeleted: 'true'};
|
||||
this.apiService.getTransactions(options).subscribe(txs => {
|
||||
txs.forEach(tx => {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
if(tx.deleted) {
|
||||
if(this.recentTransactions) {
|
||||
for(let i = 0; i < this.recentTransactions.length; i++) {
|
||||
if(this.recentTransactions[i].id === tx.id) {
|
||||
this.recentTransactions.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.deletedTxs.next(tx);
|
||||
} else {
|
||||
if(this.recentTransactions) {
|
||||
this.recentTransactions.push(tx);
|
||||
}
|
||||
this.newTxs.next(tx);
|
||||
}
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getNewTransactions(): Observable<Transaction> {
|
||||
return this.newTxs.asObservable();
|
||||
}
|
||||
|
||||
getDeletedTransactions(): Observable<Transaction> {
|
||||
return this.deletedTxs.asObservable();
|
||||
}
|
||||
|
||||
getRecentTransactions(): Observable<Transaction[]> {
|
||||
if(this.recentTransactions) {
|
||||
return Observable.of(this.recentTransactions);
|
||||
}
|
||||
|
||||
return this.apiService.getTransactions({limit: 50}).do(transactions => {
|
||||
this.recentTransactions = transactions;
|
||||
transactions.forEach(tx => {
|
||||
if(tx.updated > this.transactionLastUpdated) {
|
||||
this.transactionLastUpdated = tx.updated;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getLastTransactions(count: number): Observable<Transaction[]> {
|
||||
return this.getRecentTransactions()
|
||||
.map(txs => {
|
||||
return txs.sort((a, b) => {
|
||||
return b.date.getTime() - a.date.getTime();
|
||||
});
|
||||
})
|
||||
.map(txs => {
|
||||
return txs.slice(0, count);
|
||||
})
|
||||
.switchMap(initialTxs => {
|
||||
let txs = initialTxs;
|
||||
|
||||
return Observable.of(initialTxs)
|
||||
.concat(this.getNewTransactions()
|
||||
.map(tx => {
|
||||
// TODO check date
|
||||
txs.unshift(tx);
|
||||
txs.pop();
|
||||
return txs;
|
||||
}).merge(this.getDeletedTransactions()
|
||||
.map(tx => {
|
||||
for(let i = 0; i < txs.length; i++) {
|
||||
if(txs[i].id === tx.id) {
|
||||
txs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return txs;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getNewTransactionsByAccount(accountId: string): Observable<Transaction> {
|
||||
return this.getNewTransactions().filter(tx => {
|
||||
for(let split of tx.splits) {
|
||||
if(split.accountId === accountId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
getDeletedTransactionsByAccount(accountId: string): Observable<Transaction> {
|
||||
return this.getDeletedTransactions().filter(tx => {
|
||||
for(let split of tx.splits) {
|
||||
if(split.accountId === accountId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
getTransactionsByAccount (accountId: string, options: any = {}): Observable<Transaction[]> {
|
||||
return this.apiService.getTransactionsByAccount(accountId, options);
|
||||
}
|
||||
|
||||
getTransactions(options: any = {}): Observable<Transaction[]> {
|
||||
return this.apiService.getTransactions(options);
|
||||
}
|
||||
|
||||
newTransaction(transaction: Transaction): Observable<Transaction> {
|
||||
return this.apiService.postTransaction(transaction);
|
||||
}
|
||||
|
||||
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
|
||||
return this.apiService.putTransaction(oldId, transaction);
|
||||
}
|
||||
|
||||
deleteTransaction(id: string): Observable<any> {
|
||||
return this.apiService.deleteTransaction(id);
|
||||
}
|
||||
}
|
||||
37
src/app/core/user.service.ts
Normal file
37
src/app/core/user.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { User } from '../shared/user';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private user: User;
|
||||
|
||||
constructor(private apiService: ApiService) {
|
||||
|
||||
}
|
||||
|
||||
getUser(): Observable<User> {
|
||||
return this.apiService.getUser();
|
||||
}
|
||||
|
||||
postUser(user: User): Observable<User> {
|
||||
return this.apiService.postUser(user);
|
||||
}
|
||||
|
||||
putUser(user: User): Observable<User> {
|
||||
return this.apiService.putUser(user);
|
||||
}
|
||||
|
||||
verifyUser(code: string): Observable<any> {
|
||||
return this.apiService.verifyUser(code);
|
||||
}
|
||||
|
||||
resetPassword(email: string): Observable<any> {
|
||||
return this.apiService.resetPassword(email);
|
||||
}
|
||||
|
||||
confirmResetPassword(password: string, code: string): Observable<User> {
|
||||
return this.apiService.confirmResetPassword(password, code);
|
||||
}
|
||||
}
|
||||
235
src/app/core/websocket.service.ts
Normal file
235
src/app/core/websocket.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger } from './logger';
|
||||
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
||||
import { WebSocketSubject } from 'rxjs/observable/dom/WebSocketSubject';
|
||||
import { Message } from '../shared/message';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import 'rxjs/add/operator/filter';
|
||||
import 'rxjs/add/operator/retryWhen';
|
||||
import 'rxjs/add/operator/repeatWhen';
|
||||
import 'rxjs/add/operator/delay';
|
||||
|
||||
var version = '^0.1.8';
|
||||
|
||||
@Injectable()
|
||||
export class WebSocketService {
|
||||
|
||||
private socket$: WebSocketSubject<Message>;
|
||||
private outputSocket$: Subject<Message>;
|
||||
private subscriptions: Message[];
|
||||
private reconnected: boolean;
|
||||
private sequenceNumber: number;
|
||||
private lastPongDate: Date;
|
||||
private closed: boolean;
|
||||
private authErrorCount: number;
|
||||
|
||||
constructor(private log: Logger) {
|
||||
this.reconnected = false;
|
||||
this.subscriptions = [];
|
||||
this.outputSocket$ = new Subject<Message>();
|
||||
this.authErrorCount = 0;
|
||||
}
|
||||
|
||||
init(url: string, key: string) {
|
||||
this.closed = false;
|
||||
this.socket$ = new WebSocketSubject({
|
||||
url: url,
|
||||
openObserver: {
|
||||
next: value => {
|
||||
this.log.debug('websocket connected!');
|
||||
this.sequenceNumber = -1;
|
||||
this.detectSleep();
|
||||
|
||||
if(this.reconnected) {
|
||||
this.authenticate(key);
|
||||
this.sendReconnectMessage();
|
||||
|
||||
this.log.debug('resubscribing to events');
|
||||
this.subscriptions.forEach(message => {
|
||||
this.log.debug(message);
|
||||
this.socket$.next(message);
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
closeObserver: {
|
||||
next: value => {
|
||||
this.log.debug('websocket closed!');
|
||||
this.log.debug(value);
|
||||
|
||||
if(value.code === 4000) {
|
||||
// authentication error
|
||||
// this could be because the socket got reconnected and we need
|
||||
// to send an authenticate message
|
||||
this.authErrorCount++;
|
||||
|
||||
if(this.authErrorCount >= 3) {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(value.code >= 4001) {
|
||||
// other intentional errors we should just stop trying to reconnect
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket$.retryWhen(errors$ => {
|
||||
if(this.closed) {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
return errors$.delay(1000).do(err => {
|
||||
this.log.debug('Websocket error');
|
||||
this.log.debug(err);
|
||||
|
||||
this.reconnected = true;
|
||||
});
|
||||
}).repeatWhen(completed => {
|
||||
if(this.closed) {
|
||||
throw new Error('closed');
|
||||
}
|
||||
|
||||
return completed.delay(1000).do(err => {
|
||||
this.log.debug('Reconnecting to websocket because it closed');
|
||||
this.reconnected = true;
|
||||
})
|
||||
}).subscribe(message => {
|
||||
this.log.debug('Received message. Our sequenceNumber is ' + this.sequenceNumber);
|
||||
this.log.debug(message);
|
||||
|
||||
this.authErrorCount = 0;
|
||||
|
||||
if(message.type === 'pong') {
|
||||
this.lastPongDate = new Date();
|
||||
}
|
||||
|
||||
if(message.sequenceNumber === 0 && this.sequenceNumber > 0) {
|
||||
// reconnected on us
|
||||
this.log.debug('Websocket reconnected on us');
|
||||
this.authenticate(key);
|
||||
this.sendReconnectMessage();
|
||||
this.sequenceNumber = 0;
|
||||
return;
|
||||
} else if(message.sequenceNumber !== this.sequenceNumber + 1) {
|
||||
// got a bad sequence number
|
||||
// need to reconnect and resync
|
||||
this.log.debug('Websocket out of sync');
|
||||
this.socket$.error({code: 3791, reason: 'Out of sync'});
|
||||
return;
|
||||
}
|
||||
|
||||
this.sequenceNumber = message.sequenceNumber;
|
||||
this.outputSocket$.next(message);
|
||||
}, err => {
|
||||
this.log.error(err);
|
||||
}, () => {
|
||||
this.log.debug('Websocket complete.');
|
||||
});
|
||||
|
||||
this.authenticate(key);
|
||||
}
|
||||
|
||||
subscribe(type: string, orgId: string): Observable<Message> {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: type,
|
||||
action: 'subscribe',
|
||||
data: orgId
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
this.subscriptions.push(message);
|
||||
|
||||
return this.outputSocket$.filter(message => {
|
||||
return message.type === type || message.type === 'reconnect';
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribe(type: string, orgId: string) {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: type,
|
||||
action: 'unsubscribe',
|
||||
data: orgId
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
this.subscriptions = this.subscriptions.filter(message => {
|
||||
return !(message.type === type && message.data === orgId);
|
||||
});
|
||||
}
|
||||
|
||||
detectSleep() {
|
||||
let lastDate = new Date();
|
||||
let interval = setInterval(() => {
|
||||
let currentDate = new Date();
|
||||
if(currentDate.getTime() - lastDate.getTime() > 10000) {
|
||||
// Detected sleep
|
||||
this.log.debug('Sleep detected! Sending ping.');
|
||||
let date = new Date();
|
||||
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'ping',
|
||||
action: 'ping',
|
||||
data: null
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
|
||||
setTimeout(() => {
|
||||
this.checkForPong(date);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
lastDate = currentDate;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
checkForPong(date: Date) {
|
||||
if(!this.lastPongDate || this.lastPongDate.getTime() < date.getTime()) {
|
||||
this.log.debug('no pong response');
|
||||
this.socket$.error({code: 3792, reason: 'No pong response'});
|
||||
}
|
||||
}
|
||||
|
||||
sendReconnectMessage() {
|
||||
this.log.debug('notifiyng subscribers of reconnect event');
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'reconnect',
|
||||
action: 'reconnect',
|
||||
data: null
|
||||
});
|
||||
|
||||
this.outputSocket$.next(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.log.debug('Closed websocket');
|
||||
this.closed = true;
|
||||
this.socket$.unsubscribe();
|
||||
}
|
||||
|
||||
authenticate(key: string) {
|
||||
let message = new Message({
|
||||
version: version,
|
||||
sequenceNumber: -1,
|
||||
type: 'authenticate',
|
||||
action: 'authenticate',
|
||||
data: key
|
||||
});
|
||||
|
||||
this.socket$.next(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user