src/transactions/controllers/edit.ts
import type { Account, StoredAccountType } from "~/accounts/types";
import type {
CashTransaction,
CategorisableTransaction,
PayeeCashTransaction,
SecurityTransaction,
SplitTransaction,
SplitTransactionChild,
SubcategorisableTransaction,
Subtransaction,
SubtransactionType,
SubtransferTransaction,
Transaction,
TransactionDirection,
TransactionType,
TransferrableTransaction,
} from "~/transactions/types";
import type { Category, DisplayCategory } from "~/categories/types";
import type { Entity, EntityModel } from "~/loot/types";
import type AccountModel from "~/accounts/models/account";
import type CategoryModel from "~/categories/models/category";
import type OgModalErrorService from "~/og-components/og-modal-error/services/og-modal-error";
import type { Payee } from "~/payees/types";
import type PayeeModel from "~/payees/models/payee";
import type { Security } from "~/securities/types";
import type SecurityModel from "~/securities/models/security";
import type TransactionModel from "~/transactions/models/transaction";
import angular from "angular";
export default class TransactionEditController {
public transaction: Transaction;
public readonly mode: "Add" | "Edit";
public loadingLastTransaction = false;
public totalAllocated: number | null = null;
public errorMessage: string | null = null;
private readonly showError: (message?: unknown) => void;
public constructor(
$scope: angular.IScope,
private readonly $uibModalInstance: angular.ui.bootstrap.IModalInstanceService,
private readonly $q: angular.IQService,
private readonly $timeout: angular.ITimeoutService,
private readonly filterFilter: angular.IFilterFilter,
private readonly limitToFilter: angular.IFilterLimitTo,
private readonly currencyFilter: angular.IFilterCurrency,
private readonly numberFilter: angular.IFilterNumber,
private readonly payeeModel: PayeeModel,
private readonly securityModel: SecurityModel,
private readonly categoryModel: CategoryModel,
private readonly accountModel: AccountModel,
private readonly transactionModel: TransactionModel,
ogModalErrorService: OgModalErrorService,
private readonly originalTransaction: Transaction,
) {
this.showError = ogModalErrorService.showError.bind(ogModalErrorService);
this.transaction = angular.extend(
{ id: null },
originalTransaction,
) as Transaction;
this.mode = null === this.transaction.id ? "Add" : "Edit";
// Watch the subtransactions array and recalculate the total allocated
$scope.$watch(
(): SplitTransactionChild[] =>
(this.transaction as SplitTransaction).subtransactions,
(): void => {
if (
undefined !==
((this.transaction as SplitTransaction).subtransactions as
| SplitTransactionChild[]
| undefined)
) {
this.totalAllocated = (
this.transaction as SplitTransaction
).subtransactions.reduce(
(total: number, subtransaction: SplitTransactionChild): number =>
total +
(isNaN(Number(subtransaction.amount))
? 0
: Number(subtransaction.amount) *
(subtransaction.direction === this.transaction.direction
? 1
: -1)),
0,
);
// If we're adding a new transaction, join the subtransaction memos and update the parent memo
if (null === this.transaction.id) {
this.memoFromSubtransactions();
}
}
},
true,
);
// Prefetch the payees list so that the cache is populated
payeeModel.all().catch(this.showError);
}
// List of payees for the typeahead
public payees(filter: string, limit: number): angular.IPromise<Payee[]> {
return this.payeeModel
.all()
.then((payees: Payee[]): Payee[] =>
this.limitToFilter(this.filterFilter(payees, { name: filter }), limit),
);
}
// List of securities for the typeahead
public securities(
filter: string,
limit: number,
): angular.IPromise<Security[]> {
return this.securityModel
.all()
.then((securities: Security[]): Security[] =>
this.limitToFilter(
this.filterFilter(securities, { name: filter }),
limit,
),
);
}
// List of categories for the typeahead
public categories(
filter: string,
limit: number,
parent?: Category | null,
includeSplits = false,
): angular.IPromise<DisplayCategory[]> | DisplayCategory[] {
// If a parent was specified but it doesn't have an id, return an empty array
if (undefined !== parent && null !== parent && isNaN(Number(parent.id))) {
return [];
}
return this.categoryModel
.all(parent?.id)
.then((categories: Category[]): DisplayCategory[] => {
let psuedoCategories: DisplayCategory[] = categories;
// For the category dropdown, include psuedo-categories that change the transaction type
if (undefined === parent || null === parent) {
if (includeSplits) {
psuedoCategories = (
[
{ id: "SplitTo", name: "Split To" },
{ id: "SplitFrom", name: "Split From" },
{ id: "Payslip", name: "Payslip" },
{ id: "LoanRepayment", name: "Loan Repayment" },
] as DisplayCategory[]
).concat(psuedoCategories);
}
psuedoCategories = (
[
{ id: "TransferTo", name: "Transfer To" },
{ id: "TransferFrom", name: "Transfer From" },
] as DisplayCategory[]
).concat(psuedoCategories);
}
return this.limitToFilter(
this.filterFilter(psuedoCategories, { name: filter }),
limit,
);
});
}
// List of investment categories for the typeahead
public investmentCategories(filter?: string): DisplayCategory[] {
const categories: DisplayCategory[] = [
{ id: "Buy", name: "Buy" },
{ id: "Sell", name: "Sell" },
{ id: "DividendTo", name: "Dividend To" },
{ id: "AddShares", name: "Add Shares" },
{ id: "RemoveShares", name: "Remove Shares" },
{ id: "TransferTo", name: "Transfer To" },
{ id: "TransferFrom", name: "Transfer From" },
];
return undefined === filter
? categories
: this.filterFilter(categories, { name: filter });
}
// Returns true if the passed value is typeof string (and is not empty)
public isString(object: Record<string, unknown> | string): boolean {
return "string" === typeof object && object.length > 0;
}
// Handler for payee changes
public payeeSelected(): void {
// If we're adding a new transaction and an existing payee is selected
if (
null === this.transaction.id &&
"object" === typeof (this.transaction as PayeeCashTransaction).payee
) {
// Show the loading indicator
this.loadingLastTransaction = true;
// Get the previous transaction for the payee
this.payeeModel
.findLastTransaction(
Number(
((this.transaction as PayeeCashTransaction).payee as Payee).id,
),
this.transaction.primary_account.account_type as StoredAccountType,
)
.then(this.getSubtransactions.bind(this))
.then(this.useLastTransaction.bind(this))
.then((): false => (this.loadingLastTransaction = false))
.catch(this.showError);
}
}
// Handler for security changes
public securitySelected(): void {
// If we're adding a new transaction and an existing security is selected
if (
null === this.transaction.id &&
"object" === typeof (this.transaction as SecurityTransaction).security
) {
// Show the loading indicator
this.loadingLastTransaction = true;
// Get the previous transaction for the security
this.securityModel
.findLastTransaction(
Number(
((this.transaction as SecurityTransaction).security as Security).id,
),
this.transaction.primary_account.account_type as StoredAccountType,
)
.then(this.getSubtransactions.bind(this))
.then(this.useLastTransaction.bind(this))
.then((): false => (this.loadingLastTransaction = false))
.catch(this.showError);
}
}
// Handler for category changes (`index` is the subtransaction index, or null for the main transaction)
public categorySelected(index?: number): void {
const transaction: SplitTransactionChild | Transaction = isNaN(
Number(index),
)
? this.transaction
: (this.transaction as SplitTransaction).subtransactions[Number(index)];
let type: SubtransactionType | TransactionType | undefined,
direction: TransactionDirection | undefined,
parentId: number | undefined;
// Check the category selection
if (
undefined !== transaction.category &&
"object" === typeof transaction.category
) {
if (isNaN(Number(index))) {
switch (String(transaction.category.id)) {
case "TransferTo":
type = "Transfer";
direction = "outflow";
break;
case "TransferFrom":
type = "Transfer";
direction = "inflow";
break;
case "SplitTo":
type = "Split";
direction = "outflow";
break;
case "SplitFrom":
type = "Split";
direction = "inflow";
break;
case "Payslip":
type = "Payslip";
direction = "inflow";
break;
case "LoanRepayment":
type = "LoanRepayment";
direction = "outflow";
break;
default:
type = "Basic";
({ direction } = (
transaction as CategorisableTransaction & TransferrableTransaction
).category as Category);
break;
}
/*
* If we have switched to a Split, Payslip or Loan Repayment and there are currently no subtransactions,
* create some stubs, copying the current transaction details into the first entry
*/
switch (type) {
case "Split":
case "Payslip":
case "LoanRepayment":
if (
undefined ===
((transaction as SplitTransaction).subtransactions as
| SplitTransactionChild[]
| undefined)
) {
(transaction as SplitTransaction).subtransactions = [
{
memo: transaction.memo,
amount: (transaction as CashTransaction).amount,
},
{},
{},
{},
];
}
break;
default:
}
} else {
switch (String(transaction.category.id)) {
case "TransferTo":
type = "Subtransfer";
direction = "outflow";
break;
case "TransferFrom":
type = "Subtransfer";
direction = "inflow";
break;
default:
type = "Sub";
({ direction } = (
transaction as CategorisableTransaction & TransferrableTransaction
).category as Category);
break;
}
}
parentId = (
(transaction as CategorisableTransaction).category as Category
).id;
}
// Update the transaction type & direction
transaction.transaction_type =
type ?? (isNaN(Number(index)) ? "Basic" : "Sub");
transaction.direction = direction ?? "outflow";
// Make sure the subcategory is still valid
if (
undefined !== (transaction as SubcategorisableTransaction).subcategory &&
null !== (transaction as SubcategorisableTransaction).subcategory &&
((transaction as SubcategorisableTransaction).subcategory as Category)
.parent_id !== parentId
) {
(transaction as SubcategorisableTransaction).subcategory = null;
}
}
// Handler for investment category changes
public investmentCategorySelected(): void {
let { transaction_type, direction } = this.transaction;
// Check the category selection
if ("object" === typeof this.transaction.category) {
switch (String(this.transaction.category.id)) {
case "TransferTo":
transaction_type = "SecurityTransfer";
direction = "outflow";
break;
case "TransferFrom":
transaction_type = "SecurityTransfer";
direction = "inflow";
break;
case "RemoveShares":
transaction_type = "SecurityHolding";
direction = "outflow";
break;
case "AddShares":
transaction_type = "SecurityHolding";
direction = "inflow";
break;
case "Sell":
transaction_type = "SecurityInvestment";
direction = "outflow";
break;
case "Buy":
transaction_type = "SecurityInvestment";
direction = "inflow";
break;
case "DividendTo":
transaction_type = "Dividend";
direction = "outflow";
break;
default:
}
// Update the transaction type & direction
this.transaction.transaction_type = transaction_type;
this.transaction.direction = direction;
}
}
// Handler for primary account changes
public primaryAccountSelected(): void {
if (
null !== (this.transaction as TransferrableTransaction).account &&
undefined !==
((this.transaction as TransferrableTransaction).account as
| Account
| undefined) &&
undefined !== (this.transaction.primary_account as Account | undefined) &&
this.transaction.primary_account.id ===
((this.transaction as TransferrableTransaction).account as Account).id
) {
// Primary account and transfer account can't be the same, so clear the transfer account
(this.transaction as TransferrableTransaction).account = null;
}
}
// Joins the subtransaction memos and updates the parent memo
public memoFromSubtransactions(): void {
this.transaction.memo = (
this.transaction as SplitTransaction
).subtransactions.reduce(
(memo: string, subtransaction: SplitTransactionChild): string =>
`${memo}${
undefined === subtransaction.memo
? ""
: `${memo ? "; " : ""}${subtransaction.memo}`
}`,
"",
);
}
// List of accounts for the typeahead
public accounts(filter: string, limit: number): angular.IPromise<Account[]> {
return this.accountModel.all().then((accounts: Account[]): Account[] => {
let filteredAccounts: Account[] = accounts;
// Exclude investment accounts by default
const accountFilter: angular.IFilterFilterPatternObject = {
name: filter,
account_type: "!investment",
};
// Filter the current account from the results (can't transfer to self)
if (
undefined !== (this.transaction.primary_account as Account | undefined)
) {
filteredAccounts = this.filterFilter(
filteredAccounts,
{ name: `!${this.transaction.primary_account.name}` },
true,
);
}
// For security transfers, only include investment accounts
if ("SecurityTransfer" === this.transaction.transaction_type) {
accountFilter.account_type = "investment";
}
return this.limitToFilter(
this.filterFilter(filteredAccounts, accountFilter),
limit,
);
});
}
// Add a new subtransaction
public addSubtransaction(): void {
(this.transaction as SplitTransaction).subtransactions.push({});
}
// Deletes a subtransaction
public deleteSubtransaction(index: number): void {
(this.transaction as SplitTransaction).subtransactions.splice(index, 1);
}
// Adds any unallocated amount to the specified subtransaction
public addUnallocatedAmount(index: number): void {
const amount: number | undefined = Number(
(this.transaction as SplitTransaction).subtransactions[index].amount,
);
(this.transaction as SplitTransaction).subtransactions[index].amount =
(isNaN(amount) ? 0 : amount) +
((this.transaction as SplitTransaction).amount -
Number(this.totalAllocated));
}
// Updates the transaction amount and memo when the quantity, price or commission change
public updateInvestmentDetails(): void {
const QUANTITY_DECIMAL_PLACES = 4,
PRICE_DECIMAL_PLACES = 3,
AMOUNT_DECIMAL_PLACES = 2;
if ("SecurityInvestment" === this.transaction.transaction_type) {
// Base amount is the quantity multiplied by the price
const amount = Number(
(
Number(this.transaction.quantity) * Number(this.transaction.price)
).toFixed(AMOUNT_DECIMAL_PLACES),
),
commission = Number(this.transaction.commission);
this.transaction.amount = isNaN(amount) ? 0 : amount;
// For a purchase, commission is added to the cost; for a sale, commission is subtracted from the proceeds
if ("inflow" === this.transaction.direction) {
this.transaction.amount += isNaN(commission) ? 0 : commission;
} else {
this.transaction.amount -= isNaN(commission) ? 0 : commission;
}
}
// If we're adding a new buy or sell transaction, update the memo with the details
if (
null === this.transaction.id &&
"SecurityInvestment" === this.transaction.transaction_type
) {
const quantity: string =
Number(this.transaction.quantity) > 0
? this.numberFilter(
this.transaction.quantity,
QUANTITY_DECIMAL_PLACES,
)
: "",
price: string =
Number(this.transaction.price) > 0
? ` @ ${this.currencyFilter(
this.transaction.price,
undefined,
PRICE_DECIMAL_PLACES,
)}`
: "",
commission: string =
Number(this.transaction.commission) > 0
? ` (${
"inflow" === this.transaction.direction ? "plus" : "less"
} ${this.currencyFilter(this.transaction.commission)} commission)`
: "";
this.transaction.memo = quantity + price + commission;
}
}
// Save and close the modal
public save(): void {
this.errorMessage = null;
this.transactionModel
.save(this.transaction)
.then(this.invalidateCaches.bind(this))
.then(this.updateLruCaches.bind(this))
.then((transaction: Transaction): void =>
this.$uibModalInstance.close(transaction),
)
.catch(
(error: unknown): string =>
(this.errorMessage = (error as angular.IHttpResponse<string>).data),
);
}
// Dismiss the modal without saving
public cancel(): void {
this.$uibModalInstance.dismiss();
}
// Fetches the subtransactions for a transaction
private getSubtransactions(
transaction?: Transaction,
): angular.IPromise<Transaction> | Transaction | undefined {
if (undefined === transaction) {
return undefined;
}
// If the last transaction was a Split/Loan Repayment/Payslip; fetch the subtransactions
switch (transaction.transaction_type) {
case "Split":
case "LoanRepayment":
case "Payslip":
transaction.subtransactions = [];
return this.transactionModel
.findSubtransactions(Number(transaction.id))
.then((subtransactions: Subtransaction[]): Transaction => {
// Strip the subtransaction ids
transaction.subtransactions = subtransactions.map(
(subtransaction: Subtransaction): Subtransaction => {
subtransaction.id = null;
return subtransaction;
},
);
return transaction;
});
default:
return transaction;
}
}
// Merges the details of a previous transaction into the current one
private useLastTransaction(transaction?: Partial<Transaction>): void {
if (undefined === transaction) {
return;
}
// Strip the id, transaction date, primary account, status & flag
delete transaction.id;
delete transaction.transaction_date;
delete transaction.primary_account;
delete transaction.status;
delete (transaction as Partial<TransferrableTransaction>).related_status;
delete transaction.flag_type;
delete transaction.flag;
// Merge the last transaction details into the transaction on the scope
this.transaction = angular.extend(
this.transaction,
transaction,
) as Transaction;
// Depending on which field has focus, re-trigger the focus event handler to format/select the new value
angular.forEach(
angular.element(
"#amount, #category, #subcategory, #account, #quantity, #price, #commission, #memo",
),
(field: Element): void => {
if (field === document.activeElement) {
this.$timeout(
(): void =>
angular.element(field).triggerHandler("focus") as undefined,
).catch(this.showError);
}
},
);
}
// Helper function to invalidate the $http caches after saving a transaction
private invalidateCaches(
savedTransaction: Transaction,
): angular.IPromise<Transaction> {
// Create a deferred so that we return a promise
const q: angular.IDeferred<Transaction> = this.$q.defer(),
models: Record<string, EntityModel> = {
primary_account: this.accountModel,
payee: this.payeeModel,
category: this.categoryModel,
subcategory: this.categoryModel,
account: this.accountModel,
security: this.securityModel,
};
let resolve = true;
/*
* Compare each facet of the saved transaction with the original values
* For any that have changed, invalidate the original from the $http cache
*/
angular.forEach(Object.keys(models), (key: keyof Transaction): void => {
const originalValue: number | null =
(this.originalTransaction[key] as Entity | undefined)?.id ?? null,
savedValue: number | null =
(savedTransaction[key] as Entity | undefined)?.id ?? null;
if (null !== originalValue && originalValue !== savedValue) {
models[key].flush(originalValue);
}
});
/*
* For subtransactions, we can't be sure if the values have changed or not (as the ordering may have changed)
* so just invalidate any categories or accounts
*/
switch (this.originalTransaction.transaction_type) {
case "Split":
case "LoanRepayment":
case "Payslip":
// Delay resolving the promise
resolve = false;
this.transactionModel
.findSubtransactions(Number(this.originalTransaction.id))
.then((subtransactions: SplitTransactionChild[]): void => {
angular.forEach(
subtransactions,
(subtransaction: SplitTransactionChild): void => {
if (
undefined !== subtransaction.category &&
Number((subtransaction.category as Category).id)
) {
this.categoryModel.flush(
((subtransaction as Subtransaction).category as Category)
.id,
);
}
if (
undefined !==
(subtransaction as Subtransaction).subcategory &&
null !== (subtransaction as Subtransaction).subcategory &&
Number(
((subtransaction as Subtransaction).subcategory as Category)
.id,
)
) {
this.categoryModel.flush(
((subtransaction as Subtransaction).subcategory as Category)
.id,
);
}
if (
undefined !==
((subtransaction as SubtransferTransaction).account as
| Account
| undefined) &&
Number((subtransaction as SubtransferTransaction).account.id)
) {
this.accountModel.flush(
(subtransaction as SubtransferTransaction).account.id,
);
}
},
);
// Resolve the promise
q.resolve(savedTransaction);
})
.catch(this.showError);
break;
default:
}
// Resolve the promise (unless explicitly delayed)
if (resolve) {
q.resolve(savedTransaction);
}
// Return the promise
return q.promise;
}
// Helper function to update the LRU caches after saving a transaction
private updateLruCaches(
transaction: Transaction,
): angular.IPromise<Transaction> {
// Create a deferred so that we return a promise
const q: angular.IDeferred<Transaction> = this.$q.defer();
let resolve = true;
// Add the primary account to the LRU cache
this.accountModel.addRecent(transaction.primary_account);
// Add the payee or security to the LRU cache
if (
"investment" === transaction.primary_account.account_type.toLowerCase()
) {
this.securityModel.addRecent(
(transaction as SecurityTransaction).security as Security,
);
} else {
this.payeeModel.addRecent(
(transaction as PayeeCashTransaction).payee as Payee,
);
}
switch (transaction.transaction_type) {
case "Basic":
// Add the category and subcategory to the LRU cache
this.categoryModel.addRecent(transaction.category as Category);
if (
undefined !== transaction.subcategory &&
null !== transaction.subcategory
) {
this.categoryModel.addRecent(transaction.subcategory as Category);
}
break;
case "Transfer":
case "SecurityTransfer":
case "SecurityInvestment":
case "Dividend":
// Add the account to the LRU cache
this.accountModel.addRecent(transaction.account as Account);
break;
case "Split":
case "LoanRepayment":
case "Payslip":
// Delay resolving the promise
resolve = false;
this.transactionModel
.findSubtransactions(Number(transaction.id))
.then((subtransactions: SplitTransactionChild[]): void => {
angular.forEach(
subtransactions,
(subtransaction: SplitTransactionChild): void => {
if ("Subtransfer" === subtransaction.transaction_type) {
// Add the account to the LRU cache
this.accountModel.addRecent(
(subtransaction as SubtransferTransaction).account,
);
} else {
// Add the category and subcategory to the LRU cache
this.categoryModel.addRecent(
subtransaction.category as Category,
);
if (
undefined !==
(subtransaction as Subtransaction).subcategory &&
null !== (subtransaction as Subtransaction).subcategory
) {
this.categoryModel.addRecent(
(subtransaction as Subtransaction)
.subcategory as Category,
);
}
}
},
);
// Resolve the promise
q.resolve(transaction);
})
.catch(this.showError);
break;
default:
}
// Resolve the promise (unless explicitly delayed)
if (resolve) {
q.resolve(transaction);
}
// Return the promise
return q.promise;
}
}
TransactionEditController.$inject = [
"$scope",
"$uibModalInstance",
"$q",
"$timeout",
"filterFilter",
"limitToFilter",
"currencyFilter",
"numberFilter",
"payeeModel",
"securityModel",
"categoryModel",
"accountModel",
"transactionModel",
"ogModalErrorService",
"transaction",
];