diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index c72beb421f..70c09a539b 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -198,15 +198,32 @@ public class OrganizationsController : Controller } var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id); var billingInfo = await _paymentService.GetBillingAsync(organization); + var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization); var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null; var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1; var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1; var serviceAccounts = organization.UseSecretsManager ? await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(id) : -1; + var smSeats = organization.UseSecretsManager ? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) : -1; - return View(new OrganizationEditModel(organization, provider, users, ciphers, collections, groups, policies, - billingInfo, billingSyncConnection, _globalSettings, secrets, projects, serviceAccounts, smSeats)); + + return View(new OrganizationEditModel( + organization, + provider, + users, + ciphers, + collections, + groups, + policies, + billingInfo, + billingHistoryInfo, + billingSyncConnection, + _globalSettings, + secrets, + projects, + serviceAccounts, + smSeats)); } [HttpPost] diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 54d13d8196..27cf453f72 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -3,9 +3,9 @@ using System.Net; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -27,14 +27,38 @@ public class OrganizationEditModel : OrganizationViewModel LicenseKey = RandomLicenseKey; } - public OrganizationEditModel(Organization org, Provider provider, IEnumerable orgUsers, - IEnumerable ciphers, IEnumerable collections, IEnumerable groups, - IEnumerable policies, BillingInfo billingInfo, IEnumerable connections, - GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int occupiedSmSeats) - : base(org, provider, connections, orgUsers, ciphers, collections, groups, policies, secrets, projects, - serviceAccounts, occupiedSmSeats) + public OrganizationEditModel( + Organization org, + Provider provider, + IEnumerable orgUsers, + IEnumerable ciphers, + IEnumerable collections, + IEnumerable groups, + IEnumerable policies, + BillingInfo billingInfo, + BillingHistoryInfo billingHistoryInfo, + IEnumerable connections, + GlobalSettings globalSettings, + int secrets, + int projects, + int serviceAccounts, + int occupiedSmSeats) + : base( + org, + provider, + connections, + orgUsers, + ciphers, + collections, + groups, + policies, + secrets, + projects, + serviceAccounts, + occupiedSmSeats) { BillingInfo = billingInfo; + BillingHistoryInfo = billingHistoryInfo; BraintreeMerchantId = globalSettings.Braintree.MerchantId; Name = org.DisplayName(); @@ -73,6 +97,7 @@ public class OrganizationEditModel : OrganizationViewModel } public BillingInfo BillingInfo { get; set; } + public BillingHistoryInfo BillingHistoryInfo { get; set; } public string RandomLicenseKey => CoreHelpers.SecureRandomString(20); public string FourteenDayExpirationDate => DateTime.Now.AddDays(14).ToString("yyyy-MM-ddTHH:mm"); public string BraintreeMerchantId { get; set; } diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index ad64e6e4f5..1db3e51dd7 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -95,7 +95,7 @@ {

Billing Information

@await Html.PartialAsync("_BillingInformation", - new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" }) + new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" }) } @await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model) diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index ba9d04e3af..aa71ebb921 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -95,7 +95,8 @@ public class UsersController : Controller var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections); var billingInfo = await _paymentService.GetBillingAsync(user); - return View(new UserEditModel(user, ciphers, billingInfo, _globalSettings)); + var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); + return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings)); } [HttpPost] diff --git a/src/Admin/Models/BillingInformationModel.cs b/src/Admin/Models/BillingInformationModel.cs index 7445f01314..ecc06919fa 100644 --- a/src/Admin/Models/BillingInformationModel.cs +++ b/src/Admin/Models/BillingInformationModel.cs @@ -1,10 +1,11 @@ -using Bit.Core.Models.Business; +using Bit.Core.Billing.Models; namespace Bit.Admin.Models; public class BillingInformationModel { public BillingInfo BillingInfo { get; set; } + public BillingHistoryInfo BillingHistoryInfo { get; set; } public Guid? UserId { get; set; } public Guid? OrganizationId { get; set; } public string Entity { get; set; } diff --git a/src/Admin/Models/UserEditModel.cs b/src/Admin/Models/UserEditModel.cs index 4252cd5cb4..f739af1995 100644 --- a/src/Admin/Models/UserEditModel.cs +++ b/src/Admin/Models/UserEditModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Models; using Bit.Core.Entities; -using Bit.Core.Models.Business; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; @@ -11,11 +11,16 @@ public class UserEditModel : UserViewModel { public UserEditModel() { } - public UserEditModel(User user, IEnumerable ciphers, BillingInfo billingInfo, + public UserEditModel( + User user, + IEnumerable ciphers, + BillingInfo billingInfo, + BillingHistoryInfo billingHistoryInfo, GlobalSettings globalSettings) : base(user, ciphers) { BillingInfo = billingInfo; + BillingHistoryInfo = billingHistoryInfo; BraintreeMerchantId = globalSettings.Braintree.MerchantId; Name = user.Name; @@ -31,6 +36,7 @@ public class UserEditModel : UserViewModel } public BillingInfo BillingInfo { get; set; } + public BillingHistoryInfo BillingHistoryInfo { get; set; } public string RandomLicenseKey => CoreHelpers.SecureRandomString(20); public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm"); public string BraintreeMerchantId { get; set; } diff --git a/src/Admin/Views/Shared/_BillingInformation.cshtml b/src/Admin/Views/Shared/_BillingInformation.cshtml index ba83bb9b50..bdae3c4213 100644 --- a/src/Admin/Views/Shared/_BillingInformation.cshtml +++ b/src/Admin/Views/Shared/_BillingInformation.cshtml @@ -3,10 +3,10 @@ @model BillingInformationModel @{ - var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction) + var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction) : AccessControlService.UserHasPermission(Permission.Org_BillingInformation_CreateEditTransaction); - var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice) + var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice) : AccessControlService.UserHasPermission(Permission.Org_BillingInformation_DownloadInvoice); } @@ -16,11 +16,11 @@
Invoices
- @if(Model.BillingInfo.Invoices?.Any() ?? false) + @if(Model.BillingHistoryInfo.Invoices?.Any() ?? false) { - @foreach(var invoice in Model.BillingInfo.Invoices) + @foreach(var invoice in Model.BillingHistoryInfo.Invoices) { @@ -28,7 +28,7 @@ - @if (canDownloadInvoice) + @if (canDownloadInvoice) {
@invoice.Date @invoice.Amount.ToString("C") @(invoice.Paid ? "Paid" : "Unpaid") @@ -49,11 +49,11 @@
Transactions
- @if(Model.BillingInfo.Transactions?.Any() ?? false) + @if(Model.BillingHistoryInfo.Transactions?.Any() ?? false) { - @foreach(var transaction in Model.BillingInfo.Transactions) + @foreach(var transaction in Model.BillingHistoryInfo.Transactions) { diff --git a/src/Admin/Views/Users/Edit.cshtml b/src/Admin/Views/Users/Edit.cshtml index a567c07dc3..2bc326d227 100644 --- a/src/Admin/Views/Users/Edit.cshtml +++ b/src/Admin/Views/Users/Edit.cshtml @@ -92,7 +92,7 @@ {

Billing Information

@await Html.PartialAsync("_BillingInformation", - new BillingInformationModel { BillingInfo = Model.BillingInfo, UserId = Model.User.Id, Entity = "User" }) + new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, UserId = Model.User.Id, Entity = "User" }) } @if (canViewGeneral) { diff --git a/src/Api/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs similarity index 52% rename from src/Api/Controllers/AccountsBillingController.cs rename to src/Api/Billing/Controllers/AccountsBillingController.cs index 9e480301f2..63a9bb44e6 100644 --- a/src/Api/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,51 +1,42 @@ -using Bit.Api.Models.Response; +using Bit.Api.Billing.Models.Responses; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Billing.Controllers; [Route("accounts/billing")] [Authorize("Application")] -public class AccountsBillingController : Controller +public class AccountsBillingController( + IPaymentService paymentService, + IUserService userService) : Controller { - private readonly IPaymentService _paymentService; - private readonly IUserService _userService; - - public AccountsBillingController( - IPaymentService paymentService, - IUserService userService) - { - _paymentService = paymentService; - _userService = userService; - } - [HttpGet("history")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetBillingHistory() + public async Task GetBillingHistoryAsync() { - var user = await _userService.GetUserByPrincipalAsync(User); + var user = await userService.GetUserByPrincipalAsync(User); if (user == null) { throw new UnauthorizedAccessException(); } - var billingInfo = await _paymentService.GetBillingHistoryAsync(user); + var billingInfo = await paymentService.GetBillingHistoryAsync(user); return new BillingHistoryResponseModel(billingInfo); } [HttpGet("payment-method")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetPaymentMethod() + public async Task GetPaymentMethodAsync() { - var user = await _userService.GetUserByPrincipalAsync(User); + var user = await userService.GetUserByPrincipalAsync(User); if (user == null) { throw new UnauthorizedAccessException(); } - var billingInfo = await _paymentService.GetBillingBalanceAndSourceAsync(user); + var billingInfo = await paymentService.GetBillingAsync(user); return new BillingPaymentResponseModel(billingInfo); } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index b0c7545896..2f5b493567 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,5 +1,4 @@ using Bit.Api.Billing.Models.Responses; -using Bit.Api.Models.Response; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; @@ -33,6 +32,21 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } + [HttpGet("history")] + public async Task GetHistoryAsync([FromRoute] Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return TypedResults.NotFound(); + } + + var billingInfo = await paymentService.GetBillingHistoryAsync(organization); + + return TypedResults.Ok(billingInfo); + } + [HttpGet] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingAsync(Guid organizationId) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index f11ea4c347..f3323ae806 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -45,7 +45,7 @@ public class OrganizationsController( ISubscriberService subscriberService) : Controller { - [HttpGet("{id}/billing-status")] + [HttpGet("{id:guid}/billing-status")] public async Task GetBillingStatus(Guid id) { if (!await currentContext.EditPaymentMethods(id)) diff --git a/src/Api/Models/Response/BillingResponseModel.cs b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs similarity index 60% rename from src/Api/Models/Response/BillingResponseModel.cs rename to src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs index c5232242f0..0a4ebdb8dd 100644 --- a/src/Api/Models/Response/BillingResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs @@ -1,45 +1,24 @@ -using Bit.Core.Enums; +using Bit.Core.Billing.Models; +using Bit.Core.Enums; using Bit.Core.Models.Api; -using Bit.Core.Models.Business; -namespace Bit.Api.Models.Response; +namespace Bit.Api.Billing.Models.Responses; -public class BillingResponseModel : ResponseModel +public class BillingHistoryResponseModel : ResponseModel { - public BillingResponseModel(BillingInfo billing) - : base("billing") + public BillingHistoryResponseModel(BillingHistoryInfo billing) + : base("billingHistory") { - Balance = billing.Balance; - PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; Transactions = billing.Transactions?.Select(t => new BillingTransaction(t)); Invoices = billing.Invoices?.Select(i => new BillingInvoice(i)); } - - public decimal Balance { get; set; } - public BillingSource PaymentSource { get; set; } public IEnumerable Invoices { get; set; } public IEnumerable Transactions { get; set; } } -public class BillingSource -{ - public BillingSource(BillingInfo.BillingSource source) - { - Type = source.Type; - CardBrand = source.CardBrand; - Description = source.Description; - NeedsVerification = source.NeedsVerification; - } - - public PaymentMethodType Type { get; set; } - public string CardBrand { get; set; } - public string Description { get; set; } - public bool NeedsVerification { get; set; } -} - public class BillingInvoice { - public BillingInvoice(BillingInfo.BillingInvoice inv) + public BillingInvoice(BillingHistoryInfo.BillingInvoice inv) { Amount = inv.Amount; Date = inv.Date; @@ -59,7 +38,7 @@ public class BillingInvoice public class BillingTransaction { - public BillingTransaction(BillingInfo.BillingTransaction transaction) + public BillingTransaction(BillingHistoryInfo.BillingTransaction transaction) { CreatedDate = transaction.CreatedDate; Amount = transaction.Amount; diff --git a/src/Api/Models/Response/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs similarity index 79% rename from src/Api/Models/Response/BillingPaymentResponseModel.cs rename to src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs index dcc0046133..5c43522aca 100644 --- a/src/Api/Models/Response/BillingPaymentResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs @@ -1,7 +1,7 @@ -using Bit.Core.Models.Api; -using Bit.Core.Models.Business; +using Bit.Core.Billing.Models; +using Bit.Core.Models.Api; -namespace Bit.Api.Models.Response; +namespace Bit.Api.Billing.Models.Responses; public class BillingPaymentResponseModel : ResponseModel { diff --git a/src/Api/Billing/Models/Responses/BillingResponseModel.cs b/src/Api/Billing/Models/Responses/BillingResponseModel.cs new file mode 100644 index 0000000000..172f784b50 --- /dev/null +++ b/src/Api/Billing/Models/Responses/BillingResponseModel.cs @@ -0,0 +1,34 @@ +using Bit.Core.Billing.Models; +using Bit.Core.Enums; +using Bit.Core.Models.Api; + +namespace Bit.Api.Billing.Models.Responses; + +public class BillingResponseModel : ResponseModel +{ + public BillingResponseModel(BillingInfo billing) + : base("billing") + { + Balance = billing.Balance; + PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; + } + + public decimal Balance { get; set; } + public BillingSource PaymentSource { get; set; } +} + +public class BillingSource +{ + public BillingSource(BillingInfo.BillingSource source) + { + Type = source.Type; + CardBrand = source.CardBrand; + Description = source.Description; + NeedsVerification = source.NeedsVerification; + } + + public PaymentMethodType Type { get; set; } + public string CardBrand { get; set; } + public string Description { get; set; } + public bool NeedsVerification { get; set; } +} diff --git a/src/Api/Models/Response/BillingHistoryResponseModel.cs b/src/Api/Models/Response/BillingHistoryResponseModel.cs deleted file mode 100644 index e0e85f0699..0000000000 --- a/src/Api/Models/Response/BillingHistoryResponseModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Models.Api; -using Bit.Core.Models.Business; - -namespace Bit.Api.Models.Response; - -public class BillingHistoryResponseModel : ResponseModel -{ - public BillingHistoryResponseModel(BillingInfo billing) - : base("billingHistory") - { - Transactions = billing.Transactions?.Select(t => new BillingTransaction(t)); - Invoices = billing.Invoices?.Select(i => new BillingInvoice(i)); - } - public IEnumerable Invoices { get; set; } - public IEnumerable Transactions { get; set; } -} diff --git a/src/Core/Billing/Models/BillingHistoryInfo.cs b/src/Core/Billing/Models/BillingHistoryInfo.cs new file mode 100644 index 0000000000..2a7f2b7584 --- /dev/null +++ b/src/Core/Billing/Models/BillingHistoryInfo.cs @@ -0,0 +1,57 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Stripe; + +namespace Bit.Core.Billing.Models; + +public class BillingHistoryInfo +{ + public IEnumerable Invoices { get; set; } = new List(); + public IEnumerable Transactions { get; set; } = new List(); + + public class BillingTransaction + { + public BillingTransaction(Transaction transaction) + { + Id = transaction.Id; + CreatedDate = transaction.CreationDate; + Refunded = transaction.Refunded; + Type = transaction.Type; + PaymentMethodType = transaction.PaymentMethodType; + Details = transaction.Details; + Amount = transaction.Amount; + RefundedAmount = transaction.RefundedAmount; + } + + public Guid Id { get; set; } + public DateTime CreatedDate { get; set; } + public decimal Amount { get; set; } + public bool? Refunded { get; set; } + public bool? PartiallyRefunded => !Refunded.GetValueOrDefault() && RefundedAmount.GetValueOrDefault() > 0; + public decimal? RefundedAmount { get; set; } + public TransactionType Type { get; set; } + public PaymentMethodType? PaymentMethodType { get; set; } + public string Details { get; set; } + } + + public class BillingInvoice + { + public BillingInvoice(Invoice inv) + { + Date = inv.Created; + Url = inv.HostedInvoiceUrl; + PdfUrl = inv.InvoicePdf; + Number = inv.Number; + Paid = inv.Paid; + Amount = inv.Total / 100M; + } + + public decimal Amount { get; set; } + public DateTime? Date { get; set; } + public string Url { get; set; } + public string PdfUrl { get; set; } + public string Number { get; set; } + public bool Paid { get; set; } + } + +} diff --git a/src/Core/Billing/Models/BillingInfo.cs b/src/Core/Billing/Models/BillingInfo.cs new file mode 100644 index 0000000000..5301c4eede --- /dev/null +++ b/src/Core/Billing/Models/BillingInfo.cs @@ -0,0 +1,97 @@ +using Bit.Core.Enums; +using Stripe; + +namespace Bit.Core.Billing.Models; + +public class BillingInfo +{ + public decimal Balance { get; set; } + public BillingSource PaymentSource { get; set; } + + public class BillingSource + { + public BillingSource() { } + + public BillingSource(PaymentMethod method) + { + if (method.Card == null) + { + return; + } + + Type = PaymentMethodType.Card; + var card = method.Card; + Description = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}, {card.ExpMonth:00}/{card.ExpYear}"; + CardBrand = card.Brand; + } + + public BillingSource(IPaymentSource source) + { + switch (source) + { + case BankAccount bankAccount: + var bankStatus = bankAccount.Status switch + { + "verified" => "verified", + "errored" => "invalid", + "verification_failed" => "verification failed", + _ => "unverified" + }; + Type = PaymentMethodType.BankAccount; + Description = $"{bankAccount.BankName}, *{bankAccount.Last4} - {bankStatus}"; + NeedsVerification = bankAccount.Status is "new" or "validated"; + break; + case Card card: + Type = PaymentMethodType.Card; + Description = $"{card.Brand}, *{card.Last4}, {card.ExpMonth:00}/{card.ExpYear}"; + CardBrand = card.Brand; + break; + case Source { Card: not null } src: + Type = PaymentMethodType.Card; + Description = $"{src.Card.Brand}, *{src.Card.Last4}, {src.Card.ExpMonth:00}/{src.Card.ExpYear}"; + CardBrand = src.Card.Brand; + break; + } + } + + public BillingSource(Braintree.PaymentMethod method) + { + switch (method) + { + case Braintree.PayPalAccount paypal: + Type = PaymentMethodType.PayPal; + Description = paypal.Email; + break; + case Braintree.CreditCard card: + Type = PaymentMethodType.Card; + Description = $"{card.CardType.ToString()}, *{card.LastFour}, " + + $"{card.ExpirationMonth.PadLeft(2, '0')}/{card.ExpirationYear}"; + CardBrand = card.CardType.ToString(); + break; + case Braintree.UsBankAccount bank: + Type = PaymentMethodType.BankAccount; + Description = $"{bank.BankName}, *{bank.Last4}"; + break; + default: + throw new NotSupportedException("Method not supported."); + } + } + + public BillingSource(Braintree.UsBankAccountDetails bank) + { + Type = PaymentMethodType.BankAccount; + Description = $"{bank.BankName}, *{bank.Last4}"; + } + + public BillingSource(Braintree.PayPalDetails paypal) + { + Type = PaymentMethodType.PayPal; + Description = paypal.PayerEmail; + } + + public PaymentMethodType Type { get; set; } + public string CardBrand { get; set; } + public string Description { get; set; } + public bool NeedsVerification { get; set; } + } +} diff --git a/src/Core/Models/Business/BillingInfo.cs b/src/Core/Models/Business/BillingInfo.cs deleted file mode 100644 index 1e1915566c..0000000000 --- a/src/Core/Models/Business/BillingInfo.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.Enums; -using Stripe; - -namespace Bit.Core.Models.Business; - -public class BillingInfo -{ - public decimal Balance { get; set; } - public BillingSource PaymentSource { get; set; } - public IEnumerable Invoices { get; set; } = new List(); - public IEnumerable Transactions { get; set; } = new List(); - - public class BillingSource - { - public BillingSource() { } - - public BillingSource(PaymentMethod method) - { - if (method.Card != null) - { - Type = PaymentMethodType.Card; - Description = $"{method.Card.Brand?.ToUpperInvariant()}, *{method.Card.Last4}, " + - string.Format("{0}/{1}", - string.Concat(method.Card.ExpMonth < 10 ? - "0" : string.Empty, method.Card.ExpMonth), - method.Card.ExpYear); - CardBrand = method.Card.Brand; - } - } - - public BillingSource(IPaymentSource source) - { - if (source is BankAccount bankAccount) - { - Type = PaymentMethodType.BankAccount; - Description = $"{bankAccount.BankName}, *{bankAccount.Last4} - " + - (bankAccount.Status == "verified" ? "verified" : - bankAccount.Status == "errored" ? "invalid" : - bankAccount.Status == "verification_failed" ? "verification failed" : "unverified"); - NeedsVerification = bankAccount.Status == "new" || bankAccount.Status == "validated"; - } - else if (source is Card card) - { - Type = PaymentMethodType.Card; - Description = $"{card.Brand}, *{card.Last4}, " + - string.Format("{0}/{1}", - string.Concat(card.ExpMonth < 10 ? - "0" : string.Empty, card.ExpMonth), - card.ExpYear); - CardBrand = card.Brand; - } - else if (source is Source src && src.Card != null) - { - Type = PaymentMethodType.Card; - Description = $"{src.Card.Brand}, *{src.Card.Last4}, " + - string.Format("{0}/{1}", - string.Concat(src.Card.ExpMonth < 10 ? - "0" : string.Empty, src.Card.ExpMonth), - src.Card.ExpYear); - CardBrand = src.Card.Brand; - } - } - - public BillingSource(Braintree.PaymentMethod method) - { - if (method is Braintree.PayPalAccount paypal) - { - Type = PaymentMethodType.PayPal; - Description = paypal.Email; - } - else if (method is Braintree.CreditCard card) - { - Type = PaymentMethodType.Card; - Description = $"{card.CardType.ToString()}, *{card.LastFour}, " + - string.Format("{0}/{1}", - string.Concat(card.ExpirationMonth.Length == 1 ? - "0" : string.Empty, card.ExpirationMonth), - card.ExpirationYear); - CardBrand = card.CardType.ToString(); - } - else if (method is Braintree.UsBankAccount bank) - { - Type = PaymentMethodType.BankAccount; - Description = $"{bank.BankName}, *{bank.Last4}"; - } - else - { - throw new NotSupportedException("Method not supported."); - } - } - - public BillingSource(Braintree.UsBankAccountDetails bank) - { - Type = PaymentMethodType.BankAccount; - Description = $"{bank.BankName}, *{bank.Last4}"; - } - - public BillingSource(Braintree.PayPalDetails paypal) - { - Type = PaymentMethodType.PayPal; - Description = paypal.PayerEmail; - } - - public PaymentMethodType Type { get; set; } - public string CardBrand { get; set; } - public string Description { get; set; } - public bool NeedsVerification { get; set; } - } - - public class BillingTransaction - { - public BillingTransaction(Transaction transaction) - { - Id = transaction.Id; - CreatedDate = transaction.CreationDate; - Refunded = transaction.Refunded; - Type = transaction.Type; - PaymentMethodType = transaction.PaymentMethodType; - Details = transaction.Details; - Amount = transaction.Amount; - RefundedAmount = transaction.RefundedAmount; - } - - public Guid Id { get; set; } - public DateTime CreatedDate { get; set; } - public decimal Amount { get; set; } - public bool? Refunded { get; set; } - public bool? PartiallyRefunded => !Refunded.GetValueOrDefault() && RefundedAmount.GetValueOrDefault() > 0; - public decimal? RefundedAmount { get; set; } - public TransactionType Type { get; set; } - public PaymentMethodType? PaymentMethodType { get; set; } - public string Details { get; set; } - } - - public class BillingInvoice - { - public BillingInvoice(Invoice inv) - { - Date = inv.Created; - Url = inv.HostedInvoiceUrl; - PdfUrl = inv.InvoicePdf; - Number = inv.Number; - Paid = inv.Paid; - Amount = inv.Total / 100M; - } - - public decimal Amount { get; set; } - public DateTime? Date { get; set; } - public string Url { get; set; } - public string PdfUrl { get; set; } - public string Number { get; set; } - public bool Paid { get; set; } - } -} diff --git a/src/Core/Repositories/ITransactionRepository.cs b/src/Core/Repositories/ITransactionRepository.cs index 142c4dbf5e..911d021b42 100644 --- a/src/Core/Repositories/ITransactionRepository.cs +++ b/src/Core/Repositories/ITransactionRepository.cs @@ -5,8 +5,8 @@ namespace Bit.Core.Repositories; public interface ITransactionRepository : IRepository { - Task> GetManyByUserIdAsync(Guid userId); - Task> GetManyByOrganizationIdAsync(Guid organizationId); - Task> GetManyByProviderIdAsync(Guid providerId); + Task> GetManyByUserIdAsync(Guid userId, int? limit = null); + Task> GetManyByOrganizationIdAsync(Guid organizationId, int? limit = null); + Task> GetManyByProviderIdAsync(Guid providerId, int? limit = null); Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 2496d623f3..bee69f9c68 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -43,8 +44,7 @@ public interface IPaymentService string paymentToken, TaxInfo taxInfo = null); Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount); Task GetBillingAsync(ISubscriber subscriber); - Task GetBillingHistoryAsync(ISubscriber subscriber); - Task GetBillingBalanceAndSourceAsync(ISubscriber subscriber); + Task GetBillingHistoryAsync(ISubscriber subscriber); Task GetSubscriptionAsync(ISubscriber subscriber); Task GetTaxInfoAsync(ISubscriber subscriber); Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 1e00118247..fbd1a873fe 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -1555,20 +1556,6 @@ public class StripePaymentService : IPaymentService } public async Task GetBillingAsync(ISubscriber subscriber) - { - var customer = await GetCustomerAsync(subscriber.GatewayCustomerId, GetCustomerPaymentOptions()); - var billingInfo = new BillingInfo - { - Balance = GetBillingBalance(customer), - PaymentSource = await GetBillingPaymentSourceAsync(customer), - Invoices = await GetBillingInvoicesAsync(customer), - Transactions = await GetBillingTransactionsAsync(subscriber) - }; - - return billingInfo; - } - - public async Task GetBillingBalanceAndSourceAsync(ISubscriber subscriber) { var customer = await GetCustomerAsync(subscriber.GatewayCustomerId, GetCustomerPaymentOptions()); var billingInfo = new BillingInfo @@ -1580,13 +1567,13 @@ public class StripePaymentService : IPaymentService return billingInfo; } - public async Task GetBillingHistoryAsync(ISubscriber subscriber) + public async Task GetBillingHistoryAsync(ISubscriber subscriber) { var customer = await GetCustomerAsync(subscriber.GatewayCustomerId); - var billingInfo = new BillingInfo + var billingInfo = new BillingHistoryInfo { - Transactions = await GetBillingTransactionsAsync(subscriber), - Invoices = await GetBillingInvoicesAsync(customer) + Transactions = await GetBillingTransactionsAsync(subscriber, 20), + Invoices = await GetBillingInvoicesAsync(customer, 20) }; return billingInfo; @@ -1936,44 +1923,66 @@ public class StripePaymentService : IPaymentService return customer; } - private async Task> GetBillingTransactionsAsync(ISubscriber subscriber) + private async Task> GetBillingTransactionsAsync(ISubscriber subscriber, int? limit = null) { - ICollection transactions = null; - if (subscriber is User) + var transactions = subscriber switch { - transactions = await _transactionRepository.GetManyByUserIdAsync(subscriber.Id); - } - else if (subscriber is Organization) - { - transactions = await _transactionRepository.GetManyByOrganizationIdAsync(subscriber.Id); - } + User => await _transactionRepository.GetManyByUserIdAsync(subscriber.Id, limit), + Organization => await _transactionRepository.GetManyByOrganizationIdAsync(subscriber.Id, limit), + _ => null + }; return transactions?.OrderByDescending(i => i.CreationDate) - .Select(t => new BillingInfo.BillingTransaction(t)); - + .Select(t => new BillingHistoryInfo.BillingTransaction(t)); } - private async Task> GetBillingInvoicesAsync(Customer customer) + private async Task> GetBillingInvoicesAsync(Customer customer, + int? limit = null) { if (customer == null) { return null; } - var options = new StripeInvoiceListOptions - { - Customer = customer.Id, - SelectAll = true - }; - try { - var invoices = await _stripeAdapter.InvoiceListAsync(options); + var paidInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + { + Customer = customer.Id, + SelectAll = !limit.HasValue, + Limit = limit, + Status = "paid" + }); + var openInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + { + Customer = customer.Id, + SelectAll = !limit.HasValue, + Limit = limit, + Status = "open" + }); + var uncollectibleInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + { + Customer = customer.Id, + SelectAll = !limit.HasValue, + Limit = limit, + Status = "uncollectible" + }); - return invoices - .Where(invoice => invoice.Status != "void" && invoice.Status != "draft") + var paidInvoices = await paidInvoicesTask; + var openInvoices = await openInvoicesTask; + var uncollectibleInvoices = await uncollectibleInvoicesTask; + + var invoices = paidInvoices + .Concat(openInvoices) + .Concat(uncollectibleInvoices); + + var result = invoices .OrderByDescending(invoice => invoice.Created) - .Select(invoice => new BillingInfo.BillingInvoice(invoice)); + .Select(invoice => new BillingHistoryInfo.BillingInvoice(invoice)); + + return limit.HasValue + ? result.Take(limit.Value) + : result; } catch (StripeException exception) { diff --git a/src/Infrastructure.Dapper/Repositories/TransactionRepository.cs b/src/Infrastructure.Dapper/Repositories/TransactionRepository.cs index cb8d7d9bba..5ac930b695 100644 --- a/src/Infrastructure.Dapper/Repositories/TransactionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/TransactionRepository.cs @@ -18,44 +18,46 @@ public class TransactionRepository : Repository, ITransaction : base(connectionString, readOnlyConnectionString) { } - public async Task> GetManyByUserIdAsync(Guid userId) + public async Task> GetManyByUserIdAsync(Guid userId, int? limit = null) { using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.QueryAsync( $"[{Schema}].[Transaction_ReadByUserId]", - new { UserId = userId }, + new { UserId = userId, Limit = limit ?? int.MaxValue }, commandType: CommandType.StoredProcedure); return results.ToList(); } } - public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + public async Task> GetManyByOrganizationIdAsync(Guid organizationId, int? limit = null) { - using (var connection = new SqlConnection(ConnectionString)) - { - var results = await connection.QueryAsync( - $"[{Schema}].[Transaction_ReadByOrganizationId]", - new { OrganizationId = organizationId }, - commandType: CommandType.StoredProcedure); + await using var connection = new SqlConnection(ConnectionString); - return results.ToList(); - } + var results = await connection.QueryAsync( + $"[{Schema}].[Transaction_ReadByOrganizationId]", + new { OrganizationId = organizationId, Limit = limit ?? int.MaxValue }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); } - public async Task> GetManyByProviderIdAsync(Guid providerId) + public async Task> GetManyByProviderIdAsync(Guid providerId, int? limit = null) { await using var sqlConnection = new SqlConnection(ConnectionString); + var results = await sqlConnection.QueryAsync( $"[{Schema}].[Transaction_ReadByProviderId]", - new { ProviderId = providerId }, + new { ProviderId = providerId, Limit = limit ?? int.MaxValue }, commandType: CommandType.StoredProcedure); + return results.ToList(); } public async Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId) { + // maybe come back to this using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.QueryAsync( diff --git a/src/Infrastructure.EntityFramework/Repositories/TransactionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/TransactionRepository.cs index 24c070e9de..f586c68bd2 100644 --- a/src/Infrastructure.EntityFramework/Repositories/TransactionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/TransactionRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Models; +using LinqToDB; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -15,46 +16,60 @@ public class TransactionRepository : Repository GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId) { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.Transactions - .FirstOrDefaultAsync(t => (t.GatewayId == gatewayId && t.Gateway == gatewayType)); - return Mapper.Map(results); - } + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var results = await EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(dbContext.Transactions, t => (t.GatewayId == gatewayId && t.Gateway == gatewayType)); + return Mapper.Map(results); } - public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + public async Task> GetManyByOrganizationIdAsync(Guid organizationId, int? limit = null) { - using (var scope = ServiceScopeFactory.CreateScope()) + using var scope = ServiceScopeFactory.CreateScope(); + + var dbContext = GetDatabaseContext(scope); + var query = dbContext.Transactions + .Where(t => t.OrganizationId == organizationId && !t.UserId.HasValue); + + if (limit.HasValue) { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.Transactions - .Where(t => (t.OrganizationId == organizationId && !t.UserId.HasValue)) - .ToListAsync(); - return Mapper.Map>(results); + query = query.OrderByDescending(o => o.CreationDate).Take(limit.Value); } + + var results = await EntityFrameworkQueryableExtensions.ToListAsync(query); + return Mapper.Map>(results); } - public async Task> GetManyByUserIdAsync(Guid userId) + public async Task> GetManyByUserIdAsync(Guid userId, int? limit = null) { - using (var scope = ServiceScopeFactory.CreateScope()) + using var scope = ServiceScopeFactory.CreateScope(); + + var dbContext = GetDatabaseContext(scope); + var query = dbContext.Transactions + .Where(t => t.UserId == userId); + + if (limit.HasValue) { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.Transactions - .Where(t => (t.UserId == userId)) - .ToListAsync(); - return Mapper.Map>(results); + query = query.OrderByDescending(o => o.CreationDate).Take(limit.Value); } + + var results = await EntityFrameworkQueryableExtensions.ToListAsync(query); + + return Mapper.Map>(results); } - public async Task> GetManyByProviderIdAsync(Guid providerId) + public async Task> GetManyByProviderIdAsync(Guid providerId, int? limit = null) { using var serviceScope = ServiceScopeFactory.CreateScope(); var databaseContext = GetDatabaseContext(serviceScope); - var results = await databaseContext.Transactions - .Where(transaction => transaction.ProviderId == providerId) - .ToListAsync(); + var query = databaseContext.Transactions + .Where(transaction => transaction.ProviderId == providerId); + + if (limit.HasValue) + { + query = query.Take(limit.Value); + } + + var results = await EntityFrameworkQueryableExtensions.ToListAsync(query); return Mapper.Map>(results); } } diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql index 27b42fe3af..e6f600c1fd 100644 --- a/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql @@ -1,14 +1,16 @@ CREATE PROCEDURE [dbo].[Transaction_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER + @OrganizationId UNIQUEIDENTIFIER, + @Limit INT AS BEGIN SET NOCOUNT ON SELECT - * + TOP (@Limit) * FROM [dbo].[TransactionView] WHERE - [UserId] IS NULL - AND [OrganizationId] = @OrganizationId + [OrganizationId] = @OrganizationId + ORDER BY + [CreationDate] DESC END diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByProviderId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByProviderId.sql index 48c234a602..5b5ccd3d02 100644 --- a/src/Sql/dbo/Stored Procedures/Transaction_ReadByProviderId.sql +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByProviderId.sql @@ -1,13 +1,16 @@ CREATE PROCEDURE [dbo].[Transaction_ReadByProviderId] - @ProviderId UNIQUEIDENTIFIER + @ProviderId UNIQUEIDENTIFIER, + @Limit INT AS BEGIN SET NOCOUNT ON SELECT - * + TOP (@Limit) * FROM [dbo].[TransactionView] WHERE [ProviderId] = @ProviderId + ORDER BY + [CreationDate] DESC END diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql index 2994600def..4d905d88cd 100644 --- a/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql @@ -1,13 +1,16 @@ CREATE PROCEDURE [dbo].[Transaction_ReadByUserId] - @UserId UNIQUEIDENTIFIER + @UserId UNIQUEIDENTIFIER, + @Limit INT AS BEGIN SET NOCOUNT ON SELECT - * + TOP (@Limit) * FROM [dbo].[TransactionView] WHERE [UserId] = @UserId + ORDER BY + [CreationDate] DESC END diff --git a/test/Core.Test/Models/Business/BillingInfo.cs b/test/Core.Test/Billing/Models/BillingInfo.cs similarity index 70% rename from test/Core.Test/Models/Business/BillingInfo.cs rename to test/Core.Test/Billing/Models/BillingInfo.cs index c6c1ae56fd..774f2f1d80 100644 --- a/test/Core.Test/Models/Business/BillingInfo.cs +++ b/test/Core.Test/Billing/Models/BillingInfo.cs @@ -1,7 +1,7 @@ -using Bit.Core.Models.Business; +using Bit.Core.Billing.Models; using Xunit; -namespace Bit.Core.Test.Models.Business; +namespace Bit.Core.Test.Billing.Models; public class BillingInfoTests { @@ -14,7 +14,7 @@ public class BillingInfoTests Total = 2000, }; - var billingInvoice = new BillingInfo.BillingInvoice(invoice); + var billingInvoice = new BillingHistoryInfo.BillingInvoice(invoice); // Should have been set from Total Assert.Equal(20M, billingInvoice.Amount); diff --git a/util/Migrator/DbScripts/2024-05-30_00_OrganizationTransactionsReadImprovements.sql b/util/Migrator/DbScripts/2024-05-30_00_OrganizationTransactionsReadImprovements.sql new file mode 100644 index 0000000000..ea613b51c8 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-30_00_OrganizationTransactionsReadImprovements.sql @@ -0,0 +1,16 @@ +CREATE OR ALTER PROCEDURE [dbo].[Transaction_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @Limit INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + TOP (@Limit) * + FROM + [dbo].[TransactionView] + WHERE + [OrganizationId] = @OrganizationId + ORDER BY + [CreationDate] DESC +END diff --git a/util/Migrator/DbScripts/2024-05-30_01_UserTransactionsReadImprovements.sql b/util/Migrator/DbScripts/2024-05-30_01_UserTransactionsReadImprovements.sql new file mode 100644 index 0000000000..90305640af --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-30_01_UserTransactionsReadImprovements.sql @@ -0,0 +1,16 @@ +CREATE OR ALTER PROCEDURE [dbo].[Transaction_ReadByUserId] + @UserId UNIQUEIDENTIFIER, + @Limit INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + TOP (@Limit) * + FROM + [dbo].[TransactionView] + WHERE + [UserId] = @UserId + ORDER BY + [CreationDate] DESC +END diff --git a/util/Migrator/DbScripts/2024-05-30_02_ProviderTransactionsReadImprovements.sql b/util/Migrator/DbScripts/2024-05-30_02_ProviderTransactionsReadImprovements.sql new file mode 100644 index 0000000000..08ffd2d288 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-30_02_ProviderTransactionsReadImprovements.sql @@ -0,0 +1,16 @@ +CREATE OR ALTER PROCEDURE [dbo].[Transaction_ReadByProviderId] + @ProviderId UNIQUEIDENTIFIER, + @Limit INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + TOP (@Limit) * + FROM + [dbo].[TransactionView] + WHERE + [ProviderId] = @ProviderId + ORDER BY + [CreationDate] DESC +END
@transaction.CreatedDate