initial commit

This commit is contained in:
Patrick Nagurny
2018-10-19 11:28:08 -04:00
commit 5ff09d328d
139 changed files with 23448 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from '../shared/shared.module';
import { AppRoutingModule } from '../app-routing.module';
import { AccountsPage } from './accounts';
import { NewAccountPage } from './new';
import { EditAccountPage } from './edit';
import { TreeComponent } from './tree';
@NgModule({
declarations: [
AccountsPage,
NewAccountPage,
EditAccountPage,
TreeComponent
],
imports: [
BrowserModule,
NgbModule,
ReactiveFormsModule,
SharedModule,
AppRoutingModule
],
exports: [TreeComponent],
providers: []
})
export class AccountModule { }

View File

@@ -0,0 +1,7 @@
<h1>Accounts</h1>
<div class="section">
<account-tree></account-tree>
<button type="button" class="btn btn-primary" (click)="newAccount()">New Account</button>
</div>

View File

View File

@@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-accounts',
templateUrl: './accounts.html',
styleUrls: ['./accounts.scss']
})
export class AccountsPage implements OnInit {
constructor(private router: Router) { }
ngOnInit() {
}
newAccount() {
this.router.navigate(['/accounts/new']);
}
}

54
src/app/account/edit.html Normal file
View File

@@ -0,0 +1,54 @@
<h1>Edit Account</h1>
<div class="section">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Name</label>
<div class="col-sm-9">
<input formControlName="name" id="name" type="text" class="form-control" placeholder="Account Name" />
</div>
</div>
<div class="form-group row">
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
<div class="col-sm-9">
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="Currency" />
</div>
</div>
<div class="form-group row">
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
<div class="col-sm-9">
<input formControlName="precision" id="precision" type="number" class="form-control" placeholder="Decimal Places" />
</div>
</div>
<div class="form-group row">
<label for="parent" class="col-sm-3 col-form-label">Parent Account</label>
<div class="col-sm-9">
<select class="form-control" id="parent" formControlName="parent">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName | slice:0:50}}
</option>
</select>
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Update</button>
<button type="button" class="btn btn-primary" (click)="confirmDelete()">Delete</button>
</form>
</div>
<ng-template #confirmDeleteModal let-c="close" let-d="dismiss">
<div class="modal-header">
<h4 class="modal-title">Confirm delete</h4>
<button type="button" class="close" aria-label="Close" (click)="d()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this account?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="d()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="c()">Delete</button>
</div>
</ng-template>

105
src/app/account/edit.ts Normal file
View File

@@ -0,0 +1,105 @@
import { Component, ViewChild, ElementRef } from '@angular/core';
import { Logger } from '../core/logger';
import { Router, ActivatedRoute } from '@angular/router';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { AccountService } from '../core/account.service';
import { Account, AccountApi, AccountTree } from '../shared/account';
import { AppError } from '../shared/error';
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
import 'rxjs/add/operator/first';
@Component({
selector: 'app-accounts-edit',
templateUrl: 'edit.html'
})
export class EditAccountPage {
public form: FormGroup;
public selectAccounts: any[];
public error: AppError;
private account: Account;
private accountTree: AccountTree;
@ViewChild('confirmDeleteModal') confirmDeleteModal: ElementRef;
constructor(
private log: Logger,
private router: Router,
private route: ActivatedRoute,
private accountService: AccountService,
private fb: FormBuilder,
private modalService: NgbModal
) {
this.form = fb.group({
'name': [null, Validators.required],
'currency': [null],
'precision': [null],
'parent': [null, Validators.required]
});
this.accountService.getAccountTree().first().subscribe(tree => {
this.accountTree = tree;
this.selectAccounts = tree.getFlattenedAccounts();
let accountId = this.route.snapshot.paramMap.get('id');
this.account = tree.accountMap[accountId];
this.form = fb.group({
'name': [this.account.name, Validators.required],
'currency': [this.account.currency],
'precision': [this.account.precision],
'parent': [this.account.parent.id, Validators.required]
});
});
}
onSubmit() {
let account = new AccountApi(this.form.value);
let parentAccount = this.accountTree.accountMap[account.parent];
if(!parentAccount) {
this.error = new AppError('Invalid parent account');
return;
}
account.id = this.account.id;
account.orgId = this.account.orgId;
account.debitBalance = parentAccount.debitBalance;
account.currency = account.currency || parentAccount.currency;
account.precision = account.precision !== null ? account.precision : parentAccount.precision;
this.log.debug('put account');
this.log.debug(account);
this.accountService.putAccount(account)
.subscribe(
account => {
this.log.debug(account);
this.router.navigate(['/accounts']);
},
err => {
this.error = err;
}
);
}
confirmDelete() {
this.modalService.open(this.confirmDeleteModal).result.then((result) => {
this.log.debug('delete');
this.accountService.deleteAccount(this.account.id)
.subscribe(() => {
this.log.debug('successfully deleted account ' + this.account.id);
this.router.navigate(['/accounts']);
}, error => {
this.error = error;
})
}, (reason) => {
this.log.debug('cancel delete');
});
}
}

36
src/app/account/new.html Normal file
View File

@@ -0,0 +1,36 @@
<h1>New Account</h1>
<div class="section">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Name</label>
<div class="col-sm-9">
<input formControlName="name" id="name" type="text" class="form-control" placeholder="Account Name" />
</div>
</div>
<div class="form-group row">
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
<div class="col-sm-9">
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="Currency" />
</div>
</div>
<div class="form-group row">
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
<div class="col-sm-9">
<input formControlName="precision" id="precision" type="number" class="form-control" placeholder="Decimal Places" />
</div>
</div>
<div class="form-group row">
<label for="parent" class="col-sm-3 col-form-label">Parent Account</label>
<div class="col-sm-9">
<select class="form-control" id="parent" formControlName="parent">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName | slice:0:50}}
</option>
</select>
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Create</button>
</form>
</div>

75
src/app/account/new.ts Normal file
View File

@@ -0,0 +1,75 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { Account, AccountApi, AccountTree } from '../shared/account';
import { Util } from '../shared/util';
import { AppError } from '../shared/error';
@Component({
selector: 'app-accounts-new',
templateUrl: 'new.html'
})
export class NewAccountPage {
public form: FormGroup;
public selectAccounts: any[];
public error: AppError;
private accountTree: AccountTree;
constructor(
private router: Router,
private accountService: AccountService,
private orgService: OrgService,
private fb: FormBuilder) {
let org = this.orgService.getCurrentOrg();
this.form = fb.group({
'name': ['', Validators.required],
'currency': [org.currency],
'precision': [org.precision],
'parent': [null, Validators.required]
});
this.accountService.getAccountTree().subscribe(tree => {
this.accountTree = tree;
this.selectAccounts = tree.getFlattenedAccounts();
});
}
onSubmit() {
let account = new AccountApi(this.form.value);
account.id = Util.newGuid();
let parentAccount = this.accountTree.accountMap[account.parent];
if(!parentAccount) {
this.error = new AppError('Invalid parent account');
return;
}
let org = this.orgService.getCurrentOrg();
account.orgId = org.id;
account.debitBalance = parentAccount.debitBalance;
account.currency = account.currency || parentAccount.currency;
account.precision = account.precision !== null ? account.precision : parentAccount.precision;
this.accountService.newAccount(account)
.subscribe(
account => {
this.router.navigate(['/accounts']);
},
err => {
this.error = err;
}
);
}
}

13
src/app/account/tree.html Normal file
View File

@@ -0,0 +1,13 @@
<div class="container-fluid">
<div class="row" *ngFor="let account of accounts$ | async" [ngClass]="{expanded: isExpanded(account), hidden: !isVisible(account)}" [attr.depth]="account.depth">
<div class="col-8 name" (click)="click(account)">
<span *ngIf="account.children.length" class="expander"></span>
<span *ngIf="!account.children.length" class="noexpander"></span>
{{account.name | slice:0:30}}
<span class="edit"><a [routerLink]="'/accounts/' + account.id + '/edit'">Edit</a></span>
</div>
<div class="col-4 balance text-right">
<span>{{account | accountBalance:'price'}}</span>
</div>
</div>
</div>

73
src/app/account/tree.scss Normal file
View File

@@ -0,0 +1,73 @@
.name {
min-height:2rem;
}
.hidden {
display: none
}
.row {
cursor: pointer;
}
.row:not(.expanded) > div > .expander {
display: inline-block;
background-image: url("/assets/plus.svg");
width: 11px;
height: 11px;
margin-right: 5px;
}
.row.expanded > div > .expander {
display: inline-block;
background-image: url("/assets/minus.svg");
width: 11px;
height: 11px;
margin-right: 5px;
}
.noexpander {
display: inline-block;
width: 11px;
margin-right: 5px;
}
.edit {
display: none;
padding-left: 10px;
}
.row:hover .edit {
display: inline;
}
.row[depth="1"] .name {
padding-left: 15px;
}
.row[depth="2"] .name {
padding-left: 30px;
}
.row[depth="3"] .name {
padding-left: 45px;
}
.row[depth="4"] .name {
padding-left: 60px;
}
.row[depth="5"] .name {
padding-left: 75px;
}
.row .name {
padding-left: 90px;
}
@media (min-width: 768px) {
.row[depth="1"] .balance {
padding-right: 15px;
}
.row[depth="2"] .balance {
padding-right: 30px;
}
.row[depth="3"] .balance {
padding-right: 45px;
}
.row[depth="4"] .balance {
padding-right: 60px;
}
.row[depth="5"] .balance {
padding-right: 75px;
}
.row .balance {
padding-right: 90px;
}
}

123
src/app/account/tree.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Component, Input, OnInit } from '@angular/core';
import { Logger } from '../core/logger';
import { Router } from '@angular/router';
import { Account } from '../shared/account';
import { Org } from '../shared/org';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { ConfigService } from '../core/config.service';
import { SessionService } from '../core/session.service';
import { Observable } from 'rxjs/Observable';
@Component({
selector: 'account-tree',
templateUrl: 'tree.html',
styleUrls: ['./tree.scss']
})
export class TreeComponent implements OnInit {
public accounts$: Observable<Account[]>;
private accounts: any[];
private expandedAccounts: any = {};
private visibleAccounts: any = {};
private org: Org;
constructor(
private log: Logger,
private router: Router,
private accountService: AccountService,
private orgService: OrgService,
private configService: ConfigService,
private sessionService: SessionService) {
}
ngOnInit() {
this.sessionService.setLoading(true);
this.org = this.orgService.getCurrentOrg();
this.expandedAccounts = this.configService.get('expandedAccounts') || {};
this.visibleAccounts = {};
this.log.debug('tree init');
this.accounts$ = this.accountService.getFlattenedAccounts()
.do(accounts => {
this.log.debug('NEW TREE');
this.sessionService.setLoading(false);
this.expandTopLevel(accounts);
}
);
}
expandTopLevel(accounts: Account[]) {
for(let account of accounts) {
if(account.depth === 1 && this.isExpanded(account)) {
this.showExpandedDescendants(account);
} else if(account.depth === 1) {
this.showAccount(account);
}
}
}
isExpanded(account) {
return this.expandedAccounts[account.id];
}
toggleExpanded(account) {
this.expandedAccounts[account.id] = !this.expandedAccounts[account.id];
this.configService.put('expandedAccounts', this.expandedAccounts);
}
isVisible(account) {
return this.visibleAccounts[account.id];
}
hideAccount(account) {
this.visibleAccounts[account.id] = false;
}
showAccount(account) {
this.visibleAccounts[account.id] = true;
}
click(account) {
if(account.children.length) {
this.toggleExpanded(account);
if(this.isExpanded(account)) {
this.showExpandedDescendants(account);
} else {
this.hideDescendants(account);
}
} else {
this.router.navigate(['/accounts/' + account.id + '/transactions']);
}
}
edit(account: Account) {
// let modal = this.modalCtrl.create(EditAccountPage, {account: account});
// modal.present();
// modal.onWillDismiss(() => {
// this.ngOnChanges();
// });
}
hideDescendants(account: Account) {
account.children.forEach(child => {
this.hideAccount(child);
this.hideDescendants(child);
});
}
showExpandedDescendants(account: Account) {
this.showAccount(account);
account.children.forEach(child => {
this.showAccount(child);
if(this.isExpanded(child)) {
this.showExpandedDescendants(child);
}
});
}
}

View File

@@ -0,0 +1,51 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardPage } from './dashboard/dashboard';
import { AccountsPage } from './account/accounts';
import { NewAccountPage } from './account/new';
import { EditAccountPage } from './account/edit';
import { TxListPage } from './transaction/list';
import { LoginPage } from './user/login';
import { LogoutPage } from './user/logout';
import { VerifyUserPage } from './user/verify';
import { ResetPasswordPage } from './user/reset';
import { RegisterPage } from './register/register';
import { NewOrgPage } from './org/neworg';
import { OrgPage } from './org/org';
import { SettingsPage } from './settings/settings';
import { PriceDbPage } from './price/pricedb';
import { ReportsPage } from './reports/reports';
import { IncomeReport } from './reports/income';
import { BalanceSheetReport } from './reports/balancesheet';
import { ReconcilePage } from './reconcile/reconcile';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'user/verify', component: VerifyUserPage },
{ path: 'user/reset-password', component: ResetPasswordPage },
{ path: 'dashboard', component: DashboardPage },
{ path: 'accounts', component: AccountsPage },
{ path: 'accounts/new', component: NewAccountPage },
{ path: 'accounts/:id/transactions', component: TxListPage },
{ path: 'accounts/:id/edit', component: EditAccountPage },
{ path: 'reports', component: ReportsPage },
{ path: 'reports/income', component: IncomeReport },
{ path: 'reports/balancesheet', component: BalanceSheetReport },
{ path: 'login', component: LoginPage },
{ path: 'logout', component: LogoutPage },
{ path: 'register', component: RegisterPage },
{ path: 'orgs/new', component: NewOrgPage },
{ path: 'orgs', component: OrgPage },
{ path: 'settings', component: SettingsPage },
{ path: 'tools/reconcile', component: ReconcilePage },
{ path: 'prices', component: PriceDbPage }
];
@NgModule({
imports: [ RouterModule.forRoot(routes, {initialNavigation: false}) ],
exports: [ RouterModule ]
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,80 @@
<!--The content below is only a placeholder and can be replaced.-->
<nav class="navbar navbar-expand-sm navbar-dark bg-primary">
<a *ngIf="navItems['/login'].hidden" class="navbar-brand" routerLink="/"><img src="assets/oa-logo1.svg" width="176" height="23" alt="Open Accounting"/></a>
<a *ngIf="!navItems['/login'].hidden" class="navbar-brand" href="/"><img src="assets/oa-logo1.svg" width="176" height="23" alt="Open Accounting"/></a>
<button class="navbar-toggler d-md-none" type="button" (click)="isTopNavCollapsed = !isTopNavCollapsed" data-toggle="collapse" aria-controls="leftNav" aria-expanded="false" aria-label="Toggle navigation">
<!-- <span class="navbar-toggler-icon"></span> -->
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 30 30" width="30" height="30" focusable="false"><title>Menu</title><path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-miterlimit="10" d="M4 7h22M4 15h22M4 23h22"/></svg>
</button>
<div [ngbCollapse]="isTopNavCollapsed" class="collapse navbar-collapse" id="topNav">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="#">About <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/pricing">Pricing</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/docs/getting-started">Docs</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api">API</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/openaccounting" target="_blank">Source Code</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/contact">Contact</a>
</li>
</ul>
<div class="login">
<ul class="navbar-nav">
<li class="nav-item" *ngIf="!navItems['/login'].hidden">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item" *ngIf="!navItems['/login'].hidden">
<a class="nav-link" href="/register">Sign Up</a>
</li>
<li class="nav-item" *ngIf="!navItems['/logout'].hidden">
<a class="nav-link" routerLink="/logout">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid" [ngClass]="{loading: sessionService.isLoading()}">
<div class="row">
<div class="col-12 col-md-3 sidebar" *ngIf="!hideLeftNav" [ngClass]="{display: showLeftNav}">
<!-- <span class="navbar-toggler-icon"></span> -->
<nav class="left-nav-links" id="leftNav">
<ul class="nav">
<li *ngFor="let item of leftNav">
<div *ngIf="!item.hidden">
<a [routerLink]="item.link" [ngClass]="{active: router.isActive(item.link)}">{{item.name}}</a>
</div>
</li>
</ul>
<h5 *ngIf="loggedIn" class="tools">Tools</h5>
<ul *ngIf="loggedIn" class="nav">
<li *ngFor="let item of toolsNav">
<div *ngIf="!item.hidden">
<a [routerLink]="item.link" [ngClass]="{active: router.isActive(item.link)}">{{item.name}}</a>
</div>
</li>
</ul>
</nav>
</div>
<div class="col content">
<button *ngIf="!showLeftNav" [ngClass]="{display: hideLeftNav}" class="navbar-toggler" type="button" (click)="showLeftNav = true; hideLeftNav = false;" data-toggle="collapse" aria-controls="leftNav" aria-expanded="false" aria-label="Toggle navigation">
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 30 30" width="30" height="30" focusable="false"><title>Menu</title><path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-miterlimit="10" d="M4 7h22M4 15h22M4 23h22"/></svg>
</button>
<router-outlet></router-outlet>
</div>
</div>
</div>
<div class="footer">
<p>Copyright &copy; 2018 Open Accounting, LLC<br>
<a href="/tou">Terms of Use</a> | <a href="/privacy-policy">Privacy Policy</a></p>
</div>

View File

@@ -0,0 +1,76 @@
@import '~sass/variables';
.navbar-dark .navbar-nav .nav-link {
color: #fff;
}
.navbar-brand {
line-height: 1.5
}
@media (min-width: 768px) {
.sidebar {
border-right: 1px solid rgba(0,0,0,.06);
height: 100vh;
}
}
#topNav {
line-height: 1.5
}
#leftNav {
margin-top: 10px;
line-height: 2
}
#leftNav li > div {
border-bottom: 1px solid rgba(0,0,0,.06);
}
#leftNav a {
display: block;
padding: .25rem 1.5rem;
color: #466e9a;
}
#leftNav a.active {
color: $blue;
}
#leftNav .nav {
display: block;
}
#leftNav .tools {
margin: 1rem 0 0 0;
padding: .25rem 1.5rem;
}
.loading {
cursor: wait;
opacity: 0.4;
}
.content {
padding: 1rem 0;
background-color: #f8fcff;
}
@media (max-width: 767px) {
.sidebar:not(.display) {
display: none;
}
}
.content .navbar-toggler {
position: absolute;
top: 0px;
left: 0px;
}
@media (min-width: 768px) {
.content .navbar-toggler:not(.display) {
display: none;
}
}

211
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,211 @@
import { Component, OnInit } from '@angular/core';
import { Location } from '@angular/common';
import { Router, NavigationEnd } from '@angular/router';
import { Logger } from './core/logger';
import { SessionService } from './core/session.service';
import { ConfigService } from './core/config.service';
import { OrgService } from './core/org.service';
import { AccountService } from './core/account.service';
import { TransactionService } from './core/transaction.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
public showLeftNav: boolean = false;
public hideLeftNav: boolean = false;
public isTopNavCollapsed: boolean = false;
public loggedIn: boolean = false;
public navItems: any = {
'/dashboard': {
link: '/dashboard',
name: 'Dashboard'
},
'/accounts': {
link: '/accounts',
name: 'Accounts'
},
'/reports': {
link: '/reports',
name: 'Reports'
},
'/prices': {
link: '/prices',
name: 'Price Database',
},
'/orgs': {
link: '/orgs',
name: 'Organization'
},
'/settings': {
link: '/settings',
name: 'Settings'
},
'/login': {
link: '/login',
name: 'Login',
hidden: true
},
'/logout': {
link: '/logout',
name: 'Logout'
},
'/tools/reconcile': {
link: '/tools/reconcile',
name: 'Reconcile'
}
};
public leftNav: any[] = [
this.navItems['/dashboard'],
this.navItems['/accounts'],
this.navItems['/reports'],
this.navItems['/prices'],
this.navItems['/orgs'],
this.navItems['/settings']
];
public toolsNav: any[] = [
this.navItems['/tools/reconcile']
];
// Allowed unauthenticated links besides login
public passthroughLinks: any[] = [
'/register',
'/user/verify',
'/user/reset-password',
'/settings'
];
constructor(
private log: Logger,
private router: Router,
private location: Location,
public sessionService: SessionService,
private configService: ConfigService,
private orgService: OrgService,
private accountService: AccountService,
private transactionService: TransactionService) {
this.log.setLevel(Logger.INFO);
}
hideNavItem(link: string) {
this.navItems[link].hidden = true;
}
showNavItem(link: string) {
this.navItems[link].hidden = false;
}
ngOnInit() {
this.log.info('app init');
this.sessionService.getSessions().subscribe(([user, org]) => {
this.log.debug('appComponent: new session');
//this.dataService.setLoading(false);
if(!user) {
this.loggedIn = false;
this.log.debug('no user');
this.showLoggedOutMenu();
let passthrough = false;
this.passthroughLinks.forEach(link => {
if(this.location.path().startsWith(link)) {
passthrough = true;
}
})
if(passthrough) {
this.router.initialNavigation();
return;
}
this.router.navigate(['/login']);
return;
}
if(!org) {
this.loggedIn = true;
this.log.debug('display new org page');
this.showCreateOrgMenu();
// display new org screen
// TODO allow joining of exisitng orgs
this.router.navigate(['/orgs/new']);
return;
}
this.loggedIn = true;
this.showLoggedInMenu();
if(
this.router.url === '/login' ||
this.router.url === '/orgs' ||
this.router.url === '/orgs/new'
) {
this.router.navigate(['/dashboard']);
return;
}
this.router.initialNavigation();
});
this.configService.init().subscribe(() => {
this.log.debug('config loaded');
this.sessionService.init();
});
this.router.events.filter(val => {
return val instanceof NavigationEnd;
}).subscribe(val => {
let event = val as NavigationEnd;
if(event.url.match(/^\/accounts\/(.+?)\/transactions/)) {
this.hideLeftNav = true;
this.showLeftNav = false;
} else {
this.hideLeftNav = false;
this.showLeftNav = false;
}
});
}
showLoggedInMenu() {
this.showNavItem('/dashboard');
this.showNavItem('/accounts');
this.showNavItem('/reports');
this.showNavItem('/prices');
this.showNavItem('/orgs');
this.showNavItem('/tools/reconcile');
this.showNavItem('/logout');
this.hideNavItem('/login');
}
showCreateOrgMenu() {
this.hideNavItem('/dashboard');
this.hideNavItem('/accounts');
this.hideNavItem('/reports');
this.hideNavItem('/prices');
this.hideNavItem('/orgs');
this.hideNavItem('/tools/reconcile');
this.showNavItem('/logout');
this.hideNavItem('/login');
}
showLoggedOutMenu() {
this.hideNavItem('/dashboard');
this.hideNavItem('/accounts');
this.hideNavItem('/reports');
this.hideNavItem('/prices');
this.hideNavItem('/orgs');
this.hideNavItem('/tools/reconcile');
this.hideNavItem('/logout');
this.showNavItem('/login');
}
}

43
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,43 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { CoreModule } from './core/core.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { AccountModule } from './account/account.module';
import { UserModule } from './user/user.module';
import { RegisterModule } from './register/register.module';
import { OrgModule } from './org/org.module';
import { TransactionModule } from './transaction/transaction.module';
import { ReportsModule } from './reports/reports.module';
import { SettingsModule } from './settings/settings.module';
import { ReconcileModule } from './reconcile/reconcile.module';
import { PriceModule } from './price/price.module';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
NgbModule.forRoot(),
AppRoutingModule,
CoreModule.forRoot(),
DashboardModule,
AccountModule,
UserModule,
RegisterModule,
OrgModule,
TransactionModule,
ReportsModule,
SettingsModule,
ReconcileModule,
PriceModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View 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;
});
});
});
});

View 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
View 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);
}
};
}

View 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);
}
}

View 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);
}
}

View 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
View 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);
}
}
}

View 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);
}
}

View 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);
});
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,78 @@
<h1>Dashboard</h1>
<p class="description">
<a href="/docs/getting-started" target="_blank">Click here</a> for help getting started.
</p>
<div class="section">
<!-- <div class="card getting-started">
<button type="button" class="close" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<div class="card-body">
<p><a href="/docs/getting-started" target="_blank">Click here</a> for help getting started.</p>
</div>
</div> -->
<div class="card budget">
<div class="card-body">
<div class="container-fluid" [ngClass]="{expanded: budgetExpanded}">
<div class="row">
<div class="col-8">
<h5>Current spending</h5>
</div>
<div class="col-4 amount">
<h5>{{expenseAmount | currencyFormat:org.precision}}</h5>
</div>
</div>
<div class="row" *ngFor="let expense of expenseAccounts" [routerLink]="'/accounts/'+expense.id+'/transactions'" [ngClass]="{hidden: hiddenExpenses[expense.id]}">
<div class="col-8 name">
{{expense.fullName | accountName:2}}
</div>
<div class="col-4 amount">
{{expense.nativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
<div class="row">
<div class="col-8">
<a [routerLink]="" (click)="toggleExpandedBudget()">
<span *ngIf="budgetExpanded">Less</span>
<span *ngIf="!budgetExpanded">More</span>
</a>
</div>
<div class="col-4 amount">
<a routerLink="/reports/income">See all expenses</a>
</div>
</div>
</div>
</div>
</div>
<div class="card recent">
<div class="card-body">
<div class="container-fluid" [ngClass]="{expanded: recentExpanded}">
<div class="row">
<div class="col-12">
<h5>Recent transactions</h5>
</div>
</div>
<div class="row" *ngFor="let recentTx of recentTxs" [routerLink]="'/accounts/'+recentTx.account.id+'/transactions'" [ngClass]="{hidden: recentTx.hidden}">
<div class="col-2 date">
{{recentTx.tx.date | date:"M/d"}}
</div>
<div class="col-6 description">
{{recentTx.tx.description}}
</div>
<div class="col-4 amount" [ngClass]="{'negative': recentTx.split.amount > 0}">
{{-recentTx.split.amount | currencyFormat:recentTx.account.precision}}
</div>
</div>
<div class="row">
<div class="col-12">
<a [routerLink]="" (click)="toggleExpandedRecent()">
<span *ngIf="recentExpanded">Less</span>
<span *ngIf="!recentExpanded">More</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from '../app-routing.module';
import { SharedModule } from '../shared/shared.module';
import { DashboardPage } from './dashboard';
@NgModule({
declarations: [
DashboardPage
],
imports: [
BrowserModule,
AppRoutingModule,
SharedModule
],
providers: []
})
export class DashboardModule { }

View File

@@ -0,0 +1,44 @@
@import '../../sass/variables';
.row {
cursor: pointer;
}
.getting-started {
position: relative;
.close {
position: absolute;
top: 0px;
right: 0px;
}
.card-body {
padding: 0
}
}
.budget {
.hidden {
display: none
}
.expanded .hidden {
display: flex
}
.amount {
text-align: right;
}
}
.recent {
.amount {
text-align: right;
}
.amount.negative {
color: $negative;
}
.hidden {
display: none
}
.expanded .hidden {
display: flex
}
}

View File

@@ -0,0 +1,129 @@
import { Component, OnInit } from '@angular/core';
import { Logger } from '../core/logger';
import { TransactionService } from '../core/transaction.service';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { SessionService } from '../core/session.service';
import { Transaction, Split } from '../shared/transaction';
import { Org } from '../shared/org';
import { Account, AccountTree } from '../shared/account';
import { TxListPage } from '../transaction/list';
import { IncomeReport } from '../reports/income';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/take';
class RecentTx {
split: Split;
account: Account;
tx: Transaction;
hidden: boolean;
}
@Component({
selector: 'app-dashboard',
templateUrl: 'dashboard.html',
styleUrls: ['./dashboard.scss']
})
export class DashboardPage implements OnInit {
public org: Org;
public expenseAmount: number;
public budgetExpanded: boolean = false;
public recentExpanded: boolean = false;
public expenseAccounts: Account[] = [];
public hiddenExpenses: any = {};
public recentTxs: RecentTx[];
private numBudgetItems: number = 5;
private numSplits: number = 5;
constructor(
private log: Logger,
private txService: TransactionService,
private accountService: AccountService,
private orgService: OrgService,
private sessionService: SessionService) {
}
ngOnInit() {
this.sessionService.setLoading(true);
this.log.debug('dashboard init');
let periodStart = this.accountService.getPeriodStart();
this.org = this.orgService.getCurrentOrg();
this.log.debug('org', this.org);
let tree$ = this.accountService.getAccountTreeWithPeriodBalance(periodStart);
tree$.do(tree => {
let expenses = tree.getAccountByName('Expenses', 1);
this.expenseAmount = expenses.totalNativeBalanceCost;
this.expenseAccounts = tree.getFlattenedAccounts().filter(account => {
return tree.accountIsChildOf(account, expenses) && account.recentTxCount;
}).sort((a, b) => {
return b.recentTxCount - a.recentTxCount;
}).slice(0, this.numBudgetItems * 2);
this.hiddenExpenses = {};
this.expenseAccounts.forEach((account, index) => {
if(index >= this.numBudgetItems) {
this.hiddenExpenses[account.id] = true;
}
});
})
.switchMap(tree => {
let expenses = tree.getAccountByName('Expenses', 1);
let income = tree.getAccountByName('Income', 1);
return this.txService.getLastTransactions(this.numSplits * 4).take(1)
.map(txs => {
this.log.debug('lastTxs');
this.log.debug(txs);
return txs.map(tx => {
let splits = tx.splits.filter(split => {
let account = tree.accountMap[split.accountId];
if(!account || !split.amount) {
return false;
}
return tree.accountIsChildOf(account, expenses) || tree.accountIsChildOf(account, income);
});
return splits.map(split => {
let recentTx = new RecentTx();
recentTx.split = split;
recentTx.account = tree.accountMap[split.accountId];
recentTx.tx = tx;
return recentTx;
});
}).reduce((acc, recentTxs) => {
return acc.concat(recentTxs);
}, [])
}).map(recentTxs => {
return recentTxs.slice(0, this.numSplits * 2);
}).map(recentTxs => {
return recentTxs.map((recentTx, index) => {
if(index >= this.numSplits) {
recentTx.hidden = true;
}
return recentTx;
})
});
})
.subscribe(recentTxs => {
this.log.debug('recentTxs', recentTxs);
this.recentTxs = recentTxs;
this.sessionService.setLoading(false);
});
}
toggleExpandedBudget() {
this.budgetExpanded = !this.budgetExpanded;
}
toggleExpandedRecent() {
this.recentExpanded = !this.recentExpanded;
}
}

View File

@@ -0,0 +1,351 @@
export const personalAccounts: any =
[
{
"name": "Checking",
"parent": "Assets"
},
{
"name": "Savings",
"parent": "Assets"
},
{
"name": "Cash",
"parent": "Assets"
},
{
"name": "Opening Balances",
"parent": "Equity"
},
{
"name": "Auto Loan",
"parent": "Liabilities"
},
{
"name": "Credit Card",
"parent": "Liabilities"
},
{
"name": "Mortgage",
"parent": "Liabilities"
},
{
"name": "Auto",
"parent": "Expenses"
},
{
"name": "Depreciation",
"parent": "Auto"
},
{
"name": "Fees",
"parent": "Auto"
},
{
"name": "Gas",
"parent": "Auto"
},
{
"name": "Interest",
"parent": "Auto"
},
{
"name": "Lease",
"parent": "Auto"
},
{
"name": "Parking",
"parent": "Auto"
},
{
"name": "Repairs/Maintenance",
"parent": "Auto"
},
{
"name": "Taxes",
"parent": "Auto"
},
{
"name": "Tolls",
"parent": "Auto"
},
{
"name": "Bank",
"parent": "Expenses"
},
{
"name": "ATM Fees",
"parent": "Bank"
},
{
"name": "Credit Card Interest",
"parent": "Bank"
},
{
"name": "Foreign Fees",
"parent": "Bank"
},
{
"name": "Wire Fees",
"parent": "Bank"
},
{
"name": "Books",
"parent": "Expenses"
},
{
"name": "Charity",
"parent": "Expenses"
},
{
"name": "Child Care",
"parent": "Expenses"
},
{
"name": "Clothes",
"parent": "Expenses"
},
{
"name": "Computer",
"parent": "Expenses"
},
{
"name": "Dental",
"parent": "Expenses"
},
{
"name": "Expenses",
"parent": "Dental"
},
{
"name": "Insurance",
"parent": "Dental"
},
{
"name": "Education",
"parent": "Expenses"
},
{
"name": "Entertainment",
"parent": "Expenses"
},
{
"name": "Music/Movies",
"parent": "Entertainment"
},
{
"name": "Recreation",
"parent": "Entertainment"
},
{
"name": "Food",
"parent": "Expenses"
},
{
"name": "Eat Out",
"parent": "Food"
},
{
"name": "Groceries",
"parent": "Food"
},
{
"name": "Gifts",
"parent": "Expenses"
},
{
"name": "Gym",
"parent": "Expenses"
},
{
"name": "Haircuts",
"parent": "Expenses"
},
{
"name": "Housing",
"parent": "Expenses"
},
{
"name": "Closing Costs",
"parent": "Housing"
},
{
"name": "Fees",
"parent": "Housing"
},
{
"name": "Interest",
"parent": "Housing"
},
{
"name": "Rent",
"parent": "Housing"
},
{
"name": "Repairs/Maintenance",
"parent": "Housing"
},
{
"name": "Taxes",
"parent": "Housing"
},
{
"name": "Utilities",
"parent": "Housing"
},
{
"name": "Internet",
"parent": "Expenses"
},
{
"name": "Job",
"parent": "Expenses"
},
{
"name": "Kitchen",
"parent": "Expenses"
},
{
"name": "Laundry/Dry Clean",
"parent": "Expenses"
},
{
"name": "Medical",
"parent": "Expenses"
},
{
"name": "Expenses",
"parent": "Medical"
},
{
"name": "Insurance",
"parent": "Medical"
},
{
"name": "Miscellaneous",
"parent": "Expenses"
},
{
"name": "Phone",
"parent": "Expenses"
},
{
"name": "Shipping",
"parent": "Expenses"
},
{
"name": "Shoes",
"parent": "Expenses"
},
{
"name": "Subscriptions",
"parent": "Expenses"
},
{
"name": "Supplies",
"parent": "Expenses"
},
{
"name": "Toiletries",
"parent": "Expenses"
},
{
"name": "Transportation",
"parent": "Expenses"
},
{
"name": "Bus",
"parent": "Transportation"
},
{
"name": "Metro",
"parent": "Transportation"
},
{
"name": "Taxi/Uber",
"parent": "Transportation"
},
{
"name": "Travel",
"parent": "Expenses"
},
{
"name": "Hotels",
"parent": "Travel"
},
{
"name": "Souvenirs",
"parent": "Travel"
},
{
"name": "Transportation",
"parent": "Travel"
},
{
"name": "Vision",
"parent": "Expenses"
},
{
"name": "Expenses",
"parent": "Vision"
},
{
"name": "Insurance",
"parent": "Vision"
},
{
"name": "Capital Gains",
"parent": "Income"
},
{
"name": "Long Term",
"parent": "Capital Gains"
},
{
"name": "Short Term",
"parent": "Capital Gains"
},
{
"name": "Untaxed",
"parent": "Capital Gains"
},
{
"name": "Dividends",
"parent": "Income"
},
{
"name": "Taxed",
"parent": "Dividends"
},
{
"name": "Untaxed",
"parent": "Dividends"
},
{
"name": "Interest",
"parent": "Income"
},
{
"name": "Bonds",
"parent": "Interest"
},
{
"name": "Checking/Savings Interest",
"parent": "Interest"
},
{
"name": "Salary",
"parent": "Income"
},
{
"name": "Taxes",
"parent": "Income"
},
{
"name": "Federal",
"parent": "Taxes"
},
{
"name": "State",
"parent": "Taxes",
}
];

45
src/app/org/neworg.html Normal file
View File

@@ -0,0 +1,45 @@
<h1>Organization</h1>
<div class="section">
<h2>Join Organization</h2>
<p>If you have an invite code, enter it here to join an existing organization.</p>
<form [formGroup]="joinOrgForm" (ngSubmit)="joinOrgSubmit()">
<div class="form-group row">
<label for="inviteId" class="col-sm-3 col-form-label">Invite Code</label>
<div class="col-sm-9">
<input formControlName="inviteId" type="text" class="form-control" id="inviteId">
</div>
</div>
<p *ngIf="joinOrgError" class="error">{{joinOrgError.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!joinOrgForm.valid">Join</button>
</form>
</div>
<div class="section">
<h2>New Organization</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Name</label>
<input formControlName="name" type="text" class="form-control" id="name" placeholder="Organization name">
</div>
<div class="form-group">
<label for="currency">Currency</label>
<input formControlName="currency" type="text" class="form-control" id="currency" placeholder="Currency">
</div>
<div class="form-group">
<label for="precision">Decimal Places</label>
<input formControlName="precision" type="text" class="form-control" id="precision" placeholder="Decimal Places">
</div>
<div class="form-group">
<label for="createDefaultAccounts">Create default accounts<br>(can be customized later)</label>
<input formControlName="createDefaultAccounts" id="createDefaultAccounts" type="checkbox" class="form-control" />
</div>
<p *ngIf="error" class="error">
{{error.message}}
</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Submit</button>
</form>
</div>

77
src/app/org/neworg.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Component } from '@angular/core';
import { Logger } from '../core/logger';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { OrgService } from '../core/org.service';
import { Org } from '../shared/org';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
@Component({
selector: 'app-neworg',
templateUrl: 'neworg.html'
})
export class NewOrgPage {
public form: FormGroup;
public error: AppError;
public joinOrgForm: FormGroup;
public joinOrgError: AppError;
constructor(
private log: Logger,
private orgService: OrgService,
private fb: FormBuilder
) {
this.form = fb.group({
'name': ['', Validators.required],
'currency': ['USD', Validators.required],
'precision': [2, Validators.required],
'createDefaultAccounts': [true, Validators.required]
});
this.joinOrgForm = fb.group({
'inviteId': [null, Validators.required]
});
}
onSubmit() {
//this.dataService.setLoading(true);
let org = new Org(this.form.value);
org.id = Util.newGuid();
this.log.debug(org);
this.orgService.newOrg(org, this.form.value['createDefaultAccounts'])
.subscribe(
org => {
this.log.debug(org);
},
error => {
//this.dataService.setLoading(false);
this.log.debug('An error occurred!');
this.log.debug(error);
this.error = error;
}
);
}
joinOrgSubmit() {
this.log.debug('join org');
this.log.debug(this.joinOrgForm.value.id);
this.orgService.acceptInvite(this.joinOrgForm.value.inviteId)
.switchMap(invite => {
return this.orgService.selectOrg(invite.orgId)
})
.subscribe(org => {
console.log('joined org ' + org.id);
}, err => {
this.joinOrgError = err;
})
}
}

117
src/app/org/org.html Normal file
View File

@@ -0,0 +1,117 @@
<h1>Organization</h1>
<div class="section">
<h2>Choose Organization</h2>
<form [formGroup]="chooseOrgForm" (ngSubmit)="chooseOrgSubmit()">
<div class="form-group row">
<label for="id" class="col-sm-3 col-form-label">Current Organization</label>
<div class="col-sm-9">
<select class="form-control" id="id" formControlName="id">
<option *ngFor="let org of orgs" [value]="org.id">
{{org.name}}
</option>
</select>
</div>
</div>
<p *ngIf="chooseOrgError" class="error">{{chooseOrgError.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!chooseOrgForm.valid">Update</button>
</form>
</div>
<div class="section">
<h2>Join Organization</h2>
<form [formGroup]="joinOrgForm" (ngSubmit)="joinOrgSubmit()">
<div class="form-group row">
<label for="inviteId" class="col-sm-3 col-form-label">Invite Code</label>
<div class="col-sm-9">
<input formControlName="inviteId" type="text" class="form-control" id="inviteId">
</div>
</div>
<p *ngIf="joinOrgError" class="error">{{joinOrgError.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!joinOrgForm.valid">Join</button>
</form>
</div>
<div *ngIf="invites !== null"class="section">
<h2 >Invite to {{currentOrg.name}}</h2>
<form [formGroup]="inviteForm" (ngSubmit)="inviteSubmit()">
<div class="form-group row">
<label for="email" class="col-sm-3 col-form-label">Email</label>
<div class="col-sm-9">
<input formControlName="email" type="text" class="form-control" id="email">
</div>
</div>
<p *ngIf="inviteFormError" class="error">{{inviteFormError.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!inviteForm.valid">Invite</button>
</form>
<div class="container-fluid mt-3">
<div class="row">
<div class="col-3">
<strong>Code</strong>
</div>
<div class="col-5">
<strong>Email</strong>
</div>
<div class="col-2">
<strong>Status</strong>
</div>
<div class="col-2">
</div>
</div>
<div class="row" *ngFor="let invite of invites">
<div class="col-3">
{{invite.id}}
</div>
<div class="col-5">
{{invite.email}}
</div>
<div class="col-2">
<span *ngIf="invite.accepted">accepted</span>
<span *ngIf="!invite.accepted">pending</span>
</div>
<div class="col-2">
<a (click)="deleteInvite(invite)">Delete</a>
</div>
</div>
</div>
</div>
<div class="section">
<h2>Create Organization</h2>
<form [formGroup]="newOrgForm" (ngSubmit)="newOrgSubmit()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Name</label>
<div class="col-sm-9">
<input formControlName="name" type="text" class="form-control" id="name" placeholder="Organization name">
</div>
</div>
<div class="form-group row">
<label for="currency" class="col-sm-3 col-form-label">Currency</label>
<div class="col-sm-9">
<input formControlName="currency" type="text" class="form-control" id="currency" placeholder="Currency">
</div>
</div>
<div class="form-group row">
<label for="precision" class="col-sm-3 col-form-label">Decimal Places</label>
<div class="col-sm-9">
<input formControlName="precision" type="text" class="form-control" id="precision" placeholder="Decimal Places">
</div>
</div>
<div class="form-group row">
<label for="createDefaultAccounts" class="col-sm-3 col-form-label">Create default accounts<br>(can be customized later)</label>
<div class="col-sm-9">
<input formControlName="createDefaultAccounts" id="createDefaultAccounts" type="checkbox" class="form-control" />
</div>
</div>
<p *ngIf="newOrgError" class="error">{{newOrgError.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!newOrgForm.valid">Create New Organization</button>
</form>
</div>

19
src/app/org/org.module.ts Normal file
View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NewOrgPage } from './neworg';
import { OrgPage } from './org';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
NewOrgPage,
OrgPage
],
imports: [
BrowserModule,
ReactiveFormsModule
],
providers: []
})
export class OrgModule { }

151
src/app/org/org.ts Normal file
View File

@@ -0,0 +1,151 @@
import { Component } from '@angular/core';
import { Logger } from '../core/logger';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { OrgService } from '../core/org.service';
import { User } from '../shared/user';
import { Org } from '../shared/org';
import { Invite } from '../shared/invite';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
@Component({
selector: 'app-org',
templateUrl: 'org.html'
})
export class OrgPage {
public currentOrg: Org;
public orgs: Org[] = [];
public chooseOrgForm: FormGroup;
public chooseOrgError: AppError;
public joinOrgForm: FormGroup;
public joinOrgError: AppError;
public inviteForm: FormGroup;
public inviteFormError: AppError;
public newOrgForm: FormGroup;
public newOrgError: AppError;
public invites: Invite[];
constructor(
private log: Logger,
private orgService: OrgService,
private fb: FormBuilder
) {
this.invites = null;
this.chooseOrgForm = fb.group({
'id': [null, Validators.required]
});
this.joinOrgForm = fb.group({
'inviteId': [null, Validators.required]
});
this.inviteForm = fb.group({
'email': [null, Validators.required]
});
this.newOrgForm = fb.group({
'name': ['', Validators.required],
'currency': ['', Validators.required],
'precision': [null, Validators.required],
'createDefaultAccounts': [true, Validators.required]
});
}
ngOnInit() {
this.currentOrg = this.orgService.getCurrentOrg();
this.chooseOrgForm.setValue({id: this.currentOrg.id});
this.newOrgForm.setValue(
{
name: '',
currency: this.currentOrg.currency,
precision: this.currentOrg.precision,
createDefaultAccounts: true
}
);
this.orgService.getOrgs().subscribe(orgs => {
this.orgs = orgs;
});
this.orgService.getInvites().subscribe(invites => {
this.invites = invites;
});
}
chooseOrgSubmit() {
this.log.debug('choose new org');
this.log.debug(this.chooseOrgForm.value.id);
//this.dataService.setLoading(true);
this.orgService.selectOrg(this.chooseOrgForm.value.id).subscribe(org => {
this.log.debug('new org');
this.log.debug(org);
});
}
joinOrgSubmit() {
this.log.debug('join org');
this.log.debug(this.joinOrgForm.value.id);
this.orgService.acceptInvite(this.joinOrgForm.value.inviteId)
.switchMap(invite => {
return this.orgService.selectOrg(invite.orgId)
})
.subscribe(org => {
console.log('joined org ' + org.id);
}, err => {
this.joinOrgError = err;
})
}
inviteSubmit() {
this.log.debug('invite');
this.log.debug(this.inviteForm.value);
let invite = new Invite({email: this.inviteForm.value.email});
this.orgService.newInvite(invite).subscribe(invite => {
this.invites.push(invite);
this.inviteForm.reset();
}, err => {
this.inviteFormError = err;
})
}
newOrgSubmit() {
//this.dataService.setLoading(true);
let org = new Org(this.newOrgForm.value);
org.id = Util.newGuid();
this.log.debug(org);
this.orgService.newOrg(org, this.newOrgForm.value['createDefaultAccounts'])
.subscribe(
org => {
this.log.debug(org);
},
error => {
//this.dataService.setLoading(false);
this.log.debug('An error occurred!');
this.log.debug(error);
this.newOrgError = error;
}
);
}
deleteInvite(invite: Invite) {
this.orgService.deleteInvite(invite.id).subscribe(() => {
this.invites = this.invites.filter(inv => {
return inv.id !== invite.id;
});
}, err => {
this.inviteFormError = err;
})
}
}

View File

@@ -0,0 +1,33 @@
<div class="modal-header">
<h4 class="modal-title">Price</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form [formGroup]="form">
<div class="form-group row">
<label for="currency" class="col-sm-4 col-form-label">Currency</label>
<div class="col-sm-8">
<input formControlName="currency" id="currency" type="text" class="form-control" placeholder="" />
</div>
</div>
<div class="form-group row">
<label for="date" class="col-sm-4 col-form-label">Date</label>
<div class="col-sm-8">
<input formControlName="date" id="date" type="date" class="form-control" />
</div>
</div>
<div class="form-group row">
<label for="price" class="col-sm-4 col-form-label">Price</label>
<div class="col-sm-8">
<input formControlName="price" id="price" type="number" class="form-control" />
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="!form.valid">Save</button>
</div>

View File

View File

@@ -0,0 +1,97 @@
import { Component, Input } from '@angular/core';
import { Logger } from '../core/logger';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { AppError } from '../shared/error';
import {
FormControl,
FormGroup,
FormArray,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { Util } from '../shared/util';
import { PriceService } from '../core/price.service';
import { Price } from '../shared/price';
import { Observable } from 'rxjs/Observable';
@Component({
selector: 'price-modal',
templateUrl: './price-modal.html',
styleUrls: ['./price-modal.scss']
})
export class PriceModal {
public form: FormGroup;
public error: AppError;
private originalDate: Date;
constructor(
public activeModal: NgbActiveModal,
private log: Logger,
private priceService: PriceService,
private fb: FormBuilder
) {
let dateString = Util.getLocalDateString(new Date());
this.form = fb.group({
'id': [null],
'currency': ['', Validators.required],
'date': [dateString, Validators.required],
'price': [null, Validators.required]
});
}
setData(data: any) {
console.log(data);
this.originalDate = data.date;
this.form.patchValue({
id: data.id,
currency: data.currency,
date: Util.getLocalDateString(data.date),
price: data.price
});
}
save() {
this.error = null;
let date = this.form.value.id ? this.originalDate : new Date();
let formDate = Util.getDateFromLocalDateString(this.form.value.date);
if(formDate.getTime()) {
// make the time be at the very end of the day
formDate.setHours(23, 59, 59, 999);
}
let sameDay = formDate.getFullYear() === date.getFullYear() &&
formDate.getMonth() === date.getMonth() &&
formDate.getDate() === date.getDate();
if(formDate.getTime() && !sameDay) {
date = formDate;
}
let price = new Price(this.form.value);
price.date = date;
if(this.form.value.id) {
// update
this.priceService.updatePrice(price).subscribe(price => {
this.activeModal.close();
}, err => {
this.error = err;
});
return;
}
// new price
price.id = Util.newGuid();
this.priceService.newPrice(price).subscribe(price => {
this.activeModal.close();
}, err => {
this.error = err;
});
}
}

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from '../shared/shared.module';
import { AppRoutingModule } from '../app-routing.module';
import { PriceDbPage } from './pricedb';
import { PriceModal } from './price-modal';
@NgModule({
declarations: [
PriceDbPage,
PriceModal
],
imports: [
BrowserModule,
NgbModule,
ReactiveFormsModule,
SharedModule,
AppRoutingModule
],
providers: [],
entryComponents: [PriceModal]
})
export class PriceModule { }

View File

@@ -0,0 +1,36 @@
<h1>Price Database</h1>
<div class="description">
If you have different currencies or stocks, you can use the price editor to input their current exchange rates or prices.
</div>
<div class="section">
<div class="container-fluid">
<div *ngFor="let currency of currencies$ | async">
<div class="row" depth="1" [ngClass]="{expanded: isExpanded(currency)}">
<div class="col-12" (click)="click(currency)">
<span class="expander"></span>
{{currency}}
</div>
</div>
<ng-container *ngIf="isExpanded(currency)">
<div class="row" *ngFor="let price of prices$[currency] | async" depth="2">
<div class="col-4 date">
{{price.date | date:"M/d/y"}}
</div>
<div class="col-4 price">
{{price.price * multiplier | currencyFormat:org.precision:org.currency}}
</div>
<div class="col-4 edit">
<a (click)="editPrice(price)">Edit</a> | <a (click)="deletePrice(price)">Delete</a>
</div>
</div>
</ng-container>
</div>
</div>
<p class="error" *ngIf="error">{{error}}</p>
<button type="button" class="btn btn-primary mt-3" (click)="newPrice()">New Price</button>
</div>

View File

@@ -0,0 +1,23 @@
.row[depth="1"] {
cursor: pointer;
}
.row:not(.expanded) > div > .expander {
display: inline-block;
background-image: url("/assets/plus.svg");
width: 11px;
height: 11px;
margin-right: 5px;
}
.row.expanded > div > .expander {
display: inline-block;
background-image: url("/assets/minus.svg");
width: 11px;
height: 11px;
margin-right: 5px;
}
.row[depth="1"] .date {
padding-left: 15px;
}
.row[depth="2"] .date {
padding-left: 60px;
}

101
src/app/price/pricedb.ts Normal file
View File

@@ -0,0 +1,101 @@
import { Component } from '@angular/core';
import { Logger } from '../core/logger';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { SessionService } from '../core/session.service';
import { PriceService } from '../core/price.service';
import { Price } from '../shared/price';
import { Org } from '../shared/org';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/forkJoin';
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
import { PriceModal } from './price-modal';
@Component({
selector: 'app-pricedb',
templateUrl: 'pricedb.html',
styleUrls: ['./pricedb.scss']
})
export class PriceDbPage {
public org: Org;
public currencies$: Observable<string[]>;
public prices$: {[currency: string]: Observable<Price[]>};
public error: AppError;
public multiplier: number;
private expandedCurrencies: {[currency: string]: boolean};
constructor(
private log: Logger,
private sessionService: SessionService,
private priceService: PriceService,
private modalService: NgbModal
) {
this.org = sessionService.getOrg();
this.multiplier = Math.pow(10, this.org.precision);
this.expandedCurrencies = {};
this.prices$ = {};
this.currencies$ = this.priceService.getPricesNearestInTime(new Date()).map(prices => {
return prices.map(price => {
return price.currency;
}).sort((a, b) => {
return a.localeCompare(b);
});
}).do(currencies => {
currencies.forEach(currency => {
this.prices$[currency] = this.priceService.getPricesByCurrency(currency).do(prices => {
this.log.debug('got prices for ' + currency);
});
});
});
}
isExpanded(currency: string) {
return this.expandedCurrencies[currency];
}
click(currency: string) {
this.expandedCurrencies[currency] = !this.expandedCurrencies[currency];
}
newPrice() {
let modal = this.modalService.open(PriceModal);
modal.result.then((result) => {
this.log.debug('price modal save');
}, (reason) => {
this.log.debug('cancel price modal');
});
}
editPrice(price: Price) {
let modal = this.modalService.open(PriceModal);
modal.componentInstance.setData(price);
modal.result.then((result) => {
this.log.debug('price modal save');
}, (reason) => {
this.log.debug('cancel price modal');
});
}
deletePrice(price: Price) {
this.error = null;
this.priceService.deletePrice(price.id).subscribe(() => {
// do nothing
}, err => {
this.error = err;
});
}
}

View File

@@ -0,0 +1,56 @@
<div class="modal-header">
<h4 class="modal-title">Reconcile</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="container-fluid">
<div class="row">
<div class="col-6">Inflows</div>
<div class="col-6">Outflows</div>
</div>
<div class="row">
<div class="col-6 inflows">
<div class="container-fluid">
<div *ngFor="let item of inflows" class="row">
<div class="col-3">{{item.tx.date | date:"M/d/y"}}</div>
<div class="col-4">{{item.tx.description}}</div>
<div class="col-3">{{item.amount | currencyFormat:account.precision:account.currency}}</div>
<div class="col-2">
<input type="checkbox" (click)="toggleReconciled(item)" />
</div>
</div>
</div>
</div>
<div class="col-6 outflows">
<div class="container-fluid">
<div *ngFor="let item of outflows" class="row">
<div class="col-3">{{item.tx.date | date:"M/d/y"}}</div>
<div class="col-4">{{item.tx.description}}</div>
<div class="col-3">{{item.amount | currencyFormat:account.precision:account.currency}}</div>
<div class="col-2">
<input type="checkbox" (click)="toggleReconciled(item)"/>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-4 offset-8">
Balance: {{balance | currencyFormat:account.precision:account.currency}}<br>
Reconciled: {{reconciled | currencyFormat:account.precision:account.currency}}<br>
Difference: {{(balance - reconciled) | currencyFormat:account.precision:account.currency}}
</div>
</div>
<div class="row">
<div class="col-12">
<p *ngIf="error" class="error">{{error.message}}</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()">Complete</button>
</div>

View File

@@ -0,0 +1,5 @@
.inflows,
.outflows {
height: 300px;
overflow-y: scroll;
}

View File

@@ -0,0 +1,226 @@
import { Component, Input } from '@angular/core';
import { Logger } from '../core/logger';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Transaction, Split } from '../shared/transaction';
import { Account, AccountTree } from '../shared/account';
import { Org } from '../shared/org';
import { AppError } from '../shared/error';
import {
FormControl,
FormGroup,
FormArray,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { Util } from '../shared/util';
import { OrgService } from '../core/org.service';
import { TransactionService } from '../core/transaction.service';
import { SessionService } from '../core/session.service';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/from';
import 'rxjs/add/operator/mergeMap';
import { Reconciliation } from './reconciliation';
class TxItem {
tx: Transaction;
amount: number;
splitIndex: number;
reconciled: boolean;
}
@Component({
selector: 'reconcile-modal',
templateUrl: './reconcile-modal.html',
styleUrls: ['./reconcile-modal.scss']
})
export class ReconcileModal {
public account: Account;
public reconciliation: Reconciliation;
public items: TxItem[];
public inflows: TxItem[];
public outflows: TxItem[];
public form: FormGroup;
public balance: number;
public reconciled: number;
public error: AppError;
constructor(
public activeModal: NgbActiveModal,
private log: Logger,
private txService: TransactionService,
private sessionService: SessionService,
private fb: FormBuilder
) {}
setData(account: Account, rec: Reconciliation) {
this.account = account;
this.inflows = [];
this.outflows = [];
this.reconciliation = rec;
this.balance = rec.endBalance;
this.reconciled = rec.startBalance;
let txs$ = this.txService.getTransactionsByAccount(this.account.id);
let newTxs$ = this.txService.getNewTransactionsByAccount(this.account.id);
let deletedTxs$ = this.txService.getDeletedTransactionsByAccount(this.account.id);
txs$.mergeMap(txs => txs).concat(newTxs$)
.filter(tx => {
let data = tx.getData();
let reconciled = true;
let reconciledSplits = Object.keys(data.reconciledSplits || []).map(index => parseInt(index));
tx.splits.forEach((split, index) => {
if(split.accountId === this.account.id && reconciledSplits.indexOf(index) === -1) {
reconciled = false;
}
});
return !reconciled;
})
.subscribe(tx => {
// insert tx into list
this.addTransaction(tx);
});
deletedTxs$.subscribe(tx => {
this.removeTransaction(tx);
// remove tx from list
});
}
addTransaction(tx: Transaction) {
tx.splits.forEach((split, index) => {
if(split.accountId !== this.account.id) {
return;
}
let item = new TxItem();
item.tx = tx;
item.amount = Math.abs(split.amount);
item.splitIndex = index;
item.reconciled = false;
if(split.amount >= 0) {
this.inflows.push(item);
} else {
this.outflows.push(item);
}
});
this.sort();
}
removeTransaction(tx: Transaction) {
for(let i = 0; i < this.inflows.length; i++) {
let item = this.inflows[i];
if(item.tx.id === tx.id) {
this.inflows.splice(i, 1);
}
}
for(let i = 0; i < this.outflows.length; i++) {
let item = this.outflows[i];
if(item.tx.id === tx.id) {
this.outflows.splice(i, 1);
}
}
}
sort() {
this.inflows.sort((a, b) => {
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
if(dateDiff) {
return dateDiff;
}
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
if(insertedDiff) {
return insertedDiff;
}
});
this.outflows.sort((a, b) => {
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
if(dateDiff) {
return dateDiff;
}
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
if(insertedDiff) {
return insertedDiff;
}
});
}
toggleReconciled(item: TxItem) {
item.reconciled = !item.reconciled;
let data = item.tx.getData();
if(item.reconciled) {
if(!data.reconciledSplits) {
data.reconciledSplits = {};
}
data.reconciledSplits[item.splitIndex] = this.reconciliation.endDate;
if(this.account.debitBalance) {
this.reconciled += item.tx.splits[item.splitIndex].amount;
} else {
this.reconciled -= item.tx.splits[item.splitIndex].amount;
}
} else {
if(!data.reconciledSplits) {
return;
}
delete data.reconciledSplits[item.splitIndex];
if(this.account.debitBalance) {
this.reconciled -= item.tx.splits[item.splitIndex].amount;
} else {
this.reconciled += item.tx.splits[item.splitIndex].amount;
}
}
item.tx.setData(data);
}
save() {
if(this.balance !== this.reconciled) {
this.error = new AppError('Reconciled amount doesn\'t match balance');
return;
}
this.sessionService.setLoading(true);
let txs = this.inflows.filter(item => item.reconciled).map(item => item.tx);
txs = txs.concat(this.outflows.filter(item => item.reconciled).map(item => item.tx));
Observable.from(txs).mergeMap(tx => {
let oldId = tx.id;
tx.id = Util.newGuid();
return this.txService.putTransaction(oldId, tx);
}, 8).subscribe(tx => {
this.log.debug('Saved tx ' + tx.id);
}, err => {
this.error = err;
this.sessionService.setLoading(false);
}, () => {
this.sessionService.setLoading(false);
this.activeModal.close();
});
}
}

View File

@@ -0,0 +1,62 @@
<h1>Reconcile Account</h1>
<div class="section">
<h2>Select Account</h2>
<form class="form-inline" [formGroup]="accountForm" (ngSubmit)="onChooseAccount()">
<div class="form-group mx-sm-3 mb-2">
<label for="accountId" class="sr-only">Account</label>
<select class="form-control" id="accountId" formControlName="accountId">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName | slice:0:50}}
</option>
</select>
</div>
<button type="submit" class="btn btn-primary mb-2">Select Account</button>
</form>
</div>
<div class="section">
<h2>New Reconciliation</h2>
<form [formGroup]="newReconcile" (ngSubmit)="startReconcile()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Start Date</label>
<div class="col-sm-9">
<input formControlName="startDate" id="startDate" type="date" class="form-control" />
</div>
</div>
<div class="form-group row">
<label for="currency" class="col-sm-3 col-form-label">Beginning Balance</label>
<div class="col-sm-9">
<input formControlName="startBalance" id="startBalance" type="text" class="form-control"/>
</div>
</div>
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">End Date</label>
<div class="col-sm-9">
<input formControlName="endDate" id="endDate" type="date" class="form-control" />
</div>
</div>
<div class="form-group row">
<label for="currency" class="col-sm-3 col-form-label">Ending Balance</label>
<div class="col-sm-9">
<input formControlName="endBalance" id="endBalance" type="text" class="form-control" />
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!newReconcile.valid">Start Reconciliation</button>
</form>
</div>
<div class="section">
<h2>Past Reconciliations</h2>
<div *ngFor="let rec of pastReconciliations">
Period: {{rec.startDate | date:"M/d/y"}} - {{rec.endDate | date:"M/d/y"}}<br>
Beginning Balance: {{rec.startBalance | currencyFormat:account.precision:account.currency}}<br>
Ending Balance: {{rec.endBalance | currencyFormat:account.precision:account.currency}}<br>
<br>
</div>
</div>

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from '../shared/shared.module';
import { AppRoutingModule } from '../app-routing.module';
import { ReconcilePage } from './reconcile';
import { ReconcileModal } from './reconcile-modal';
@NgModule({
declarations: [
ReconcilePage,
ReconcileModal
],
imports: [
BrowserModule,
NgbModule,
ReactiveFormsModule,
SharedModule,
AppRoutingModule
],
providers: [],
entryComponents: [ReconcileModal]
})
export class ReconcileModule { }

View File

@@ -0,0 +1,226 @@
import { Component } from '@angular/core';
import { Logger } from '../core/logger';
import { Router } from '@angular/router';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { TransactionService } from '../core/transaction.service';
import { Account, AccountApi, AccountTree } from '../shared/account';
import { Transaction } from '../shared/transaction';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
import { ReconcileModal } from './reconcile-modal';
import { Reconciliation } from './reconciliation';
@Component({
selector: 'app-reconcile',
templateUrl: 'reconcile.html'
})
export class ReconcilePage {
public accountForm: FormGroup;
public newReconcile: FormGroup;
public selectAccounts: any[];
public account: Account;
public pastReconciliations: Reconciliation[];
public unreconciledTxs: Transaction[];
public error: AppError;
private accountTree: AccountTree;
constructor(
private router: Router,
private log: Logger,
private accountService: AccountService,
private orgService: OrgService,
private txService: TransactionService,
private fb: FormBuilder,
private modalService: NgbModal) {
let org = this.orgService.getCurrentOrg();
this.accountForm = fb.group({
'accountId': [null, Validators.required]
});
this.newReconcile = fb.group({
'startDate': ['', Validators.required],
'startBalance': [{value: 0, disabled: true}, Validators.required],
'endDate': ['', Validators.required],
'endBalance': [0, Validators.required]
});
this.accountService.getAccountTree().subscribe(tree => {
this.accountTree = tree;
this.selectAccounts = tree.getFlattenedAccounts();
});
}
onChooseAccount() {
let account = this.accountTree.accountMap[this.accountForm.value.accountId];
if(!account) {
this.error = new AppError('Invalid account');
return;
}
this.account = account;
this.processTransactions();
}
startReconcile() {
let value = this.newReconcile.getRawValue();
let rec = new Reconciliation();
rec.startDate = Util.getDateFromLocalDateString(value.startDate);
rec.endDate = Util.getDateFromLocalDateString(value.endDate);
rec.startBalance = Math.round(parseFloat(value.startBalance) * Math.pow(10, this.account.precision));
rec.endBalance = Math.round(parseFloat(value.endBalance) * Math.pow(10, this.account.precision));
this.log.debug(rec);
let modal = this.modalService.open(ReconcileModal, {size: 'lg'});
modal.componentInstance.setData(this.account, rec, this.unreconciledTxs);
modal.result.then((result) => {
this.log.debug('reconcile modal save');
this.pastReconciliations.unshift(rec);
this.newReconcile.patchValue(
{
startDate: Util.getLocalDateString(rec.endDate),
startBalance: rec.endBalance / Math.pow(10, this.account.precision),
endBalance: 0,
endDate: ''
}
);
}, (reason) => {
this.log.debug('cancel reconcile modal');
});
}
processTransactions() {
// Get all transactions for account
// Figure out reconciliations
// startDate is date of first transaction
// add up reconciled splits for given endDate to get endBalance
// sort by most recent first
// most recent endDate is used for startDate
// most recent endBalance is used for startBalance
// guess at next endDate
this.unreconciledTxs = [];
this.pastReconciliations = [];
this.txService.getTransactionsByAccount(this.account.id).subscribe(txs => {
let reconcileMap: {[date: number]: Reconciliation} = {};
let firstStartDate: Date = null;
let firstEndDate: Date = null;
txs.forEach(tx => {
if(!firstStartDate || (!firstEndDate && tx.date < firstStartDate)) {
firstStartDate = tx.date;
}
let data = tx.getData();
if(!data.reconciledSplits) {
this.unreconciledTxs.push(tx);
return;
}
let reconciled = true;
let splitIndexes = Object.keys(data.reconciledSplits).map(index => parseInt(index));
tx.splits.forEach((split, index) => {
if(split.accountId !== this.account.id) {
return;
}
if(splitIndexes.indexOf(index) === -1) {
reconciled = false;
return;
}
let endDate = new Date(data.reconciledSplits[index]);
if(!firstEndDate || endDate < firstEndDate) {
firstEndDate = endDate;
firstStartDate = new Date(tx.date);
}
if(endDate.getTime() === firstEndDate.getTime() && tx.date < firstStartDate) {
firstStartDate = new Date(tx.date);
}
if(!reconcileMap[endDate.getTime()]) {
reconcileMap[endDate.getTime()] = new Reconciliation();
reconcileMap[endDate.getTime()].endDate = endDate;
reconcileMap[endDate.getTime()].net = 0;
}
let r = reconcileMap[endDate.getTime()];
if(this.account.debitBalance) {
r.net += split.amount;
} else {
r.net -= split.amount;
}
});
if(!reconciled) {
this.unreconciledTxs.push(tx);
}
});
// Figure out starting date, beginning balance and ending balance
let dates = Object.keys(reconcileMap).sort((a, b) => {
return parseInt(a) - parseInt(b);
}).map(time => {
return new Date(parseInt(time));
});
if(!dates.length) {
if(firstStartDate) {
this.newReconcile.patchValue({startDate: Util.getLocalDateString(firstStartDate)});
}
return;
}
let firstRec = reconcileMap[dates[0].getTime()];
firstRec.startDate = firstStartDate;
firstRec.startBalance = 0;
firstRec.endBalance = firstRec.net;
this.pastReconciliations.unshift(firstRec);
let lastRec = firstRec;
for(let i = 1; i < dates.length; i++) {
let rec = reconcileMap[dates[i].getTime()];
rec.startDate = new Date(lastRec.endDate);
rec.startBalance = lastRec.endBalance;
rec.endBalance = rec.startBalance + rec.net;
this.pastReconciliations.unshift(rec);
lastRec = rec;
}
this.newReconcile.patchValue(
{
startDate: Util.getLocalDateString(lastRec.endDate),
startBalance: lastRec.endBalance / Math.pow(10, this.account.precision)
}
);
});
}
}

View File

@@ -0,0 +1,7 @@
export class Reconciliation {
startDate: Date;
startBalance: number;
endDate: Date;
endBalance: number;
net: number;
}

View File

@@ -0,0 +1,54 @@
<h1>Register</h1>
<div class="section">
<form *ngIf="!registered" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="firstName" class="col-sm-3 col-form-label">First Name</label>
<div class="col-sm-9">
<input formControlName="firstName" id="firstName" type="text" class="form-control" placeholder="First Name" />
</div>
</div>
<div class="form-group row">
<label for="lastName" class="col-sm-3 col-form-label">Last Name</label>
<div class="col-sm-9">
<input formControlName="lastName" id="lastName" type="text" class="form-control" placeholder="Last Name" />
</div>
</div>
<div class="form-group row">
<label for="email" class="col-sm-3 col-form-label">Email</label>
<div class="col-sm-9">
<input formControlName="email" id="email" type="email" class="form-control" placeholder="Email" />
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-3 col-form-label">Password</label>
<div class="col-sm-9">
<input formControlName="password" id="password" type="password" class="form-control" placeholder="Password" />
</div>
</div>
<div class="form-group row">
<label for="password2" class="col-sm-3 col-form-label">Repeat Password</label>
<div class="col-sm-9">
<input formControlName="password2" id="password2" type="password" class="form-control" placeholder="Repeat Password" />
</div>
</div>
<p *ngIf="form.controls.password2.errors?.mismatchedPassword">
Passwords do not match.
</p>
<div class="form-group row">
<div class="col-sm-3">
</div>
<div class="col-sm-9">
<input formControlName="agreeToTerms" id="agreeToTerms" type="checkbox" class="mr-2" />
<label for="agreeToTerms">I agree to <a href="/tou" target="_blank">Terms of Use</a> and <a href="/privacy-policy" target="_blank">Privacy Policy</a>.</label>
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Register</button>
</form>
<div *ngIf="registered">
<p>An email was sent to {{email}}. Please click on the link to verify your account.</p>
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RegisterPage } from './register';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
RegisterPage
],
imports: [
BrowserModule,
ReactiveFormsModule
],
providers: []
})
export class RegisterModule { }

View File

View File

@@ -0,0 +1,70 @@
import { Component } from '@angular/core';
import { Logger } from '../core/logger';
import {
FormGroup,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { UserService } from '../core/user.service';
import { ConfigService } from '../core/config.service';
import { User } from '../shared/user';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
@Component({
selector: 'app-register',
templateUrl: 'register.html'
})
export class RegisterPage {
public registered: boolean = false;
private form: FormGroup;
private email: string;
private error: AppError;
constructor(
private log: Logger,
private userService: UserService,
private configService: ConfigService,
private fb: FormBuilder
) {
this.form = fb.group({
'firstName': ['', Validators.required],
'lastName': ['', Validators.required],
'email': ['', Validators.required],
'password': ['', Validators.required],
'password2': ['', Validators.required],
'agreeToTerms': [false, Validators.required]
}, {
validator: this.passwordMatchValidator
});
}
onSubmit() {
let formUser = new User(this.form.value);
formUser.id = Util.newGuid();
this.log.debug(formUser);
this.userService.postUser(formUser)
.subscribe(
user => {
this.log.debug(user);
this.registered = true;
this.email = user.email;
},
error => {
this.log.debug('An error occurred!');
this.log.debug(error);
this.error = error;
}
);
}
passwordMatchValidator(control: AbstractControl) {
if(control.get('password').value === control.get('password2').value) {
return null;
} else {
control.get('password2').setErrors({mismatchedPassword: true});
}
}
}

View File

@@ -0,0 +1,96 @@
<h1>Balance Sheet<br>{{date | date:"shortDate"}} (<a [routerLink]="" (click)="toggleShowOptionsForm()">options</a>)</h1>
<div class="section">
<div *ngIf="showOptionsForm" class="card card-body">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Date</label>
<div class="col-sm-9">
<input formControlName="date" type="date" class="form-control" id="date">
</div>
</div>
<div class="form-group row">
<label for="priceSource" class="col-sm-3 col-form-label">Price Source</label>
<div class="col-sm-9">
<select class="form-control" id="priceSource" formControlName="priceSource">
<option value="cost">Cost</option>
<option value="price">Nearest In Time</option>
</select>
</div>
</div>
<p *ngIf="error">{{error.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!form.valid">Update</button>
</form>
</div>
<div *ngIf="assetAccount" class="container-fluid report">
<div class="row" depth="1">
<div class="col-8">
<h4>Assets</h4>
</div>
<div class="col-4 amount">
<h4 *ngIf="priceSource === 'price'">{{+assetAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
<h4 *ngIf="priceSource === 'cost'">{{+assetAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
<div class="row" *ngFor="let account of assetAccounts" [attr.depth]="account.depth">
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
<span *ngIf="account.children.length">{{account.name}}</span>
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
</div>
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
{{+account.totalNativeBalancePrice | currencyFormat:org.precision}}
</div>
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
{{+account.totalNativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
<div class="row">
<div class="col-12">
<hr/>
</div>
</div>
<div class="row" depth="1">
<div class="col-8">
<h4>Liabilities</h4>
</div>
<div class="col-4 amount">
<h4 *ngIf="priceSource === 'price'">{{-liabilityAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
<h4 *ngIf="priceSource === 'cost'">{{-liabilityAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
<div class="row" *ngFor="let account of liabilityAccounts" [attr.depth]="account.depth">
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
<span *ngIf="account.children.length">{{account.name}}</span>
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
</div>
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
{{-account.totalNativeBalancePrice | currencyFormat:org.precision}}
</div>
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
<div class="row" depth="1">
<div class="col-8">
<h4>Equity</h4>
</div>
<div class="col-4 amount">
<h4 *ngIf="priceSource === 'price'">{{-equityAccount.totalNativeBalancePrice | currencyFormat:org.precision}}</h4>
<h4 *ngIf="priceSource === 'cost'">{{-equityAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
<div class="row" *ngFor="let account of equityAccounts" [attr.depth]="account.depth">
<div class="col-8 name" *ngIf="account.totalNativeBalancePrice || account.totalNativeBalanceCost">
<span *ngIf="account.children.length">{{account.name}}</span>
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
</div>
<div class="col-4 amount" *ngIf="priceSource === 'price' && account.totalNativeBalancePrice">
{{-account.totalNativeBalancePrice | currencyFormat:org.precision}}
</div>
<div class="col-4 amount" *ngIf="priceSource === 'cost' && account.totalNativeBalanceCost">
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,159 @@
import { Component } from '@angular/core';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { ConfigService } from '../core/config.service';
import { SessionService } from '../core/session.service';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Account } from '../shared/account';
import { Org } from '../shared/org';
import { TxListPage } from '../transaction/list';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
@Component({
selector: 'app-balancesheet',
templateUrl: 'balancesheet.html',
styleUrls: ['./reports.scss']
})
export class BalanceSheetReport {
public org: Org;
public date: Date;
public assetAccount: Account;
public assetAccounts: Account[] = [];
public liabilityAccount: Account;
public liabilityAccounts: Account[] = [];
public equityAccount: Account;
public equityAccounts: Account[] = [];
public amounts: any = {};
public form: FormGroup;
public error: AppError;
public showOptionsForm: boolean = false;
private treeSubscription: Subscription;
private priceSource: string;
constructor(
private fb: FormBuilder,
private accountService: AccountService,
private orgService: OrgService,
private configService: ConfigService,
private sessionService: SessionService) {
this.date = new Date();
this.priceSource = 'price';
let reportData = this.configService.get('reportData');
if(reportData && reportData.balanceSheet) {
let reportConfig = reportData.balanceSheet;
if(reportConfig.date) {
this.date = new Date(reportConfig.date);
}
if(reportConfig.priceSource) {
this.priceSource = reportConfig.priceSource;
}
}
this.form = fb.group({
date: [Util.getLocalDateString(this.date), Validators.required],
priceSource: [this.priceSource, Validators.required]
});
}
ngOnInit() {
this.sessionService.setLoading(true);
this.org = this.orgService.getCurrentOrg();
this.amounts = {};
this.assetAccount = null;
this.treeSubscription = this.accountService.getAccountTreeAtDate(this.date)
.subscribe(tree => {
this.sessionService.setLoading(false);
this.assetAccount = tree.getAccountByName('Assets', 1);
this.assetAccounts = tree.getFlattenedAccounts(this.assetAccount);
this.liabilityAccount = tree.getAccountByName('Liabilities', 1);
this.liabilityAccounts = tree.getFlattenedAccounts(this.liabilityAccount);
this.equityAccount = tree.getAccountByName('Equity', 1);
this.equityAccounts = tree.getFlattenedAccounts(this.equityAccount);
let incomeAccount = tree.getAccountByName('Income', 1);
let expenseAccount = tree.getAccountByName('Expenses', 1);
let retainedEarnings = new Account({
id: 'Retained Earnings',
name: 'Retained Earnings',
depth: 2,
children: [null], // hack to fool template into not displaying a link
totalNativeBalanceCost: incomeAccount.totalNativeBalanceCost +
expenseAccount.totalNativeBalanceCost,
totalNativeBalancePrice: incomeAccount.totalNativeBalancePrice +
expenseAccount.totalNativeBalancePrice
});
let unrealizedGains = new Account({
id: 'Unrealized Gains',
name: 'Unrealized Gains',
depth: 2,
children: [null], // hack to fool template into not displaying a link
totalNativeBalanceCost: -(this.assetAccount.totalNativeBalanceCost +
this.liabilityAccount.totalNativeBalanceCost +
this.equityAccount.totalNativeBalanceCost +
retainedEarnings.totalNativeBalanceCost),
totalNativeBalancePrice: -(this.assetAccount.totalNativeBalancePrice +
this.liabilityAccount.totalNativeBalancePrice +
this.equityAccount.totalNativeBalancePrice +
retainedEarnings.totalNativeBalancePrice)
});
this.equityAccounts.push(retainedEarnings);
this.equityAccounts.push(unrealizedGains);
// TODO is this modifying a tree that might be used elsewhere?
// Not all functions are pure...
this.equityAccount.totalNativeBalanceCost = -this.assetAccount.totalNativeBalanceCost
- this.liabilityAccount.totalNativeBalanceCost;
this.equityAccount.totalNativeBalancePrice = -this.assetAccount.totalNativeBalancePrice
- this.liabilityAccount.totalNativeBalancePrice;
// this.dataService.setLoading(false);
});
}
onSubmit() {
this.treeSubscription.unsubscribe();
//this.dataService.setLoading(true);
this.showOptionsForm = false;
this.date = Util.getDateFromLocalDateString(this.form.value.date);
this.priceSource = this.form.value.priceSource;
let reportData = this.configService.get('reportData');
if(!reportData) {
reportData = {};
}
reportData.balanceSheet = {
date: this.date,
priceSource: this.priceSource
}
this.configService.put('reportData', reportData);
this.ngOnInit();
}
toggleShowOptionsForm() {
this.showOptionsForm = !this.showOptionsForm;
}
}

View File

@@ -0,0 +1,65 @@
<h1>Income Statement<br>{{startDate | date:"shortDate"}} - {{endDate.getTime() - 1 | date:"shortDate"}} (<a [routerLink]="" (click)="toggleShowDateForm()">edit</a>)</h1>
<div class="section">
<form *ngIf="showDateForm" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">Start Date</label>
<div class="col-sm-9">
<input formControlName="startDate" type="date" class="form-control" id="startDate">
</div>
</div>
<div class="form-group row">
<label for="name" class="col-sm-3 col-form-label">End Date</label>
<div class="col-sm-9">
<input formControlName="endDate" type="date" class="form-control" id="endDate">
</div>
</div>
<p *ngIf="error">{{error.message}}</p>
<button class="btn btn-primary" type="submit" [disabled]="!form.valid">Update</button>
</form>
<div *ngIf="incomeAccount" class="container-fluid report">
<div class="row" depth="1">
<div class="col-8">
<h4>Income</h4>
</div>
<div class="col-4 amount">
<h4>{{-incomeAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
<div class="row" *ngFor="let account of incomeAccounts" [attr.depth]="account.depth">
<div class="col-8 name" *ngIf="account.totalNativeBalanceCost">
<span *ngIf="account.children.length">{{account.name}}</span>
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
</div>
<div class="col-4 amount" *ngIf="account.totalNativeBalanceCost">
{{-account.totalNativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
<div class="row" depth="1">
<div class="col-8">
<h4>Expenses</h4>
</div>
<div class="col-4 amount">
<h4>{{expenseAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
<div class="row" *ngFor="let account of expenseAccounts" [attr.depth]="account.depth">
<div class="col-8 name" *ngIf="account.totalNativeBalanceCost">
<span *ngIf="account.children.length">{{account.name}}</span>
<span *ngIf="!account.children.length"><a [routerLink]="'/accounts/' + account.id + '/transactions'">{{account.name | slice:0:30}}</a></span>
</div>
<div class="col-4 amount" *ngIf="account.totalNativeBalanceCost">
{{account.totalNativeBalanceCost | currencyFormat:org.precision}}
</div>
</div>
<div class="row" depth="1">
<div class="col-8">
<h4>Net Income</h4>
</div>
<div class="col-4 amount">
<h4>{{-incomeAccount.totalNativeBalanceCost - expenseAccount.totalNativeBalanceCost | currencyFormat:org.precision}}</h4>
</div>
</div>
</div>
</div>

113
src/app/reports/income.ts Normal file
View File

@@ -0,0 +1,113 @@
import { Component } from '@angular/core';
import { AccountService } from '../core/account.service';
import { OrgService } from '../core/org.service';
import { ConfigService } from '../core/config.service';
import { SessionService } from '../core/session.service';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import 'rxjs/add/observable/zip';
import { Account, AccountTree } from '../shared/account';
import { Org } from '../shared/org';
import { TxListPage } from '../transaction/list';
import {
FormGroup,
FormControl,
Validators,
FormBuilder,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { AppError } from '../shared/error';
import { Util } from '../shared/util';
@Component({
selector: 'app-income',
templateUrl: 'income.html',
styleUrls: ['./reports.scss']
})
export class IncomeReport {
public org: Org;
public startDate: Date;
public endDate: Date;
public incomeAccount: Account;
public incomeAccounts: Account[] = [];
public expenseAccount: Account;
public expenseAccounts: Account[] = [];
public form: FormGroup;
public error: AppError;
public showDateForm: boolean = false;
private treeSubscription: Subscription;
constructor(
private fb: FormBuilder,
private accountService: AccountService,
private orgService: OrgService,
private configService: ConfigService,
private sessionService: SessionService) {
this.startDate = new Date();
this.startDate.setDate(1);
this.startDate.setHours(0, 0, 0, 0);
this.endDate = new Date(this.startDate);
this.endDate.setMonth(this.startDate.getMonth() + 1);
let reportData = this.configService.get('reportData');
if(reportData && reportData.income) {
let reportConfig = reportData.income;
if(reportConfig.startDate) {
this.startDate = new Date(reportConfig.startDate);
}
if(reportConfig.endDate) {
this.endDate = new Date(reportConfig.endDate);
}
}
this.form = fb.group({
startDate: [Util.getLocalDateString(this.startDate), Validators.required],
endDate: [Util.getLocalDateString(new Date(this.endDate.getTime() - 1)), Validators.required]
});
}
ngOnInit() {
this.sessionService.setLoading(true);
this.org = this.orgService.getCurrentOrg();
this.treeSubscription = this.accountService.getAccountTreeWithPeriodBalance(this.startDate, this.endDate)
.subscribe(tree => {
this.sessionService.setLoading(false);
this.incomeAccount = tree.getAccountByName('Income', 1);
this.incomeAccounts = tree.getFlattenedAccounts(this.incomeAccount);
this.expenseAccount = tree.getAccountByName('Expenses', 1);
this.expenseAccounts = tree.getFlattenedAccounts(this.expenseAccount);
});
}
toggleShowDateForm() {
this.showDateForm = !this.showDateForm;
}
onSubmit() {
this.treeSubscription.unsubscribe();
//this.dataService.setLoading(true);
this.showDateForm = false;
this.startDate = Util.getDateFromLocalDateString(this.form.value.startDate);
this.endDate = Util.getDateFromLocalDateString(this.form.value.endDate);
this.endDate.setDate(this.endDate.getDate() + 1);
let reportData = this.configService.get('reportData');
if(!reportData) {
reportData = {};
}
reportData.income = {
startDate: this.startDate,
endDate: this.endDate
}
this.configService.put('reportData', reportData);
this.ngOnInit();
}
}

View File

@@ -0,0 +1,8 @@
<h1>Reports</h1>
<div class="section">
<ul>
<li *ngFor="let report of reports">
<a [routerLink]="report.url">{{report.title}}</a>
</li>
</ul>
</div>

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReportsPage } from './reports';
import { IncomeReport } from './income';
import { BalanceSheetReport } from './balancesheet';
import { ReactiveFormsModule } from '@angular/forms';
import { AppRoutingModule } from '../app-routing.module';
import { SharedModule } from '../shared/shared.module';
@NgModule({
declarations: [
ReportsPage,
IncomeReport,
BalanceSheetReport
],
imports: [
BrowserModule,
ReactiveFormsModule,
AppRoutingModule,
SharedModule
],
providers: []
})
export class ReportsModule { }

View File

@@ -0,0 +1,51 @@
.title {
text-align: center;
}
.report {
h4 {
font-size: 1.3rem
}
.total {
font-weight: bold
}
> .row[depth="1"]:first-child {
padding-top: 0px;
}
.row[depth="1"] {
padding-top: 20px;
}
.row[depth="1"] div:first-child,
.row[depth="2"] .name {
padding-left: 0px;
}
.row[depth="3"] .name {
padding-left: 20px;
}
.row[depth="4"] .name {
padding-left: 40px;
}
.row[depth="5"] .name {
padding-left: 60px;
}
.row .name {
padding-left: 60px;
}
.row[depth="1"] div:last-child,
.row[depth="2"] .amount {
padding-right: 0px
}
.row[depth="3"] .amount {
padding-right: 100px;
}
.row[depth="4"] .amount {
padding-right: 200px;
}
.row[depth="5"] .amount {
padding-right: 300px;
}
.row .amount {
padding-right: 300px;
text-align: right;
}
}

View File

@@ -0,0 +1,18 @@
import { Component } from '@angular/core';
@Component({
selector: 'page-reports',
templateUrl: 'reports.html'
})
export class ReportsPage {
reports: Array<{title: string, url: string}>;
constructor() {
this.reports = [
{ title: 'Income Statement', url: '/reports/income' },
{ title: 'Balance Sheet', url: '/reports/balancesheet'}
];
}
}

View File

@@ -0,0 +1,79 @@
<h1>Settings</h1>
<div class="description">
Use the forms below to change your settings.
</div>
<div class="section">
<h2>General</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group row">
<label for="server" class="col-sm-3 col-form-label">Server</label>
<div class="col-sm-9">
<input formControlName="server" id="server" type="text" class="form-control" placeholder="Server" />
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Save</button>
</form>
</div>
<div *ngIf="sessionService.getUser()">
<div class="section">
<h2>Change Password</h2>
<form [formGroup]="changePasswordForm" (ngSubmit)="changePassword()">
<div class="form-group row">
<label for="password" class="col-sm-3 col-form-label">New Password</label>
<div class="col-sm-9">
<input formControlName="password" id="password" type="password" class="form-control" />
</div>
</div>
<div class="form-group row">
<label for="password2" class="col-sm-3 col-form-label">Repeat Password</label>
<div class="col-sm-9">
<input formControlName="password2" id="password2" type="password" class="form-control" />
</div>
</div>
<p *ngIf="changePasswordForm.controls.password2.errors?.mismatchedPassword">
Passwords do not match.
</p>
<p *ngIf="changePasswordError" class="error">{{changePasswordError.message}}</p>
<button type="submit" class="btn btn-primary" [disabled]="!changePasswordForm.valid">Save</button>
</form>
</div>
<div class="section">
<h2>API Keys</h2>
<div class="container-fluid">
<form *ngFor="let item of keyItems; let i = index" [formGroup]="item.form">
<div class="row py-2">
<div class="col-5">
<input type="hidden" formControlName="id" id="id">
{{item.form.value.id}}
</div>
<div class="col-5">
<input type="text" formControlName="label" id="label">
</div>
<div class="col-2">
<div *ngIf="item.exists && item.form.dirty"><a (click)="updateKey(item)">Update</a></div>
<div *ngIf="item.exists"><a (click)="deleteKey(item)">Delete</a></div>
<div *ngIf="!item.exists"><a (click)="postKey(item)">Save</a></div>
</div>
</div>
</form>
<div class="row">
<div class="col-12">
<a (click)="newKey()">Create new API key</a>
</div>
</div>
<div class="row" *ngIf="keyError">
<div class="col-12">
<p class="error">{{keyError.message}}</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { SettingsPage } from './settings';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
SettingsPage,
],
imports: [
BrowserModule,
ReactiveFormsModule
],
providers: []
})
export class SettingsModule { }

View File

@@ -0,0 +1,145 @@
import { Component } from '@angular/core';
import {
FormGroup,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { ConfigService } from '../core/config.service';
import { ApiKeyService } from '../core/apikey.service';
import { SessionService } from '../core/session.service';
import { UserService } from '../core/user.service';
import { AppError } from '../shared/error';
import { ApiKey } from '../shared/apikey';
import { Util } from '../shared/util';
import { User } from '../shared/user';
class KeyItem {
exists: boolean;
form: FormGroup;
}
@Component({
selector: 'app-settings',
templateUrl: 'settings.html'
})
export class SettingsPage {
public form: FormGroup;
public error: AppError;
private changePasswordForm: FormGroup;
private changePasswordError: AppError;
private keyItems: KeyItem[];
private keyError: AppError;
constructor(
private configService: ConfigService,
private apiKeyService: ApiKeyService,
public sessionService: SessionService,
private userService: UserService,
private fb: FormBuilder
) {
}
ngOnInit() {
let server = this.configService.get('server');
this.form = this.fb.group({
'server': [server, Validators.required],
});
if(!this.sessionService.getUser()) {
return;
}
this.changePasswordForm = this.fb.group({
'password': [null, Validators.required],
'password2': [null, Validators.required]
}, {
validator: this.passwordMatchValidator
});
this.keyItems = [];
this.apiKeyService.getApiKeys().subscribe(keys => {
keys.forEach(key => {
this.keyItems.push({
exists: true,
form: this.fb.group({
'id': [key.id, Validators.required],
'label': [key.label, Validators.required]
})
});
});
});
}
onSubmit() {
this.configService.put('server', this.form.value.server);
}
changePassword() {
let user = new User({
password: this.changePasswordForm.value.password
});
this.userService.putUser(user).subscribe(() => {
this.changePasswordError = new AppError('Successfully changed password');
}, err => {
this.changePasswordError = err;
});
}
passwordMatchValidator(control: AbstractControl) {
if(control.get('password').value === control.get('password2').value) {
return null;
} else {
control.get('password2').setErrors({mismatchedPassword: true});
}
}
newKey() {
let key = new ApiKey({
id: Util.newGuid(),
label: ''
});
this.keyItems.push({
exists: false,
form: this.fb.group({
'id': [key.id, Validators.required],
'label': [key.label, Validators.required]
})
});
}
postKey(item: KeyItem) {
let key = new ApiKey(item.form.value);
this.apiKeyService.newApiKey(key).subscribe(() => {
item.exists = true;
item.form.markAsPristine();
}, err => {
this.keyError = err;
})
}
updateKey(item: KeyItem) {
let key = new ApiKey(item.form.value);
this.apiKeyService.putApiKey(key).subscribe(newKey => {
item.form.markAsPristine();
}, err => {
this.keyError = err;
})
}
deleteKey(item: KeyItem) {
let key = new ApiKey(item.form.value);
this.apiKeyService.deleteApiKey(key.id).subscribe(() => {
// remove item from list
this.keyItems = this.keyItems.filter(item => {
return item.form.value['id'] !== key.id;
});
}, err => {
this.keyError = err;
})
}
}

View File

@@ -0,0 +1,28 @@
import { Pipe, PipeTransform } from '@angular/core';
import { CurrencyFormatPipe } from './currency-format.pipe';
import { Account } from './account';
@Pipe({name: 'accountBalance'})
export class AccountBalancePipe implements PipeTransform {
constructor(private currencyFormatPipe: CurrencyFormatPipe) {
}
transform(account: Account, balanceType = 'price'): string {
let sign = account.debitBalance ? 1 : -1;
let nativeBalance = 0;
switch(balanceType) {
case 'cost':
nativeBalance = account.totalNativeBalanceCost;
break;
case 'price':
nativeBalance = account.totalNativeBalancePrice;
break;
default:
throw new Error('Invalid balance type ' + balanceType);
}
return this.currencyFormatPipe.transform(sign * nativeBalance, account.orgPrecision, account.orgCurrency);
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({name: 'accountName'})
export class AccountNamePipe implements PipeTransform {
constructor() {
}
transform(name: string, depth: number): string {
let parts = name.split(':');
let accountString = '';
if(!depth) {
depth = 1;
}
parts = parts.slice(depth - 1, parts.length);
return parts.join(':');
}
}

168
src/app/shared/account.ts Normal file
View File

@@ -0,0 +1,168 @@
export class AccountApi {
id: string;
orgId: string;
inserted: Date;
updated: Date;
name: string;
parent: string;
currency: string;
precision: number;
debitBalance: boolean;
balance: number;
nativeBalance: number;
readOnly: boolean;
recentTxCount: number;
price: number;
constructor(options: any = {}) {
this.id = options.id;
this.orgId = options.orgId;
this.inserted = options.inserted ? new Date(options.inserted): null;
this.updated = options.updated ? new Date(options.updated): null;
this.name = options.name;
this.parent = options.parent ? options.parent : '';
this.currency = options.currency;
this.precision = options.precision ? parseInt(options.precision) : 0;
this.debitBalance = options.debitBalance;
this.balance = options.balance;
this.nativeBalance = options.nativeBalance;
this.readOnly = options.readOnly;
this.recentTxCount = options.recentTxCount ? parseInt(options.recentTxCount) : 0;
this.price = options.price;
}
}
export class Account {
id: string;
orgId: string;
inserted: Date;
updated: Date;
name: string;
fullName: string;
parent: Account;
currency: string;
precision: number;
debitBalance: boolean;
balance: number;
totalBalance: number;
nativeBalanceCost: number;
totalNativeBalanceCost: number;
nativeBalancePrice: number;
totalNativeBalancePrice: number;
price: number;
orgCurrency: string;
orgPrecision: number;
readOnly: boolean;
depth: number;
recentTxCount: number;
children: Account[];
constructor(options: any = {}) {
this.id = options.id;
this.orgId = options.orgId;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.name = options.name;
this.fullName = options.fullName;
this.parent = options.parent;
this.currency = options.currency;
this.precision = options.precision ? parseInt(options.precision) : 0;
this.debitBalance = options.debitBalance || false;
this.balance = options.balance || 0;
this.totalBalance = options.totalBalance || 0;
this.nativeBalanceCost = options.nativeBalance || 0;
this.totalNativeBalanceCost = options.totalNativeBalanceCost || options.totalNativeBalance || 0;
this.nativeBalancePrice = options.nativeBalancePrice || 0;
this.totalNativeBalancePrice = options.totalNativeBalancePrice || 0;
this.price = options.price || 0;
this.orgCurrency = options.orgCurrency;
this.orgPrecision = options.orgPrecision ? parseInt(options.orgPrecision) : 0;
this.readOnly = options.readOnly;
this.depth = options.depth;
this.recentTxCount = options.recentTxCount || 0;
this.children = options.children || [];
}
}
export class AccountTree {
accountMap: { [accountId: number]: Account; };
rootAccount: Account;
constructor(options: any = {}) {
this.accountMap = options.accountMap;
this.rootAccount = options.rootAccount;
}
getFlattenedAccounts(node?: Account): Account[] {
if(!node) {
node = this.rootAccount;
}
let flattened = [];
for(let account of node.children) {
flattened.push(account);
flattened = flattened.concat(this.getFlattenedAccounts(account));
}
return flattened;
}
getAccountByName (name: string, depth?: number): Account {
for(let id in this.accountMap) {
let account = this.accountMap[id];
if(account.name === name) {
if(!depth || account.depth === depth) {
return account;
}
}
}
return null;
}
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;
}
getAccountAtoms(node?: Account): Account[] {
if(!node) {
node = this.rootAccount;
}
let accounts = [];
for(let i = 0; i < node.children.length; i++) {
let child = node.children[i];
if(!child.children.length) {
accounts.push(child);
} else {
accounts = accounts.concat(this.getAccountAtoms(child));
}
}
return accounts;
}
getAccountLabel(account: Account, depth: number) {
let node = account;
let accountArray = [account.name];
while(node.parent.depth >= depth) {
node = node.parent;
accountArray.unshift(node.name);
}
return accountArray.join(':');
}
}

15
src/app/shared/apikey.ts Normal file
View File

@@ -0,0 +1,15 @@
export class ApiKey {
id: string;
inserted: Date;
updated: Date;
userId: string;
label: string;
constructor(options: any = {}) {
this.id = options.id;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.userId = options.userId;
this.label = options.label;
}
}

13
src/app/shared/config.ts Normal file
View File

@@ -0,0 +1,13 @@
export class Config {
server: string;
email: string;
password: string; // switch to session based auth
defaultOrg: string;
reportData: any;
constructor(options: any = {}) {
this.server = options.server || 'https://openaccounting.io:8080/api';
this.email = options.email;
this.password = options.password;
this.defaultOrg = options.defaultOrg;
}
}

View File

@@ -0,0 +1,27 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DecimalPipe } from '@angular/common';
@Pipe({name: 'currencyFormat'})
export class CurrencyFormatPipe implements PipeTransform {
constructor(private decimalPipe: DecimalPipe) {
}
transform(amount: number, precision: number, currency = 'USD'): string {
if(amount === null || amount === undefined) {
return '';
}
let prefix = amount < 0 ? '-' : '';
if(currency === 'USD') {
prefix += '$';
}
let minDigits = Math.min(2, precision);
return prefix +
this.decimalPipe.transform(
Math.abs(amount) / Math.pow(10, precision),
'1.' + minDigits + '-' + precision);
}
}

8
src/app/shared/error.ts Normal file
View File

@@ -0,0 +1,8 @@
export class AppError extends Error {
constructor(m: string, public code?: number) {
super(m);
// Set the prototype explicitly.
Object.setPrototypeOf(this, AppError.prototype);
}
}

17
src/app/shared/invite.ts Normal file
View File

@@ -0,0 +1,17 @@
export class Invite {
id: string;
orgId: string;
inserted: Date;
updated: Date;
email: string;
accepted: boolean;
constructor(options: any = {}) {
this.id = options.id;
this.orgId = options.orgId;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.email = options.email;
this.accepted = options.accepted;
}
}

19
src/app/shared/message.ts Normal file
View File

@@ -0,0 +1,19 @@
export class Message {
version: string;
sequenceNumber: number;
type: string;
action: string;
data: any;
constructor(options: any = {}) {
this.version = options.version;
this.sequenceNumber = options.sequenceNumber;
this.type = options.type;
this.action = options.action;
this.data = options.data;
}
toString(): string {
return JSON.stringify(this);
}
}

16
src/app/shared/org.ts Normal file
View File

@@ -0,0 +1,16 @@
export class Org {
id: string;
inserted: Date;
updated: Date;
name: string;
currency: string;
precision: number;
constructor(options: any = {}) {
this.id = options.id;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.name = options.name;
this.currency = options.currency;
this.precision = options.precision && parseInt(options.precision);
}
}

17
src/app/shared/price.ts Normal file
View File

@@ -0,0 +1,17 @@
export class Price {
id: string;
currency: string;
date: Date;
inserted: Date;
updated: Date;
price: number;
constructor(options: any = {}) {
this.id = options.id;
this.currency = options.currency;
this.date = options.date ? new Date(options.date) : null;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.price = options.price;
}
}

View File

@@ -0,0 +1,6 @@
export class SessionOptions {
createDefaultAccounts: boolean;
constructor(options: any = {}) {
this.createDefaultAccounts = options.createDefaultAccounts;
}
};

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { CurrencyFormatPipe } from './currency-format.pipe';
import { AccountNamePipe } from './account-name.pipe';
import { AccountBalancePipe } from './account-balance.pipe';
@NgModule({
imports: [],
declarations: [CurrencyFormatPipe, AccountNamePipe, AccountBalancePipe],
exports: [CurrencyFormatPipe, AccountNamePipe, AccountBalancePipe],
providers: [DecimalPipe, CurrencyFormatPipe]
})
export class SharedModule { }

View File

@@ -0,0 +1,49 @@
export class Transaction {
id: string;
orgId: string;
userId: string;
date: Date;
inserted: Date;
updated: Date;
description: string;
data: any;
deleted: boolean;
splits: Split[];
constructor(options: any = {}) {
this.id = options.id;
this.orgId = options.id;
this.userId = options.id;
this.date = options.date ? new Date(options.date) : null;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.description = options.description;
this.data = options.data;
this.deleted = options.deleted;
this.splits = options.splits ? options.splits.map(split => new Split(split)) : [];
}
getData(): any {
try {
return JSON.parse(this.data);
} catch(e) {
return {};
}
}
setData(data: any) {
this.data = JSON.stringify(data);
}
// constructor(init?:Partial<Transaction>) {
// Object.assign(this, init);
// }
}
export class Split {
accountId: string;
amount: number;
nativeAmount: number;
constructor(init?:Partial<Split>) {
Object.assign(this, init);
}
}

22
src/app/shared/user.ts Normal file
View File

@@ -0,0 +1,22 @@
export class User {
id: string;
inserted: Date;
updated: Date;
firstName: string;
lastName: string;
email: string;
password: string;
agreeToTerms: boolean;
emailVerified: boolean;
constructor(options: any = {}) {
this.id = options.id || this.id;
this.inserted = options.inserted ? new Date(options.inserted) : null;
this.updated = options.updated ? new Date(options.updated) : null;
this.firstName = options.firstName || this.firstName;
this.lastName = options.lastName || this.lastName;
this.email = options.email || this.email;
this.password = options.password || this.password;
this.agreeToTerms = options.agreeToTerms || false;
this.emailVerified = options.emailVerified || this.emailVerified;
}
}

37
src/app/shared/util.ts Normal file
View File

@@ -0,0 +1,37 @@
export class Util {
static getLocalDateString(input: Date) {
let year = input.getFullYear().toString();
let month = (input.getMonth() + 1).toString();
let date = input.getDate().toString();
if(month.length < 2) {
month = '0' + month;
}
if(date.length < 2) {
date = '0' + date;
}
return year + '-' + month + '-' + date;
}
static getDateFromLocalDateString(input: string) {
let parts = input.split('-');
let date = new Date();
date.setHours(0, 0, 0, 0);
date.setFullYear(parseInt(parts[0]));
date.setMonth(parseInt(parts[1]) - 1);
date.setDate(parseInt(parts[2]));
return date;
}
static newGuid() {
let arr = new Uint8Array(16);
window.crypto.getRandomValues(arr);
return Array.prototype.map.call(arr, val => {
return ('00' + val.toString(16)).slice(-2);
}).join('');
}
}

View File

@@ -0,0 +1,86 @@
<div class="modal-header">
<h4 class="modal-title">Transaction</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form [formGroup]="form">
<div class="form-group row">
<label for="date" class="col-sm-4 col-form-label">Date</label>
<div class="col-sm-8">
<input formControlName="date" id="date" type="text" class="form-control" />
</div>
</div>
<div class="form-group row">
<label for="description" class="col-sm-4 col-form-label">Description</label>
<div class="col-sm-8">
<input formControlName="description" id="description" type="text" class="form-control"/>
</div>
</div>
<div class="row">
<div class="col-sm-3">
Account
</div>
<div class="col-sm-4">
Debit
</div>
<div class="col-sm-4">
Credit
</div>
<div class="col-sm-1">
</div>
</div>
<div *ngFor="let split of getSplitControls(); let i=index" [formGroup]="split" class="splits">
<div *ngIf="debitVisible(i) || creditVisible(i)" class="row">
<div class="col-sm-3"></div>
<div *ngIf="!debitVisible(i)" class="col-sm-4"></div>
<div *ngIf="debitVisible(i)" class="col-sm-2">{{getCurrency(split.value.accountId)}}</div>
<div *ngIf="debitVisible(i)" class="col-sm-2">{{org.currency}}</div>
<div *ngIf="!creditVisible(i)" class="col-sm-4"></div>
<div *ngIf="creditVisible(i)" class="col-sm-2">{{org.currency}}</div>
<div *ngIf="creditVisible(i)" class="col-sm-2">{{getCurrency(split.value.accountId)}}</div>
</div>
<div class="row">
<div class="col-sm-3 account">
<select class="form-control" formControlName="accountId">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName}}
</option>
</select>
</div>
<div *ngIf="!debitVisible(i)" class="col-sm-4">
<button *ngIf="!creditVisible(i) || !split.value.credit" type="button" class="btn btn-primary" (click)="showDebit(i)">Debit</button>
</div>
<div *ngIf="debitVisible(i)" class="col-sm-2">
<input type="text" class="form-control" formControlName="debit" />
</div>
<div *ngIf="debitVisible(i)" class="col-sm-2">
<input type="text" class="form-control" formControlName="debitNative" />
</div>
<div *ngIf="!creditVisible(i)" class="col-sm-4">
<button *ngIf="!debitVisible(i) || !split.value.debit" type="button" class="btn btn-primary" (click)="showCredit(i)">Credit</button>
</div>
<div *ngIf="creditVisible(i)" class="col-sm-2">
<input type="text" class="form-control" formControlName="creditNative" />
</div>
<div *ngIf="creditVisible(i)" class="col-sm-2">
<input type="text" class="form-control" formControlName="credit" />
</div>
<div class="col-sm-1">
<a (click)="deleteSplit(i)">X</a>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-12 p-3">
<a (click)="addSplit()">Add Split</a>
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="activeModal.dismiss()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="submit()">Save</button>
</div>

View File

@@ -0,0 +1,9 @@
/*.account {
overflow: hidden;
direction: rtl;
text-overflow: ellipsis;
white-space: nowrap;
}*/
.splits .row {
margin-bottom: 10px;
}

View File

@@ -0,0 +1,245 @@
import { Component, Input } from '@angular/core';
import { Logger } from '../core/logger';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TxItem } from './txitem';
import { Transaction, Split } from '../shared/transaction';
import { Account, AccountTree } from '../shared/account';
import { Org } from '../shared/org';
import { AppError } from '../shared/error';
import {
FormControl,
FormGroup,
FormArray,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { Util } from '../shared/util';
import { OrgService } from '../core/org.service';
import { TransactionService } from '../core/transaction.service';
@Component({
selector: 'advancededit',
templateUrl: './advancededit.html',
styleUrls: ['./advancededit.scss']
})
export class AdvancedEdit {
public form: FormGroup;
public error: AppError;
private item: TxItem;
private accountTree: AccountTree;
private selectAccounts: Account[];
private org: Org;
private visibleDebits: any = {};
private visibleCredits: any = {};
constructor(
public activeModal: NgbActiveModal,
private log: Logger,
private fb: FormBuilder,
private orgService: OrgService,
private txService: TransactionService
) {}
setData(item: TxItem, accountTree: AccountTree) {
this.item = item;
this.accountTree = accountTree;
this.selectAccounts = accountTree.getFlattenedAccounts().filter(account => {
return !account.children.length;
});
this.org = this.orgService.getCurrentOrg();
let dateString = Util.getLocalDateString(item.tx.date);
this.form = new FormGroup({
date: new FormControl(dateString),
description: new FormControl(item.tx.description),
splits: this.fb.array([])
});
let orgPrecision = this.org.precision;
let splits = this.form.get('splits') as FormArray;
for(let split of item.tx.splits) {
let precision = orgPrecision;
let account = this.accountTree.accountMap[split.accountId];
if(account) {
precision = account.precision;
}
let control = new FormGroup({
accountId: new FormControl(split.accountId),
debit: new FormControl(
split.amount >= 0 ? split.amount / Math.pow(10, precision) : null
),
credit: new FormControl(
split.amount < 0 ? -split.amount / Math.pow(10, precision) : null
),
debitNative: new FormControl(
split.nativeAmount >= 0 ? split.nativeAmount / Math.pow(10, orgPrecision) : null
),
creditNative: new FormControl(
split.nativeAmount < 0 ? -split.nativeAmount / Math.pow(10, orgPrecision) : null
)
}, {updateOn: 'blur'});
// control.valueChanges.subscribe(val => {
// this.solveEquations(item);
// this.fillEmptySplit(item);
// });
splits.push(control);
console.log(splits);
//this.fillEmptySplit(item);
}
}
getCurrency(accountId: string) {
let account = this.accountTree.accountMap[accountId];
return account ? account.currency : '';
}
submit() {
console.log('submit');
console.log(this.form.value);
this.error = null;
let date = this.item.tx.id ? this.item.tx.date : new Date();
let formDate = Util.getDateFromLocalDateString(this.form.value.date);
if(formDate.getTime()) {
// make the time be at the very end of the day
formDate.setHours(23, 59, 59, 999);
}
let sameDay = formDate.getFullYear() === date.getFullYear() &&
formDate.getMonth() === date.getMonth() &&
formDate.getDate() === date.getDate();
if(formDate.getTime() && !sameDay) {
date = formDate;
}
let tx = new Transaction({
id: this.item.tx.id,
date: date,
description: this.form.value.description,
splits: []
});
for(let i = 0; i < this.form.value.splits.length; i++) {
let split = this.form.value.splits[i];
let account = this.accountTree.accountMap[split.accountId];
if(!account) {
this.error = new AppError('Invalid account');
return;
}
let amount = split.debit ? parseFloat(split.debit) : -parseFloat(split.credit);
amount = Math.round(amount * Math.pow(10, account.precision));
let nativeAmount = split.debitNative ? parseFloat(split.debitNative) : -parseFloat(split.creditNative);
nativeAmount = Math.round(nativeAmount * Math.pow(10, this.org.precision))
tx.splits.push(new Split({
accountId: split.accountId,
amount: amount,
nativeAmount: nativeAmount
}));
}
this.log.debug(tx);
if(tx.id) {
// update tx
let oldId = tx.id;
tx.id = Util.newGuid();
this.txService.putTransaction(oldId, tx)
.subscribe(tx => {
this.activeModal.close();
}, error => {
this.error = error;
});
} else {
// new tx
tx.id = Util.newGuid();
this.txService.newTransaction(tx)
.subscribe(tx => {
this.activeModal.close();
}, error => {
this.error = error;
});
}
}
addSplit() {
this.log.debug('add split');
let splits = this.form.get('splits') as FormArray;
let control = new FormGroup({
accountId: new FormControl(),
debit: new FormControl(),
credit: new FormControl(),
debitNative: new FormControl(),
creditNative: new FormControl()
}, {updateOn: 'blur'});
// control.valueChanges.subscribe(val => {
// this.solveEquations(item);
// this.fillEmptySplit(item);
// });
splits.push(control);
// this.fillEmptySplit(item);
}
deleteSplit(index) {
this.log.debug('delete split');
let splits = this.form.get('splits') as FormArray;
splits.removeAt(index);
this.visibleDebits = {};
this.visibleCredits = {};
}
getSplitControls(): AbstractControl[] {
return (this.form.get('splits') as FormArray).controls;
}
debitVisible(index: number) {
let splits = this.getSplitControls();
return this.visibleDebits[index] || splits[index].value.debit;
}
creditVisible(index: number) {
let splits = this.getSplitControls();
return this.visibleCredits[index] || splits[index].value.credit;
}
showDebit(index: number) {
this.visibleDebits[index] = true;
this.visibleCredits[index] = false;
}
showCredit(index: number) {
this.visibleCredits[index] = true;
this.visibleDebits[index] = false;
}
}

View File

@@ -0,0 +1,7 @@
<div class="autocomplete" [ngClass]="{visible: visible}">
<div class="inner">
<div class="suggestion" *ngFor="let tx of txs$ | async">
<a (click)="click(tx)">{{tx.description}}</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
.autocomplete {
display: none;
position: relative;
z-index: 3;
.inner {
position: absolute;
top: 0px;
left: 0px;
background-color: #fff;
border: 1px solid #d4d4d4;
border-bottom: none;
}
.suggestion {
padding: 10px;
border-bottom: 1px solid #d4d4d4;
}
}
.autocomplete.visible {
display: block;
}

View File

@@ -0,0 +1,68 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Logger } from '../core/logger';
import { TxItem } from './txitem';
import { EmptyObservable } from 'rxjs/observable/EmptyObservable';
import { TransactionService } from '../core/transaction.service';
import { Transaction } from '../shared/transaction';
import { Observable } from 'rxjs/Observable';
@Component({
selector: 'tx-autocomplete',
templateUrl: 'autocomplete.html',
styleUrls: ['./autocomplete.scss']
})
export class Autocomplete {
@Input() item: TxItem;
@Input() accountId: string;
@Output() tx = new EventEmitter<Transaction>();
public visible: boolean;
public txs$: Observable<Transaction[]>;
constructor(
private log: Logger,
private txService: TransactionService) {
}
ngOnInit() {
this.txs$ = this.item.edit$
.switchMap(() => {
let control = this.item.form.get('description');
return this.item.form.get('description').valueChanges;
})
.debounceTime(100)
.filter(description => {
if(!description || description.length < 3) {
this.visible = false;
return false;
}
return true;
})
.switchMap(description => {
this.log.debug('autocomplete', description);
let options = {limit: 5, descriptionStartsWith: description};
return this.txService.getTransactionsByAccount(this.accountId, options);
}).map(txs => {
let txMap = {};
return txs.filter(tx => {
if(!txMap[tx.description]) {
txMap[tx.description] = true;
return true;
}
return false;
});
}).do((txs) => {
if(txs.length) {
this.visible = true;
}
});
}
click(tx: Transaction) {
this.tx.emit(tx);
this.visible = false;
}
}

View File

@@ -0,0 +1,6 @@
<div class="breadcrumbs">
<span *ngFor="let account of accountCrumbs; let i = index">
<span><a routerLink="/accounts">{{account.name}}</a></span>
<span *ngIf="i < accountCrumbs.length - 1"> &gt; </span>
</span>
</div>

View File

View File

@@ -0,0 +1,25 @@
import { Component, Input } from '@angular/core';
import { Account } from '../shared/account';
@Component({
selector: 'breadcrumbs',
templateUrl: 'breadcrumbs.html',
styleUrls: ['./breadcrumbs.scss']
})
export class Breadcrumbs {
@Input() account: Account;
public accountCrumbs: Account[];
constructor() {
this.accountCrumbs = [];
}
ngOnInit() {
let currentAccount = this.account;
while(currentAccount && currentAccount.depth > 0) {
this.accountCrumbs.unshift(currentAccount);
currentAccount = currentAccount.parent;
}
}
}

View File

@@ -0,0 +1,124 @@
<h1 *ngIf="account">{{account.name | slice:0:30}}</h1>
<div class="section">
<div *ngIf="account" class="mb-2">
<breadcrumbs [account]="account"></breadcrumbs>
</div>
<div class="wrapper">
<div class="header">
<div class="container-fluid">
<div class="row">
<div class="col custom-3">
<span>Date</span>
</div>
<div class="col custom-7">
<span>Description</span>
</div>
<div class="col custom-5">
<span>Transfer</span>
</div>
<div class="col custom-3">
<span>Debit</span>
</div>
<div class="col custom-3">
<span>Credit</span>
</div>
<div class="col custom-3">
<span>Balance</span>
</div>
</div>
</div>
</div>
<div class="body" #body id="mybody" (scroll)="onScroll()">
<div class="container-fluid">
<form [id]="'form' + item.tx.id + item.activeSplitIndex" [formGroup]="item.form" *ngFor="let item of items; let i = index">
<div class="row" (click)="editTransaction(item, $event)" [ngClass]="{odd: !(i % 2), editing: item.editing}">
<div class="col custom-3 date">
<span *ngIf="!item.editing" class="date">{{item.tx.date | date:"M/d/y"}}</span>
<input *ngIf="item.editing" type="date" formControlName="date" placeholder="Date" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
</div>
<div class="col custom-7 description">
<div *ngIf="!item.editing">{{item.tx.description}}</div>
<input *ngIf="item.editing" type="text" formControlName="description" placeholder="Description" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
<tx-autocomplete [item]="item" [accountId]="accountId" (tx)="autocomplete(item, $event)"></tx-autocomplete>
</div>
<div class="col custom-5 transfer">
<span *ngIf="!item.editing" class="transfer">{{getTransferString(item) | slice:0:50}}</span>
<select *ngIf="item.editing" class="form-control" formControlName="accountId" [attr.disabled]="item.showSplits ? '' : null" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName | slice:0:50}}
</option>
</select>
</div>
<div class="col custom-3 debit">
<span *ngIf="!item.editing" class="debit">{{getDebit(item) | currencyFormat:account.precision:account.currency}}</span>
<input *ngIf="item.editing" type="text" formControlName="debit" placeholder="Debit" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
</div>
<div class="col custom-3 credit">
<span *ngIf="!item.editing" class="credit">{{getCredit(item) | currencyFormat:account.precision:account.currency}}</span>
<input *ngIf="item.editing" type="text" formControlName="credit" placeholder="Credit" class="form-control" (keyup.enter)="onEnter(item, $event)" (blur)="onBlur(item)"/>
</div>
<div class="col custom-3 balance" [ngClass]="{'negative': item.balance < 0}">
<span *ngIf="!item.editing" class="balance">{{item.balance | currencyFormat:account.precision:account.currency}}</span>
<div *ngIf="item.editing" ngbDropdown class="d-inline-block">
<button class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>Action</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<button class="dropdown-item" (click)="addSplit(item)">Split</button>
<button class="dropdown-item" (click)="advancedEdit(item)">Adv. Edit</button>
<button *ngIf="item.tx.id" class="dropdown-item" (click)="deleteTransaction(item)">Delete</button>
</div>
</div>
<!-- <a *ngIf="item.editing" [routerLink]="" (click)="addSplit(item)" (mousedown)="preventBlur(item)">Split</a><br/>
<a *ngIf="item.editing" [routerLink]="" (click)="advancedEdit(item)" (mousedown)="preventBlur(item)">Advanced Edit</a><br/>
<a *ngIf="item.editing && item.tx.id" [routerLink]="" (click)="deleteTransaction(item)" (mousedown)="preventBlur(item)">Delete</a> -->
</div>
</div>
<div class="row" *ngFor="let split of item.form.get('splits').controls; let i=index" [formGroup]="split">
<div class="col custom-3">
</div>
<div class="col custom-7 add-split">
<a [routerLink]="" (click)="deleteSplit(item, i)" (mousedown)="preventBlur(item)">Remove Split</a>
</div>
<div class="col custom-5">
<select class="form-control" formControlName="accountId" (keyup.enter)="onEnter(item, $event)">
<option *ngFor="let account of selectAccounts" [value]="account.id">
{{account.fullName}}
</option>
</select>
</div>
<div class="col custom-3">
<input type="text" formControlName="debit" placeholder="Debit" class="form-control" (keyup.enter)="onEnter(item, $event)"/>
</div>
<div class="col custom-3">
<input type="text" formControlName="credit" placeholder="Credit" class="form-control" (keyup.enter)="onEnter(item, $event)"/>
</div>
<div class="col custom-3 add-split">
<a *ngIf="i === item.form.get('splits').controls.length - 1" [routerLink]="" (click)="addSplit(item)" (mousedown)="preventBlur(item)">Add Split</a>
<!-- <button type="submit">hidden submit</button> -->
</div>
</div>
</form>
</div>
</div>
</div>
<p *ngIf="error" class="error">{{error.message}}</p>
</div>
<ng-template #confirmDeleteModal let-c="close" let-d="dismiss">
<div class="modal-header">
<h4 class="modal-title">Confirm delete</h4>
<button type="button" class="close" aria-label="Close" (click)="d()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this transaction?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="d()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="c()">Delete</button>
</div>
</ng-template>

View File

@@ -0,0 +1,76 @@
@import '../../sass/variables';
.row > div {
border-bottom: 1px solid #bdd7ef;
}
.header {
span {
font-weight: bold;
}
}
.row.odd {
background-color: #e4f6ff;
}
.negative {
color: $negative;
}
.description > div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer {
overflow: hidden;
direction: rtl;
text-overflow: ellipsis;
white-space: nowrap;
}
.editing .transfer {
direction: ltr;
}
.body {
overflow-y: auto;
height: calc(100vh - 170px);
}
.custom-3 {
flex: 0 0 12.5%;
max-width: 12.5%;
}
.custom-5 {
flex: 0 0 20.8333%;
max-width: 20.8333%;
}
.custom-7 {
flex: 0 0 29.1666%;
max-width: 29.1666%;
}
.form-control {
padding: .2rem .2rem;
}
.add-split {
padding-top: .4rem;
padding-bottom: .4rem;
}
button[type="submit"] {
display: none;
}
input[type="date"] {
padding: 5px 0px;
font-size: 0.75rem
}
input[type="date"]::-webkit-inner-spin-button,
input[type="date"]::-webkit-clear-button {
display: none
}

862
src/app/transaction/list.ts Normal file
View File

@@ -0,0 +1,862 @@
import { Component, Input, OnInit, ViewChild, ElementRef, AfterViewChecked, Renderer } from '@angular/core';
import { Logger } from '../core/logger';
import { ActivatedRoute } from '@angular/router';
import { TransactionService } from '../core/transaction.service';
import { AccountService } from '../core/account.service';
import { Account, AccountTree } from '../shared/account';
import { Transaction, Split} from '../shared/transaction';
import { AppError } from '../shared/error';
//import { EditTxPage } from './edit';
import {
FormControl,
FormGroup,
FormArray,
Validators,
FormBuilder,
AbstractControl
} from '@angular/forms';
import { NgbModal, ModalDismissReasons } from '@ng-bootstrap/ng-bootstrap';
import { Util } from '../shared/util';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/mergeMap';
import { AdvancedEdit } from './advancededit';
import { TxItem } from './txitem';
import { Subject } from 'rxjs';
@Component({
selector: 'app-txlist',
templateUrl: 'list.html',
styleUrls: ['./list.scss']
})
export class TxListPage implements OnInit, AfterViewChecked {
@ViewChild('body') body: ElementRef;
@ViewChild('confirmDeleteModal') confirmDeleteModal: ElementRef;
public account: Account;
public items: TxItem[];
public error: AppError;
private accountId: string;
private accountTree: AccountTree;
private balance: number;
private splits: any[];
private selectAccounts: Account[];
private needsScroll: boolean;
private needsLittleScroll: boolean;
private scrollLastHeight: number;
private limit: number;
private skip: number;
private historyFinished: boolean;
private fetching: boolean;
private date: Date;
private scrollSubject: Subject<any>;
private hasScrolled: boolean;
constructor(
private log: Logger,
private route: ActivatedRoute,
private txService: TransactionService,
private accountService: AccountService,
private fb: FormBuilder,
private renderer: Renderer,
private modalService: NgbModal
) {
this.items = [];
this.limit = 50;
this.historyFinished = false;
this.fetching = false;
this.scrollSubject = new Subject<any>();
this.hasScrolled = false;
}
ngOnInit() {
this.accountId = this.route.snapshot.paramMap.get('id'); //+this.route.snapshot.paramMap.get('id');
this.accountService.getAccountTree().subscribe(tree => {
this.account = tree.accountMap[this.accountId];
this.selectAccounts = tree.getFlattenedAccounts().filter(account => {
return !account.children.length;
});
if(!this.accountTree) {
this.accountTree = tree;
this.skip = 0;
this.date = new Date();
let newTx = new Transaction({
date: new Date(),
splits: []
});
newTx.date.setHours(23, 59, 59, 999);
newTx.splits.push(new Split({
accountId: this.account.id
}));
newTx.splits.push(new Split());
this.appendTransaction(newTx);
let options = {limit: this.limit, beforeInserted: this.date.getTime()};
let latestTxs$ = this.txService.getTransactionsByAccount(this.accountId, options).take(1);
let newTxs$ = this.txService.getNewTransactionsByAccount(this.accountId);
let deletedTxs$ = this.txService.getDeletedTransactionsByAccount(this.accountId);
latestTxs$.mergeMap(txs => txs).concat(newTxs$)
.subscribe(tx => {
// insert tx into list
this.addTransaction(tx);
});
deletedTxs$.subscribe(tx => {
this.removeTransaction(tx);
// remove tx from list
});
}
this.accountTree = tree;
this.updateBalances();
});
this.scrollSubject.debounceTime(100).subscribe(obj => {
if(obj.percent < 0.2 && !this.fetching && !this.historyFinished) {
this.fetchMoreTransactions();
}
});
}
fetchMoreTransactions() {
this.fetching = true;
this.log.debug('Fetching ' + this.limit + ' more transactions');
this.skip += this.limit;
let options = {limit: this.limit, skip: this.skip, beforeInserted: this.date.getTime()};
this.txService.getTransactionsByAccount(this.accountId, options).subscribe(txs => {
txs.forEach(tx => {
this.addTransaction(tx);
});
if(txs.length < this.limit) {
this.historyFinished = true;
}
this.fetching = false;
this.needsScroll = false;
this.needsLittleScroll = false;
this.scrollLastHeight = this.body.nativeElement.scrollHeight;
});
}
addTransaction(tx: Transaction) {
this.insertTransaction(tx);
// it should only scroll to bottom if the user has not scrolled yet
if(!this.hasScrolled) {
this.needsScroll = true;
}
}
removeTransaction(tx: Transaction) {
this.log.debug('remove tx');
this.log.debug(tx);
for(let i = 0; i < this.items.length; i++) {
if(this.items[i].tx.id === tx.id) {
this.items.splice(i, 1);
}
}
this.sortItems();
this.updateBalances();
}
ngAfterViewChecked() {
if(this.needsLittleScroll) {
this.scrollALittle();
this.needsLittleScroll = false;
}
let lastItemEditing = this.items.length && this.items[this.items.length - 1].editing;
if(this.needsScroll || lastItemEditing) {
this.scrollToBottom();
this.needsScroll = false;
}
if(this.scrollLastHeight) {
this.scrollDiffHeight();
this.scrollLastHeight = null;
}
}
onScroll() {
this.hasScrolled = true;
let element = this.body.nativeElement;
this.scrollSubject.next({
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
percent: element.scrollTop / (element.scrollHeight - element.clientHeight)
});
}
scrollToBottom() {
let element = this.body.nativeElement;
element.scrollTop = element.scrollHeight;
}
scrollALittle() {
let element = this.body.nativeElement;
element.scrollTop += 50;
}
scrollDiffHeight() {
let element = this.body.nativeElement;
let diff = element.scrollHeight - this.scrollLastHeight;
element.scrollTop += diff;
}
sortItems() {
this.items.sort((a, b) => {
// sort in ascending order
if(!a.tx.date) {
return 1;
}
if(!b.tx.date) {
return -1;
}
let dateDiff = a.tx.date.getTime() - b.tx.date.getTime();
if(dateDiff) {
return dateDiff;
}
let insertedDiff = a.tx.inserted.getTime() - b.tx.inserted.getTime();
return insertedDiff;
});
}
getTransferString(item: TxItem) {
if(!item.tx.id) {
return '';
}
let transferAccountId = this.getTransferAccountId(item);
if(!transferAccountId) {
return 'Split Transaction';
}
let transferAccount = this.accountTree.accountMap[transferAccountId];
if(!transferAccount) {
return 'Unidentified';
}
return transferAccount.fullName;
}
getTransferAccountId(item: TxItem): string {
let transferAccountId = null;
if(item.tx.splits.length === 2) {
transferAccountId = item.tx.splits[0].accountId === this.account.id ?
item.tx.splits[1].accountId :
item.tx.splits[0].accountId;
}
return transferAccountId;
}
getDebit(item: TxItem) {
return item.activeSplit.amount >= 0 ? item.activeSplit.amount : null;
}
getCredit(item: TxItem) {
return item.activeSplit.amount < 0 ? -item.activeSplit.amount : null;
}
createTxItems(transaction: Transaction) {
let items: TxItem[] = [];
for(let i = 0; i < transaction.splits.length; i++) {
let split = transaction.splits[i];
if(split.accountId !== this.accountId) {
continue;
}
let item = new TxItem();
item.tx = transaction;
item.form = this.fb.group({
splits: this.fb.array([])
});
item.activeSplit = split;
item.activeSplitIndex = i;
item.balance = 0;
item.editing = false;
item.edit$ = new Subject<any>();
items.push(item);
}
return items;
}
appendTransaction(transaction: Transaction) {
let items = this.createTxItems(transaction);
this.items = this.items.concat(items);
}
replaceTransaction(transaction: Transaction) {
let items = this.createTxItems(transaction);
// remove tx from list
for(let i = 0; i < this.items.length; i++) {
if(this.items[i].tx.id === transaction.id) {
this.items.splice(i, 1);
}
}
// add new items
this.items = this.items.concat(items);
this.sortItems();
this.updateBalances();
}
insertTransaction(transaction: Transaction) {
this.appendTransaction(transaction);
this.sortItems();
this.updateBalances();
}
updateBalances() {
let balance = this.account.debitBalance ? this.account.balance : -this.account.balance;
for(let i = this.items.length - 1; i >= 0; i--) {
let item = this.items[i];
item.balance = balance;
if(item.activeSplit.amount) {
if(this.account.debitBalance) {
balance -= item.activeSplit.amount;
} else {
balance += item.activeSplit.amount;
}
}
}
}
onTransaction(transaction: Transaction) {
this.insertTransaction(transaction);
}
editTransaction(item: TxItem, $event) {
if(item.editing) {
return;
}
this.log.debug($event);
this.log.debug('edit tx');
this.log.debug(item);
item.editing = true;
let dateString = Util.getLocalDateString(item.tx.date);
this.log.debug(item);
let debit = this.getDebit(item);
let credit = this.getCredit(item);
let transferAccountId = this.getTransferAccountId(item);
if(item.tx.splits.length > 2) {
transferAccountId = this.account.id;
}
item.form = new FormGroup({
date: new FormControl(dateString),
description: new FormControl(item.tx.description, {updateOn: 'change'}),
debit: new FormControl(debit ? debit / Math.pow(10, this.account.precision) : null),
credit: new FormControl(credit ? credit / Math.pow(10, this.account.precision) : null),
accountId: new FormControl(transferAccountId),
splits: this.fb.array([])
}, {updateOn: 'blur'});
let valueChanges = item.form.get('debit').valueChanges
.merge(item.form.get('credit').valueChanges)
.merge(item.form.get('splits').valueChanges);
valueChanges.subscribe(val => {
if(!val) {
return;
}
this.log.debug('value changes', val);
this.solveEquations(item);
this.fillEmptySplit(item);
});
if(item.tx.splits.length > 2) {
let splits = item.form.get('splits') as FormArray;
for(let split of item.tx.splits) {
if(split.accountId === this.accountId) {
continue;
}
let control = new FormGroup({
accountId: new FormControl(split.accountId),
debit: new FormControl(
split.amount >= 0 ? split.amount / Math.pow(10, this.account.precision) : null
),
credit: new FormControl(
split.amount < 0 ? -split.amount / Math.pow(10, this.account.precision) : null
)
}, {updateOn: 'blur'});
control.valueChanges.subscribe(val => {
this.solveEquations(item);
this.fillEmptySplit(item);
});
splits.push(control);
this.fillEmptySplit(item);
}
}
setTimeout(() => {
if($event && $event.target.className) {
let cName = $event.target.classList[$event.target.classList.length - 1];
try {
this.renderer.selectRootElement('#form' + item.tx.id + item.activeSplitIndex + ' .' + cName + ' input').focus();
} catch(e) {
// don't do anything if the element doesn't exist
}
}
}, 10);
// let modal = this.modalCtrl.create(EditTxPage, {transaction: transaction});
// modal.present();
// modal.onWillDismiss(() => {
// this.loadData();
// })
item.edit$.next(null);
}
preventBlur(item: TxItem) {
this.log.debug('prevent blur');
item.preventBlur = true;
}
onBlur(item: TxItem) {
this.log.debug('blur2');
setTimeout(() => {
this.log.debug('blur', item.form.pristine);
if(item.preventBlur) {
item.preventBlur = false;
return;
}
if(!item.form.pristine) {
return;
}
let elem = document.activeElement as any;
if(elem.form && elem.form.id === 'form' + item.tx.id + item.activeSplitIndex) {
return;
}
item.form = this.fb.group({
splits: this.fb.array([])
});
item.editing = false;
}, 100); // timeout needs to be longer than in editTransaction
}
deleteSplit(item: TxItem, index) {
item.form.markAsDirty();
this.log.debug('delete split');
let splits = item.form.get('splits') as FormArray;
if(splits.length === 1) {
item.form.patchValue({
accountId: splits.at(0).get('accountId').value
});
}
splits.removeAt(index);
}
addSplit(item: TxItem) {
item.form.markAsDirty();
//item.form.pristine = false;
this.log.debug('add split');
// scroll down a little
this.needsLittleScroll = true;
let splits = item.form.get('splits') as FormArray;
if(splits.length === 0) {
this.addFirstSplit(item);
return;
}
let control = new FormGroup({
accountId: new FormControl(),
debit: new FormControl(),
credit: new FormControl()
}, {updateOn: 'blur'});
control.valueChanges.subscribe(val => {
this.solveEquations(item);
this.fillEmptySplit(item);
});
splits.push(control);
this.fillEmptySplit(item);
}
addFirstSplit(item: TxItem) {
let splits = item.form.get('splits') as FormArray;
let accountId = item.form.get('accountId').value || null;
let debit = item.form.get('debit').value || null;
let credit = item.form.get('credit').value || null;
item.form.patchValue({
accountId: this.account.id
});
let control = new FormGroup({
accountId: new FormControl(accountId),
debit: new FormControl(credit),
credit: new FormControl(debit)
}, {updateOn: 'blur'});
control.valueChanges.subscribe(val => {
this.solveEquations(item);
this.fillEmptySplit(item);
});
splits.push(control);
this.fillEmptySplit(item);
}
fillEmptySplit(item: TxItem) {
this.log.debug('fill empty split');
// Total up splits and fill in any empty split with the leftover value
let splits = item.form.get('splits') as FormArray;
let emptySplit: AbstractControl;
let amount = item.form.get('debit').value - item.form.get('credit').value;
if(amount === 0) {
emptySplit = item.form;
this.log.debug('base split is empty');
}
for(let i = 0; i < splits.length; i++) {
let split = splits.at(i);
amount += parseFloat(split.get('debit').value) || 0;
amount -= parseFloat(split.get('credit').value) || 0;
if(!split.get('debit').value && !split.get('credit').value) {
emptySplit = split;
}
}
if(emptySplit) {
let precision = 2;
let account = this.accountTree.accountMap[emptySplit.get('accountId').value];
if (account) {
precision = account.precision;
}
amount = this.round(-amount, precision);
this.log.debug('amount', amount);
if(amount) {
emptySplit.patchValue({
debit: amount >= 0 ? amount : '',
credit: amount < 0 ? -amount : ''
});
}
}
}
round(amount, precision) {
return Math.round(amount * Math.pow(10, precision)) / Math.pow(10, precision);
}
submit(item: TxItem) {
this.error = null;
this.log.debug('submit!');
this.log.debug(item.form.value);
if(item.form.pristine) {
return;
}
let date = item.tx.id ? item.tx.date : new Date();
let formDate = Util.getDateFromLocalDateString(item.form.value.date);
date = this.computeTransactionDate(formDate, date);
let tx = new Transaction({
id: item.tx.id,
date: date,
description: item.form.value.description,
splits: []
});
if(!item.form.value.splits.length) {
let amount = item.form.value.debit ? parseFloat(item.form.value.debit) : -parseFloat(item.form.value.credit);
amount = Math.round(amount * Math.pow(10, this.account.precision));
tx.splits.push(new Split({
accountId: this.account.id,
amount: amount,
nativeAmount: amount
}));
tx.splits.push(new Split({
accountId: item.form.value.accountId,
amount: -amount,
nativeAmount: -amount
}));
} else {
let amount = item.form.value.debit ? parseFloat(item.form.value.debit) : -parseFloat(item.form.value.credit);
amount = Math.round(amount * Math.pow(10, this.account.precision));
tx.splits.push(new Split({
accountId: item.form.value.accountId,
amount: amount,
nativeAmount: amount
}));
}
for(let i = 0; i < item.form.value.splits.length; i++) {
let split = item.form.value.splits[i];
let account = this.accountTree.accountMap[split.accountId];
if(!account) {
this.error = new AppError('Invalid account');
return;
}
let amount = split.debit ? parseFloat(split.debit) : -parseFloat(split.credit);
amount = Math.round(amount * Math.pow(10, account.precision));
tx.splits.push(new Split({
accountId: split.accountId,
amount: amount,
nativeAmount: amount
}));
}
this.log.debug(tx);
if(tx.id) {
// update tx
let oldId = tx.id;
tx.id = Util.newGuid();
this.txService.putTransaction(oldId, tx)
.subscribe(tx => {
// do nothing
}, error => {
this.error = error;
});
} else {
// new tx
let splits = item.form.get('splits') as FormArray;
while(splits.length) {
splits.removeAt(0);
}
item.form.reset();
let newTx = new Transaction({
date: new Date(),
splits: []
});
newTx.date.setHours(23, 59, 59, 999);
newTx.splits.push(new Split({
accountId: this.account.id
}));
newTx.splits.push(new Split());
item.tx = newTx;
item.editing = false;
item.activeSplit = newTx.splits[0];
item.activeSplitIndex = 0;
tx.id = Util.newGuid();
this.txService.newTransaction(tx)
.subscribe(tx => {
// do nothing
}, error => {
this.error = error;
});
}
}
computeTransactionDate(formDate: Date, txDate: Date): Date {
if(formDate.getTime()) {
// make the time be at the very end of the day
formDate.setHours(23, 59, 59, 999);
}
let sameDay = formDate.getFullYear() === txDate.getFullYear() &&
formDate.getMonth() === txDate.getMonth() &&
formDate.getDate() === txDate.getDate();
if(formDate.getTime() && !sameDay) {
txDate = formDate;
}
return txDate;
}
deleteTransaction(item) {
this.modalService.open(this.confirmDeleteModal).result.then((result) => {
this.log.debug('delete');
this.txService.deleteTransaction(item.tx.id)
.subscribe(() => {
this.log.debug('successfully deleted transaction ' + item.tx.id);
}, error => {
this.error = error;
})
}, (reason) => {
this.log.debug('cancel delete');
});
}
advancedEdit(item) {
let modal = this.modalService.open(AdvancedEdit, {size: 'lg'});
modal.componentInstance.setData(item, this.accountTree);
modal.result.then((result) => {
this.log.debug('advanced edit save');
this.log.debug(item.form);
}, (reason) => {
this.log.debug('cancel advanced edit');
});
}
onEnter(item, $event) {
$event.target.blur();
this.submit(item);
}
solveEquations(item: TxItem) {
this.log.debug('solveEquations');
let originalDebit = item.form.get('debit').value;
let originalCredit = item.form.get('credit').value;
let precision = this.account.precision;
let debit = originalDebit ? this.round(this.solve('' + originalDebit), precision) : '';
let credit = originalCredit ? this.round(this.solve('' + originalCredit), precision) : '';
if((originalDebit && debit !== originalDebit) || (originalCredit && credit !== originalCredit)) {
this.log.debug('patch', debit, credit);
this.log.debug('original', originalDebit, originalCredit);
item.form.patchValue({
debit: debit,
credit: credit
});
}
let splits = item.form.get('splits') as FormArray;
for(let i = 0; i < splits.length; i++) {
let split = splits.at(i);
let originalDebit = split.get('debit').value;
let originalCredit = split.get('credit').value;
let debit = originalDebit ? this.round(this.solve('' + originalDebit), precision) : '';
let credit = originalCredit ? this.round(this.solve('' + originalCredit), precision) : '';
if((originalDebit && debit !== originalDebit) || (originalCredit && credit !== originalCredit)) {
this.log.debug('patch', debit, credit);
this.log.debug('original', originalDebit, originalCredit);
split.patchValue({
debit: debit,
credit: credit
});
}
}
}
solve(input: string) {
// first pass: +-
for(let i = input.length - 1; i >= 0; i--) {
if(input.charAt(i) === '+') {
return this.solve(input.slice(0, i)) + this.solve(input.slice(i + 1));
} else if(input.charAt(i) === '-') {
return this.solve(input.slice(0, i)) - this.solve(input.slice(i + 1));
}
}
// second pass: */
for(let i = input.length - 1; i >= 0; i--) {
if(input.charAt(i) === '*') {
return this.solve(input.slice(0, i)) * this.solve(input.slice(i + 1));
} else if(input.charAt(i) === '/') {
return this.solve(input.slice(0, i)) / this.solve(input.slice(i + 1));
}
}
return parseFloat(input.trim()) || 0;
}
autocomplete(item: TxItem, tx: Transaction) {
this.log.debug('chose tx', tx);
let formDate = Util.getDateFromLocalDateString(item.form.value.date);
item.tx = new Transaction(
{
date: this.computeTransactionDate(formDate, new Date()),
description: tx.description,
splits: tx.splits
}
);
for(let i = 0; i < tx.splits.length; i++) {
let split = tx.splits[i];
if(split.accountId !== this.accountId) {
continue;
}
item.activeSplit = split;
item.activeSplitIndex = i;
}
this.log.debug(tx);
item.editing = false;
item.preventBlur = true;
this.editTransaction(item, {target: {className: 'description', classList: ['description']}});
item.form.markAsDirty();
}
}

View File

@@ -0,0 +1,31 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { TxListPage } from './list';
import { SharedModule } from '../shared/shared.module';
import { AppRoutingModule } from '../app-routing.module';
import { AdvancedEdit } from './advancededit';
import { Autocomplete } from './autocomplete';
import { Breadcrumbs } from './breadcrumbs';
@NgModule({
declarations: [
TxListPage,
AdvancedEdit,
Autocomplete,
Breadcrumbs
],
imports: [
BrowserModule,
NgbModule,
ReactiveFormsModule,
SharedModule,
AppRoutingModule
],
exports: [],
providers: [],
entryComponents: [AdvancedEdit]
})
export class TransactionModule { }

View File

@@ -0,0 +1,14 @@
import { Transaction, Split} from '../shared/transaction';
import { FormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
export class TxItem {
tx: Transaction;
activeSplit: Split;
activeSplitIndex: number;
form: FormGroup;
balance: number;
editing: boolean;
preventBlur: boolean;
edit$: Subject<any>;
}

29
src/app/user/login.html Normal file
View File

@@ -0,0 +1,29 @@
<h1>Login</h1>
<div class="section">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="email">Email</label>
<input formControlName="email" type="email" class="form-control" id="email" placeholder="Enter email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input formControlName="password" type="password" class="form-control" id="password" placeholder="Password">
</div>
<div class="form-group form-check">
<input formControlName="stayLoggedIn" id="stayLoggedIn" type="checkbox" class="form-check-input" />
<label for="stayedLoggedIn" class="form-check-label">Stay logged in</label>
</div>
<p *ngIf="error" class="error">
{{error.message}}
</p>
<p *ngIf="resetSuccess">
Password reset link sent.
</p>
<p>
<a routerLink="/register">Register for a new account</a><br>
<a (click)="resetPassword()">Reset password</a>
</p>
<button type="submit" class="btn btn-primary" [disabled]="!form.valid">Login</button>
</form>
</div>

0
src/app/user/login.scss Normal file
View File

Some files were not shown because too many files have changed in this diff Show More