initial commit

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

View File

@@ -0,0 +1,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;
}