SuperFinance Digital Banking SaaS: Step-3 - Open accounts, Perform Transactions and Manage Nominees

Introduction

As part of this tutorial, we’re building SuperFinance - a digital banking SaaS using ASPSecurityKit. In prior steps, we’ve learnt about SF’s features, type of users, security mechanisms to be employed, data models, permissions as well as building a self-service portal for banking institutions that enables on-boarding, setting up and managing branches, staff users, accounts and customers.

In this step, we’ll build the workflows or interfaces for customers (account holder/nominee) to sign up, create accounts, add nominees, view account details (balance/transactions), perform transfers. We’ll also build cash deposit and withdrawal actions on accounts which are only available to bank staff and requires customers to visit the branch. We’ll continue to make use of various features of activity-data authorization (ADA) to implement exactly the authorization controls we need for different scenarios we’ve got.

On-boarding Customers

Similar to the bank institutions, SuperFinance intends to provide self-service interfaces for banks' customers to sign up and setup/manage their financial accounts with the bank(s) of their choice. A customer isn’t owned by any one bank; she can open account in any number of banks.

For this feature, we need the following:

Register View Model

We’ve reused the register model came with Premium source package as is.

RegisterCustomerUserAsync Method in SFUserManager

RegisterCustomerUserAsync
public async Task<LoginResult> RegisterCustomerUserAsync(AppUser user, string verificationUrl, string contactUrl, bool createAuthCookie)
{
	var result = await RegisterUserAsync(user, verificationUrl, contactUrl, createAuthCookie).ConfigureAwait(false);
	if (result != null)
	{
		await AddCustomerPermitsAsync(user.Username).ConfigureAwait(false);

		// reload permissions added as part of above call.
		await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
	}

	return result;
}

View Raw File

  • The method is similar to AddCustomerUserAsync we had discussed in the manage accounts section - just that it doesn’t send out a welcome email.

SignUp in UserController

SignUp
[AllowAnonymous]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> SignUp(RegisterModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			var result = await this.userManager.RegisterCustomerUserAsync(
				new AppUser
				{
					Name = model.Name,
					Username = model.Username,
					Password = model.Password
				},
				VerificationUrl(), ContactUrl(), true);

			if (result != null && result.IsSuccess)
			{
				if (this.securitySettings.MustHaveBeenVerified)
				{
					return RedirectWithMessage("Verify",
						string.Format(Messages.VerificationMailSent, this.userService.CurrentUsername), OpResult.Success);
				}

				return RedirectToAction("Open", "Account");
			}
		}
		catch (OpException ex)
		{
			if (GetResultForEmailServiceError(ex, false, "Index", "Home") is var result && result != null)
				return result;

			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	// If we got this far, something failed, redisplay form
	return View(model);
}

View Raw File

Authorization

The AllowAnonymous attribute placed over the SignUp action indicates to ASK’s security pipeline that the given action is accessible without authentication - publicly and anonymously.

SignUp UI

SuperFinance customer signup page

Open and List Bank Accounts

As part of this feature, we’ll build interfaces to let customer open new bank accounts and view list of accounts they have access to (including as a nominee). Also, a customer can only open investment accounts; the loan account creation is only possible via the staff interfaces.

For this feature, we need the following:

Models

We’ve already seen Account model in the staff manage account section.

OpenAccountModel
public class OpenAccountModel
{
	[Required]
	[Display(Name = "Bank")]
	public Guid BankId { get; set; }

	public IList<Bank> Banks { get; set; }

	[Required]
	[Display(Name = "Branch")]
	public Guid BranchId { get; set; }

	[Required]
	[Display(Name = "Account Type")]
	public Guid AccountTypeId { get; set; }

	[MaxLength(24)]
	[Required]
	[Display(Name = "Identity Number")]
	public string IdentityNumber { get; set; }

	[Display(Name = "Nominee Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	public string NomineeUsername { get; set; }
}

View Raw File

  • You might have noticed that there are so many EntityIds in OpenAccount model but how come they are getting authorized without us granting a permit to the customer users on them? It’s an effect of granting OpenAccount as a general permit, it being an implied permission of Customer permission. All these EntityIds are required to open an account by any customer on the platform, so there’s no access authorization required for them.

AccountManager

OpenAccountAsync
public async Task<Account> OpenAccountAsync(OpenAccountModel model, string registerUrl, string loginUrl)
{
	var dbAccount = new DbAccount
	{
		Id = Guid.NewGuid(),
		Number = Guid.NewGuid().ToString("N").Substring(0, 10).ToUpper(),
		Status = AccountStatus.PendingApproval,
		IdentityNumber = model.IdentityNumber,
		AccountTypeId = model.AccountTypeId,
		BranchId = model.BranchId,
		OwningUserId = this.userService.CurrentUserId,
		CreatedDate = DateTime.UtcNow
	};

	var account = await AddAccountAsync(dbAccount, model.NomineeUsername, registerUrl, loginUrl, 0)
		.ConfigureAwait(false);

	await this.suspensionManager.AddEntityAsync(new SuspendedEntity(account.Id.GetValueOrDefault(), "Account", account.Status.ToString()));

	return account;
}
GetAccountsAsync
public async Task<List<Account>> GetAccountsAsync(Guid? userId)
{
	var accounts = await this.dbContext.Accounts
		.Include(x => x.OwningUser)
		.Include(x => x.AccountType)
		.Include(x => x.Branch)
		.Include(x => x.Nominees)
		.Where(x => x.OwningUserId == userId || x.Nominees.Any(x => x.NomineeUserId == userId))
		.OrderBy(p => p.Number)
		.Select(x => new Account
		{
			Id = x.Id,
			AccountNumber = x.Number,
			IdentityNumber = x.IdentityNumber,
			Status = x.Status,
			Branch = x.Branch.Name,
			AccountType = x.AccountType.Name,
			AccountKind = x.AccountType.Kind,
			CreatedDate = x.CreatedDate,
			IsOwnAccount = x.Nominees.All(y => y.NomineeUserId != userId)
		})
		.ToListAsync().ConfigureAwait(false);

	return accounts;
}

View Raw File

  • In OpenAccountAsync, the flow is working as follows:
    1. Create the account and grant AccountHolder role permission on the new account to the current customer user.
    2. If the nominee user already exists, grant it the AccountNominee role permission and send an email notification.
    3. If it doesn’t, create an invitation and send an invite email.
    4. We’re also inserting an entity suspension record because the new account is opened with PendingApproval status and suspension helps in limiting access to non-active accounts. We’ll talk about entity suspension rules in step4 – Advance Security controls.
  • In GetAccountsAsync, we’re not only retrieving accounts that the customer owns but also the accounts he has access to as a nominee.

AccountController

Open
public async Task<ActionResult> Open()
{
	var model = new OpenAccountModel
	{
		Banks = await this.bankManager.ListBanksAsync()
	};

	return View(model);
}

[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Open(OpenAccountModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			var result = await this.accountManager.OpenAccountAsync(model, RegisterUrl(), LoginUrl());
			if (result != null)
			{
				return RedirectToAction("Index");
			}
		}
		catch (OpException ex)
		{
			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	model.Banks = await this.bankManager.ListBanksAsync();

	// If we got this far, something failed, redisplay form
	return View(model);
}
List
[AuthAction("Index")]
[HttpPost]
[PossessesPermissionCode]
public async Task<ActionResult> List()
{
	return await SecureJsonAction(async () =>
	{
		var accounts = await this.accountManager.GetAccountsAsync(this.userService.CurrentUserId);
		return Json(ApiResponse.List(accounts, accounts.Count));
	});
}
ListBranches
[AuthAction("Open")]
[HttpGet]
public async Task<ActionResult> ListBranches(Guid bankId)
{
	return await SecureJsonAction(async () =>
	{
		var branches = await this.branchManager.GetBranchesAsync(bankId, null);
		return Json(ApiResponse.List(branches, branches.Count));
	});
}
ListAccountTypes
[AuthAction("Open")]
[HttpGet]
public async Task<ActionResult> ListAccountTypes(Guid bankId)
{
	return await SecureJsonAction(async () =>
	{
		var accountTypes = await this.accountTypeManager.GetAccountTypesAsync(bankId, AccountKind.Investment);
		return Json(ApiResponse.List(accountTypes, accountTypes.Count));
	});
}

View Raw File

  • There’s no paging in List accounts action which is intentional. We don’t expect a customer having that many accounts for paging to make sense (though he can if he so wishes!)

Authorization

UIs

SuperFinance customer accounts page
SuperFinance open new account page for customer

Account Details and Transactions

As part of this feature, we’ll build interfaces to view account details and perform transactions over the account. While customers can perform transfers, staff can do cash withdrawal and deposits but neither can do both. The related permissions have already been setup as per their roles as part of step-1: Permissions section.

For this feature, we need the following:

Models

Transaction
public class Transaction
{
	public Guid Id { get; set; }

	public DateTime Date { get; set; }

	public double Amount { get; set; }

	public TransactionType TransactionType { get; set; }

	public string Remarks { get; set; }
}

View Raw File

TransactionModel
public class TransactionModel
{
	[Required]
	[Display(Name = "Account")]
	public Guid AccountId { get; set; }

	[Required]
	[Display(Name = "Amount")]
	public double Amount { get; set; }

	[Display(Name = "Remarks")]
	public string Remarks { get; set; }
}
TransferModel
public class TransferModel
{
	[Required]
	[Display(Name = "From Account")]
	[Authorize("AccountId")]
	public Guid FromAccountId { get; set; }

	[Required]
	[Display(Name = "To Account")]
	public string ToAccountNumber { get; set; }

	[Required]
	[Display(Name = "Amount")]
	public double Amount { get; set; }

	[Display(Name = "Remarks")]
	public string Remarks { get; set; }
}

View Raw File

TransactionManager

CreateDepositAsync
public async Task CreateDepositAsync(TransactionModel transaction)
{
	var dbTransaction = new DbTransaction
	{
		Id = Guid.NewGuid(),
		Date = DateTime.UtcNow,
		AccountId = transaction.AccountId,
		Amount = transaction.Amount,
		TransactionType = TransactionType.Credit,
		Remarks = transaction.Remarks
	};

	this.dbContext.Transactions.Add(dbTransaction);
	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
CreateTransferAsync
public async Task CreateTransferAsync(TransferModel transfer)
{
	var toAccount = await this.dbContext.Accounts.FirstOrDefaultAsync(x => x.Number == transfer.ToAccountNumber)
		.ConfigureAwait(false);
	if (toAccount == null)
	{
		throw new OpException(OpResult.DoNotExist, "Account does not exist");
	}

	var dbDebitTransaction = new DbTransaction
	{
		Id = Guid.NewGuid(),
		Date = DateTime.UtcNow,
		AccountId = transfer.FromAccountId,
		Amount = transfer.Amount,
		TransactionType = TransactionType.Debit,
		Remarks = transfer.Remarks
	};

	var dbCreditTransaction = new DbTransaction
	{
		Id = Guid.NewGuid(),
		Date = DateTime.UtcNow,
		AccountId = toAccount.Id,
		Amount = transfer.Amount,
		TransactionType = TransactionType.Credit,
		Remarks = transfer.Remarks
	};

	var dbTransfer = new DbTransfer
	{
		Id = Guid.NewGuid(),
		CreditTransactionId = dbCreditTransaction.Id,
		DebitTransactionId = dbDebitTransaction.Id,
		CreatedDate = DateTime.UtcNow
	};

	this.dbContext.Transactions.AddRange(dbDebitTransaction, dbCreditTransaction);
	this.dbContext.Transfers.Add(dbTransfer);

	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
CreateWithdrawalAsync
public async Task CreateWithdrawalAsync(TransactionModel transaction)
{
	var dbTransaction = new DbTransaction
	{
		Id = Guid.NewGuid(),
		Date = DateTime.UtcNow,
		AccountId = transaction.AccountId,
		Amount = transaction.Amount,
		TransactionType = TransactionType.Debit,
		Remarks = transaction.Remarks
	};

	this.dbContext.Transactions.Add(dbTransaction);
	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}
GetTransactionsAsync
public async Task<PagedResult<Transaction>> GetTransactionsAsync(Guid accountId, int startIndex, int pageSize)
{
	var list = await this.dbContext.Transactions.AsNoTracking()
		.Select(dummy => new
		{
			Total = this.dbContext.Transactions.Count(p => p.AccountId == accountId),
			ThisPage = this.dbContext.Transactions.Where(p => p.AccountId == accountId)
				.OrderBy(p => p.Date).Skip(startIndex).Take(pageSize)
				.Select(x => new Transaction
				{
					Id = x.Id,
					Date = x.Date,
					TransactionType = x.TransactionType,
					Amount = x.Amount,
					Remarks = x.Remarks
				})
				.ToList()
		}).FirstOrDefaultAsync().ConfigureAwait(false);

	return new PagedResult<Transaction>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}

View Raw File

  • The CreateDepositAsync creates a credit transaction while the CreateWithdrawalAsync creates a debit transaction.
  • The CreateTransferAsync is creating a debit transaction on the source account and a credit transaction on the destination account and also linking them together in the transfer model. If the destination account doesn’t exist, an error is thrown.

AccountDetailsController

Deposit
[AuthPermission("CreateDeposit")]
public ActionResult Deposit(Guid accountId)
{
	var model = new TransactionModel { AccountId = accountId };
	return View(model);
}

[AuthPermission("CreateDeposit")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Deposit(TransactionModel model)
{
	if (ModelState.IsValid)
	{
		await this.transactionManager.CreateDepositAsync(model);
		return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.AccountId }, SFMessages.DepositCreated, OpResult.Success);
	}

	return View(model);
}
Transfer
[AuthPermission("CreateTransfer")]
public ActionResult Transfer(Guid accountId)
{
	var model = new TransferModel { FromAccountId = accountId };
	return View(model);
}

[AuthPermission("CreateTransfer")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Transfer(TransferModel model)
{
	if (ModelState.IsValid)
	{
		await this.transactionManager.CreateTransferAsync(model);
		return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.FromAccountId }, SFMessages.TransferCreated, OpResult.Success);
	}

	return View(model);
}
Withdrawal
[AuthPermission("CreateWithdrawal")]
public ActionResult Withdrawal(Guid accountId)
{
	var model = new TransactionModel { AccountId = accountId };
	return View(model);
}

[AuthPermission("CreateWithdrawal")]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Withdrawal(TransactionModel model)
{
	if (ModelState.IsValid)
	{
		await this.transactionManager.CreateWithdrawalAsync(model);
		return RedirectWithMessage("Index", "AccountDetails", new { accountId = model.AccountId }, SFMessages.WithdrawalCreated, OpResult.Success);
	}

	return View(model);
}
List
[AuthAction("Index")]
[HttpPost]
public async Task<ActionResult> List(Guid accountId, int jtStartIndex, int jtPageSize)
{
	return await SecureJsonAction(async () =>
	{
		var transactions = await this.transactionManager.GetTransactionsAsync(accountId, jtStartIndex, jtPageSize);
		return JsonResponse(ApiResponse.List(transactions.Records, transactions.TotalCount));
	});
}

View Raw File

AccountId ReferencesLoader

public async Task<List<IdReference>> AccountId(Guid id)
{
	return await dbContext.Accounts
		.Where(x => x.Id == id)
		.Select(x => new List<Guid> { x.Id, x.BranchId, x.Branch.BankId })
		.ToIdReferenceListAsync();
}

View Raw File

Read the next section to learn more about it.

Authorization

UIs

Staff doing a deposit on an account
Staff doing a withdrawal on an account
Customer doing a transfer on an account

Account Details
@using SuperFinance.DataModels
@model AccountDetailsModel

@{
	ViewBag.Title = "Details for Account " + Model.Account.AccountNumber;
	ViewBag.jTableStyle = "~/Scripts/dist/jtable/themes/metro/blue/jtable.css";
}

<section class="home-content">
	<h1> @ViewBag.Title </h1>
	<hr />

	<div class="row">
		<div class="col-6">
			<dl>
				<dt>Account Balance</dt>
				<dd>@Model.Account.Balance</dd>
			</dl>
		</div>
		<div class="col-6">
			<dl>
				<dt>Holder Name</dt>
				<dd>@Model.Account.Name</dd>
			</dl>
		</div>
	</div>

	<div class="row">
		<div class="col-6">
			<dl>
				<dt>Account Type</dt>
				<dd>@Model.Account.AccountType</dd>
			</dl>
		</div>
		<div class="col-6">
			<dl>
				<dt>Branch</dt>
				<dd>@Model.Account.Branch</dd>
			</dl>
		</div>
	</div>

	<div class="row">
		<div class="col-6">
			<dl>
				<dt>Account Status</dt>
				<dd>@Model.Account.Status</dd>
			</dl>
		</div>
		<div class="col-6">
			<dl>
				<dt>Opened On</dt>
				<dd>@Html.DisplayFor(m => m.Account.CreatedDate)</dd>
			</dl>
		</div>
	</div>

	<div class="btn-group">
		@if (Model.Account.AccountKind == AccountKind.Investment && Context.UserService().IsAuthorized(SFPermissionCodes.CreateTransfer, Model.Account.Id.GetValueOrDefault()))
		{
			<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Transfer", new { accountId = Model.Account.Id })'">Create Transfer</button>
		}
		@if (Context.UserService().PossessesPermission(SFPermissionCodes.CreateDeposit))
		{
			<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Deposit", new { accountId = Model.Account.Id })'">Create Deposit</button>
		}
		@if (Context.UserService().PossessesPermission(SFPermissionCodes.CreateWithdrawal) && Model.Account.AccountKind == AccountKind.Investment)
		{
			<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("Withdrawal", new { accountId = Model.Account.Id })'">Create Withdrawal</button>
		}
		@if (Context.UserService().PossessesPermission(SFPermissionCodes.ChangeStatus))
		{
			<button id="transfer" class="btn btn-default" onclick="window.location.href = '@Url.Action("ChangeStatus", "ManageAccount", new { accountId = Model.Account.Id })'">Change Status</button>
		}
	</div>
	<div id="tableContainer" class="jtable-div"></div>
</section>

@section scripts
{

	<script src="~/scripts/dist/jquery-ui.min.js"></script>
	<script src="~/scripts/dist/jtable.min.js"></script>

	<script type="text/javascript">
            $(function () {

                $('#tableContainer').jtable({
                        title: 'Transaction History',
                        paging: true,
                        sorting: false,
                        columnSelectable: false,
                        AntiForgeryToken: '@Html.AntiForgeryTokenValue()',
                        actions: {
                            listAction: '@Url.Action("List", "AccountDetails", new { accountId = Model.Account.Id })',
                        },
                        fields: {
                            Id: {
                                key: true,
                                create: false,
                                edit: false,
                                list: false
                            },
                            Date: {
								title: 'Date'
                            },
                            Amount: {
								title: 'Amount'
                            },
                            TransactionType: {
								title: 'Transaction Type',
								options: { '0': 'Credit', '1': 'Debit' }
							},
							Remarks: {
								title: 'Remarks'
							}
                        }
                }).jtable('load');
            });
	</script>
}

@section cssImport
{
	<link href="@Url.Content(ViewBag.jTableStyle)" rel="stylesheet" type="text/css" />

	<style>
		.child-opener-image {
			cursor: pointer;
		}

		.child-opener-image-column {
			text-align: center;
		}

		.jtable-dialog-form {
			min-width: 220px;
		}

			.jtable-dialog-form input[type="text"] {
				min-width: 200px;
			}

		div.jtable-main-container > div.jtable-title {
			background-color: #008cba;
		}

		.jtable-column-header {
			background-color: #808080;
		}

		.jtable-toolbar > .jtable-toolbar-item {
			background-color: #808080;
		}

		.jtable-command-column-header {
			background-color: #808080;
		}

		div.jtable-main-container > div.jtable-bottom-panel {
			background-color: #808080;
		}

		.jtable-page-list > jtable-page-number-disabled {
			background-color: #808080;
		}

		div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-space, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-first, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-last, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-previous, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-next, div.jtable-main-container > div.jtable-bottom-panel .jtable-page-list .jtable-page-number-active {
			background-color: #808080;
		}

		div.jtable-main-container > div.jtable-title div.jtable-toolbar span.jtable-toolbar-item {
			background-color: #808080;
		}

			div.jtable-main-container > div.jtable-title div.jtable-toolbar span.jtable-toolbar-item:hover {
				background-color: #008cba;
			}
	</style>
}

View Raw File

  • In above view, we’re rendering each transaction action button as per permission required for that action.

Manage Nominees

As part of this feature, we’ll build interfaces to let customers add or remove nominees for their accounts. A nominee has read-only access to the account it’s nominated for.

For this feature, we need the following:

Models

AccountNominee
public class AccountNominee
{
	public Guid? Id { get; set; }

	public string Name { get; set; }

	public string Username { get; set; }

	public Guid? AccountId { get; set; }
}

View Raw File

AddNomineeModel
public class AddNomineeModel
{
	[MaxLength(100)]
	[Required]
	[Display(Name = "Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	[DoNotAuthorize]
	public string Username { get; set; }

	[Required]
	public Guid? AccountId { get; set; }
}

View Raw File

  • The Username in AddNomineeModel could be referring to an existing user or a new user - see AccountManager section below for more details. Using DoNotAuthorizeAttribute to indicate to ADA to not consider this otherwise EntityId value for authorization.

AccountManager

AddNomineeAsync
public async Task<AccountNominee> AddNomineeAsync(Guid accountId, string nomineeEmail, string registerUrl,
	string loginUrl)
{
	var dbNomineeUser = await this.dbContext.Users.Include(x => x.PermitGroups)
		.SingleOrDefaultAsync(x => x.Username == nomineeEmail).ConfigureAwait(false);
	if (dbNomineeUser != null)
	{
		if (dbNomineeUser.UserType != UserType.Customer)
		{
			throw new OpException(OpResult.InvalidInput, string.Format(SFMessages.NotACustomerUser, nomineeEmail));
		}

		var dbAccountNominee = new DbAccountNominee
		{
			Id = Guid.NewGuid(),
			AccountId = accountId,
			NomineeUserId = dbNomineeUser.Id
		};

		this.dbContext.AccountNominees.Add(dbAccountNominee);
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		await this.permitRepository
			.AddPermitAsync(dbNomineeUser.Id, SFPermissionCodes.AccountNominee, accountId)
			.ConfigureAwait(false);

		await SendNomineeNotificationMailAsync(nomineeEmail, loginUrl).ConfigureAwait(false);

		return new AccountNominee
		{
			Id = dbAccountNominee.Id,
			Name = dbNomineeUser.Name,
			Username = dbNomineeUser.Username,
			AccountId = accountId
		};
	}
	else
	{
		var dbNomineeInvitation = new DbUserInvitation
		{
			Id = Guid.NewGuid(),
			Date = DateTime.UtcNow,
			AccountId = accountId,
			EmailAddress = nomineeEmail
		};

		this.dbContext.UserInvitations.Add(dbNomineeInvitation);
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		await SendNomineeInviteMailAsync(nomineeEmail, registerUrl).ConfigureAwait(false);

		return null;
	}
}
DeleteNomineeAsync
public async Task DeleteNomineeAsync(Guid id)
{
	var dbAccountNominee = await this.dbContext.AccountNominees
		.Where(m => m.Id == id)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbAccountNominee != null)
	{
		await this.permitRepository
			.RemovePermitAsync(dbAccountNominee.NomineeUserId, SFPermissionCodes.AccountNominee, dbAccountNominee.AccountId)
			.ConfigureAwait(false);

		this.dbContext.AccountNominees.Remove(dbAccountNominee);
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
	}
	else
	{
		throw new OpException(OpResult.DoNotExist);
	}
}
GetNomineesAsync
public async Task<List<AccountNominee>> GetNomineesAsync(Guid accountId)
{
	return await this.dbContext.AccountNominees.Where(x => x.AccountId == accountId)
		.OrderBy(x => x.NomineeUser.Name)
		.Select(x => new AccountNominee
		{
			Id = x.Id,
			Name = x.NomineeUser.Name,
			Username = x.NomineeUser.Username,
			AccountId = x.AccountId
		})
		.ToListAsync().ConfigureAwait(false);
}

View Raw File

  • In AddNomineeAsync, the flow is working as follows:
    1. If the nominee user already exists, create an AccountNominee record and grant an AccountNominee permit on the AccountId to that user, and then notify the user about the new nomination she’s received.
    2. If it doesn’t, create an invitation and send an invite email.
  • In DeleteNomineeAsync, we’re deleting not just the AccountNominee record, but also the associated AccountNominee permit we had granted to the user on the account.

NomineeController

List
[AuthAction("Index")]
[HttpPost]
public async Task<ActionResult> List(Guid accountId)
{
	return await SecureJsonAction(async () =>
	{
		var result = await this.accountManager.GetNomineesAsync(accountId);
		return Json(ApiResponse.List(result, result.Count));
	});
}
Add
public ActionResult Add(Guid accountId)
{
	var model = new AddNomineeModel
	{
		AccountId = accountId
	};

	return View(model);
}

[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Add(AddNomineeModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			var nominee = await this.accountManager.AddNomineeAsync(model.AccountId.GetValueOrDefault(), model.Username, RegisterUrl(), LoginUrl());
			return RedirectWithMessage("Index", "Account", null,
				nominee == null ? SFMessages.NomineeInvited : SFMessages.NomineeCreated, OpResult.Success);
		}
		catch (OpException ex)
		{
			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	return View(model);
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("NomineeId")] Guid id)
{
	return await SecureJsonAction(async () =>
	{
		await this.accountManager.DeleteNomineeAsync(id);
		return Json(ApiResponse.Success());
	});
}
  • There’s no paging in List nominees action which is intentional. We don’t expect an account having that many nominees for paging to make sense (though it can if customer so wishes!)

NomineeId ReferencesLoader

public async Task<List<IdReference>> NomineeId(Guid id)
{
	return await dbContext.AccountNominees
		.Where(x => x.Id == id)
		.Select(x => new List<Guid> { x.AccountId, x.Account.BranchId, x.Account.Branch.BankId })
		.ToIdReferenceListAsync();
}

View Raw File

Authorization

Nominee Management UI

Showing nominees of an account
Customer creating a nominee for an account

Try Out the Live Demo

Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.

Don't Miss Out!

Be the first to get notified when the new quality content related to web app security like the one you're reading is posted.