1
0
mirror of https://github.com/bitwarden/server.git synced 2025-03-28 16:15:52 +01:00

[AC-2567] Billing Performance Improvements (#4143)

* Moved AccountsBilling controller to be owned by Billing

* Added org billing history endpoint

* Updated GetBillingInvoicesAsync to only retrieve paid, open, and uncollectible invoices, and added option to limit results

* Removed invoices and transactions from GetBillingAsync

* Limiting the number of invoices and transactions returned

* Moved Billing models to Billing namespace

* Split billing info and billing history objects

* Removed billing method GetBillingBalanceAndSourceAsync

* Removed unused using

* Cleaned up BillingInfo a bit

* Update migration scripts to use `CREATE OR ALTER` instead of checking for the `OBJECT_ID`

* Applying limit to aggregated invoices after they return from Stripe
This commit is contained in:
Conner Turnbull 2024-06-11 13:55:23 -04:00 committed by GitHub
parent f615858724
commit fc1c488a78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 474 additions and 341 deletions

View File

@ -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]

View File

@ -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<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups,
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> 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<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers,
IEnumerable<Collection> collections,
IEnumerable<Group> groups,
IEnumerable<Policy> policies,
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
IEnumerable<OrganizationConnection> 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; }

View File

@ -95,7 +95,7 @@
{
<h2>Billing Information</h2>
@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)

View File

@ -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]

View File

@ -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; }

View File

@ -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<Cipher> ciphers, BillingInfo billingInfo,
public UserEditModel(
User user,
IEnumerable<Cipher> 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; }

View File

@ -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 @@
<dt class="col-sm-4 col-lg-3">Invoices</dt>
<dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Invoices?.Any() ?? false)
@if(Model.BillingHistoryInfo.Invoices?.Any() ?? false)
{
<table class="table">
<tbody>
@foreach(var invoice in Model.BillingInfo.Invoices)
@foreach(var invoice in Model.BillingHistoryInfo.Invoices)
{
<tr>
<td>@invoice.Date</td>
@ -28,7 +28,7 @@
</td>
<td>@invoice.Amount.ToString("C")</td>
<td>@(invoice.Paid ? "Paid" : "Unpaid")</td>
@if (canDownloadInvoice)
@if (canDownloadInvoice)
{
<td>
<a target="_blank" rel="noreferrer" href="@invoice.PdfUrl" title="Download Invoice">
@ -49,11 +49,11 @@
<dt class="col-sm-4 col-lg-3">Transactions</dt>
<dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Transactions?.Any() ?? false)
@if(Model.BillingHistoryInfo.Transactions?.Any() ?? false)
{
<table class="table">
<tbody>
@foreach(var transaction in Model.BillingInfo.Transactions)
@foreach(var transaction in Model.BillingHistoryInfo.Transactions)
{
<tr>
<td>@transaction.CreatedDate</td>

View File

@ -92,7 +92,7 @@
{
<h2>Billing Information</h2>
@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)
{

View File

@ -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<BillingHistoryResponseModel> GetBillingHistory()
public async Task<BillingHistoryResponseModel> 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<BillingPaymentResponseModel> GetPaymentMethod()
public async Task<BillingPaymentResponseModel> 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);
}
}

View File

@ -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<IResult> 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<IResult> GetBillingAsync(Guid organizationId)

View File

@ -45,7 +45,7 @@ public class OrganizationsController(
ISubscriberService subscriberService)
: Controller
{
[HttpGet("{id}/billing-status")]
[HttpGet("{id:guid}/billing-status")]
public async Task<OrganizationBillingStatusResponseModel> GetBillingStatus(Guid id)
{
if (!await currentContext.EditPaymentMethods(id))

View File

@ -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<BillingInvoice> Invoices { get; set; }
public IEnumerable<BillingTransaction> 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;

View File

@ -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
{

View File

@ -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; }
}

View File

@ -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<BillingInvoice> Invoices { get; set; }
public IEnumerable<BillingTransaction> Transactions { get; set; }
}

View File

@ -0,0 +1,57 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Billing.Models;
public class BillingHistoryInfo
{
public IEnumerable<BillingInvoice> Invoices { get; set; } = new List<BillingInvoice>();
public IEnumerable<BillingTransaction> Transactions { get; set; } = new List<BillingTransaction>();
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; }
}
}

View File

@ -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; }
}
}

View File

@ -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<BillingInvoice> Invoices { get; set; } = new List<BillingInvoice>();
public IEnumerable<BillingTransaction> Transactions { get; set; } = new List<BillingTransaction>();
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; }
}
}

View File

@ -5,8 +5,8 @@ namespace Bit.Core.Repositories;
public interface ITransactionRepository : IRepository<Transaction, Guid>
{
Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId);
Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId);
Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId, int? limit = null);
Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId, int? limit = null);
Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId, int? limit = null);
Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId);
}

View File

@ -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<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount);
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
Task<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber);
Task<BillingInfo> GetBillingBalanceAndSourceAsync(ISubscriber subscriber);
Task<BillingHistoryInfo> GetBillingHistoryAsync(ISubscriber subscriber);
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);

View File

@ -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<BillingInfo> 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<BillingInfo> 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<BillingInfo> GetBillingHistoryAsync(ISubscriber subscriber)
public async Task<BillingHistoryInfo> 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<IEnumerable<BillingInfo.BillingTransaction>> GetBillingTransactionsAsync(ISubscriber subscriber)
private async Task<IEnumerable<BillingHistoryInfo.BillingTransaction>> GetBillingTransactionsAsync(ISubscriber subscriber, int? limit = null)
{
ICollection<Transaction> 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<IEnumerable<BillingInfo.BillingInvoice>> GetBillingInvoicesAsync(Customer customer)
private async Task<IEnumerable<BillingHistoryInfo.BillingInvoice>> 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)
{

View File

@ -18,44 +18,46 @@ public class TransactionRepository : Repository<Transaction, Guid>, ITransaction
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId)
public async Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId, int? limit = null)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Transaction>(
$"[{Schema}].[Transaction_ReadByUserId]",
new { UserId = userId },
new { UserId = userId, Limit = limit ?? int.MaxValue },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId)
public async Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId, int? limit = null)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Transaction>(
$"[{Schema}].[Transaction_ReadByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
await using var connection = new SqlConnection(ConnectionString);
return results.ToList();
}
var results = await connection.QueryAsync<Transaction>(
$"[{Schema}].[Transaction_ReadByOrganizationId]",
new { OrganizationId = organizationId, Limit = limit ?? int.MaxValue },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
public async Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId)
public async Task<ICollection<Transaction>> GetManyByProviderIdAsync(Guid providerId, int? limit = null)
{
await using var sqlConnection = new SqlConnection(ConnectionString);
var results = await sqlConnection.QueryAsync<Transaction>(
$"[{Schema}].[Transaction_ReadByProviderId]",
new { ProviderId = providerId },
new { ProviderId = providerId, Limit = limit ?? int.MaxValue },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
public async Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId)
{
// maybe come back to this
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Transaction>(

View File

@ -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<Core.Entities.Transaction, Trans
public async Task<Core.Entities.Transaction> 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<Core.Entities.Transaction>(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<Core.Entities.Transaction>(results);
}
public async Task<ICollection<Core.Entities.Transaction>> GetManyByOrganizationIdAsync(Guid organizationId)
public async Task<ICollection<Core.Entities.Transaction>> 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<List<Core.Entities.Transaction>>(results);
query = query.OrderByDescending(o => o.CreationDate).Take(limit.Value);
}
var results = await EntityFrameworkQueryableExtensions.ToListAsync(query);
return Mapper.Map<List<Core.Entities.Transaction>>(results);
}
public async Task<ICollection<Core.Entities.Transaction>> GetManyByUserIdAsync(Guid userId)
public async Task<ICollection<Core.Entities.Transaction>> 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<List<Core.Entities.Transaction>>(results);
query = query.OrderByDescending(o => o.CreationDate).Take(limit.Value);
}
var results = await EntityFrameworkQueryableExtensions.ToListAsync(query);
return Mapper.Map<List<Core.Entities.Transaction>>(results);
}
public async Task<ICollection<Core.Entities.Transaction>> GetManyByProviderIdAsync(Guid providerId)
public async Task<ICollection<Core.Entities.Transaction>> 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<List<Core.Entities.Transaction>>(results);
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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