scottohara/loot

View on GitHub
src/transactions/controllers/index.test.ts

Summary

Maintainability
F
5 days
Test Coverage
A
100%
import type {
    BaseTransaction,
    BasicTransaction,
    CategorisableTransaction,
    PayeeCashTransaction,
    SecurityHoldingTransaction,
    SecurityTransaction,
    SplitTransaction,
    SplitTransactionChild,
    SplitTransactionType,
    SubcategorisableTransaction,
    Transaction,
    TransactionBatch,
    TransactionFetchDirection,
    TransactionType,
    TransferTransaction,
    TransferrableTransaction,
} from "~/transactions/types";
import type {
    ControllerTestFactory,
    EventMock,
    JQueryMouseEventObjectMock,
} from "~/mocks/types";
import type { Entity, EntityModel } from "~/loot/types";
import type {
    StateMock,
    UibModalMock,
    UibModalMockResolves,
} from "~/mocks/node-modules/angular/types";
import { addDays, startOfDay, subDays } from "date-fns";
import {
    createBasicTransaction,
    createSecurityHoldingTransaction,
    createSplitTransaction,
    createSubtransaction,
    createSubtransferTransaction,
    createTransferTransaction,
} from "~/mocks/transactions/factories";
import type { Account } from "~/accounts/types";
import type AccountModel from "~/accounts/models/account";
import type { Category } from "~/categories/types";
import type CategoryModel from "~/categories/models/category";
import type MockDependenciesProvider from "~/mocks/loot/mockdependencies";
import type { OgModalConfirm } from "~/og-components/og-modal-confirm/types";
import type { OgTableActionHandlers } from "~/og-components/og-table-navigable/types";
import type OgTableNavigableService from "~/og-components/og-table-navigable/services/og-table-navigable";
import type OgViewScrollService from "~/og-components/og-view-scroll/services/og-view-scroll";
import type { Payee } from "~/payees/types";
import type { Security } from "~/securities/types";
import type SecurityModel from "~/securities/models/security";
import type { SinonStub } from "sinon";
import type TransactionIndexController from "~/transactions/controllers";
import type { TransactionModelMock } from "~/mocks/transactions/types";
import angular from "angular";
import createAccount from "~/mocks/accounts/factories";
import createCategory from "~/mocks/categories/factories";
import createPayee from "~/mocks/payees/factories";
import createSecurity from "~/mocks/securities/factories";
import sinon from "sinon";

describe("TransactionIndexController", (): void => {
    let transactionIndexController: TransactionIndexController,
        controllerTest: ControllerTestFactory,
        $transitions: angular.ui.IStateParamsService,
        $uibModal: UibModalMock,
        $timeout: angular.ITimeoutService,
        $window: angular.IWindowService,
        $state: StateMock,
        transactionModel: TransactionModelMock,
        accountModel: AccountModel,
        categoryModel: CategoryModel,
        securityModel: SecurityModel,
        ogTableNavigableService: OgTableNavigableService,
        ogViewScrollService: OgViewScrollService,
        contextModel: EntityModel,
        context: Entity | string,
        transactionBatch: TransactionBatch,
        deregisterTransitionSuccessHook: SinonStub;

    // Load the modules
    beforeEach(
        angular.mock.module(
            "lootMocks",
            "lootTransactions",
            (mockDependenciesProvider: MockDependenciesProvider): void =>
                mockDependenciesProvider.load([
                    "$uibModal",
                    "$window",
                    "$state",
                    "transactionModel",
                    "accountModel",
                    "categoryModel",
                    "securityModel",
                    "contextModel",
                    "context",
                    "transactionBatch",
                ]),
        ) as Mocha.HookFunction,
    );

    // Configure & compile the object under test
    beforeEach(
        angular.mock.inject(
            (
                _controllerTest_: ControllerTestFactory,
                _$transitions_: angular.ui.IStateParamsService,
                _$uibModal_: UibModalMock,
                _$timeout_: angular.ITimeoutService,
                _$window_: angular.IWindowService,
                _$state_: StateMock,
                _transactionModel_: TransactionModelMock,
                _accountModel_: AccountModel,
                _categoryModel_: CategoryModel,
                _securityModel_: SecurityModel,
                _ogTableNavigableService_: OgTableNavigableService,
                _ogViewScrollService_: OgViewScrollService,
                _contextModel_: EntityModel,
                _context_: Entity,
                _transactionBatch_: TransactionBatch,
            ): void => {
                controllerTest = _controllerTest_;
                $transitions = _$transitions_;
                $uibModal = _$uibModal_;
                $timeout = _$timeout_;
                $window = _$window_;
                $state = _$state_;
                transactionModel = _transactionModel_;
                accountModel = _accountModel_;
                categoryModel = _categoryModel_;
                securityModel = _securityModel_;
                ogTableNavigableService = _ogTableNavigableService_;
                ogViewScrollService = _ogViewScrollService_;
                contextModel = _contextModel_;
                context = _context_;
                transactionBatch = _transactionBatch_;
                deregisterTransitionSuccessHook = sinon.stub();
                sinon
                    .stub($transitions, "onSuccess")
                    .returns(deregisterTransitionSuccessHook);
                sinon.stub(ogViewScrollService, "scrollTo");
                transactionIndexController = controllerTest(
                    "TransactionIndexController",
                ) as TransactionIndexController;
            },
        ) as Mocha.HookFunction,
    );

    it("should make the passed context available to the view", (): Chai.Assertion =>
        expect(transactionIndexController.context).to.deep.equal(context));

    it("should make the passed context type available to the view", (): Chai.Assertion =>
        expect(String(transactionIndexController.contextType)).to.equal(
            contextModel.type,
        ));

    it("should not set a context type when a context model was not specified", (): void => {
        transactionIndexController = controllerTest("TransactionIndexController", {
            contextModel: null,
        }) as TransactionIndexController;
        expect(transactionIndexController.contextType).to.be.undefined;
    });

    it("should fetch the show all details setting", (): Chai.Assertion =>
        expect(transactionModel.allDetailsShown).to.have.been.called);

    it("should make today's date available to the view", (): Chai.Assertion =>
        expect(transactionIndexController.today).to.deep.equal(
            startOfDay(new Date()),
        ));

    it("should set an empty array of transactions to the view", (): void => {
        transactionIndexController = controllerTest("TransactionIndexController", {
            transactionBatch: {
                transactions: { length: 0 } as Transaction[],
                openingBalance: 0,
                atEnd: false,
            },
        }) as TransactionIndexController;
        expect(transactionIndexController.transactions).to.be.an("array");
        expect(transactionIndexController.transactions).to.be.empty;
    });

    it("should process the passed transaction batch", (): number =>
        (transactionIndexController["openingBalance"] =
            transactionBatch.openingBalance));

    it("should ensure the transaction is focussed when the transaction id state param is present", (): void => {
        $state.params.transactionId = "1";
        transactionIndexController = controllerTest("TransactionIndexController", {
            $state,
        }) as TransactionIndexController;
        transactionIndexController.tableActions.focusRow = sinon.stub();
        $timeout.flush();
        expect(
            (transactionIndexController.tableActions as OgTableActionHandlers)
                .focusRow,
        ).to.have.been.calledWith(0);
    });

    it("should set the previous/next loading indicators to false", (): void => {
        expect(transactionIndexController.loading.prev).to.be.false;
        expect(transactionIndexController.loading.next).to.be.false;
    });

    it("should register a success transition hook", (): Chai.Assertion =>
        expect($transitions.onSuccess).to.have.been.calledWith(
            { to: "**.transactions.transaction" },
            sinon.match.func,
        ));

    it("should deregister the success transition hook when the scope is destroyed", (): void => {
        (transactionIndexController as angular.IController).$scope.$emit(
            "$destroy",
        );
        expect(deregisterTransitionSuccessHook).to.have.been.called;
    });

    it("should ensure the transaction is focussed when the transaction id state param changes", (): void => {
        const toParams = { transactionId: "1" };

        sinon.stub(
            transactionIndexController,
            "transitionSuccessHandler" as keyof TransactionIndexController,
        );
        $transitions.onSuccess.firstCall.args[1]({
            params: sinon.stub().withArgs("to").returns(toParams),
        });
        expect(
            transactionIndexController["transitionSuccessHandler"],
        ).to.have.been.calledWith(Number(toParams.transactionId));
    });

    it("should scroll to the bottom when the controller loads", (): void => {
        $timeout.flush();
        expect(ogViewScrollService["scrollTo"]).to.have.been.calledWith("bottom");
    });

    describe("editTransaction", (): void => {
        let transaction: Transaction, contextChangedStub: SinonStub;

        beforeEach((): void => {
            contextChangedStub = sinon.stub(
                transactionIndexController,
                "contextChanged" as keyof TransactionIndexController,
            );
            sinon.stub(
                transactionIndexController,
                "updateClosingBalance" as keyof TransactionIndexController,
            );
            sinon.stub(transactionIndexController, "getTransactions");
            sinon.stub(
                transactionIndexController,
                "updateRunningBalances" as keyof TransactionIndexController,
            );
            sinon.stub(
                transactionIndexController,
                "focusTransaction" as keyof TransactionIndexController,
            );
            transaction = angular.copy(transactionIndexController.transactions[1]);
        });

        it("should disable navigation on the table", (): void => {
            transactionIndexController["editTransaction"]();
            expect(ogTableNavigableService.enabled).to.be.false;
        });

        describe("(edit existing)", (): void => {
            it("should do nothing if the transaction can't be edited", (): void => {
                sinon
                    .stub(
                        transactionIndexController,
                        "isAllowed" as keyof TransactionIndexController,
                    )
                    .returns(false);
                transactionIndexController["editTransaction"](1);
                expect(Boolean(ogTableNavigableService.enabled)).to.be.true;
                expect($uibModal.open).to.not.have.been.called;
            });

            it("should open the edit transaction modal with a transaction", (): void => {
                transactionIndexController["editTransaction"](1);
                expect($uibModal.open).to.have.been.called;
                expect(
                    ($uibModal.resolves as UibModalMockResolves)
                        .transaction as Transaction,
                ).to.deep.equal(transaction);
                expect(transactionModel.findSubtransactions).to.not.have.been.called;
            });

            const scenarios: SplitTransactionType[] = [
                "Split",
                "LoanRepayment",
                "Payslip",
            ];

            scenarios.forEach((scenario: SplitTransactionType): void => {
                it(`should prefetch the subtransactions for a ${scenario} transaction`, (): void => {
                    transactionIndexController.transactions[1].transaction_type =
                        scenario;
                    transactionIndexController["editTransaction"](1);
                    expect(transactionModel.findSubtransactions).to.have.been.calledWith(
                        transaction.id,
                    );
                    (
                        ($uibModal.resolves as UibModalMockResolves)
                            .transaction as angular.IPromise<Transaction>
                    ).then(
                        (resolvedTransaction: Transaction): Chai.Assertion =>
                            expect(resolvedTransaction).to.have.property("subtransactions"),
                    );
                });
            });

            it("should update the closing balance when the modal is closed", (): void => {
                const originalTransaction: Transaction = angular.copy(transaction);

                transaction.memo = "edited transaction";
                transactionIndexController["editTransaction"](1);
                $uibModal.close(transaction);
                expect(
                    transactionIndexController["updateClosingBalance"],
                ).to.have.been.calledWith(originalTransaction, transaction);
            });

            it("should update the transaction in the list of transactions when the modal is closed", (): void => {
                transaction.memo = "edited transaction";
                transactionIndexController["editTransaction"](1);
                $uibModal.close(transaction);
                expect(transactionIndexController.transactions).to.include(transaction);
            });
        });

        describe("(add new)", (): void => {
            let newTransaction: Partial<BaseTransaction>;

            beforeEach((): void => {
                newTransaction = {
                    transaction_type: "Basic",
                    transaction_date: startOfDay(new Date()),
                } as Partial<BaseTransaction>;
            });

            describe("(default values)", (): void => {
                beforeEach((): void => {
                    transactionModel.lastTransactionDate = subDays(
                        startOfDay(new Date()),
                        1,
                    );
                    (newTransaction as Transaction).transaction_date =
                        transactionModel.lastTransactionDate;
                });

                describe("(context type is security)", (): void => {
                    it("should open the edit transaction modal with a default security", (): void => {
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            {
                                contextModel: securityModel as EntityModel,
                                context: createSecurity() as Entity,
                            },
                        ) as TransactionIndexController;
                        (newTransaction as SecurityTransaction).transaction_type =
                            "SecurityHolding";
                        (newTransaction as SecurityTransaction).security =
                            transactionIndexController.context as Security;
                    });
                });

                describe("(context type is not security)", (): void => {
                    it("should open the edit transaction modal with a default primary account if the context type is account", (): void => {
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            {
                                contextModel: accountModel as EntityModel,
                                context: createAccount() as Entity,
                            },
                        ) as TransactionIndexController;
                        (newTransaction as Transaction).primary_account =
                            transactionIndexController.context as Account;
                    });

                    it("should open the edit transaction modal with a default payee if the context type is payee", (): Payee =>
                        ((newTransaction as PayeeCashTransaction).payee =
                            transactionIndexController.context as Payee));

                    it("should open the edit transaction modal with a default category if the context type is category and the context is a category", (): void => {
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            {
                                contextModel: categoryModel as EntityModel,
                                context: createCategory() as Entity,
                            },
                        ) as TransactionIndexController;
                        (newTransaction as CategorisableTransaction).category =
                            transactionIndexController.context as Category;
                        (newTransaction as SubcategorisableTransaction).subcategory = null;
                    });

                    it("should open the edit transaction modal with a default category and subcategory if the context type is category and the context is a subcategory", (): void => {
                        (newTransaction as CategorisableTransaction).category =
                            createCategory();
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            {
                                contextModel: categoryModel as EntityModel,
                                context: createCategory({
                                    parent: (newTransaction as CategorisableTransaction)
                                        .category as Category,
                                }) as Entity,
                            },
                        ) as TransactionIndexController;
                        (newTransaction as SubcategorisableTransaction).subcategory =
                            transactionIndexController.context as Category;
                    });
                });

                describe("(context type is unknown)", (): void => {
                    it("should not set any context", (): TransactionIndexController =>
                        (transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            { contextModel: null },
                        ) as TransactionIndexController));
                });

                afterEach((): void => {
                    transactionIndexController["editTransaction"]();
                    expect($uibModal.open).to.have.been.called;
                    expect(
                        ($uibModal.resolves as UibModalMockResolves)
                            .transaction as Transaction,
                    ).to.deep.equal(newTransaction);
                });
            });

            it("should update the closing balance when the modal is closed", (): void => {
                // No original transaction, leave uninitialised
                let originalTransaction;

                transactionIndexController["editTransaction"]();
                $uibModal.close(newTransaction as Transaction);
                expect(
                    transactionIndexController["updateClosingBalance"],
                ).to.have.been.calledWith(originalTransaction, newTransaction);
            });

            it("should add the new transaction to the list of transactions when the modal is closed", (): void => {
                (newTransaction as PayeeCashTransaction).payee = context as Payee;
                transactionIndexController["editTransaction"]();
                $uibModal.close(newTransaction as Transaction);
                expect(
                    transactionIndexController.transactions.pop() as Transaction,
                ).to.deep.equal(newTransaction);
            });
        });

        it("should check if the context has changed when the modal is closed", (): void => {
            transactionIndexController["editTransaction"](1);
            $uibModal.close(transaction);
            expect(
                transactionIndexController["contextChanged"],
            ).to.have.been.calledWith(transaction);
        });

        describe("(on context changed)", (): void => {
            beforeEach((): void => {
                contextChangedStub.returns(true);
                sinon.stub(
                    transactionIndexController,
                    "removeTransaction" as keyof TransactionIndexController,
                );
                transactionIndexController["editTransaction"](1);
            });

            it("should remove the transaction from the list of transactions", (): void => {
                $uibModal.close(transaction);
                expect(
                    transactionIndexController["removeTransaction"],
                ).to.have.been.calledWith(1);
            });
        });

        describe("(transaction date is before the current batch", (): void => {
            it("should fetch a new transaction batch starting from the new transaction date", (): void => {
                transaction.transaction_date = subDays(
                    transactionIndexController.firstTransactionDate,
                    1,
                );
                transactionIndexController["editTransaction"](1);
                $uibModal.close(transaction);
                expect(
                    transactionIndexController["getTransactions"],
                ).to.have.been.calledWith(
                    "next",
                    subDays(transaction.transaction_date, 1),
                    transaction.id,
                );
            });
        });

        describe("(transaction date is after the current batch", (): void => {
            beforeEach((): void => {
                transaction.transaction_date = addDays(
                    transactionIndexController.lastTransactionDate,
                    1,
                );
                transactionIndexController["editTransaction"](1);
            });

            it("should not fetch a new transaction batch if we're already at the end", (): void => {
                transactionIndexController["atEnd"] = true;
                $uibModal.close(transaction);
                expect(transactionIndexController["getTransactions"]).to.not.have.been
                    .called;
            });

            it("should fetch a new transaction batch ending at the transaction date if we're not already at the end", (): void => {
                transactionIndexController["atEnd"] = false;
                $uibModal.close(transaction);
                expect(
                    transactionIndexController["getTransactions"],
                ).to.have.been.calledWith(
                    "prev",
                    addDays(transaction.transaction_date as Date, 1),
                    transaction.id,
                );
            });
        });

        describe("transaction date is within the current batch, or we're at the end", (): void => {
            it("should not fetch a new transaction batch when the modal is closed", (): void => {
                transactionIndexController["editTransaction"](1);
                $uibModal.close(transaction);
                expect(transactionIndexController["getTransactions"]).to.not.have.been
                    .called;
            });

            it("should resort the transaction list when the modal is closed", (): void => {
                transaction.id = 999;
                transaction.transaction_date = subDays(startOfDay(new Date()), 1);
                transactionIndexController["editTransaction"](1);
                $uibModal.close(transaction);
                expect(
                    transactionIndexController.transactions.pop() as Transaction,
                ).to.deep.equal(transaction);
            });

            it("should recalculate the running balances when the modal is closed", (): void => {
                transactionIndexController["editTransaction"]();
                $uibModal.close(transaction);
                expect(transactionIndexController["updateRunningBalances"]).to.have.been
                    .called;
            });

            it("should focus the transaction when the modal is closed", (): void => {
                transactionIndexController["editTransaction"]();
                $uibModal.close(transaction);
                expect(
                    transactionIndexController["focusTransaction"],
                ).to.have.been.calledWith(transaction.id);
            });
        });

        it("should not change the transactions list when the modal is dismissed", (): void => {
            const originalTransactions = angular.copy(
                transactionIndexController.transactions,
            );

            transactionIndexController["editTransaction"]();
            $uibModal.dismiss();
            expect(transactionIndexController.transactions).to.deep.equal(
                originalTransactions,
            );
        });

        it("should enable navigation on the table when the modal is closed", (): void => {
            transactionIndexController["editTransaction"]();
            $uibModal.close(transaction);
            expect(ogTableNavigableService.enabled).to.be.true;
        });

        it("should enable navigation on the table when the modal is dimissed", (): void => {
            transactionIndexController["editTransaction"]();
            $uibModal.dismiss();
            expect(ogTableNavigableService.enabled).to.be.true;
        });
    });

    describe("contextChanged", (): void => {
        let transaction: Record<string, Entity> & Transaction;

        beforeEach(
            (): Transaction =>
                (transaction = angular.copy(
                    transactionIndexController.transactions[1],
                ) as Record<string, Entity> & Transaction),
        );

        describe("(search mode)", (): void => {
            beforeEach(
                (): TransactionIndexController =>
                    (transactionIndexController = controllerTest(
                        "TransactionIndexController",
                        { contextModel: null, context: "Search" },
                    ) as TransactionIndexController),
            );

            it("should return true when the transaction memo no longer contains the search query", (): void => {
                transaction.memo = "test memo";
                expect(transactionIndexController["contextChanged"](transaction)).to.be
                    .true;
            });

            it("should return false when the transaction memo contains the search query", (): void => {
                transaction.memo = "test search";
                expect(transactionIndexController["contextChanged"](transaction)).to.be
                    .false;
            });
        });

        describe("(context mode)", (): void => {
            const scenarios: {
                type: "account" | "category" | "payee" | "security";
                field: keyof BasicTransaction | keyof SecurityTransaction;
                contextFactory: () => Entity;
            }[] = [
                {
                    type: "account",
                    field: "primary_account",
                    contextFactory: createAccount,
                },
                { type: "payee", field: "payee", contextFactory: createPayee },
                { type: "security", field: "security", contextFactory: createSecurity },
                { type: "category", field: "category", contextFactory: createCategory },
                {
                    type: "category",
                    field: "subcategory",
                    contextFactory: (): Entity =>
                        createCategory({ parent: createCategory() }),
                },
            ];
            let contextModels: Record<string, EntityModel>;

            beforeEach((): void => {
                contextModels = {
                    account: accountModel,
                    payee: contextModel,
                    security: securityModel,
                    category: categoryModel,
                };
            });

            angular.forEach(
                scenarios,
                (scenario: {
                    type: "account" | "category" | "payee" | "security";
                    field: string;
                    contextFactory: () => Entity;
                }): void => {
                    it(`should return true when the context type is ${scenario.type} and the transaction ${scenario.field} no longer matches the context`, (): void => {
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            {
                                contextModel: contextModels[scenario.type],
                                context: scenario.contextFactory(),
                            },
                        ) as TransactionIndexController;
                        transaction[scenario.field] = scenario.contextFactory();
                        expect(transactionIndexController["contextChanged"](transaction)).to
                            .be.true;
                    });

                    it(`should return false when the context type is ${scenario.type} and the transaction ${scenario.field} matches the context`, (): void => {
                        context = scenario.contextFactory();
                        transactionIndexController = controllerTest(
                            "TransactionIndexController",
                            { contextModel: contextModels[scenario.type], context },
                        ) as TransactionIndexController;
                        transaction[scenario.field] = context;
                        expect(transactionIndexController["contextChanged"](transaction)).to
                            .be.false;
                    });
                },
            );

            it("should return false when the transaction field is undefined", (): void => {
                transactionIndexController = controllerTest(
                    "TransactionIndexController",
                    { contextModel: accountModel as EntityModel },
                ) as TransactionIndexController;
                delete (transaction as Partial<Transaction>).primary_account;
                expect(transactionIndexController["contextChanged"](transaction)).to.be
                    .false;
            });
        });
    });

    describe("deleteTransaction", (): void => {
        let transaction: Transaction;

        beforeEach((): void => {
            transaction = angular.copy(transactionIndexController.transactions[1]);
            sinon.stub(
                transactionIndexController,
                "removeTransaction" as keyof TransactionIndexController,
            );
        });

        it("should do nothing if the transaction can't be deleted", (): void => {
            sinon
                .stub(
                    transactionIndexController,
                    "isAllowed" as keyof TransactionIndexController,
                )
                .returns(false);
            transactionIndexController["deleteTransaction"](1);
            expect(Boolean(ogTableNavigableService.enabled)).to.be.true;
            expect($uibModal.open).to.not.have.been.called;
        });

        it("should disable navigation on the table", (): void => {
            transactionIndexController["deleteTransaction"](1);
            expect(ogTableNavigableService.enabled).to.be.false;
        });

        it("should open the delete transaction modal with a transaction", (): void => {
            transactionIndexController["deleteTransaction"](1);
            expect($uibModal.open).to.have.been.called;
            expect(
                ($uibModal.resolves as UibModalMockResolves).transaction as Transaction,
            ).to.deep.equal(transaction);
        });

        it("should remove the transaction from the transactions list when the modal is closed", (): void => {
            transactionIndexController["deleteTransaction"](1);
            $uibModal.close(transaction);
            expect(
                transactionIndexController["removeTransaction"],
            ).to.have.been.calledWith(1);
        });

        it("should enable navigation on the table when the modal is closed", (): void => {
            transactionIndexController["deleteTransaction"](1);
            $uibModal.close(transaction);
            expect(ogTableNavigableService.enabled).to.be.true;
        });

        it("should enable navigation on the table when the modal is dimissed", (): void => {
            transactionIndexController["deleteTransaction"](1);
            $uibModal.dismiss();
            expect(ogTableNavigableService.enabled).to.be.true;
        });
    });

    describe("removeTransaction", (): void => {
        let transaction: Transaction;

        beforeEach((): void => {
            transaction = angular.copy(transactionIndexController.transactions[1]);
            sinon.stub(
                transactionIndexController,
                "updateClosingBalance" as keyof TransactionIndexController,
            );
        });

        it("should update the closing balance if the transaction was not focussed", (): void => {
            transactionIndexController["removeTransaction"](1);
            expect(
                transactionIndexController["updateClosingBalance"],
            ).to.have.been.calledWith(transaction);
        });

        it("should remove the transaction from the transactions list", (): void => {
            transactionIndexController["removeTransaction"](1);
            expect(transactionIndexController.transactions).to.not.include(
                transaction,
            );
        });

        it("should transition to the parent state if the transaction was focussed", (): void => {
            $state.currentState("**.transaction");
            transactionIndexController["removeTransaction"](1);
            expect($state.go).to.have.been.calledWith("^");
        });
    });

    describe("updateClosingBalance", (): void => {
        it("should do nothing if the context doesn't have a closing balance property", (): void => {
            context = "";
            transactionIndexController = controllerTest(
                "TransactionIndexController",
                { context },
            ) as TransactionIndexController;
            transactionIndexController["updateClosingBalance"](
                createBasicTransaction({ amount: 1 }),
            );
            expect(transactionIndexController).to.not.have.property(
                "closing_balance",
            );
        });

        describe("(context has a closing balance property)", (): void => {
            let transaction: Transaction | undefined, expected: number;

            beforeEach((): void => {
                transaction = createBasicTransaction({ amount: 1 });
                (transactionIndexController.context as Account).closing_balance = 0;
            });

            describe("(original transaction)", (): void => {
                it("should do nothing if undefined", (): void => {
                    transaction = undefined;
                    expected = 0;
                });

                it("should reduce the closing balance by the transaction amount when the direction is inflow", (): void => {
                    (transaction as Transaction).direction = "inflow";
                    expected = -1;
                });

                it("should increase the closing balance by the transaction amount when the direction is outflow", (): void => {
                    (transaction as Transaction).direction = "outflow";
                    expected = 1;
                });

                afterEach((): void =>
                    transactionIndexController["updateClosingBalance"](
                        transaction as Transaction,
                    ),
                );
            });

            describe("(new transaction)", (): void => {
                it("should do nothing if undefined", (): void => {
                    transaction = undefined;
                    expected = 0;
                });

                it("should increase the closing balance by the transaction amount when the direction is inflow", (): void => {
                    (transaction as Transaction).direction = "inflow";
                    expected = 1;
                });

                it("should reduce the closing balance by the transaction amount when the direction is outflow", (): void => {
                    (transaction as Transaction).direction = "outflow";
                    expected = -1;
                });

                afterEach((): void =>
                    transactionIndexController["updateClosingBalance"](
                        createBasicTransaction(),
                        transaction as Transaction,
                    ),
                );
            });

            afterEach(
                (): Chai.Assertion =>
                    expect(
                        (transactionIndexController.context as Account).closing_balance,
                    ).to.equal(expected),
            );
        });
    });

    describe("isAllowed", (): void => {
        let transaction: SplitTransactionChild | Transaction;

        beforeEach((): void => {
            sinon.stub(
                transactionIndexController,
                "promptToSwitchAccounts" as keyof TransactionIndexController,
            );
            transaction = angular.copy(transactionIndexController.transactions[1]);
            transaction.primary_account = createAccount();
        });

        describe("(not allowed)", (): void => {
            const scenarios: {
                action: "delete" | "edit";
                type: TransactionType;
                message: string;
            }[] = [
                {
                    action: "edit",
                    type: "Sub",
                    message:
                        "This transaction is part of a split transaction. You can only edit it from the parent account. Would you like to switch to the parent account now?",
                },
                {
                    action: "delete",
                    type: "Sub",
                    message:
                        "This transaction is part of a split transaction. You can only delete it from the parent account. Would you like to switch to the parent account now?",
                },
                {
                    action: "edit",
                    type: "Subtransfer",
                    message:
                        "This transaction is part of a split transaction. You can only edit it from the parent account. Would you like to switch to the parent account now?",
                },
                {
                    action: "delete",
                    type: "Subtransfer",
                    message:
                        "This transaction is part of a split transaction. You can only delete it from the parent account. Would you like to switch to the parent account now?",
                },
                {
                    action: "edit",
                    type: "Dividend",
                    message:
                        "This is an investment transaction. You can only edit it from the investment account. Would you like to switch to the investment account now?",
                },
                {
                    action: "edit",
                    type: "SecurityInvestment",
                    message:
                        "This is an investment transaction. You can only edit it from the investment account. Would you like to switch to the investment account now?",
                },
            ];

            angular.forEach(
                scenarios,
                (scenario: {
                    action: "delete" | "edit";
                    type: TransactionType;
                    message: string;
                }): void => {
                    it(`should prompt to switch accounts when attempting to ${scenario.action} a ${scenario.type} transaction`, (): void => {
                        transaction.transaction_type = scenario.type;
                        transactionIndexController["isAllowed"](
                            scenario.action,
                            transaction,
                        );
                        expect(
                            transactionIndexController["promptToSwitchAccounts"],
                        ).to.have.been.calledWith(scenario.message, transaction);
                    });

                    it(`should return false when attempting to ${scenario.action} a ${scenario.type} transaction`, (): void => {
                        transaction.transaction_type = scenario.type;
                        expect(
                            transactionIndexController["isAllowed"](
                                scenario.action,
                                transaction,
                            ),
                        ).to.be.false;
                    });
                },
            );
        });

        describe("(allowed)", (): void => {
            const scenarios: {
                action: "delete" | "edit";
                type: TransactionType;
                account_type?: "investment";
            }[] = [
                { action: "edit", type: "Basic" },
                { action: "delete", type: "Basic" },
                { action: "edit", type: "Dividend", account_type: "investment" },
                { action: "delete", type: "Dividend" },
                {
                    action: "edit",
                    type: "SecurityInvestment",
                    account_type: "investment",
                },
                { action: "delete", type: "SecurityInvestment" },
            ];

            angular.forEach(
                scenarios,
                (scenario: {
                    action: "delete" | "edit";
                    type: TransactionType;
                    account_type?: "investment";
                }): void => {
                    it(`should not prompt to switch accounts when attempting to ${
                        scenario.action
                    } a ${scenario.type} transaction${
                        undefined === scenario.account_type
                            ? ""
                            : ` from an ${scenario.account_type} acount`
                    }`, (): void => {
                        transaction.transaction_type = scenario.type;
                        (transaction.primary_account as Account).account_type =
                            scenario.account_type ??
                            (transaction.primary_account as Account).account_type;
                        transactionIndexController["isAllowed"](
                            scenario.action,
                            transaction,
                        );
                        expect(transactionIndexController["promptToSwitchAccounts"]).to.not
                            .have.been.called;
                    });

                    it(`should return true when attempting to ${scenario.action} a ${
                        scenario.type
                    } transaction${
                        undefined === scenario.account_type
                            ? ""
                            : ` from an ${scenario.account_type} acount`
                    }`, (): void => {
                        transaction.transaction_type = scenario.type;
                        (transaction.primary_account as Account).account_type =
                            scenario.account_type ??
                            (transaction.primary_account as Account).account_type;
                        expect(
                            transactionIndexController["isAllowed"](
                                scenario.action,
                                transaction,
                            ),
                        ).to.be.true;
                    });
                },
            );
        });
    });

    describe("promptToSwitchAccounts", (): void => {
        let message: string, transaction: Transaction;

        beforeEach((): void => {
            sinon.stub(transactionIndexController, "switchAccount");
            sinon.stub(transactionIndexController, "switchPrimaryAccount");
            message = "test message";
            transaction = angular.copy(transactionIndexController.transactions[1]);
            (transaction as TransferrableTransaction).account = createAccount();
            transaction.primary_account = createAccount();
            transactionIndexController["promptToSwitchAccounts"](
                message,
                transaction,
            );
        });

        it("should disable navigation on the table", (): Chai.Assertion =>
            expect(ogTableNavigableService.enabled).to.be.false);

        it("should prompt the user to switch to the other account", (): void => {
            expect($uibModal.open).to.have.been.called;
            expect(
                (($uibModal.resolves as UibModalMockResolves).confirm as OgModalConfirm)
                    .message,
            ).to.equal(message);
        });

        it("should switch to the other account when the modal is closed", (): void => {
            $uibModal.close();
            expect(
                transactionIndexController["switchAccount"],
            ).to.have.been.calledWith(null, transaction);
        });

        it("should switch to the primary account if there is no other account when the modal is closed", (): void => {
            (transaction as TransferrableTransaction).account = null;
            $uibModal.close();
            expect(
                transactionIndexController["switchPrimaryAccount"],
            ).to.have.been.calledWith(null, transaction);
        });

        it("should enable navigation on the table when the modal is closed", (): void => {
            $uibModal.close();
            expect(ogTableNavigableService.enabled).to.be.true;
        });

        it("should enable navigation on the table when the modal is dismissed", (): void => {
            $uibModal.dismiss();
            expect(ogTableNavigableService.enabled).to.be.true;
        });
    });

    describe("tableActions.selectAction", (): void => {
        describe("(not reconciling)", (): void => {
            it("should edit a transaction", (): void => {
                sinon.stub(
                    transactionIndexController,
                    "editTransaction" as keyof TransactionIndexController,
                );
                transactionIndexController.tableActions.selectAction(1);
                expect(
                    transactionIndexController["editTransaction"],
                ).to.have.been.calledWith(1);
            });
        });

        describe("(reconciling)", (): void => {
            beforeEach((): void => {
                transactionIndexController = controllerTest(
                    "TransactionIndexController",
                    { contextModel: accountModel },
                ) as TransactionIndexController;
                transactionIndexController.reconciling = true;
                sinon.stub(transactionIndexController, "toggleCleared");
            });

            it("should set the transaction status to Cleared if not already", (): void => {
                transactionIndexController.transactions[1].status = "";
                transactionIndexController.tableActions.selectAction(1);
                expect(transactionIndexController.transactions[1].status).to.equal(
                    "Cleared",
                );
            });

            it("should clear the transaction status if set to Cleared", (): void => {
                transactionIndexController.transactions[1].status = "Cleared";
                transactionIndexController.tableActions.selectAction(1);
                expect(transactionIndexController.transactions[1].status).to.equal("");
            });

            it("should toggle the transaction's cleared status", (): void => {
                transactionIndexController.tableActions.selectAction(1);
                expect(
                    transactionIndexController["toggleCleared"],
                ).to.have.been.calledWith(transactionIndexController.transactions[1]);
            });
        });
    });

    describe("tableActions.editAction", (): void => {
        it("should edit the transaction", (): void => {
            sinon.stub(
                transactionIndexController,
                "editTransaction" as keyof TransactionIndexController,
            );
            transactionIndexController.tableActions.editAction(1);
            expect(
                transactionIndexController["editTransaction"],
            ).to.have.been.calledWithExactly(1);
        });
    });

    describe("tableActions.insertAction", (): void => {
        it("should insert a transaction", (): void => {
            sinon.stub(
                transactionIndexController,
                "editTransaction" as keyof TransactionIndexController,
            );
            transactionIndexController.tableActions.insertAction();
            expect(
                transactionIndexController["editTransaction"],
            ).to.have.been.calledWithExactly();
        });
    });

    describe("tableActions.deleteAction", (): void => {
        it("should delete a transaction", (): void => {
            sinon.stub(
                transactionIndexController,
                "deleteTransaction" as keyof TransactionIndexController,
            );
            transactionIndexController.tableActions.deleteAction(1);
            expect(
                transactionIndexController["deleteTransaction"],
            ).to.have.been.calledWithExactly(1);
        });
    });

    describe("tableActions.focusAction", (): void => {
        it("should focus a transaction when no transaction is currently focussed", (): void => {
            transactionIndexController.tableActions.focusAction(1);
            expect($state.go).to.have.been.calledWith(".transaction", {
                transactionId: 2,
            });
        });

        it("should focus a transaction when another transaction is currently focussed", (): void => {
            $state.currentState("**.transaction");
            transactionIndexController.tableActions.focusAction(1);
            expect($state.go).to.have.been.calledWith("^.transaction", {
                transactionId: 2,
            });
        });
    });

    describe("getTransactions", (): void => {
        let fromDate: Date;

        beforeEach((): void => {
            sinon.stub(
                transactionIndexController,
                "processTransactions" as keyof TransactionIndexController,
            );
            fromDate = new Date();
        });

        it("should show a loading indicator in the specified direction", (): void => {
            (transactionIndexController.context as Entity).id = -1;
            transactionIndexController.getTransactions("next");
            expect(transactionIndexController.loading.next).to.be.true;
        });

        it("should fetch transactions before the first transaction date when going backwards", (): void => {
            const firstTransactionDate: Date = transactionIndexController
                .transactions[0].transaction_date as Date;

            transactionIndexController.getTransactions("prev");
            expect(transactionModel.all).to.have.been.calledWith(
                "/payees/1",
                firstTransactionDate,
                "prev",
            );
        });

        it("should fetch transactions after the last transaction date when going forwards", (): void => {
            const lastTransactionDate: Date = transactionIndexController.transactions[
                transactionIndexController.transactions.length - 1
            ].transaction_date as Date;

            transactionIndexController.getTransactions("next");
            expect(transactionModel.all).to.have.been.calledWith(
                "/payees/1",
                lastTransactionDate,
                "next",
            );
        });

        it("should fetch transactions without a from date in either direction if there are no transactions", (): void => {
            transactionIndexController.transactions = [];
            transactionIndexController.getTransactions("prev");
            expect(transactionModel.all).to.have.been.calledWith("/payees/1");
        });

        it("should fetch transactions from a specified transaction date in either direction", (): void => {
            transactionIndexController.getTransactions("prev", fromDate);
            expect(transactionModel.all).to.have.been.calledWith(
                "/payees/1",
                fromDate,
            );
        });

        it("should search for transactions from a specified date in either direction", (): void => {
            transactionIndexController = controllerTest(
                "TransactionIndexController",
                { contextModel: null, context: "search" },
            ) as TransactionIndexController;
            transactionIndexController.getTransactions("prev", fromDate);
            expect(transactionModel.query).to.have.been.calledWith(
                "search",
                fromDate,
            );
        });

        it("should process the fetched transactions", (): void => {
            transactionIndexController.getTransactions("prev", fromDate, 1);
            expect(
                transactionIndexController["processTransactions"],
            ).to.have.been.calledWith(transactionBatch, fromDate, 1);
        });

        it("should hide the loading indicator after fetching the transacactions", (): void => {
            transactionIndexController.getTransactions("prev");
            expect(transactionIndexController.loading.prev).to.be.false;
        });
    });

    describe("processTransactions", (): void => {
        beforeEach((): void => {
            transactionIndexController["openingBalance"] = 0;
            transactionIndexController.transactions = [];
            transactionIndexController["atEnd"] = false;
            sinon.stub(
                transactionIndexController,
                "updateRunningBalances" as keyof TransactionIndexController,
            );
            sinon.stub(
                transactionIndexController,
                "focusTransaction" as keyof TransactionIndexController,
            );
        });

        it("should do nothing if no transactions to process", (): void => {
            transactionBatch.transactions = [];
            transactionIndexController["processTransactions"](transactionBatch);
            expect(transactionIndexController["openingBalance"]).to.equal(0);
        });

        it("should make the opening balance of the batch available to the view", (): void => {
            transactionIndexController["processTransactions"](transactionBatch);
            transactionIndexController["openingBalance"] =
                transactionBatch.openingBalance;
        });

        it("should make the transactions available to the view", (): void => {
            transactionIndexController["processTransactions"](transactionBatch);
            transactionIndexController.transactions = transactionBatch.transactions;
        });

        it("should set a flag if we've reached the end", (): void => {
            transactionIndexController["processTransactions"](
                transactionBatch,
                new Date(),
            );
            expect(transactionIndexController["atEnd"]).to.be.true;
        });

        it("should set a flag if a from date was not specified", (): void => {
            transactionBatch.atEnd = false;
            transactionIndexController["processTransactions"](transactionBatch);
            expect(transactionIndexController["atEnd"]).to.be.true;
        });

        it("should make the first transaction date available to the view", (): void => {
            const firstTransactionDate: Date = transactionBatch.transactions[0]
                .transaction_date as Date;

            transactionIndexController["processTransactions"](transactionBatch);
            expect(transactionIndexController.firstTransactionDate).to.equal(
                firstTransactionDate,
            );
        });

        it("should make the last transaction date available to the view", (): void => {
            const lastTransactionDate: Date = transactionBatch.transactions[
                transactionBatch.transactions.length - 1
            ].transaction_date as Date;

            transactionIndexController["processTransactions"](transactionBatch);
            expect(transactionIndexController.lastTransactionDate).to.equal(
                lastTransactionDate,
            );
        });

        it("should calculate the running balances", (): void => {
            transactionIndexController["processTransactions"](transactionBatch);
            expect(transactionIndexController["updateRunningBalances"]).to.have.been
                .called;
        });

        it("should focus the transaction row for a specified transaction", (): void => {
            transactionIndexController["processTransactions"](
                transactionBatch,
                undefined,
                1,
            );
            expect(
                transactionIndexController["focusTransaction"],
            ).to.have.been.calledWith(1);
        });
    });

    describe("updateRunningBalances", (): void => {
        it("should do nothing for investment accounts", (): void => {
            (transactionIndexController.context as Account).account_type =
                "investment";
            transactionIndexController["updateRunningBalances"]();
            expect(transactionIndexController.transactions).to.deep.equal(
                transactionBatch.transactions,
            );
        });

        it("should calculate a running balance on each transaction", (): void => {
            transactionIndexController["updateRunningBalances"]();
            expect(
                (transactionIndexController.transactions.pop() as Transaction).balance,
            ).to.equal(95);
        });
    });

    describe("focusTransaction", (): void => {
        beforeEach(
            (): SinonStub =>
                (transactionIndexController.tableActions.focusRow = sinon.stub()),
        );

        it("should do nothing when the specific transaction row could not be found", (): void => {
            expect(transactionIndexController["focusTransaction"](999)).to.be.NaN;
            expect(
                (transactionIndexController.tableActions as OgTableActionHandlers)
                    .focusRow,
            ).to.not.have.been.called;
        });

        it("should focus the transaction row for the specified transaction", (): void => {
            const targetIndex: number =
                transactionIndexController["focusTransaction"](1);

            $timeout.flush();
            expect(
                (transactionIndexController.tableActions as OgTableActionHandlers)
                    .focusRow,
            ).to.have.been.calledWith(targetIndex);
        });

        it("should return the index of the specified transaction", (): void => {
            const targetIndex: number =
                transactionIndexController["focusTransaction"](1);

            expect(targetIndex).to.equal(0);
        });
    });

    describe("toggleShowAllDetails", (): void => {
        it("should update the show all details setting", (): void => {
            transactionIndexController.toggleShowAllDetails(true);
            expect(transactionModel.showAllDetails).to.have.been.calledWith(true);
        });

        it("should set a flag to indicate that we're showing all details", (): void => {
            transactionIndexController.showAllDetails = false;
            transactionIndexController.toggleShowAllDetails(true);
            expect(transactionIndexController.showAllDetails).to.be.true;
        });
    });

    describe("(account context)", (): void => {
        beforeEach(
            (): TransactionIndexController =>
                (transactionIndexController = controllerTest(
                    "TransactionIndexController",
                    { contextModel: accountModel },
                ) as TransactionIndexController),
        );

        it("should set a flag to enable reconciling", (): Chai.Assertion =>
            expect(transactionIndexController.reconcilable).to.be.true);

        it("should fetch the unreconciled only setting for the current account", (): Chai.Assertion =>
            expect(accountModel["isUnreconciledOnly"]).to.have.been.calledWith(
                (transactionIndexController.context as Entity).id,
            ));

        describe("toggleUnreconciledOnly", (): void => {
            let direction: TransactionFetchDirection | null,
                fromDate: Date,
                transactionIdToFocus: number;

            beforeEach((): void => {
                transactionIndexController.unreconciledOnly = false;
                sinon.stub(transactionIndexController, "getTransactions");
                direction = "next";
                fromDate = new Date();
                transactionIdToFocus = 1;
            });

            it("should do nothing if we're currently reconciling", (): void => {
                transactionIndexController.reconciling = true;
                transactionIndexController.toggleUnreconciledOnly(true, "prev");
                expect(accountModel["unreconciledOnly"]).to.not.have.been.called;
            });

            it("should update the unreconciled only setting for the current account", (): void => {
                transactionIndexController.toggleUnreconciledOnly(true, "prev");
                expect(accountModel["unreconciledOnly"]).to.have.been.calledWith(
                    (transactionIndexController.context as Entity).id,
                    true,
                );
            });

            it("should set a flag to indicate that we're showing unreconciled transactions only", (): void => {
                transactionIndexController.toggleUnreconciledOnly(true, "prev");
                expect(transactionIndexController.unreconciledOnly).to.be.true;
            });

            it("should clear the list of transactions", (): void => {
                transactionIndexController.toggleUnreconciledOnly(true, "prev");
                expect(transactionIndexController.transactions).to.be.empty;
            });

            it("should refetch a batch of transactions in the specified direction", (): void => {
                transactionIndexController.toggleUnreconciledOnly(
                    true,
                    direction as TransactionFetchDirection,
                    fromDate,
                    transactionIdToFocus,
                );
                expect(
                    transactionIndexController["getTransactions"],
                ).to.have.been.calledWith(direction, fromDate, transactionIdToFocus);
            });

            it("should refetch a batch of transactions in the previous direction if a direction is not specified", (): void => {
                direction = null;
                transactionIndexController.toggleUnreconciledOnly(true, "prev");
                expect(
                    transactionIndexController["getTransactions"],
                ).to.have.been.calledWith("prev");
            });
        });

        describe("save", (): void => {
            let contextId: number;

            beforeEach((): void => {
                contextId = 1;
                (transactionIndexController.context as Entity).id = contextId;
                transactionIndexController.reconciling = true;
                sinon.stub(transactionIndexController, "getTransactions");
                transactionIndexController.save();
            });

            it("should update all cleared transactions to reconciled", (): Chai.Assertion =>
                expect(accountModel["reconcile"]).to.have.been.calledWith(contextId));

            it("should cleared the account's closing balance", (): Chai.Assertion =>
                expect($window.localStorage["removeItem"]).to.have.been.calledWith(
                    "lootClosingBalance-1",
                ));

            it("should exit reconcile mode", (): Chai.Assertion =>
                expect(transactionIndexController.reconciling).to.be.false);

            it("should clear the list of transactions", (): void => {
                expect(transactionIndexController.transactions).to.be.an("array");
                expect(transactionIndexController.transactions).to.be.empty;
            });

            it("should refresh the list of transactions", (): Chai.Assertion =>
                expect(
                    transactionIndexController["getTransactions"],
                ).to.have.been.calledWith("prev"));
        });

        describe("cancel", (): void => {
            it("should exit reconcile mode", (): void => {
                transactionIndexController.reconciling = true;
                transactionIndexController.cancel();
                expect(transactionIndexController.reconciling).to.be.false;
            });
        });

        describe("reconcile", (): void => {
            it("should do nothing if we're currently reconciling", (): void => {
                transactionIndexController.reconciling = true;
                transactionIndexController.reconcile();
                expect($uibModal.open).to.not.have.been.called;
            });

            describe("(not already reconciling)", (): void => {
                beforeEach((): void => {
                    sinon.stub(transactionIndexController, "toggleUnreconciledOnly");
                    transactionIndexController.reconciling = false;
                    transactionIndexController.reconcile();
                });

                it("should disable navigation on the table", (): Chai.Assertion =>
                    expect(ogTableNavigableService.enabled).to.be.false);

                it("should prompt the user for the accounts closing balance", (): void => {
                    expect($uibModal.open).to.have.been.called;
                    expect(
                        ($uibModal.resolves as UibModalMockResolves).account as Account,
                    ).to.deep.equal(transactionIndexController.context);
                });

                it("should make the closing balance available to the view when the modal is closed", (): void => {
                    const closingBalance = 100;

                    $uibModal.close(closingBalance);
                    expect(transactionIndexController["closingBalance"]).to.equal(
                        closingBalance,
                    );
                });

                it("should set the reconcile target to the difference between the reconciled closing balance and closing balance", (): void => {
                    const closingBalance = 100.009;

                    $uibModal.close(closingBalance);
                    expect(transactionIndexController.reconcileTarget).to.equal(85.01);
                });

                it("should set the cleared total to the cleared closing balance", (): void => {
                    $uibModal.close();
                    expect(transactionIndexController.clearedTotal).to.equal(1.01);
                });

                it("should set the uncleared total to the difference between cleared closing balance and the reconcile target", (): void => {
                    const closingBalance = 100.009;

                    $uibModal.close(closingBalance);
                    expect(transactionIndexController.unclearedTotal).to.equal(84);
                });

                it("should refetch the list of unreconciled transactions when the modal is closed", (): void => {
                    $uibModal.close();
                    expect(
                        transactionIndexController["toggleUnreconciledOnly"],
                    ).to.have.been.calledWith(true);
                });

                it("should enter reconcile mode when the modal is closed", (): void => {
                    $uibModal.close();
                    expect(transactionIndexController.reconciling).to.be.true;
                });

                it("should enable navigation on the table when the modal is closed", (): void => {
                    $uibModal.close();
                    expect(ogTableNavigableService.enabled).to.be.true;
                });

                it("should enable navigation on the table when the modal is dismissed", (): void => {
                    $uibModal.dismiss();
                    expect(ogTableNavigableService.enabled).to.be.true;
                });
            });
        });

        describe("updateReconciledTotals", (): void => {
            let transaction: Transaction;

            beforeEach((): void => {
                transaction = createBasicTransaction({ amount: 1.02 });
                transactionIndexController["clearedTotal"] = 100.03;
            });

            describe("(clearing an inflow transaction", (): void => {
                it("should increase the cleared total by the amount of the transaction", (): void => {
                    transaction.status = "Cleared";
                    transaction.direction = "inflow";
                    transactionIndexController["updateReconciledTotals"](transaction);
                    expect(transactionIndexController.clearedTotal).to.equal(101.05);
                });
            });

            describe("(clearing an outflow transaction", (): void => {
                it("should decrease the cleared total by the amount of the transaction", (): void => {
                    transaction.status = "Cleared";
                    transaction.direction = "outflow";
                    transactionIndexController["updateReconciledTotals"](transaction);
                    expect(transactionIndexController.clearedTotal).to.equal(99.01);
                });
            });

            describe("(unclearing an inflow transaction", (): void => {
                it("should decrease the cleared total by the amount of the transaction", (): void => {
                    transaction.status = "";
                    transaction.direction = "inflow";
                    transactionIndexController["updateReconciledTotals"](transaction);
                    expect(transactionIndexController.clearedTotal).to.equal(99.01);
                });
            });

            describe("(unclearing an outflow transaction", (): void => {
                it("should increase the cleared total by the amount of the transaction", (): void => {
                    transaction.status = "";
                    transaction.direction = "outflow";
                    transactionIndexController["updateReconciledTotals"](transaction);
                    expect(transactionIndexController.clearedTotal).to.equal(101.05);
                });
            });

            it("should set the uncleared total to the difference between the cleared total and the reconcile target", (): void => {
                transactionIndexController["reconcileTarget"] = 200.01;
                transaction.status = "Cleared";
                transaction.direction = "inflow";
                transactionIndexController["updateReconciledTotals"](transaction);
                expect(transactionIndexController.unclearedTotal).to.equal(98.96);
            });
        });

        describe("toggleCleared", (): void => {
            let transaction: Transaction;

            beforeEach((): void => {
                transaction = createBasicTransaction();
                sinon.stub(
                    transactionIndexController,
                    "updateReconciledTotals" as keyof TransactionIndexController,
                );
                transactionIndexController.toggleCleared(transaction);
            });

            it("should update the transaction status", (): Chai.Assertion =>
                expect(transactionModel.updateStatus).to.have.been.calledWith(
                    "/accounts/1",
                    transaction.id,
                    transaction.status,
                ));

            it("should update the reconciled totals", (): Chai.Assertion =>
                expect(
                    transactionIndexController["updateReconciledTotals"],
                ).to.have.been.calledWith(transaction));
        });
    });

    describe("toggleSubtransactions", (): void => {
        let event: JQueryMouseEventObjectMock, transaction: SplitTransaction;

        beforeEach((): void => {
            event = { cancelBubble: false };
            transaction = createSplitTransaction({
                id: -1,
                showSubtransactions: true,
            });
        });

        it("should toggle a flag on the transaction indicating whether subtransactions are shown", (): void => {
            transactionIndexController.toggleSubtransactions(
                event as JQueryMouseEventObject,
                transaction,
            );
            expect(transaction.showSubtransactions).to.be.false;
        });

        it("should do nothing if we're not showing subtransactions", (): void => {
            transactionIndexController.toggleSubtransactions(
                event as JQueryMouseEventObject,
                transaction,
            );
            expect(transactionModel.findSubtransactions).to.not.have.been.called;
        });

        describe("(on shown)", (): void => {
            beforeEach((): void => {
                transaction.showSubtransactions = false;
                transaction.loadingSubtransactions = false;
                transaction.subtransactions = [createSubtransaction({ id: 1 })];
            });

            it("should show a loading indicator", (): void => {
                transactionIndexController.toggleSubtransactions(
                    event as JQueryMouseEventObject,
                    transaction,
                );
                expect(transaction.showSubtransactions).to.be.true;
                expect(transaction.loadingSubtransactions).to.be.true;
            });

            it("should clear the subtransactions for the transaction", (): void => {
                transactionIndexController.toggleSubtransactions(
                    event as JQueryMouseEventObject,
                    transaction,
                );
                expect(transaction.subtransactions).to.be.an("array");
                expect(transaction.subtransactions).to.be.empty;
            });

            it("should fetch the subtransactions", (): void => {
                transaction.id = 1;
                transactionIndexController.toggleSubtransactions(
                    event as JQueryMouseEventObject,
                    transaction,
                );
                expect(transactionModel.findSubtransactions).to.have.been.calledWith(
                    transaction.id,
                );
            });

            it("should update the transaction with it's subtransactions", (): void => {
                const subtransactions = [
                    createSubtransferTransaction({ id: 1 }),
                    createSubtransaction({ id: 2 }),
                    createSubtransaction({ id: 3 }),
                ];

                transaction.id = 1;
                transactionIndexController.toggleSubtransactions(
                    event as JQueryMouseEventObject,
                    transaction,
                );
                expect(transaction.subtransactions).to.deep.equal(subtransactions);
            });

            it("should hide the loading indicator", (): void => {
                transaction.id = 1;
                transactionIndexController.toggleSubtransactions(
                    event as JQueryMouseEventObject,
                    transaction,
                );
                expect(transaction.loadingSubtransactions).to.be.false;
            });
        });

        it("should prevent the event from bubbling", (): void => {
            transactionIndexController.toggleSubtransactions(
                event as JQueryMouseEventObject,
                transaction,
            );
            expect(event.cancelBubble as boolean).to.be.true;
        });
    });

    describe("switchTo", (): void => {
        let transaction: SplitTransactionChild,
            stateParams: { id: number; transactionId?: number | null },
            $event: EventMock;

        beforeEach((): void => {
            transaction = createSubtransferTransaction({ id: 2, parent_id: 1 });

            stateParams = {
                id: 3,
                transactionId: transaction.id,
            };

            $event = { stopPropagation: sinon.stub() };
        });

        it("should transition to the specified state passing the transaction id", (): void => {
            transaction.parent_id = null;
            transactionIndexController["switchTo"](
                null,
                "state",
                stateParams.id,
                transaction,
            );
            expect($state.go).to.have.been.calledWith(
                "root.state.transactions.transaction",
                stateParams,
            );
        });

        it("should transition to the specified state passing the parent transaction id if present", (): void => {
            stateParams.transactionId = transaction.parent_id;
            transactionIndexController["switchTo"](
                null,
                "state",
                stateParams.id,
                transaction,
            );
            expect($state.go).to.have.been.calledWith(
                "root.state.transactions.transaction",
                stateParams,
            );
        });

        it("should transition to the specified state passing the transaction id for a Subtransaction", (): void => {
            transaction.transaction_type = "Sub";
            transactionIndexController["switchTo"](
                null,
                "state",
                stateParams.id,
                transaction,
            );
            expect($state.go).to.have.been.calledWith(
                "root.state.transactions.transaction",
                stateParams,
            );
        });

        it("should stop the event from propagating if present", (): void => {
            transactionIndexController["switchTo"](
                $event as Event,
                "state",
                stateParams.id,
                transaction,
            );
            expect($event.stopPropagation as SinonStub).to.have.been.called;
        });
    });

    describe("switchToAccount", (): void => {
        let id: number, transaction: Transaction;

        beforeEach((): void => {
            sinon.stub(
                transactionIndexController,
                "switchTo" as keyof TransactionIndexController,
            );
            id = 1;
            transaction = createBasicTransaction();
        });

        it("should not toggle the unreconciled only setting for the account if the transaction is not reconciled", (): void => {
            transactionIndexController["switchToAccount"](null, id, transaction);
            expect(accountModel["unreconciledOnly"]).to.not.have.been.called;
        });

        it("should toggle the unreconciled only setting for the account if the transaction is reconciled", (): void => {
            transaction.status = "Reconciled";
            transactionIndexController["switchToAccount"](null, id, transaction);
            expect(accountModel["unreconciledOnly"]).to.have.been.calledWith(
                id,
                false,
            );
        });

        it("should transition to the specified state", (): void => {
            const event: EventMock = {};

            transactionIndexController["switchToAccount"](
                event as Event,
                id,
                transaction,
            );
            expect(transactionIndexController["switchTo"]).to.have.been.calledWith(
                event,
                "accounts.account",
                id,
                transaction,
            );
        });
    });

    describe("switchAccount", (): void => {
        it("should switch to the other side of the transaction", (): void => {
            const event: EventMock = {},
                transaction: TransferTransaction = createTransferTransaction();

            sinon.stub(
                transactionIndexController,
                "switchToAccount" as keyof TransactionIndexController,
            );
            transactionIndexController["switchAccount"](event as Event, transaction);
            expect(
                transactionIndexController["switchToAccount"],
            ).to.have.been.calledWith(
                event,
                (transaction.account as Account).id,
                transaction,
            );
        });
    });

    describe("switchPrimaryAccount", (): void => {
        it("should switch to the primary account of the transaction", (): void => {
            const event: EventMock = {},
                transaction: TransferTransaction = createTransferTransaction();

            sinon.stub(
                transactionIndexController,
                "switchToAccount" as keyof TransactionIndexController,
            );
            transactionIndexController.switchPrimaryAccount(
                event as Event,
                transaction,
            );
            expect(
                transactionIndexController["switchToAccount"],
            ).to.have.been.calledWith(
                event,
                transaction.primary_account.id,
                transaction,
            );
        });
    });

    describe("switchPayee", (): void => {
        it("should switch to the payee of the transaction", (): void => {
            const event: EventMock = {},
                transaction: BasicTransaction = createBasicTransaction();

            sinon.stub(
                transactionIndexController,
                "switchTo" as keyof TransactionIndexController,
            );
            transactionIndexController.switchPayee(event as Event, transaction);
            expect(transactionIndexController["switchTo"]).to.have.been.calledWith(
                event,
                "payees.payee",
                (transaction.payee as Payee).id,
                transaction,
            );
        });
    });

    describe("switchSecurity", (): void => {
        it("should switch to the security of the transaction", (): void => {
            const event: EventMock = {},
                transaction: SecurityHoldingTransaction =
                    createSecurityHoldingTransaction();

            sinon.stub(
                transactionIndexController,
                "switchTo" as keyof TransactionIndexController,
            );
            transactionIndexController.switchSecurity(event as Event, transaction);
            expect(transactionIndexController["switchTo"]).to.have.been.calledWith(
                event,
                "securities.security",
                (transaction.security as Security).id,
                transaction,
            );
        });
    });

    describe("switchCategory", (): void => {
        it("should switch to the category of the transaction", (): void => {
            const event: EventMock = {},
                transaction: BasicTransaction = createBasicTransaction();

            sinon.stub(
                transactionIndexController,
                "switchTo" as keyof TransactionIndexController,
            );
            transactionIndexController.switchCategory(event as Event, transaction);
            expect(transactionIndexController["switchTo"]).to.have.been.calledWith(
                event,
                "categories.category",
                (transaction.category as Category).id,
                transaction,
            );
        });
    });

    describe("switchSubcategory", (): void => {
        it("should switch to the subcategory of the transaction", (): void => {
            const event: EventMock = {},
                transaction: BasicTransaction = createBasicTransaction();

            sinon.stub(
                transactionIndexController,
                "switchTo" as keyof TransactionIndexController,
            );
            transactionIndexController.switchSubcategory(event as Event, transaction);
            expect(transactionIndexController["switchTo"]).to.have.been.calledWith(
                event,
                "categories.category",
                (transaction.subcategory as Category).id,
                transaction,
            );
        });
    });

    describe("transitionSuccessHandler", (): void => {
        let transactionId: number, focusTransactionStub: SinonStub;

        beforeEach(
            (): SinonStub =>
                (focusTransactionStub = sinon
                    .stub(
                        transactionIndexController,
                        "focusTransaction" as keyof TransactionIndexController,
                    )
                    .returns(1)),
        );

        it("should ensure the transaction is focussed when the transaction id state param changes", (): void => {
            transactionId = 2;
            transactionIndexController["transitionSuccessHandler"](transactionId);
            expect(
                transactionIndexController["focusTransaction"],
            ).to.have.been.calledWith(transactionId);
        });

        describe("(transaction not found)", (): void => {
            beforeEach((): void => {
                sinon.stub(transactionIndexController, "getTransactions");
                focusTransactionStub.withArgs(3).returns(NaN);
                transactionId = 3;
            });

            it("should fetch the transaction details", (): void => {
                transactionIndexController["transitionSuccessHandler"](transactionId);
                expect(transactionModel.find).to.have.been.calledWith(transactionId);
            });

            describe("(showing unreconciled only)", (): void => {
                it("should toggle to show all transactions", (): void => {
                    const transactionDate: Date = subDays(startOfDay(new Date()), 1),
                        direction: TransactionFetchDirection = "next";

                    transactionIndexController = controllerTest(
                        "TransactionIndexController",
                        { contextModel: accountModel },
                    ) as TransactionIndexController;
                    sinon
                        .stub(
                            transactionIndexController,
                            "focusTransaction" as keyof TransactionIndexController,
                        )
                        .returns(NaN);
                    sinon.stub(transactionIndexController, "toggleUnreconciledOnly");
                    transactionIndexController.unreconciledOnly = true;
                    transactionIndexController["transitionSuccessHandler"](transactionId);
                    expect(
                        transactionIndexController["toggleUnreconciledOnly"],
                    ).to.have.been.calledWith(
                        false,
                        direction,
                        transactionDate,
                        transactionId,
                    );
                });
            });

            describe("(transaction date is before the current batch)", (): void => {
                it("should fetch a new transaction batch starting from the new transaction date", (): void => {
                    const fromDate: Date = subDays(startOfDay(new Date()), 2);

                    transactionIndexController.firstTransactionDate = startOfDay(
                        new Date(),
                    );
                    transactionIndexController["transitionSuccessHandler"](transactionId);
                    expect(
                        transactionIndexController["getTransactions"],
                    ).to.have.been.calledWith("next", fromDate);
                });
            });

            describe("(transaction date is after the current batch)", (): void => {
                it("should fetch a new transaction batch ending at the transaction date if we're not already at the end", (): void => {
                    const fromDate: Date = startOfDay(new Date());

                    transactionIndexController.lastTransactionDate = subDays(
                        startOfDay(new Date()),
                        2,
                    );
                    transactionIndexController["atEnd"] = false;
                    transactionIndexController["transitionSuccessHandler"](transactionId);
                    expect(
                        transactionIndexController["getTransactions"],
                    ).to.have.been.calledWith("prev", fromDate);
                });

                it("should fetch a new transaction batch for the current transaction date if we're already at the end", (): void => {
                    const fromDate: Date = subDays(startOfDay(new Date()), 1),
                        direction: TransactionFetchDirection = "next";

                    transactionIndexController.lastTransactionDate = subDays(
                        startOfDay(new Date()),
                        2,
                    );
                    transactionIndexController["atEnd"] = true;
                    transactionIndexController["transitionSuccessHandler"](transactionId);
                    expect(
                        transactionIndexController["getTransactions"],
                    ).to.have.been.calledWith(direction, fromDate);
                });
            });
        });
    });
});