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

61
.angular-cli.json Normal file
View File

@@ -0,0 +1,61 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"project": {
"name": "oa-web"
},
"apps": [
{
"root": "src",
"outDir": "dist",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.ts",
"polyfills": "polyfills.ts",
"test": "test.ts",
"tsconfig": "tsconfig.app.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"styles": [
"sass/styles.scss"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
],
"e2e": {
"protractor": {
"config": "./protractor.conf.js"
}
},
"lint": [
{
"project": "src/tsconfig.app.json",
"exclude": "**/node_modules/**"
},
{
"project": "src/tsconfig.spec.json",
"exclude": "**/node_modules/**"
},
{
"project": "e2e/tsconfig.e2e.json",
"exclude": "**/node_modules/**"
}
],
"test": {
"karma": {
"config": "./karma.conf.js"
}
},
"defaults": {
"styleExt": "scss",
"component": {
}
}
}

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
.htaccess
# compiled output
/dist
/dist-server
/tmp
/out-tsc
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
testem.log
/typings
# e2e
/e2e/*.js
/e2e/*.map
# System Files
.DS_Store
Thumbs.db

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2018 Open Accounting, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# Open Accounting Web Interface
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

14
e2e/app.e2e-spec.ts Normal file
View File

@@ -0,0 +1,14 @@
import { AppPage } from './app.po';
describe('oa-web App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!');
});
});

11
e2e/app.po.ts Normal file
View File

@@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}

14
e2e/tsconfig.e2e.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

33
karma.conf.js Normal file
View File

@@ -0,0 +1,33 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular/cli'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular/cli/plugins/karma')
],
client:{
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true
},
angularCli: {
environment: 'dev'
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};

13992
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "oa-web",
"description": "Open Accounting client side web application",
"version": "1.0.0",
"homepage": "https://openaccounting.io",
"author": "Open Accounting",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/openaccounting/oa-web.git"
},
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build --prod",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"dependencies": {
"@angular/animations": "5.2.0",
"@angular/common": "5.2.0",
"@angular/compiler": "5.2.0",
"@angular/core": "5.2.0",
"@angular/forms": "5.2.0",
"@angular/http": "5.2.0",
"@angular/platform-browser": "5.2.0",
"@angular/platform-browser-dynamic": "5.2.0",
"@angular/router": "5.2.0",
"@ng-bootstrap/ng-bootstrap": "2.0.0",
"bootstrap": "4.1.1",
"core-js": "^2.4.1",
"rxjs": "5.5.6",
"zone.js": "0.8.19"
},
"devDependencies": {
"@angular/cli": "1.6.5",
"@angular/compiler-cli": "^5.2.0",
"@angular/language-service": "^5.2.0",
"@types/jasmine": "~2.8.3",
"@types/jasminewd2": "~2.0.2",
"@types/node": "~6.0.60",
"codelyzer": "^4.0.1",
"jasmine-core": "~2.8.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~2.0.0",
"karma-chrome-launcher": "~2.2.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "^1.2.1",
"karma-jasmine": "~1.1.0",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.1.2",
"ts-node": "~4.1.0",
"tslint": "~5.9.1",
"typescript": "~2.5.3"
}
}

28
protractor.conf.js Normal file
View File

@@ -0,0 +1,28 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./e2e/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: 'e2e/tsconfig.e2e.json'
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

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>

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