diff --git a/src/Admin/Jobs/DatabaseUpdateStatisticsJob.cs b/src/Admin/Jobs/DatabaseUpdateStatisticsJob.cs index cd124f65fa..66be8c4ba0 100644 --- a/src/Admin/Jobs/DatabaseUpdateStatisticsJob.cs +++ b/src/Admin/Jobs/DatabaseUpdateStatisticsJob.cs @@ -22,6 +22,7 @@ namespace Bit.Admin.Jobs protected async override Task ExecuteJobAsync(IJobExecutionContext context) { await _maintenanceRepository.UpdateStatisticsAsync(); + await _maintenanceRepository.DisableCipherAutoStatsAsync(); } } } diff --git a/src/Api/Controllers/AccountsController.cs b/src/Api/Controllers/AccountsController.cs index 703f7a8dfd..eae0760ed0 100644 --- a/src/Api/Controllers/AccountsController.cs +++ b/src/Api/Controllers/AccountsController.cs @@ -28,6 +28,7 @@ namespace Bit.Api.Controllers private readonly ICipherService _cipherService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILicensingService _licenseService; + private readonly IPaymentService _paymentService; private readonly GlobalSettings _globalSettings; public AccountsController( @@ -38,6 +39,7 @@ namespace Bit.Api.Controllers ICipherService cipherService, IOrganizationUserRepository organizationUserRepository, ILicensingService licenseService, + IPaymentService paymentService, GlobalSettings globalSettings) { _userService = userService; @@ -47,6 +49,7 @@ namespace Bit.Api.Controllers _cipherService = cipherService; _organizationUserRepository = organizationUserRepository; _licenseService = licenseService; + _paymentService = paymentService; _globalSettings = globalSettings; } @@ -476,8 +479,7 @@ namespace Bit.Api.Controllers if(!_globalSettings.SelfHosted && user.Gateway != null) { - var paymentService = user.GetPaymentService(_globalSettings); - var billingInfo = await paymentService.GetBillingAsync(user); + var billingInfo = await _paymentService.GetBillingAsync(user); var license = await _userService.GenerateLicenseAsync(user, billingInfo); return new BillingResponseModel(user, billingInfo, license); } diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 340b079086..f9e8213e6d 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -24,6 +24,7 @@ namespace Bit.Api.Controllers private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; + private readonly IPaymentService _paymentService; private readonly CurrentContext _currentContext; private readonly GlobalSettings _globalSettings; @@ -32,6 +33,7 @@ namespace Bit.Api.Controllers IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, IUserService userService, + IPaymentService paymentService, CurrentContext currentContext, GlobalSettings globalSettings) { @@ -39,6 +41,7 @@ namespace Bit.Api.Controllers _organizationUserRepository = organizationUserRepository; _organizationService = organizationService; _userService = userService; + _paymentService = paymentService; _currentContext = currentContext; _globalSettings = globalSettings; } @@ -78,8 +81,7 @@ namespace Bit.Api.Controllers if(!_globalSettings.SelfHosted && organization.Gateway != null) { - var paymentService = new StripePaymentService(); - var billingInfo = await paymentService.GetBillingAsync(organization); + var billingInfo = await _paymentService.GetBillingAsync(organization); if(billingInfo == null) { throw new NotFoundException(); @@ -110,7 +112,7 @@ namespace Bit.Api.Controllers try { - var invoice = await new StripeInvoiceService().GetAsync(invoiceId); + var invoice = await new InvoiceService().GetAsync(invoiceId); if(invoice != null && invoice.CustomerId == organization.GatewayCustomerId && !string.IsNullOrWhiteSpace(invoice.HostedInvoiceUrl)) { diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index e0b378bdc6..f072672afe 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -6,5 +6,15 @@ public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret { get; set; } public virtual string BraintreeWebhookKey { get; set; } + public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); + + public class PayPalSettings + { + public virtual bool Production { get; set; } + public virtual string ClientId { get; set; } + public virtual string ClientSecret { get; set; } + public virtual string WebhookId { get; set; } + public virtual string WebhookKey { get; set; } + } } } diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs new file mode 100644 index 0000000000..2f456b54ea --- /dev/null +++ b/src/Billing/Controllers/PayPalController.cs @@ -0,0 +1,141 @@ +using Bit.Billing.Utilities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System.Data.SqlClient; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Bit.Billing.Controllers +{ + [Route("paypal")] + public class PayPalController : Controller + { + private readonly BillingSettings _billingSettings; + private readonly PayPalClient _paypalClient; + private readonly ITransactionRepository _transactionRepository; + + public PayPalController( + IOptions billingSettings, + PayPalClient paypalClient, + ITransactionRepository transactionRepository) + { + _billingSettings = billingSettings?.Value; + _paypalClient = paypalClient; + _transactionRepository = transactionRepository; + } + + [HttpPost("webhook")] + public async Task PostWebhook([FromQuery] string key) + { + if(key != _billingSettings.PayPal.WebhookKey) + { + return new BadRequestResult(); + } + + if(HttpContext?.Request == null) + { + return new BadRequestResult(); + } + + string body = null; + using(var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) + { + body = await reader.ReadToEndAsync(); + } + + if(body == null) + { + return new BadRequestResult(); + } + + var verified = await _paypalClient.VerifyWebhookAsync(body, HttpContext.Request.Headers, + _billingSettings.PayPal.WebhookId); + if(!verified) + { + return new BadRequestResult(); + } + + if(body.Contains("\"PAYMENT.SALE.COMPLETED\"")) + { + var ev = JsonConvert.DeserializeObject>(body); + var sale = ev.Resource; + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, sale.Id); + if(saleTransaction == null) + { + var ids = sale.GetIdsFromCustom(); + if(ids.Item1.HasValue || ids.Item2.HasValue) + { + try + { + await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + { + Amount = sale.Amount.TotalAmount, + CreationDate = sale.CreateTime, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = TransactionType.Charge, + Gateway = GatewayType.PayPal, + GatewayId = sale.Id, + PaymentMethodType = PaymentMethodType.PayPal, + Details = sale.Id + }); + } + // Catch foreign key violations because user/org could have been deleted. + catch(SqlException e) when(e.Number == 547) { } + } + } + } + else if(body.Contains("\"PAYMENT.SALE.REFUNDED\"")) + { + var ev = JsonConvert.DeserializeObject>(body); + var refund = ev.Resource; + var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.Id); + if(refundTransaction == null) + { + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.SaleId); + if(saleTransaction == null) + { + return new BadRequestResult(); + } + + if(!saleTransaction.Refunded.GetValueOrDefault() && + saleTransaction.RefundedAmount.GetValueOrDefault() < refund.TotalRefundedAmount.ValueAmount) + { + saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; + if(saleTransaction.RefundedAmount == saleTransaction.Amount) + { + saleTransaction.Refunded = true; + } + await _transactionRepository.ReplaceAsync(saleTransaction); + + var ids = refund.GetIdsFromCustom(); + if(ids.Item1.HasValue || ids.Item2.HasValue) + { + await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + { + Amount = refund.Amount.TotalAmount, + CreationDate = refund.CreateTime, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = TransactionType.Refund, + Gateway = GatewayType.PayPal, + GatewayId = refund.Id, + PaymentMethodType = PaymentMethodType.PayPal, + Details = refund.Id + }); + } + } + } + } + + return new OkResult(); + } + } +} diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index b30357cb12..b42d205920 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Table; +using Bit.Core; +using Bit.Core.Enums; +using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Hosting; @@ -7,6 +9,7 @@ using Microsoft.Extensions.Options; using Stripe; using System; using System.Collections.Generic; +using System.Data.SqlClient; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -20,14 +23,18 @@ namespace Bit.Billing.Controllers private readonly IHostingEnvironment _hostingEnvironment; private readonly IOrganizationService _organizationService; private readonly IOrganizationRepository _organizationRepository; + private readonly ITransactionRepository _transactionRepository; private readonly IUserService _userService; private readonly IMailService _mailService; + private readonly Braintree.BraintreeGateway _btGateway; public StripeController( + GlobalSettings globalSettings, IOptions billingSettings, IHostingEnvironment hostingEnvironment, IOrganizationService organizationService, IOrganizationRepository organizationRepository, + ITransactionRepository transactionRepository, IUserService userService, IMailService mailService) { @@ -35,8 +42,18 @@ namespace Bit.Billing.Controllers _hostingEnvironment = hostingEnvironment; _organizationService = organizationService; _organizationRepository = organizationRepository; + _transactionRepository = transactionRepository; _userService = userService; _mailService = mailService; + + _btGateway = new Braintree.BraintreeGateway + { + Environment = globalSettings.Braintree.Production ? + Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX, + MerchantId = globalSettings.Braintree.MerchantId, + PublicKey = globalSettings.Braintree.PublicKey, + PrivateKey = globalSettings.Braintree.PrivateKey + }; } [HttpPost("webhook")] @@ -47,11 +64,11 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - StripeEvent parsedEvent; + Stripe.Event parsedEvent; using(var sr = new StreamReader(HttpContext.Request.Body)) { var json = await sr.ReadToEndAsync(); - parsedEvent = StripeEventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], + parsedEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], _billingSettings.StripeWebhookSecret); } @@ -60,20 +77,17 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - if(_hostingEnvironment.IsProduction() && !parsedEvent.LiveMode) + if(_hostingEnvironment.IsProduction() && !parsedEvent.Livemode) { return new BadRequestResult(); } - var invUpcoming = parsedEvent.Type.Equals("invoice.upcoming"); var subDeleted = parsedEvent.Type.Equals("customer.subscription.deleted"); var subUpdated = parsedEvent.Type.Equals("customer.subscription.updated"); if(subDeleted || subUpdated) { - StripeSubscription subscription = Mapper.MapFromJson( - parsedEvent.Data.Object.ToString()); - if(subscription == null) + if(!(parsedEvent.Data.Object is Subscription subscription)) { throw new Exception("Subscription is null."); } @@ -113,16 +127,14 @@ namespace Bit.Billing.Controllers } } } - else if(invUpcoming) + else if(parsedEvent.Type.Equals("invoice.upcoming")) { - StripeInvoice invoice = Mapper.MapFromJson( - parsedEvent.Data.Object.ToString()); - if(invoice == null) + if(!(parsedEvent.Data.Object is Invoice invoice)) { throw new Exception("Invoice is null."); } - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); if(subscription == null) { @@ -152,11 +164,153 @@ namespace Bit.Billing.Controllers if(!string.IsNullOrWhiteSpace(email) && invoice.NextPaymentAttempt.HasValue) { - var items = invoice.StripeInvoiceLineItems.Select(i => i.Description).ToList(); + var items = invoice.Lines.Select(i => i.Description).ToList(); await _mailService.SendInvoiceUpcomingAsync(email, invoice.AmountDue / 100M, invoice.NextPaymentAttempt.Value, items, ids.Item1.HasValue); } } + else if(parsedEvent.Type.Equals("charge.succeeded")) + { + if(!(parsedEvent.Data.Object is Charge charge)) + { + throw new Exception("Charge is null."); + } + + if(charge.InvoiceId == null) + { + return new OkResult(); + } + + var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.Stripe, charge.Id); + if(chargeTransaction == null) + { + var invoiceService = new InvoiceService(); + var invoice = await invoiceService.GetAsync(charge.InvoiceId); + if(invoice == null) + { + return new OkResult(); + } + + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + if(subscription == null) + { + return new OkResult(); + } + + var ids = GetIdsFromMetaData(subscription.Metadata); + if(ids.Item1.HasValue || ids.Item2.HasValue) + { + var tx = new Transaction + { + Amount = charge.Amount / 100M, + CreationDate = charge.Created, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = TransactionType.Charge, + Gateway = GatewayType.Stripe, + GatewayId = charge.Id + }; + + if(charge.Source is Card card) + { + tx.PaymentMethodType = PaymentMethodType.Card; + tx.Details = $"{card.Brand}, *{card.Last4}"; + } + else if(charge.Source is BankAccount bankAccount) + { + tx.PaymentMethodType = PaymentMethodType.BankAccount; + tx.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}"; + } + else + { + return new OkResult(); + } + + try + { + await _transactionRepository.CreateAsync(tx); + } + // Catch foreign key violations because user/org could have been deleted. + catch(SqlException e) when(e.Number == 547) { } + } + } + } + else if(parsedEvent.Type.Equals("charge.refunded")) + { + if(!(parsedEvent.Data.Object is Charge charge)) + { + throw new Exception("Charge is null."); + } + + var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.Stripe, charge.Id); + if(chargeTransaction == null) + { + throw new Exception("Cannot find refunded charge."); + } + + var amountRefunded = charge.AmountRefunded / 100M; + + if(!chargeTransaction.Refunded.GetValueOrDefault() && + chargeTransaction.RefundedAmount.GetValueOrDefault() < amountRefunded) + { + chargeTransaction.RefundedAmount = amountRefunded; + if(charge.Refunded) + { + chargeTransaction.Refunded = true; + } + await _transactionRepository.ReplaceAsync(chargeTransaction); + + foreach(var refund in charge.Refunds) + { + var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.Stripe, refund.Id); + if(refundTransaction != null) + { + continue; + } + + await _transactionRepository.CreateAsync(new Transaction + { + Amount = refund.Amount / 100M, + CreationDate = refund.Created, + OrganizationId = chargeTransaction.OrganizationId, + UserId = chargeTransaction.UserId, + Type = TransactionType.Refund, + Gateway = GatewayType.Stripe, + GatewayId = refund.Id, + PaymentMethodType = chargeTransaction.PaymentMethodType, + Details = chargeTransaction.Details + }); + } + } + } + else if(parsedEvent.Type.Equals("invoice.payment_failed")) + { + if(!(parsedEvent.Data.Object is Invoice invoice)) + { + throw new Exception("Invoice is null."); + } + + if(invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) + { + await AttemptToPayInvoiceWithBraintreeAsync(invoice); + } + } + else if(parsedEvent.Type.Equals("invoice.created")) + { + if(!(parsedEvent.Data.Object is Invoice invoice)) + { + throw new Exception("Invoice is null."); + } + + if(UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) + { + await AttemptToPayInvoiceWithBraintreeAsync(invoice); + } + } return new OkResult(); } @@ -204,13 +358,88 @@ namespace Bit.Billing.Controllers { switch(org.PlanType) { - case Core.Enums.PlanType.FamiliesAnnually: - case Core.Enums.PlanType.TeamsAnnually: - case Core.Enums.PlanType.EnterpriseAnnually: + case PlanType.FamiliesAnnually: + case PlanType.TeamsAnnually: + case PlanType.EnterpriseAnnually: return true; default: return false; } } + + private async Task AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice) + { + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(invoice.CustomerId); + if(!customer?.Metadata?.ContainsKey("btCustomerId") ?? true) + { + return false; + } + + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + var ids = GetIdsFromMetaData(subscription?.Metadata); + if(!ids.Item1.HasValue && !ids.Item2.HasValue) + { + return false; + } + + var btObjIdField = ids.Item1.HasValue ? "organization_id" : "user_id"; + var btObjId = ids.Item1 ?? ids.Item2.Value; + var btInvoiceAmount = (invoice.AmountDue / 100M); + + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = customer.Metadata["btCustomerId"], + Options = new Braintree.TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new Braintree.TransactionOptionsPayPalRequest + { + CustomField = $"{btObjIdField}:{btObjId}" + } + }, + CustomFields = new Dictionary + { + [btObjIdField] = btObjId.ToString() + } + }); + + if(!transactionResult.IsSuccess()) + { + // TODO: Send payment failure email? + return false; + } + + try + { + var invoiceService = new InvoiceService(); + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + ["btTransactionId"] = transactionResult.Target.Id, + ["btPayPalTransactionId"] = + transactionResult.Target.PayPalDetails?.AuthorizationId + } + }); + await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + } + catch(Exception e) + { + await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id); + throw e; + } + + return true; + } + + private bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice) + { + return invoice.AmountDue > 0 && !invoice.Paid && invoice.Billing == Stripe.Billing.ChargeAutomatically && + invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; + } } } diff --git a/src/Billing/Jobs/PremiumRenewalRemindersJob.cs b/src/Billing/Jobs/PremiumRenewalRemindersJob.cs index 94dc35e875..b87703821b 100644 --- a/src/Billing/Jobs/PremiumRenewalRemindersJob.cs +++ b/src/Billing/Jobs/PremiumRenewalRemindersJob.cs @@ -17,12 +17,14 @@ namespace Bit.Billing.Jobs private readonly GlobalSettings _globalSettings; private readonly IUserRepository _userRepository; private readonly IMailService _mailService; + private readonly IPaymentService _paymentService; public PremiumRenewalRemindersJob( IOptions billingSettings, GlobalSettings globalSettings, IUserRepository userRepository, IMailService mailService, + IPaymentService paymentService, ILogger logger) : base(logger) { @@ -30,6 +32,7 @@ namespace Bit.Billing.Jobs _globalSettings = globalSettings; _userRepository = userRepository; _mailService = mailService; + _paymentService = paymentService; } protected async override Task ExecuteJobAsync(IJobExecutionContext context) @@ -37,8 +40,7 @@ namespace Bit.Billing.Jobs var users = await _userRepository.GetManyByPremiumRenewalAsync(); foreach(var user in users) { - var paymentService = user.GetPaymentService(_globalSettings); - var upcomingInvoice = await paymentService.GetUpcomingInvoiceAsync(user); + var upcomingInvoice = await _paymentService.GetUpcomingInvoiceAsync(user); if(upcomingInvoice?.Date != null) { var items = new List { "1 × Premium Membership (Annually)" }; diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index fc5716916e..6e85974981 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -39,6 +39,9 @@ namespace Bit.Billing // Repositories services.AddSqlServerRepositories(globalSettings); + // PayPal Client + services.AddSingleton(); + // Context services.AddScoped(); diff --git a/src/Billing/Utilities/PayPalClient.cs b/src/Billing/Utilities/PayPalClient.cs new file mode 100644 index 0000000000..0e203df57c --- /dev/null +++ b/src/Billing/Utilities/PayPalClient.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; + +namespace Bit.Billing.Utilities +{ + public class PayPalClient + { + private readonly HttpClient _httpClient = new HttpClient(); + private readonly string _baseApiUrl; + private readonly string _clientId; + private readonly string _clientSecret; + + private AuthResponse _authResponse; + + public PayPalClient(BillingSettings billingSettings) + { + _baseApiUrl = _baseApiUrl = !billingSettings.PayPal.Production ? "https://api.sandbox.paypal.com/{0}" : + "https://api.paypal.com/{0}"; + _clientId = billingSettings.PayPal.ClientId; + _clientSecret = billingSettings.PayPal.ClientSecret; + } + + public async Task VerifyWebhookAsync(string webhookJson, IHeaderDictionary headers, string webhookId) + { + if(webhookJson == null) + { + throw new ArgumentException("No webhook json."); + } + + if(headers == null) + { + throw new ArgumentException("No headers."); + } + + if(!headers.ContainsKey("PAYPAL-TRANSMISSION-ID")) + { + return false; + } + + await AuthIfNeededAsync(); + + var req = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(string.Format(_baseApiUrl, "v1/notifications/verify-webhook-signature")) + }; + req.Headers.Authorization = new AuthenticationHeaderValue( + _authResponse.TokenType, _authResponse.AccessToken); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var verifyRequest = new VerifyWebookRequest + { + AuthAlgo = headers["PAYPAL-AUTH-ALGO"], + CertUrl = headers["PAYPAL-CERT-URL"], + TransmissionId = headers["PAYPAL-TRANSMISSION-ID"], + TransmissionTime = headers["PAYPAL-TRANSMISSION-TIME"], + TransmissionSig = headers["PAYPAL-TRANSMISSION-SIG"], + WebhookId = webhookId + }; + var verifyRequestJson = JsonConvert.SerializeObject(verifyRequest); + verifyRequestJson = verifyRequestJson.Replace("\"__WEBHOOK_BODY__\"", webhookJson); + req.Content = new StringContent(verifyRequestJson, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(req); + if(!response.IsSuccessStatusCode) + { + throw new Exception("Failed to verify webhook"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var verifyResponse = JsonConvert.DeserializeObject(responseContent); + return verifyResponse.Verified; + } + + private async Task AuthIfNeededAsync() + { + if(_authResponse?.Expired ?? true) + { + var req = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(string.Format(_baseApiUrl, "v1/oauth2/token")) + }; + var authVal = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}")); + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", authVal); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "client_credentials") + }); + + var response = await _httpClient.SendAsync(req); + if(!response.IsSuccessStatusCode) + { + throw new Exception("Failed to auth with PayPal"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + _authResponse = JsonConvert.DeserializeObject(responseContent); + return true; + } + return false; + } + + public class VerifyWebookRequest + { + [JsonProperty("auth_algo")] + public string AuthAlgo { get; set; } + [JsonProperty("cert_url")] + public string CertUrl { get; set; } + [JsonProperty("transmission_id")] + public string TransmissionId { get; set; } + [JsonProperty("transmission_sig")] + public string TransmissionSig { get; set; } + [JsonProperty("transmission_time")] + public string TransmissionTime { get; set; } + [JsonProperty("webhook_event")] + public string WebhookEvent { get; set; } = "__WEBHOOK_BODY__"; + [JsonProperty("webhook_id")] + public string WebhookId { get; set; } + } + + public class VerifyWebookResponse + { + [JsonProperty("verification_status")] + public string VerificationStatus { get; set; } + public bool Verified => VerificationStatus == "SUCCESS"; + } + + public class AuthResponse + { + private DateTime _created; + + public AuthResponse() + { + _created = DateTime.UtcNow; + } + + [JsonProperty("scope")] + public string Scope { get; set; } + [JsonProperty("nonce")] + public string Nonce { get; set; } + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("token_type")] + public string TokenType { get; set; } + [JsonProperty("app_id")] + public string AppId { get; set; } + [JsonProperty("expires_in")] + public long ExpiresIn { get; set; } + public bool Expired => DateTime.UtcNow > _created.AddSeconds(ExpiresIn - 30); + } + + public class Event + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("event_type")] + public string EventType { get; set; } + [JsonProperty("resource_type")] + public string ResourceType { get; set; } + [JsonProperty("create_time")] + public DateTime CreateTime { get; set; } + public T Resource { get; set; } + } + + public class Refund : Sale + { + [JsonProperty("total_refunded_amount")] + public ValueInfo TotalRefundedAmount { get; set; } + [JsonProperty("sale_id")] + public string SaleId { get; set; } + } + + public class Sale + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("state")] + public string State { get; set; } + [JsonProperty("amount")] + public AmountInfo Amount { get; set; } + [JsonProperty("parent_payment")] + public string ParentPayment { get; set; } + [JsonProperty("custom")] + public string Custom { get; set; } + [JsonProperty("create_time")] + public DateTime CreateTime { get; set; } + [JsonProperty("update_time")] + public DateTime UpdateTime { get; set; } + + public Tuple GetIdsFromCustom() + { + Guid? orgId = null; + Guid? userId = null; + + if(!string.IsNullOrWhiteSpace(Custom) && Custom.Contains(":")) + { + var parts = Custom.Split(':'); + if(parts.Length > 1 && Guid.TryParse(parts[1], out var id)) + { + if(parts[0] == "user_id") + { + userId = id; + } + else if(parts[0] == "organization_id") + { + orgId = id; + } + } + } + + return new Tuple(orgId, userId); + } + } + + public class AmountInfo + { + [JsonProperty("total")] + public string Total { get; set; } + public decimal TotalAmount => Convert.ToDecimal(Total); + } + + public class ValueInfo + { + [JsonProperty("value")] + public string Value { get; set; } + public decimal ValueAmount => Convert.ToDecimal(Value); + } + } +} diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 5ea6892d03..370128a2e4 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -15,5 +15,10 @@ "braintree": { "production": true } + }, + "billingSettings": { + "payPal": { + "production": false + } } } diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 82c91ba599..ebd5714491 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -45,18 +45,25 @@ "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" + }, + "braintree": { + "production": false, + "merchantId": "SECRET", + "publicKey": "SECRET", + "privateKey": "SECRET" } }, "billingSettings": { "jobsKey": "SECRET", "stripeWebhookKey": "SECRET", "stripeWebhookSecret": "SECRET", - "braintreeWebhookKey": "SECRET" - }, - "braintree": { - "production": false, - "merchantId": "SECRET", - "publicKey": "SECRET", - "privateKey": "SECRET" + "braintreeWebhookKey": "SECRET", + "payPal": { + "production": false, + "clientId": "SECRET", + "clientSecret": "SECRET", + "webhookId": "SECRET", + "webhookKey": "SECRET" + } } } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index b86969acbb..823206c8d9 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/Core/Enums/GatewayType.cs b/src/Core/Enums/GatewayType.cs index 5a9dcdbd76..bb0e88f365 100644 --- a/src/Core/Enums/GatewayType.cs +++ b/src/Core/Enums/GatewayType.cs @@ -13,6 +13,8 @@ namespace Bit.Core.Enums [Display(Name = "Google Play Store")] PlayStore = 3, [Display(Name = "Coinbase")] - Coinbase = 4 + Coinbase = 4, + [Display(Name = "PayPal")] + PayPal = 5, } } diff --git a/src/Core/Enums/GlobalEquivalentDomainsType.cs b/src/Core/Enums/GlobalEquivalentDomainsType.cs index f5370398db..39d0a72e29 100644 --- a/src/Core/Enums/GlobalEquivalentDomainsType.cs +++ b/src/Core/Enums/GlobalEquivalentDomainsType.cs @@ -16,7 +16,7 @@ United = 11, Yahoo = 12, Zonelabs = 13, - Paypal = 14, + PayPal = 14, Avon = 15, Diapers = 16, Contacts = 17, diff --git a/src/Core/Enums/TransactionType.cs b/src/Core/Enums/TransactionType.cs new file mode 100644 index 0000000000..45baa68c06 --- /dev/null +++ b/src/Core/Enums/TransactionType.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Enums +{ + public enum TransactionType : byte + { + Charge = 0, + Credit = 1, + PromotionalCredit = 2, + ReferralCredit = 3, + Refund = 4, + } +} diff --git a/src/Core/Models/Api/Response/BillingResponseModel.cs b/src/Core/Models/Api/Response/BillingResponseModel.cs index 1a3e01225f..efc2bda096 100644 --- a/src/Core/Models/Api/Response/BillingResponseModel.cs +++ b/src/Core/Models/Api/Response/BillingResponseModel.cs @@ -12,10 +12,12 @@ namespace Bit.Core.Models.Api public BillingResponseModel(User user, BillingInfo billing, UserLicense license) : base("billing") { + CreditAmount = billing.CreditAmount; PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; Subscription = billing.Subscription != null ? new BillingSubscription(billing.Subscription) : null; - Charges = billing.Charges.Select(c => new BillingCharge(c)); - UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoice(billing.UpcomingInvoice) : null; + Transactions = billing.Transactions?.Select(t => new BillingTransaction(t)); + Invoices = billing.Invoices?.Select(i => new BillingInvoice(i)); + UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoiceInfo(billing.UpcomingInvoice) : null; StorageName = user.Storage.HasValue ? Utilities.CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB MaxStorageGb = user.MaxStorageGb; @@ -37,13 +39,15 @@ namespace Bit.Core.Models.Api } } + public decimal CreditAmount { get; set; } public string StorageName { get; set; } public double? StorageGb { get; set; } public short? MaxStorageGb { get; set; } public BillingSource PaymentSource { get; set; } public BillingSubscription Subscription { get; set; } - public BillingInvoice UpcomingInvoice { get; set; } - public IEnumerable Charges { get; set; } + public BillingInvoiceInfo UpcomingInvoice { get; set; } + public IEnumerable Invoices { get; set; } + public IEnumerable Transactions { get; set; } public UserLicense License { get; set; } public DateTime? Expiration { get; set; } } @@ -109,9 +113,9 @@ namespace Bit.Core.Models.Api } } - public class BillingInvoice + public class BillingInvoiceInfo { - public BillingInvoice(BillingInfo.BillingInvoice inv) + public BillingInvoiceInfo(BillingInfo.BillingInvoice inv) { Amount = inv.Amount; Date = inv.Date; @@ -121,28 +125,44 @@ namespace Bit.Core.Models.Api public DateTime? Date { get; set; } } - public class BillingCharge + public class BillingInvoice : BillingInvoiceInfo { - public BillingCharge(BillingInfo.BillingCharge charge) + public BillingInvoice(BillingInfo.BillingInvoice2 inv) + : base(inv) { - Amount = charge.Amount; - RefundedAmount = charge.RefundedAmount; - PaymentSource = charge.PaymentSource != null ? new BillingSource(charge.PaymentSource) : null; - CreatedDate = charge.CreatedDate; - FailureMessage = charge.FailureMessage; - Refunded = charge.Refunded; - Status = charge.Status; - InvoiceId = charge.InvoiceId; + Url = inv.Url; + PdfUrl = inv.PdfUrl; + Number = inv.Number; + Paid = inv.Paid; + } + + public string Url { get; set; } + public string PdfUrl { get; set; } + public string Number { get; set; } + public bool Paid { get; set; } + } + + public class BillingTransaction + { + public BillingTransaction(BillingInfo.BillingTransaction transaction) + { + CreatedDate = transaction.CreatedDate; + Amount = transaction.Amount; + Refunded = transaction.Refunded; + RefundedAmount = transaction.RefundedAmount; + PartiallyRefunded = transaction.PartiallyRefunded; + Type = transaction.Type; + PaymentMethodType = transaction.PaymentMethodType; + Details = transaction.Details; } public DateTime CreatedDate { get; set; } public decimal Amount { get; set; } - public BillingSource PaymentSource { get; set; } - public string Status { get; set; } - public string FailureMessage { get; set; } - public bool Refunded { get; set; } - public bool PartiallyRefunded => !Refunded && RefundedAmount > 0; - public decimal RefundedAmount { get; set; } - public string InvoiceId { get; set; } + public bool? Refunded { get; set; } + public bool? PartiallyRefunded { get; set; } + public decimal? RefundedAmount { get; set; } + public TransactionType Type { get; set; } + public PaymentMethodType? PaymentMethodType { get; set; } + public string Details { get; set; } } } diff --git a/src/Core/Models/Api/Response/OrganizationResponseModel.cs b/src/Core/Models/Api/Response/OrganizationResponseModel.cs index 1c26401b29..03623f3a0c 100644 --- a/src/Core/Models/Api/Response/OrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationResponseModel.cs @@ -67,8 +67,9 @@ namespace Bit.Core.Models.Api { PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; Subscription = billing.Subscription != null ? new BillingSubscription(billing.Subscription) : null; - Charges = billing.Charges.Select(c => new BillingCharge(c)); - UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoice(billing.UpcomingInvoice) : null; + Transactions = billing.Transactions?.Select(t => new BillingTransaction(t)); + Invoices = billing.Invoices?.Select(i => new BillingInvoice(i)); + UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoiceInfo(billing.UpcomingInvoice) : null; StorageName = organization.Storage.HasValue ? Utilities.CoreHelpers.ReadableBytesSize(organization.Storage.Value) : null; StorageGb = organization.Storage.HasValue ? Math.Round(organization.Storage.Value / 1073741824D) : 0; // 1 GB @@ -88,8 +89,9 @@ namespace Bit.Core.Models.Api public double? StorageGb { get; set; } public BillingSource PaymentSource { get; set; } public BillingSubscription Subscription { get; set; } - public BillingInvoice UpcomingInvoice { get; set; } - public IEnumerable Charges { get; set; } + public BillingInvoiceInfo UpcomingInvoice { get; set; } + public IEnumerable Invoices { get; set; } + public IEnumerable Transactions { get; set; } public DateTime? Expiration { get; set; } } } diff --git a/src/Core/Models/Business/BillingInfo.cs b/src/Core/Models/Business/BillingInfo.cs index 1e1d5d9bf8..ac4c6c0672 100644 --- a/src/Core/Models/Business/BillingInfo.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -1,5 +1,5 @@ using Bit.Core.Enums; -using Braintree; +using Bit.Core.Models.Table; using Stripe; using System; using System.Collections.Generic; @@ -9,47 +9,47 @@ namespace Bit.Core.Models.Business { public class BillingInfo { + public decimal CreditAmount { get; set; } public BillingSource PaymentSource { get; set; } public BillingSubscription Subscription { get; set; } public BillingInvoice UpcomingInvoice { get; set; } public IEnumerable Charges { get; set; } = new List(); + public IEnumerable Invoices { get; set; } = new List(); + public IEnumerable Transactions { get; set; } = new List(); public class BillingSource { - public BillingSource(Source source) + public BillingSource(IPaymentSource source) { - switch(source.Type) + if(source is BankAccount bankAccount) { - case SourceType.Card: - Type = PaymentMethodType.Card; - Description = $"{source.Card.Brand}, *{source.Card.Last4}, " + - string.Format("{0}/{1}", - string.Concat(source.Card.ExpirationMonth < 10 ? - "0" : string.Empty, source.Card.ExpirationMonth), - source.Card.ExpirationYear); - CardBrand = source.Card.Brand; - break; - case SourceType.BankAccount: - Type = PaymentMethodType.BankAccount; - Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4} - " + - (source.BankAccount.Status == "verified" ? "verified" : - source.BankAccount.Status == "errored" ? "invalid" : - source.BankAccount.Status == "verification_failed" ? "verification failed" : "unverified"); - NeedsVerification = source.BankAccount.Status == "new" || source.BankAccount.Status == "validated"; - break; - default: - break; + 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; } } - public BillingSource(PaymentMethod method) + public BillingSource(Braintree.PaymentMethod method) { - if(method is PayPalAccount paypal) + if(method is Braintree.PayPalAccount paypal) { Type = PaymentMethodType.PayPal; Description = paypal.Email; } - else if(method is CreditCard card) + else if(method is Braintree.CreditCard card) { Type = PaymentMethodType.Card; Description = $"{card.CardType.ToString()}, *{card.LastFour}, " + @@ -59,7 +59,7 @@ namespace Bit.Core.Models.Business card.ExpirationYear); CardBrand = card.CardType.ToString(); } - else if(method is UsBankAccount bank) + else if(method is Braintree.UsBankAccount bank) { Type = PaymentMethodType.BankAccount; Description = $"{bank.BankName}, *{bank.Last4}"; @@ -70,13 +70,13 @@ namespace Bit.Core.Models.Business } } - public BillingSource(UsBankAccountDetails bank) + public BillingSource(Braintree.UsBankAccountDetails bank) { Type = PaymentMethodType.BankAccount; Description = $"{bank.BankName}, *{bank.Last4}"; } - public BillingSource(PayPalDetails paypal) + public BillingSource(Braintree.PayPalDetails paypal) { Type = PaymentMethodType.PayPal; Description = paypal.PayerEmail; @@ -90,7 +90,7 @@ namespace Bit.Core.Models.Business public class BillingSubscription { - public BillingSubscription(StripeSubscription sub) + public BillingSubscription(Subscription sub) { Status = sub.Status; TrialStartDate = sub.TrialStart; @@ -106,14 +106,14 @@ namespace Bit.Core.Models.Business } } - public BillingSubscription(Subscription sub, Plan plan) + public BillingSubscription(Braintree.Subscription sub, Braintree.Plan plan) { Status = sub.Status.ToString(); if(sub.HasTrialPeriod.GetValueOrDefault() && sub.CreatedAt.HasValue && sub.TrialDuration.HasValue) { TrialStartDate = sub.CreatedAt.Value; - if(sub.TrialDurationUnit == SubscriptionDurationUnit.DAY) + if(sub.TrialDurationUnit == Braintree.SubscriptionDurationUnit.DAY) { TrialEndDate = TrialStartDate.Value.AddDays(sub.TrialDuration.Value); } @@ -127,7 +127,7 @@ namespace Bit.Core.Models.Business PeriodEndDate = sub.BillingPeriodEndDate; CancelAtEndDate = !sub.NeverExpires.GetValueOrDefault(); - Cancelled = sub.Status == SubscriptionStatus.CANCELED; + Cancelled = sub.Status == Braintree.SubscriptionStatus.CANCELED; if(Cancelled) { CancelledDate = sub.UpdatedAt.Value; @@ -159,7 +159,7 @@ namespace Bit.Core.Models.Business public class BillingSubscriptionItem { - public BillingSubscriptionItem(StripeSubscriptionItem item) + public BillingSubscriptionItem(SubscriptionItem item) { if(item.Plan != null) { @@ -168,10 +168,10 @@ namespace Bit.Core.Models.Business Interval = item.Plan.Interval; } - Quantity = item.Quantity; + Quantity = (int)item.Quantity; } - public BillingSubscriptionItem(Plan plan) + public BillingSubscriptionItem(Braintree.Plan plan) { Name = plan.Name; Amount = plan.Price.GetValueOrDefault(); @@ -179,7 +179,7 @@ namespace Bit.Core.Models.Business Quantity = 1; } - public BillingSubscriptionItem(Plan plan, AddOn addon) + public BillingSubscriptionItem(Braintree.Plan plan, Braintree.AddOn addon) { Name = addon.Name; Amount = addon.Amount.GetValueOrDefault(); @@ -196,13 +196,15 @@ namespace Bit.Core.Models.Business public class BillingInvoice { - public BillingInvoice(StripeInvoice inv) + public BillingInvoice() { } + + public BillingInvoice(Invoice inv) { Amount = inv.AmountDue / 100M; Date = inv.Date.Value; } - public BillingInvoice(Subscription sub) + public BillingInvoice(Braintree.Subscription sub) { Amount = sub.NextBillAmount.GetValueOrDefault() + sub.Balance.GetValueOrDefault(); if(Amount < 0) @@ -218,7 +220,7 @@ namespace Bit.Core.Models.Business public class BillingCharge { - public BillingCharge(StripeCharge charge) + public BillingCharge(Charge charge) { Amount = charge.Amount / 100M; RefundedAmount = charge.AmountRefunded / 100M; @@ -230,7 +232,7 @@ namespace Bit.Core.Models.Business InvoiceId = charge.InvoiceId; } - public BillingCharge(Transaction transaction) + public BillingCharge(Braintree.Transaction transaction) { Amount = transaction.Amount.GetValueOrDefault(); RefundedAmount = 0; // TODO? @@ -239,7 +241,8 @@ namespace Bit.Core.Models.Business { PaymentSource = new BillingSource(transaction.PayPalDetails); } - else if(transaction.CreditCard != null && transaction.CreditCard.CardType != CreditCardCardType.UNRECOGNIZED) + else if(transaction.CreditCard != null && + transaction.CreditCard.CardType != Braintree.CreditCardCardType.UNRECOGNIZED) { PaymentSource = new BillingSource(transaction.CreditCard); } @@ -265,5 +268,63 @@ namespace Bit.Core.Models.Business public decimal RefundedAmount { get; set; } public string InvoiceId { get; set; } } + + public class BillingTransaction + { + public BillingTransaction(Transaction transaction) + { + CreatedDate = transaction.CreationDate; + Refunded = transaction.Refunded; + Type = transaction.Type; + PaymentMethodType = transaction.PaymentMethodType; + Details = transaction.Details; + + if(transaction.RefundedAmount.HasValue) + { + RefundedAmount = Math.Abs(transaction.RefundedAmount.Value); + } + switch(transaction.Type) + { + case TransactionType.Charge: + case TransactionType.Credit: + case TransactionType.PromotionalCredit: + case TransactionType.ReferralCredit: + Amount = -1 * Math.Abs(transaction.Amount); + break; + case TransactionType.Refund: + Amount = Math.Abs(transaction.Amount); + break; + default: + break; + } + } + + 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 BillingInvoice2 : BillingInvoice + { + public BillingInvoice2(Invoice inv) + { + Url = inv.HostedInvoiceUrl; + PdfUrl = inv.InvoicePdf; + Number = inv.Number; + Paid = inv.Paid; + Amount = inv.Total / 100M; + Date = inv.Date.Value; + } + + 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/Models/Table/ISubscriber.cs b/src/Core/Models/Table/ISubscriber.cs index 84f2a6aff5..fd22d7d099 100644 --- a/src/Core/Models/Table/ISubscriber.cs +++ b/src/Core/Models/Table/ISubscriber.cs @@ -1,15 +1,19 @@ -using Bit.Core.Enums; +using System; +using Bit.Core.Enums; using Bit.Core.Services; namespace Bit.Core.Models.Table { public interface ISubscriber { + Guid Id { get; } GatewayType? Gateway { get; set; } string GatewayCustomerId { get; set; } string GatewaySubscriptionId { get; set; } string BillingEmailAddress(); string BillingName(); - IPaymentService GetPaymentService(GlobalSettings globalSettings); + string BraintreeCustomerIdPrefix(); + string BraintreeIdField(); + string GatewayIdField(); } } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index f398d3908d..f9aa28229f 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -63,6 +63,21 @@ namespace Bit.Core.Models.Table return BusinessName; } + public string BraintreeCustomerIdPrefix() + { + return "o"; + } + + public string BraintreeIdField() + { + return "organization_id"; + } + + public string GatewayIdField() + { + return "organizationId"; + } + public long StorageBytesRemaining() { if(!MaxStorageGb.HasValue) @@ -84,29 +99,6 @@ namespace Bit.Core.Models.Table return maxStorageBytes - Storage.Value; } - public IPaymentService GetPaymentService(GlobalSettings globalSettings) - { - if(Gateway == null) - { - throw new BadRequestException("No gateway."); - } - - IPaymentService paymentService = null; - switch(Gateway) - { - case GatewayType.Stripe: - paymentService = new StripePaymentService(); - break; - case GatewayType.Braintree: - paymentService = new BraintreePaymentService(globalSettings); - break; - default: - throw new NotSupportedException("Unsupported gateway."); - } - - return paymentService; - } - public Dictionary GetTwoFactorProviders() { if(string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/src/Core/Models/Table/Transaction.cs b/src/Core/Models/Table/Transaction.cs new file mode 100644 index 0000000000..637f40f403 --- /dev/null +++ b/src/Core/Models/Table/Transaction.cs @@ -0,0 +1,27 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.Table +{ + public class Transaction : ITableObject + { + public Guid Id { get; set; } + public Guid? UserId { get; set; } + public Guid? OrganizationId { get; set; } + public TransactionType Type { get; set; } + public decimal Amount { get; set; } + public bool? Refunded { get; set; } + public decimal? RefundedAmount { get; set; } + public string Details { get; set; } + public PaymentMethodType? PaymentMethodType { get; set; } + public GatewayType? Gateway { get; set; } + public string GatewayId { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + } +} diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index ce0a73319e..bd2b75455f 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -58,6 +58,21 @@ namespace Bit.Core.Models.Table return Name; } + public string BraintreeCustomerIdPrefix() + { + return "u"; + } + + public string BraintreeIdField() + { + return "user_id"; + } + + public string GatewayIdField() + { + return "userId"; + } + public Dictionary GetTwoFactorProviders() { if(string.IsNullOrWhiteSpace(TwoFactorProviders)) @@ -133,29 +148,6 @@ namespace Bit.Core.Models.Table return maxStorageBytes - Storage.Value; } - public IPaymentService GetPaymentService(GlobalSettings globalSettings) - { - if(Gateway == null) - { - throw new BadRequestException("No gateway."); - } - - IPaymentService paymentService = null; - switch(Gateway) - { - case GatewayType.Stripe: - paymentService = new StripePaymentService(); - break; - case GatewayType.Braintree: - paymentService = new BraintreePaymentService(globalSettings); - break; - default: - throw new NotSupportedException("Unsupported gateway."); - } - - return paymentService; - } - public IdentityUser ToIdentityUser(bool twoFactorEnabled) { return new IdentityUser diff --git a/src/Core/Repositories/IMaintenanceRepository.cs b/src/Core/Repositories/IMaintenanceRepository.cs index bacc86fe36..27f1b5cfc1 100644 --- a/src/Core/Repositories/IMaintenanceRepository.cs +++ b/src/Core/Repositories/IMaintenanceRepository.cs @@ -5,6 +5,7 @@ namespace Bit.Core.Repositories public interface IMaintenanceRepository { Task UpdateStatisticsAsync(); + Task DisableCipherAutoStatsAsync(); Task RebuildIndexesAsync(); Task DeleteExpiredGrantsAsync(); } diff --git a/src/Core/Repositories/ITransactionRepository.cs b/src/Core/Repositories/ITransactionRepository.cs new file mode 100644 index 0000000000..242dcfa3ce --- /dev/null +++ b/src/Core/Repositories/ITransactionRepository.cs @@ -0,0 +1,15 @@ +using System; +using Bit.Core.Models.Table; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Enums; + +namespace Bit.Core.Repositories +{ + public interface ITransactionRepository : IRepository + { + Task> GetManyByUserIdAsync(Guid userId); + Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId); + } +} diff --git a/src/Core/Repositories/SqlServer/MaintenanceRepository.cs b/src/Core/Repositories/SqlServer/MaintenanceRepository.cs index 98187b84c4..6a86662b93 100644 --- a/src/Core/Repositories/SqlServer/MaintenanceRepository.cs +++ b/src/Core/Repositories/SqlServer/MaintenanceRepository.cs @@ -27,6 +27,17 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task DisableCipherAutoStatsAsync() + { + using(var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "sp_autostats", + new { tblname = "[dbo].[Cipher]", flagc = "OFF" }, + commandType: CommandType.StoredProcedure); + } + } + public async Task RebuildIndexesAsync() { using(var connection = new SqlConnection(ConnectionString)) diff --git a/src/Core/Repositories/SqlServer/TransactionRepository.cs b/src/Core/Repositories/SqlServer/TransactionRepository.cs new file mode 100644 index 0000000000..85f2ed35b5 --- /dev/null +++ b/src/Core/Repositories/SqlServer/TransactionRepository.cs @@ -0,0 +1,62 @@ +using System; +using Bit.Core.Models.Table; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using Bit.Core.Enums; + +namespace Bit.Core.Repositories.SqlServer +{ + public class TransactionRepository : Repository, ITransactionRepository + { + public TransactionRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public TransactionRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetManyByUserIdAsync(Guid userId) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Transaction_ReadByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Transaction_ReadByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Transaction_ReadByGatewayId]", + new { Gateway = gatewayType, GatewayId = gatewayId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + } +} diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 41da719bf9..633d6faddd 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,17 +1,22 @@ using System.Threading.Tasks; using Bit.Core.Models.Table; using Bit.Core.Models.Business; +using Bit.Core.Enums; namespace Bit.Core.Services { public interface IPaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); - Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb); + Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, + Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, bool premiumAccessAddon); + Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + short additionalStorageGb); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false); Task ReinstateSubscriptionAsync(ISubscriber subscriber); - Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken); + Task UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, + string paymentToken); Task GetUpcomingInvoiceAsync(ISubscriber subscriber); Task GetBillingAsync(ISubscriber subscriber); } diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs deleted file mode 100644 index 43ec65ae01..0000000000 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Bit.Core.Models.Table; -using Braintree; -using Bit.Core.Exceptions; -using Bit.Core.Models.Business; - -namespace Bit.Core.Services -{ - public class BraintreePaymentService : IPaymentService - { - private const string PremiumPlanId = "premium-annually"; - private const string StoragePlanId = "storage-gb-annually"; - private readonly BraintreeGateway _gateway; - - public BraintreePaymentService( - GlobalSettings globalSettings) - { - _gateway = new BraintreeGateway - { - Environment = globalSettings.Braintree.Production ? - Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX, - MerchantId = globalSettings.Braintree.MerchantId, - PublicKey = globalSettings.Braintree.PublicKey, - PrivateKey = globalSettings.Braintree.PrivateKey - }; - } - - public async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId) - { - var sub = await _gateway.Subscription.FindAsync(storableSubscriber.GatewaySubscriptionId); - if(sub == null) - { - throw new GatewayException("Subscription was not found."); - } - - var req = new SubscriptionRequest - { - AddOns = new AddOnsRequest(), - Options = new SubscriptionOptionsRequest - { - ProrateCharges = true, - RevertSubscriptionOnProrationFailure = true - } - }; - - var storageItem = sub.AddOns?.FirstOrDefault(a => a.Id == storagePlanId); - if(additionalStorage > 0 && storageItem == null) - { - req.AddOns.Add = new AddAddOnRequest[] - { - new AddAddOnRequest - { - InheritedFromId = storagePlanId, - Quantity = additionalStorage, - NeverExpires = true - } - }; - } - else if(additionalStorage > 0 && storageItem != null) - { - req.AddOns.Update = new UpdateAddOnRequest[] - { - new UpdateAddOnRequest - { - ExistingId = storageItem.Id, - Quantity = additionalStorage, - NeverExpires = true - } - }; - } - else if(additionalStorage == 0 && storageItem != null) - { - req.AddOns.Remove = new string[] { storageItem.Id }; - } - - var result = await _gateway.Subscription.UpdateAsync(sub.Id, req); - if(!result.IsSuccess()) - { - throw new GatewayException("Failed to adjust storage."); - } - } - - public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber) - { - if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - await _gateway.Subscription.CancelAsync(subscriber.GatewaySubscriptionId); - } - - if(string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - return; - } - - var transactionRequest = new TransactionSearchRequest().CustomerId.Is(subscriber.GatewayCustomerId); - var transactions = _gateway.Transaction.Search(transactionRequest); - - if((transactions?.MaximumCount ?? 0) > 0) - { - foreach(var transaction in transactions.Cast().Where(c => c.RefundedTransactionId == null)) - { - await _gateway.Transaction.RefundAsync(transaction.Id); - } - } - - await _gateway.Customer.DeleteAsync(subscriber.GatewayCustomerId); - } - - public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) - { - if(subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if(string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - throw new GatewayException("No subscription."); - } - - var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId); - if(sub == null) - { - throw new GatewayException("Subscription was not found."); - } - - if(sub.Status == SubscriptionStatus.CANCELED || sub.Status == SubscriptionStatus.EXPIRED || - !sub.NeverExpires.GetValueOrDefault()) - { - throw new GatewayException("Subscription is already canceled."); - } - - if(endOfPeriod) - { - var req = new SubscriptionRequest - { - NeverExpires = false, - NumberOfBillingCycles = sub.CurrentBillingCycle - }; - - var result = await _gateway.Subscription.UpdateAsync(subscriber.GatewaySubscriptionId, req); - if(!result.IsSuccess()) - { - throw new GatewayException("Unable to cancel subscription."); - } - } - else - { - var result = await _gateway.Subscription.CancelAsync(subscriber.GatewaySubscriptionId); - if(!result.IsSuccess()) - { - throw new GatewayException("Unable to cancel subscription."); - } - } - } - - public async Task GetUpcomingInvoiceAsync(ISubscriber subscriber) - { - if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId); - if(sub != null) - { - var cancelAtEndDate = !sub.NeverExpires.GetValueOrDefault(); - var canceled = sub.Status == SubscriptionStatus.CANCELED; - if(!canceled && !cancelAtEndDate && sub.NextBillingDate.HasValue) - { - return new BillingInfo.BillingInvoice(sub); - } - } - } - return null; - } - - public async Task GetBillingAsync(ISubscriber subscriber) - { - var billingInfo = new BillingInfo(); - if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - var customer = await _gateway.Customer.FindAsync(subscriber.GatewayCustomerId); - if(customer != null) - { - if(customer.DefaultPaymentMethod != null) - { - billingInfo.PaymentSource = new BillingInfo.BillingSource(customer.DefaultPaymentMethod); - } - - var transactionRequest = new TransactionSearchRequest().CustomerId.Is(customer.Id); - var transactions = _gateway.Transaction.Search(transactionRequest); - billingInfo.Charges = transactions?.Cast().OrderByDescending(t => t.CreatedAt) - .Select(t => new BillingInfo.BillingCharge(t)); - } - } - - if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId); - if(sub != null) - { - var plans = await _gateway.Plan.AllAsync(); - var plan = plans?.FirstOrDefault(p => p.Id == sub.PlanId); - billingInfo.Subscription = new BillingInfo.BillingSubscription(sub, plan); - } - - if(!billingInfo.Subscription.Cancelled && !billingInfo.Subscription.CancelAtEndDate && - sub.NextBillingDate.HasValue) - { - billingInfo.UpcomingInvoice = new BillingInfo.BillingInvoice(sub); - } - } - - return billingInfo; - } - - public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) - { - var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest - { - PaymentMethodNonce = paymentToken, - Email = user.Email - }); - - if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) - { - throw new GatewayException("Failed to create customer."); - } - - var subId = "u" + user.Id.ToString("N").ToLower() + - Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); - - var subRequest = new SubscriptionRequest - { - Id = subId, - PaymentMethodToken = customerResult.Target.PaymentMethods[0].Token, - PlanId = PremiumPlanId - }; - - if(additionalStorageGb > 0) - { - subRequest.AddOns = new AddOnsRequest(); - subRequest.AddOns.Add = new AddAddOnRequest[] - { - new AddAddOnRequest - { - InheritedFromId = StoragePlanId, - Quantity = additionalStorageGb - } - }; - } - - var subResult = await _gateway.Subscription.CreateAsync(subRequest); - - if(!subResult.IsSuccess()) - { - await _gateway.Customer.DeleteAsync(customerResult.Target.Id); - throw new GatewayException("Failed to create subscription."); - } - - user.Gateway = Enums.GatewayType.Braintree; - user.GatewayCustomerId = customerResult.Target.Id; - user.GatewaySubscriptionId = subResult.Target.Id; - user.Premium = true; - user.PremiumExpirationDate = subResult.Target.BillingPeriodEndDate; - } - - public async Task ReinstateSubscriptionAsync(ISubscriber subscriber) - { - if(subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if(string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) - { - throw new GatewayException("No subscription."); - } - - var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId); - if(sub == null) - { - throw new GatewayException("Subscription was not found."); - } - - if(sub.Status != SubscriptionStatus.ACTIVE || sub.NeverExpires.GetValueOrDefault()) - { - throw new GatewayException("Subscription is not marked for cancellation."); - } - - var req = new SubscriptionRequest - { - NeverExpires = true, - NumberOfBillingCycles = null - }; - - var result = await _gateway.Subscription.UpdateAsync(subscriber.GatewaySubscriptionId, req); - if(!result.IsSuccess()) - { - throw new GatewayException("Unable to reinstate subscription."); - } - } - - public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken) - { - if(subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != Enums.GatewayType.Braintree) - { - throw new GatewayException("Switching from one payment type to another is not supported. " + - "Contact us for assistance."); - } - - var updatedSubscriber = false; - Customer customer = null; - - if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - customer = await _gateway.Customer.FindAsync(subscriber.GatewayCustomerId); - } - - if(customer == null) - { - var result = await _gateway.Customer.CreateAsync(new CustomerRequest - { - Email = subscriber.BillingEmailAddress(), - PaymentMethodNonce = paymentToken - }); - - if(!result.IsSuccess()) - { - throw new GatewayException("Cannot create customer."); - } - - customer = result.Target; - subscriber.Gateway = Enums.GatewayType.Braintree; - subscriber.GatewayCustomerId = customer.Id; - updatedSubscriber = true; - } - else - { - if(customer.DefaultPaymentMethod != null) - { - var deleteResult = await _gateway.PaymentMethod.DeleteAsync(customer.DefaultPaymentMethod.Token); - if(!deleteResult.IsSuccess()) - { - throw new GatewayException("Cannot delete old payment method."); - } - } - - var result = await _gateway.PaymentMethod.CreateAsync(new PaymentMethodRequest - { - PaymentMethodNonce = paymentToken, - CustomerId = customer.Id - }); - if(!result.IsSuccess()) - { - throw new GatewayException("Cannot add new payment method."); - } - } - - return updatedSubscriber; - } - } -} diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 98e886907f..1321cb24ca 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -32,7 +32,7 @@ namespace Bit.Core.Services private readonly IEventService _eventService; private readonly IInstallationRepository _installationRepository; private readonly IApplicationCacheService _applicationCacheService; - private readonly StripePaymentService _stripePaymentService; + private readonly IPaymentService _paymentService; private readonly GlobalSettings _globalSettings; public OrganizationService( @@ -50,6 +50,7 @@ namespace Bit.Core.Services IEventService eventService, IInstallationRepository installationRepository, IApplicationCacheService applicationCacheService, + IPaymentService paymentService, GlobalSettings globalSettings) { _organizationRepository = organizationRepository; @@ -66,7 +67,7 @@ namespace Bit.Core.Services _eventService = eventService; _installationRepository = installationRepository; _applicationCacheService = applicationCacheService; - _stripePaymentService = new StripePaymentService(); + _paymentService = paymentService; _globalSettings = globalSettings; } @@ -78,7 +79,22 @@ namespace Bit.Core.Services throw new NotFoundException(); } - var updated = await _stripePaymentService.UpdatePaymentMethodAsync(organization, paymentToken); + PaymentMethodType paymentMethodType; + if(paymentToken.StartsWith("btok_")) + { + paymentMethodType = PaymentMethodType.BankAccount; + } + else if(paymentToken.StartsWith("tok_")) + { + paymentMethodType = PaymentMethodType.Card; + } + else + { + paymentMethodType = PaymentMethodType.PayPal; + } + + var updated = await _paymentService.UpdatePaymentMethodAsync(organization, + paymentMethodType, paymentToken); if(updated) { await ReplaceAndUpdateCache(organization); @@ -100,7 +116,7 @@ namespace Bit.Core.Services eop = false; } - await _stripePaymentService.CancelSubscriptionAsync(organization, eop); + await _paymentService.CancelSubscriptionAsync(organization, eop); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -111,7 +127,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - await _stripePaymentService.ReinstateSubscriptionAsync(organization); + await _paymentService.ReinstateSubscriptionAsync(organization); } public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) @@ -186,15 +202,15 @@ namespace Bit.Core.Services // TODO: Groups? - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new Stripe.SubscriptionService(); if(string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { // They must have been on a free plan. Create new sub. - var subCreateOptions = new StripeSubscriptionCreateOptions + var subCreateOptions = new SubscriptionCreateOptions { CustomerId = organization.GatewayCustomerId, TrialPeriodDays = newPlan.TrialPeriodDays, - Items = new List(), + Items = new List(), Metadata = new Dictionary { { "organizationId", organization.Id.ToString() } } @@ -202,7 +218,7 @@ namespace Bit.Core.Services if(newPlan.StripePlanId != null) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = newPlan.StripePlanId, Quantity = 1 @@ -211,7 +227,7 @@ namespace Bit.Core.Services if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = newPlan.StripeSeatPlanId, Quantity = additionalSeats @@ -223,14 +239,14 @@ namespace Bit.Core.Services else { // Update existing sub. - var subUpdateOptions = new StripeSubscriptionUpdateOptions + var subUpdateOptions = new SubscriptionUpdateOptions { - Items = new List() + Items = new List() }; if(newPlan.StripePlanId != null) { - subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption + subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption { PlanId = newPlan.StripePlanId, Quantity = 1 @@ -239,7 +255,7 @@ namespace Bit.Core.Services if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null) { - subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption + subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption { PlanId = newPlan.StripeSeatPlanId, Quantity = additionalSeats @@ -271,7 +287,7 @@ namespace Bit.Core.Services throw new BadRequestException("Plan does not allow additional storage."); } - await BillingHelpers.AdjustStorageAsync(_stripePaymentService, organization, storageAdjustmentGb, + await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, plan.StripeStoragePlanId); await ReplaceAndUpdateCache(organization); } @@ -333,44 +349,74 @@ namespace Bit.Core.Services } } - var subscriptionItemService = new StripeSubscriptionItemService(); - var subscriptionService = new StripeSubscriptionService(); + var subscriptionItemService = new SubscriptionItemService(); + var subscriptionService = new SubscriptionService(); var sub = await subscriptionService.GetAsync(organization.GatewaySubscriptionId); if(sub == null) { throw new BadRequestException("Subscription not found."); } + Func> subUpdateAction = null; var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId); + var subItemOptions = sub.Items.Where(i => i.Plan.Id != plan.StripeSeatPlanId) + .Select(i => new InvoiceSubscriptionItemOptions + { + Id = i.Id, + PlanId = i.Plan.Id, + Quantity = i.Quantity, + }).ToList(); + if(additionalSeats > 0 && seatItem == null) { - await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + subItemOptions.Add(new InvoiceSubscriptionItemOptions { PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, - Prorate = true, - SubscriptionId = sub.Id }); + subUpdateAction = (prorate) => subscriptionItemService.CreateAsync( + new SubscriptionItemCreateOptions + { + PlanId = plan.StripeSeatPlanId, + Quantity = additionalSeats, + Prorate = prorate, + SubscriptionId = sub.Id + }); } else if(additionalSeats > 0 && seatItem != null) { - await subscriptionItemService.UpdateAsync(seatItem.Id, new StripeSubscriptionItemUpdateOptions + subItemOptions.Add(new InvoiceSubscriptionItemOptions { + Id = seatItem.Id, PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, - Prorate = true }); + subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(seatItem.Id, + new SubscriptionItemUpdateOptions + { + PlanId = plan.StripeSeatPlanId, + Quantity = additionalSeats, + Prorate = prorate + }); } else if(seatItem != null && additionalSeats == 0) { - await subscriptionItemService.DeleteAsync(seatItem.Id); + subItemOptions.Add(new InvoiceSubscriptionItemOptions + { + Id = seatItem.Id, + Deleted = true + }); + subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(seatItem.Id); } + var invoicedNow = false; if(additionalSeats > 0) { - await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500); + invoicedNow = await (_paymentService as StripePaymentService).PreviewUpcomingInvoiceAndPayAsync( + organization, plan.StripeSeatPlanId, subItemOptions, 500); } + await subUpdateAction(!invoicedNow); organization.Seats = (short?)newSeatTotal; await ReplaceAndUpdateCache(organization); } @@ -389,7 +435,7 @@ namespace Bit.Core.Services } var bankService = new BankAccountService(); - var customerService = new StripeCustomerService(); + var customerService = new CustomerService(); var customer = await customerService.GetAsync(organization.GatewayCustomerId); if(customer == null) { @@ -397,7 +443,7 @@ namespace Bit.Core.Services } var bankAccount = customer.Sources - .FirstOrDefault(s => s.BankAccount != null && s.BankAccount.Status != "verified")?.BankAccount; + .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; if(bankAccount == null) { throw new GatewayException("Cannot find an unverified bank account."); @@ -406,7 +452,7 @@ namespace Bit.Core.Services try { var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id, - new BankAccountVerifyOptions { AmountOne = amount1, AmountTwo = amount2 }); + new BankAccountVerifyOptions { Amounts = new List { amount1, amount2 } }); if(result.Status != "verified") { throw new GatewayException("Unable to verify account."); @@ -431,6 +477,11 @@ namespace Bit.Core.Services throw new BadRequestException("Plan does not allow additional storage."); } + if(signup.AdditionalStorageGb < 0) + { + throw new BadRequestException("You can't subtract storage!"); + } + if(!plan.CanBuyPremiumAccessAddon && signup.PremiumAccessAddon) { throw new BadRequestException("This plan does not allow you to buy the premium access addon."); @@ -441,6 +492,11 @@ namespace Bit.Core.Services throw new BadRequestException("You do not have any seats!"); } + if(signup.AdditionalSeats < 0) + { + throw new BadRequestException("You can't subtract seats!"); + } + if(!plan.CanBuyAdditionalSeats && signup.AdditionalSeats > 0) { throw new BadRequestException("Plan does not allow additional users."); @@ -453,96 +509,10 @@ namespace Bit.Core.Services $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); } - var customerService = new StripeCustomerService(); - var subscriptionService = new StripeSubscriptionService(); - StripeCustomer customer = null; - StripeSubscription subscription = null; - - // Pre-generate the org id so that we can save it with the Stripe subscription.. - var newOrgId = CoreHelpers.GenerateComb(); - - if(plan.Type == PlanType.Free) - { - var adminCount = - await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); - if(adminCount > 0) - { - throw new BadRequestException("You can only be an admin of one free organization."); - } - } - else - { - customer = await customerService.CreateAsync(new StripeCustomerCreateOptions - { - Description = signup.BusinessName, - Email = signup.BillingEmail, - SourceToken = signup.PaymentToken - }); - - var subCreateOptions = new StripeSubscriptionCreateOptions - { - CustomerId = customer.Id, - TrialPeriodDays = plan.TrialPeriodDays, - Items = new List(), - Metadata = new Dictionary { - { "organizationId", newOrgId.ToString() } - } - }; - - if(plan.StripePlanId != null) - { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption - { - PlanId = plan.StripePlanId, - Quantity = 1 - }); - } - - if(signup.AdditionalSeats > 0 && plan.StripeSeatPlanId != null) - { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption - { - PlanId = plan.StripeSeatPlanId, - Quantity = signup.AdditionalSeats - }); - } - - if(signup.AdditionalStorageGb > 0) - { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption - { - PlanId = plan.StripeStoragePlanId, - Quantity = signup.AdditionalStorageGb - }); - } - - if(signup.PremiumAccessAddon && plan.StripePremiumAccessPlanId != null) - { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption - { - PlanId = plan.StripePremiumAccessPlanId, - Quantity = 1 - }); - } - - try - { - subscription = await subscriptionService.CreateAsync(subCreateOptions); - } - catch(StripeException) - { - if(customer != null) - { - await customerService.DeleteAsync(customer.Id); - } - - throw; - } - } - var organization = new Organization { - Id = newOrgId, + // Pre-generate the org id so that we can save it with the Stripe subscription.. + Id = CoreHelpers.GenerateComb(), Name = signup.Name, BillingEmail = signup.BillingEmail, BusinessName = signup.BusinessName, @@ -560,16 +530,43 @@ namespace Bit.Core.Services SelfHost = plan.SelfHost, UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon, Plan = plan.Name, - Gateway = plan.Type == PlanType.Free ? null : (GatewayType?)GatewayType.Stripe, - GatewayCustomerId = customer?.Id, - GatewaySubscriptionId = subscription?.Id, + Gateway = null, Enabled = true, - ExpirationDate = subscription?.CurrentPeriodEnd, LicenseKey = CoreHelpers.SecureRandomString(20), CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow }; + if(plan.Type == PlanType.Free) + { + var adminCount = + await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); + if(adminCount > 0) + { + throw new BadRequestException("You can only be an admin of one free organization."); + } + } + else + { + PaymentMethodType paymentMethodType; + if(signup.PaymentToken.StartsWith("btok_")) + { + paymentMethodType = PaymentMethodType.BankAccount; + } + else if(signup.PaymentToken.StartsWith("tok_")) + { + paymentMethodType = PaymentMethodType.Card; + } + else + { + paymentMethodType = PaymentMethodType.PayPal; + } + + await _paymentService.PurchaseOrganizationAsync(organization, paymentMethodType, + signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon); + } + return await SignUpAsync(organization, signup.Owner.Id, signup.OwnerKey, signup.CollectionName, true); } @@ -630,7 +627,8 @@ namespace Bit.Core.Services var dir = $"{_globalSettings.LicenseDirectory}/organization"; Directory.CreateDirectory(dir); - File.WriteAllText($"{dir}/{organization.Id}.json", JsonConvert.SerializeObject(license, Formatting.Indented)); + System.IO.File.WriteAllText($"{dir}/{organization.Id}.json", + JsonConvert.SerializeObject(license, Formatting.Indented)); return result; } @@ -679,7 +677,7 @@ namespace Bit.Core.Services { if(withPayment) { - await _stripePaymentService.CancelAndRecoverChargesAsync(organization); + await _paymentService.CancelAndRecoverChargesAsync(organization); } if(organization.Id != default(Guid)) @@ -756,7 +754,8 @@ namespace Bit.Core.Services var dir = $"{_globalSettings.LicenseDirectory}/organization"; Directory.CreateDirectory(dir); - File.WriteAllText($"{dir}/{organization.Id}.json", JsonConvert.SerializeObject(license, Formatting.Indented)); + System.IO.File.WriteAllText($"{dir}/{organization.Id}.json", + JsonConvert.SerializeObject(license, Formatting.Indented)); organization.Name = license.Name; organization.BusinessName = license.BusinessName; @@ -787,7 +786,7 @@ namespace Bit.Core.Services { var eop = !organization.ExpirationDate.HasValue || organization.ExpirationDate.Value >= DateTime.UtcNow; - await _stripePaymentService.CancelSubscriptionAsync(organization, eop); + await _paymentService.CancelSubscriptionAsync(organization, eop); } catch(GatewayException) { } } @@ -842,8 +841,8 @@ namespace Bit.Core.Services if(updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { - var customerService = new StripeCustomerService(); - await customerService.UpdateAsync(organization.GatewayCustomerId, new StripeCustomerUpdateOptions + var customerService = new CustomerService(); + await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail, Description = organization.BusinessName @@ -1207,9 +1206,8 @@ namespace Bit.Core.Services { throw new BadRequestException("Invalid installation id"); } - - var paymentService = new StripePaymentService(); - var billingInfo = await paymentService.GetBillingAsync(organization); + + var billingInfo = await _paymentService.GetBillingAsync(organization); return new OrganizationLicense(organization, billingInfo, installationId, _licensingService); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 6b37a7bc39..7ca6dc85b9 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using Bit.Core.Exceptions; using System.Linq; using Bit.Core.Models.Business; +using Bit.Core.Enums; +using Bit.Core.Repositories; namespace Bit.Core.Services { @@ -14,109 +16,413 @@ namespace Bit.Core.Services private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; - public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) - { - var customerService = new StripeCustomerService(); - var customer = await customerService.CreateAsync(new StripeCustomerCreateOptions - { - Description = user.Name, - Email = user.Email, - SourceToken = paymentToken - }); + private readonly ITransactionRepository _transactionRepository; + private readonly Braintree.BraintreeGateway _btGateway; - var subCreateOptions = new StripeSubscriptionCreateOptions + public StripePaymentService( + ITransactionRepository transactionRepository, + GlobalSettings globalSettings) + { + _btGateway = new Braintree.BraintreeGateway { - CustomerId = customer.Id, - Items = new List(), + Environment = globalSettings.Braintree.Production ? + Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX, + MerchantId = globalSettings.Braintree.MerchantId, + PublicKey = globalSettings.Braintree.PublicKey, + PrivateKey = globalSettings.Braintree.PrivateKey + }; + _transactionRepository = transactionRepository; + } + + public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, + string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, + short additionalSeats, bool premiumAccessAddon) + { + var invoiceService = new InvoiceService(); + var customerService = new CustomerService(); + + Braintree.Customer braintreeCustomer = null; + string stipeCustomerSourceToken = null; + var stripeCustomerMetadata = new Dictionary(); + var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || + paymentMethodType == PaymentMethodType.BankAccount; + + if(stripePaymentMethod) + { + stipeCustomerSourceToken = paymentToken; + } + else if(paymentMethodType == PaymentMethodType.PayPal) + { + var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); + var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest + { + PaymentMethodNonce = paymentToken, + Email = org.BillingEmail, + Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix, + CustomFields = new Dictionary + { + [org.BraintreeIdField()] = org.Id.ToString() + } + }); + + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) + { + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; + stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); + } + else + { + throw new GatewayException("Payment method is not supported at this time."); + } + + var subCreateOptions = new SubscriptionCreateOptions + { + TrialPeriodDays = plan.TrialPeriodDays, + Items = new List(), Metadata = new Dictionary { - ["userId"] = user.Id.ToString() + [org.GatewayIdField()] = org.Id.ToString() } }; - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + if(plan.StripePlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripePlanId, + Quantity = 1 + }); + } + + if(additionalSeats > 0 && plan.StripeSeatPlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripeSeatPlanId, + Quantity = additionalSeats + }); + } + + if(additionalStorageGb > 0) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripeStoragePlanId, + Quantity = additionalStorageGb + }); + } + + if(premiumAccessAddon && plan.StripePremiumAccessPlanId != null) + { + subCreateOptions.Items.Add(new SubscriptionItemOption + { + PlanId = plan.StripePremiumAccessPlanId, + Quantity = 1 + }); + } + + Customer customer = null; + Subscription subscription = null; + try + { + customer = await customerService.CreateAsync(new CustomerCreateOptions + { + Description = org.BusinessName, + Email = org.BillingEmail, + SourceToken = stipeCustomerSourceToken, + Metadata = stripeCustomerMetadata + }); + subCreateOptions.CustomerId = customer.Id; + var subscriptionService = new SubscriptionService(); + subscription = await subscriptionService.CreateAsync(subCreateOptions); + } + catch(Exception e) + { + if(customer != null) + { + await customerService.DeleteAsync(customer.Id); + } + if(braintreeCustomer != null) + { + await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); + } + throw e; + } + + org.Gateway = GatewayType.Stripe; + org.GatewayCustomerId = customer.Id; + org.GatewaySubscriptionId = subscription.Id; + org.ExpirationDate = subscription.CurrentPeriodEnd; + } + + public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + short additionalStorageGb) + { + var invoiceService = new InvoiceService(); + var customerService = new CustomerService(); + + Braintree.Transaction braintreeTransaction = null; + Braintree.Customer braintreeCustomer = null; + string stipeCustomerSourceToken = null; + var stripeCustomerMetadata = new Dictionary(); + var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || + paymentMethodType == PaymentMethodType.BankAccount; + + if(stripePaymentMethod) + { + stipeCustomerSourceToken = paymentToken; + } + else if(paymentMethodType == PaymentMethodType.PayPal) + { + var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); + var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest + { + PaymentMethodNonce = paymentToken, + Email = user.Email, + Id = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + randomSuffix, + CustomFields = new Dictionary + { + [user.BraintreeIdField()] = user.Id.ToString() + } + }); + + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) + { + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; + stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); + } + else + { + throw new GatewayException("Payment method is not supported at this time."); + } + + var customer = await customerService.CreateAsync(new CustomerCreateOptions + { + Description = user.Name, + Email = user.Email, + SourceToken = stipeCustomerSourceToken, + Metadata = stripeCustomerMetadata + }); + + var subCreateOptions = new SubscriptionCreateOptions + { + CustomerId = customer.Id, + Items = new List(), + Metadata = new Dictionary + { + [user.GatewayIdField()] = user.Id.ToString() + } + }; + + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = PremiumPlanId, - Quantity = 1 + Quantity = 1, }); if(additionalStorageGb > 0) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = StoragePlanId, Quantity = additionalStorageGb }); } - StripeSubscription subscription = null; + var subInvoiceMetadata = new Dictionary(); + Subscription subscription = null; try { - var subscriptionService = new StripeSubscriptionService(); + if(!stripePaymentMethod) + { + var previewInvoice = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions + { + CustomerId = customer.Id, + SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items) + }); + + await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions + { + AccountBalance = -1 * previewInvoice.AmountDue + }); + + if(braintreeCustomer != null) + { + var btInvoiceAmount = (previewInvoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = braintreeCustomer.Id, + Options = new Braintree.TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new Braintree.TransactionOptionsPayPalRequest + { + CustomField = $"{user.BraintreeIdField()}:{user.Id}" + } + }, + CustomFields = new Dictionary + { + [user.BraintreeIdField()] = user.Id.ToString() + } + }); + + if(!transactionResult.IsSuccess()) + { + throw new GatewayException("Failed to charge PayPal customer."); + } + + braintreeTransaction = transactionResult.Target; + subInvoiceMetadata.Add("btTransactionId", braintreeTransaction.Id); + subInvoiceMetadata.Add("btPayPalTransactionId", + braintreeTransaction.PayPalDetails.AuthorizationId); + } + else + { + throw new GatewayException("No payment was able to be collected."); + } + } + + var subscriptionService = new SubscriptionService(); subscription = await subscriptionService.CreateAsync(subCreateOptions); + + if(!stripePaymentMethod && subInvoiceMetadata.Any()) + { + var invoices = await invoiceService.ListAsync(new InvoiceListOptions + { + SubscriptionId = subscription.Id + }); + + var invoice = invoices?.FirstOrDefault(); + if(invoice == null) + { + throw new GatewayException("Invoice not found."); + } + + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = subInvoiceMetadata + }); + } } - catch(StripeException) + catch(Exception e) { await customerService.DeleteAsync(customer.Id); - throw; + if(braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } + if(braintreeCustomer != null) + { + await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); + } + throw e; } - user.Gateway = Enums.GatewayType.Stripe; + user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; user.Premium = true; user.PremiumExpirationDate = subscription.CurrentPeriodEnd; } + private List ToInvoiceSubscriptionItemOptions( + List subItemOptions) + { + return subItemOptions.Select(si => new InvoiceSubscriptionItemOptions + { + PlanId = si.PlanId, + Quantity = si.Quantity + }).ToList(); + } + public async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId) { - var subscriptionItemService = new StripeSubscriptionItemService(); - var subscriptionService = new StripeSubscriptionService(); + var subscriptionItemService = new SubscriptionItemService(); + var subscriptionService = new SubscriptionService(); var sub = await subscriptionService.GetAsync(storableSubscriber.GatewaySubscriptionId); if(sub == null) { throw new GatewayException("Subscription not found."); } - var storageItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId); + Func> subUpdateAction = null; + var storageItem = sub.Items?.FirstOrDefault(i => i.Plan.Id == storagePlanId); + var subItemOptions = sub.Items.Where(i => i.Plan.Id != storagePlanId) + .Select(i => new InvoiceSubscriptionItemOptions + { + Id = i.Id, + PlanId = i.Plan.Id, + Quantity = i.Quantity, + }).ToList(); + if(additionalStorage > 0 && storageItem == null) { - await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + subItemOptions.Add(new InvoiceSubscriptionItemOptions { PlanId = storagePlanId, Quantity = additionalStorage, - Prorate = true, - SubscriptionId = sub.Id }); + subUpdateAction = (prorate) => subscriptionItemService.CreateAsync( + new SubscriptionItemCreateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + SubscriptionId = sub.Id, + Prorate = prorate + }); } else if(additionalStorage > 0 && storageItem != null) { - await subscriptionItemService.UpdateAsync(storageItem.Id, new StripeSubscriptionItemUpdateOptions + subItemOptions.Add(new InvoiceSubscriptionItemOptions { + Id = storageItem.Id, PlanId = storagePlanId, Quantity = additionalStorage, - Prorate = true }); + subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(storageItem.Id, + new SubscriptionItemUpdateOptions + { + PlanId = storagePlanId, + Quantity = additionalStorage, + Prorate = prorate + }); } else if(additionalStorage == 0 && storageItem != null) { - await subscriptionItemService.DeleteAsync(storageItem.Id); + subItemOptions.Add(new InvoiceSubscriptionItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(storageItem.Id); } + var invoicedNow = false; if(additionalStorage > 0) { - await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 400); + invoicedNow = await PreviewUpcomingInvoiceAndPayAsync( + storableSubscriber, storagePlanId, subItemOptions, 400); } + + await subUpdateAction(!invoicedNow); } public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber) { if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new SubscriptionService(); await subscriptionService.CancelAsync(subscriber.GatewaySubscriptionId, - new StripeSubscriptionCancelOptions()); + new SubscriptionCancelOptions()); } if(string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) @@ -124,56 +430,195 @@ namespace Bit.Core.Services return; } - var chargeService = new StripeChargeService(); - var charges = await chargeService.ListAsync(new StripeChargeListOptions + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer == null) { - CustomerId = subscriber.GatewayCustomerId - }); + return; + } - if(charges?.Data != null) + if(customer.Metadata.ContainsKey("btCustomerId")) { - var refundService = new StripeRefundService(); - foreach(var charge in charges.Data.Where(c => !c.Refunded)) + var transactionRequest = new Braintree.TransactionSearchRequest() + .CustomerId.Is(customer.Metadata["btCustomerId"]); + var transactions = _btGateway.Transaction.Search(transactionRequest); + + if((transactions?.MaximumCount ?? 0) > 0) { - await refundService.CreateAsync(charge.Id); + var txs = transactions.Cast().Where(c => c.RefundedTransactionId == null); + foreach(var transaction in txs) + { + await _btGateway.Transaction.RefundAsync(transaction.Id); + } + } + + await _btGateway.Customer.DeleteAsync(customer.Metadata["btCustomerId"]); + } + else + { + var chargeService = new ChargeService(); + var charges = await chargeService.ListAsync(new ChargeListOptions + { + CustomerId = subscriber.GatewayCustomerId + }); + + if(charges?.Data != null) + { + var refundService = new RefundService(); + foreach(var charge in charges.Data.Where(c => !c.Refunded)) + { + await refundService.CreateAsync(new RefundCreateOptions { ChargeId = charge.Id }); + } } } - var customerService = new StripeCustomerService(); await customerService.DeleteAsync(subscriber.GatewayCustomerId); } - public async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId, - int prorateThreshold = 500) + public async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId, + List subItemOptions, int prorateThreshold = 500) { - var invoiceService = new StripeInvoiceService(); - var upcomingPreview = await invoiceService.UpcomingAsync(subscriber.GatewayCustomerId, - new StripeUpcomingInvoiceOptions - { - SubscriptionId = subscriber.GatewaySubscriptionId - }); + var invoiceService = new InvoiceService(); + var invoiceItemService = new InvoiceItemService(); - var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data? - .TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount); - if(prorationAmount.GetValueOrDefault() >= prorateThreshold) + var pendingInvoiceItems = invoiceItemService.ListAutoPaging(new InvoiceItemListOptions { + CustomerId = subscriber.GatewayCustomerId + }).ToList().Where(i => i.InvoiceId == null); + var pendingInvoiceItemsDict = pendingInvoiceItems.ToDictionary(pii => pii.Id); + + var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions + { + CustomerId = subscriber.GatewayCustomerId, + SubscriptionId = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions + }); + + var itemsForInvoice = upcomingPreview.Lines?.Data? + .Where(i => pendingInvoiceItemsDict.ContainsKey(i.Id) || (i.Plan.Id == planId && i.Proration)); + var invoiceAmount = itemsForInvoice?.Sum(i => i.Amount) ?? 0; + var invoiceNow = invoiceAmount >= prorateThreshold; + if(invoiceNow) + { + // Owes more than prorateThreshold on next invoice. + // Invoice them and pay now instead of waiting until next billing cycle. + + Invoice invoice = null; + var createdInvoiceItems = new List(); + Braintree.Transaction braintreeTransaction = null; try { - // Owes more than prorateThreshold on next invoice. - // Invoice them and pay now instead of waiting until next billing cycle. - var invoice = await invoiceService.CreateAsync(subscriber.GatewayCustomerId, - new StripeInvoiceCreateOptions - { - SubscriptionId = subscriber.GatewaySubscriptionId - }); - - if(invoice.AmountDue > 0) + foreach(var ii in itemsForInvoice) { - await invoiceService.PayAsync(invoice.Id, new StripeInvoicePayOptions()); + if(pendingInvoiceItemsDict.ContainsKey(ii.Id)) + { + continue; + } + var invoiceItem = await invoiceItemService.CreateAsync(new InvoiceItemCreateOptions + { + Currency = ii.Currency, + Description = ii.Description, + CustomerId = subscriber.GatewayCustomerId, + SubscriptionId = ii.SubscriptionId, + Discountable = ii.Discountable, + Amount = ii.Amount + }); + createdInvoiceItems.Add(invoiceItem); } + + invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions + { + Billing = Billing.SendInvoice, + DaysUntilDue = 1, + CustomerId = subscriber.GatewayCustomerId + }); + + var invoicePayOptions = new InvoicePayOptions(); + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer != null) + { + if(customer.Metadata.ContainsKey("btCustomerId")) + { + invoicePayOptions.PaidOutOfBand = true; + var btInvoiceAmount = (invoiceAmount / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = customer.Metadata["btCustomerId"], + Options = new Braintree.TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new Braintree.TransactionOptionsPayPalRequest + { + CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id}" + } + }, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() + } + }); + + if(!transactionResult.IsSuccess()) + { + throw new GatewayException("Failed to charge PayPal customer."); + } + + braintreeTransaction = transactionResult.Target; + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + ["btTransactionId"] = braintreeTransaction.Id, + ["btPayPalTransactionId"] = + braintreeTransaction.PayPalDetails.AuthorizationId + } + }); + } + } + + await invoiceService.PayAsync(invoice.Id, invoicePayOptions); + } + catch(Exception e) + { + if(braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } + if(invoice != null) + { + await invoiceService.DeleteAsync(invoice.Id); + + // Restore invoice items that were brought in + foreach(var item in pendingInvoiceItems) + { + var i = new InvoiceItemCreateOptions + { + Currency = item.Currency, + Description = item.Description, + CustomerId = item.CustomerId, + SubscriptionId = item.SubscriptionId, + Discountable = item.Discountable, + Metadata = item.Metadata, + Quantity = item.Quantity, + UnitAmount = item.UnitAmount + }; + await invoiceItemService.CreateAsync(i); + } + } + else + { + foreach(var ii in createdInvoiceItems) + { + await invoiceItemService.DeleteAsync(ii.Id); + } + } + throw e; } - catch(StripeException) { } } + return invoiceNow; } public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) @@ -188,7 +633,7 @@ namespace Bit.Core.Services throw new GatewayException("No subscription."); } - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new SubscriptionService(); var sub = await subscriptionService.GetAsync(subscriber.GatewaySubscriptionId); if(sub == null) { @@ -205,8 +650,8 @@ namespace Bit.Core.Services { var canceledSub = endOfPeriod ? await subscriptionService.UpdateAsync(sub.Id, - new StripeSubscriptionUpdateOptions { CancelAtPeriodEnd = true }) : - await subscriptionService.CancelAsync(sub.Id, new StripeSubscriptionCancelOptions()); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) : + await subscriptionService.CancelAsync(sub.Id, new SubscriptionCancelOptions()); if(!canceledSub.CanceledAt.HasValue) { throw new GatewayException("Unable to cancel subscription."); @@ -233,7 +678,7 @@ namespace Bit.Core.Services throw new GatewayException("No subscription."); } - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new SubscriptionService(); var sub = await subscriptionService.GetAsync(subscriber.GatewaySubscriptionId); if(sub == null) { @@ -246,91 +691,205 @@ namespace Bit.Core.Services } var updatedSub = await subscriptionService.UpdateAsync(sub.Id, - new StripeSubscriptionUpdateOptions { CancelAtPeriodEnd = false }); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); if(updatedSub.CanceledAt.HasValue) { throw new GatewayException("Unable to reinstate subscription."); } } - public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken) + public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, + string paymentToken) { if(subscriber == null) { throw new ArgumentNullException(nameof(subscriber)); } - if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != Enums.GatewayType.Stripe) + if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != GatewayType.Stripe) { throw new GatewayException("Switching from one payment type to another is not supported. " + "Contact us for assistance."); } - var updatedSubscriber = false; + var createdCustomer = false; + Braintree.Customer braintreeCustomer = null; + string stipeCustomerSourceToken = null; + var stripeCustomerMetadata = new Dictionary(); + var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || + paymentMethodType == PaymentMethodType.BankAccount; - var cardService = new StripeCardService(); + var cardService = new CardService(); var bankSerice = new BankAccountService(); - var customerService = new StripeCustomerService(); - StripeCustomer customer = null; + var customerService = new CustomerService(); + Customer customer = null; if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer.Metadata?.Any() ?? false) + { + stripeCustomerMetadata = customer.Metadata; + } } - if(customer == null) + var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId"); + if(stripePaymentMethod) { - customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + stipeCustomerSourceToken = paymentToken; + } + else if(paymentMethodType == PaymentMethodType.PayPal) + { + if(hadBtCustomer) { - Description = subscriber.BillingName(), - Email = subscriber.BillingEmailAddress(), - SourceToken = paymentToken - }); + var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest + { + CustomerId = stripeCustomerMetadata["btCustomerId"], + PaymentMethodNonce = paymentToken + }); - subscriber.Gateway = Enums.GatewayType.Stripe; - subscriber.GatewayCustomerId = customer.Id; - updatedSubscriber = true; + if(pmResult.IsSuccess()) + { + var customerResult = await _btGateway.Customer.UpdateAsync( + stripeCustomerMetadata["btCustomerId"], new Braintree.CustomerRequest + { + DefaultPaymentMethodToken = pmResult.Target.Token + }); + + if(customerResult.IsSuccess() && customerResult.Target.PaymentMethods.Length > 0) + { + braintreeCustomer = customerResult.Target; + } + else + { + await _btGateway.PaymentMethod.DeleteAsync(pmResult.Target.Token); + hadBtCustomer = false; + } + } + else + { + hadBtCustomer = false; + } + } + + if(!hadBtCustomer) + { + var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest + { + PaymentMethodNonce = paymentToken, + Email = subscriber.BillingEmailAddress(), + Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + + Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false), + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() + } + }); + + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) + { + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; + } } else { - if(paymentToken.StartsWith("btok_")) - { - await bankSerice.CreateAsync(customer.Id, new BankAccountCreateOptions - { - SourceToken = paymentToken - }); - } - else - { - await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions - { - SourceToken = paymentToken - }); - } - - if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) - { - var source = customer.Sources.FirstOrDefault(s => s.Id == customer.DefaultSourceId); - if(source.BankAccount != null) - { - await bankSerice.DeleteAsync(customer.Id, customer.DefaultSourceId); - } - else if(source.Card != null) - { - await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); - } - } + throw new GatewayException("Payment method is not supported at this time."); } - return updatedSubscriber; + if(stripeCustomerMetadata.ContainsKey("btCustomerId")) + { + if(braintreeCustomer?.Id != stripeCustomerMetadata["btCustomerId"]) + { + var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow); + stripeCustomerMetadata.Add($"btCustomerId_{nowSec}", stripeCustomerMetadata["btCustomerId"]); + } + stripeCustomerMetadata["btCustomerId"] = braintreeCustomer?.Id; + } + else if(!string.IsNullOrWhiteSpace(braintreeCustomer?.Id)) + { + stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); + } + + try + { + if(customer == null) + { + customer = await customerService.CreateAsync(new CustomerCreateOptions + { + Description = subscriber.BillingName(), + Email = subscriber.BillingEmailAddress(), + SourceToken = stipeCustomerSourceToken, + Metadata = stripeCustomerMetadata + }); + + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = customer.Id; + createdCustomer = true; + } + + if(!createdCustomer) + { + string defaultSourceId = null; + if(stripePaymentMethod) + { + if(paymentToken.StartsWith("btok_")) + { + var bankAccount = await bankSerice.CreateAsync(customer.Id, new BankAccountCreateOptions + { + SourceToken = paymentToken + }); + defaultSourceId = bankAccount.Id; + } + else + { + var card = await cardService.CreateAsync(customer.Id, new CardCreateOptions + { + SourceToken = paymentToken, + }); + defaultSourceId = card.Id; + } + } + + foreach(var source in customer.Sources.Where(s => s.Id != defaultSourceId)) + { + if(source is BankAccount) + { + await bankSerice.DeleteAsync(customer.Id, source.Id); + } + else if(source is Card) + { + await cardService.DeleteAsync(customer.Id, source.Id); + } + } + + customer = await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions + { + Metadata = stripeCustomerMetadata, + DefaultSource = defaultSourceId + }); + } + } + catch(Exception e) + { + if(braintreeCustomer != null && !hadBtCustomer) + { + await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); + } + throw e; + } + + return createdCustomer; } public async Task GetUpcomingInvoiceAsync(ISubscriber subscriber) { if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { - var subscriptionService = new StripeSubscriptionService(); - var invoiceService = new StripeInvoiceService(); + var subscriptionService = new SubscriptionService(); + var invoiceService = new InvoiceService(); var sub = await subscriptionService.GetAsync(subscriber.GatewaySubscriptionId); if(sub != null) { @@ -338,7 +897,10 @@ namespace Bit.Core.Services { try { - var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.GatewayCustomerId); + var upcomingInvoice = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions + { + CustomerId = subscriber.GatewayCustomerId + }); if(upcomingInvoice != null) { return new BillingInfo.BillingInvoice(upcomingInvoice); @@ -354,30 +916,54 @@ namespace Bit.Core.Services public async Task GetBillingAsync(ISubscriber subscriber) { var billingInfo = new BillingInfo(); - var customerService = new StripeCustomerService(); - var subscriptionService = new StripeSubscriptionService(); - var chargeService = new StripeChargeService(); - var invoiceService = new StripeInvoiceService(); + + ICollection transactions = null; + if(subscriber is User) + { + transactions = await _transactionRepository.GetManyByUserIdAsync(subscriber.Id); + } + else if(subscriber is Organization) + { + transactions = await _transactionRepository.GetManyByOrganizationIdAsync(subscriber.Id); + } + if(transactions != null) + { + billingInfo.Transactions = transactions?.OrderByDescending(i => i.CreationDate) + .Select(t => new BillingInfo.BillingTransaction(t)); + } + + var customerService = new CustomerService(); + var subscriptionService = new SubscriptionService(); + var chargeService = new ChargeService(); + var invoiceService = new InvoiceService(); if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); if(customer != null) { - if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) + billingInfo.CreditAmount = customer.AccountBalance / 100M; + + if(customer.Metadata?.ContainsKey("btCustomerId") ?? false) { - if(customer.DefaultSourceId.StartsWith("card_")) + try { - var source = customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId); - if(source != null) + var braintreeCustomer = await _btGateway.Customer.FindAsync( + customer.Metadata["btCustomerId"]); + if(braintreeCustomer?.DefaultPaymentMethod != null) { - billingInfo.PaymentSource = new BillingInfo.BillingSource(source); + billingInfo.PaymentSource = new BillingInfo.BillingSource( + braintreeCustomer.DefaultPaymentMethod); } } - else if(customer.DefaultSourceId.StartsWith("ba_")) + catch(Braintree.Exceptions.NotFoundException) { } + } + else if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) + { + if(customer.DefaultSourceId.StartsWith("card_") || customer.DefaultSourceId.StartsWith("ba_")) { - var source = customer.Sources.Data - .FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId); + var source = customer.Sources.Data.FirstOrDefault(s => + (s is Card || s is BankAccount) && s.Id == customer.DefaultSourceId); if(source != null) { billingInfo.PaymentSource = new BillingInfo.BillingSource(source); @@ -385,13 +971,21 @@ namespace Bit.Core.Services } } - var charges = await chargeService.ListAsync(new StripeChargeListOptions + var charges = await chargeService.ListAsync(new ChargeListOptions { CustomerId = customer.Id, Limit = 20 }); billingInfo.Charges = charges?.Data?.OrderByDescending(c => c.Created) .Select(c => new BillingInfo.BillingCharge(c)); + + var invoices = await invoiceService.ListAsync(new InvoiceListOptions + { + CustomerId = customer.Id, + Limit = 20 + }); + billingInfo.Invoices = invoices?.Data?.OrderByDescending(i => i.Date) + .Select(i => new BillingInfo.BillingInvoice2(i)); } } @@ -407,7 +1001,8 @@ namespace Bit.Core.Services { try { - var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.GatewayCustomerId); + var upcomingInvoice = await invoiceService.UpcomingAsync( + new UpcomingInvoiceOptions { CustomerId = subscriber.GatewayCustomerId }); if(upcomingInvoice != null) { billingInfo.UpcomingInvoice = new BillingInfo.BillingInvoice(upcomingInvoice); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 0250d29278..4afb22049b 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -42,6 +42,7 @@ namespace Bit.Core.Services private readonly ILicensingService _licenseService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; + private readonly IPaymentService _paymentService; private readonly IDataProtector _organizationServiceDataProtector; private readonly CurrentContext _currentContext; private readonly GlobalSettings _globalSettings; @@ -67,6 +68,7 @@ namespace Bit.Core.Services IEventService eventService, IApplicationCacheService applicationCacheService, IDataProtectionProvider dataProtectionProvider, + IPaymentService paymentService, CurrentContext currentContext, GlobalSettings globalSettings) : base( @@ -94,6 +96,7 @@ namespace Bit.Core.Services _licenseService = licenseService; _eventService = eventService; _applicationCacheService = applicationCacheService; + _paymentService = paymentService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); _currentContext = currentContext; @@ -682,6 +685,11 @@ namespace Bit.Core.Services throw new BadRequestException("Already a premium user."); } + if(additionalStorageGb < 0) + { + throw new BadRequestException("You can't subtract storage!"); + } + IPaymentService paymentService = null; if(_globalSettings.SelfHosted) { @@ -706,16 +714,14 @@ namespace Bit.Core.Services throw new BadRequestException("Invalid token."); } - if(paymentToken.StartsWith("tok_")) + var paymentMethodType = PaymentMethodType.Card; + if(!paymentToken.StartsWith("tok_")) { - paymentService = new StripePaymentService(); - } - else - { - paymentService = new BraintreePaymentService(_globalSettings); + paymentMethodType = PaymentMethodType.PayPal; } - await paymentService.PurchasePremiumAsync(user, paymentToken, additionalStorageGb); + await _paymentService.PurchasePremiumAsync(user, paymentMethodType, + paymentToken, additionalStorageGb); } else { @@ -789,9 +795,8 @@ namespace Bit.Core.Services { throw new BadRequestException("Not a premium user."); } - - var paymentService = user.GetPaymentService(_globalSettings); - await BillingHelpers.AdjustStorageAsync(paymentService, user, storageAdjustmentGb, StoragePlanId); + + await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, StoragePlanId); await SaveUserAsync(user); } @@ -802,17 +807,17 @@ namespace Bit.Core.Services throw new BadRequestException("Invalid token."); } - IPaymentService paymentService = null; + PaymentMethodType paymentMethodType; if(paymentToken.StartsWith("tok_")) { - paymentService = new StripePaymentService(); + paymentMethodType = PaymentMethodType.Card; } else { - paymentService = new BraintreePaymentService(_globalSettings); + paymentMethodType = PaymentMethodType.PayPal; } - var updated = await paymentService.UpdatePaymentMethodAsync(user, paymentToken); + var updated = await _paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken); if(updated) { await SaveUserAsync(user); @@ -821,20 +826,18 @@ namespace Bit.Core.Services public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null) { - var paymentService = user.GetPaymentService(_globalSettings); var eop = endOfPeriod.GetValueOrDefault(true); if(!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue && user.PremiumExpirationDate.Value < DateTime.UtcNow) { eop = false; } - await paymentService.CancelSubscriptionAsync(user, eop); + await _paymentService.CancelSubscriptionAsync(user, eop); } public async Task ReinstatePremiumAsync(User user) { - var paymentService = user.GetPaymentService(_globalSettings); - await paymentService.ReinstateSubscriptionAsync(user); + await _paymentService.ReinstateSubscriptionAsync(user); } public async Task DisablePremiumAsync(Guid userId, DateTime? expirationDate) @@ -874,8 +877,7 @@ namespace Bit.Core.Services if(billingInfo == null && user.Gateway != null) { - var paymentService = user.GetPaymentService(_globalSettings); - billingInfo = await paymentService.GetBillingAsync(user); + billingInfo = await _paymentService.GetBillingAsync(user); } return billingInfo == null ? new UserLicense(user, _licenseService) : diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 6dac10bb0c..93f3b4fd9e 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -52,6 +52,7 @@ namespace Bit.Core.Utilities services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } if(globalSettings.SelfHosted) @@ -77,6 +78,7 @@ namespace Bit.Core.Utilities public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 0374a710ac..02ff300f81 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -26,7 +26,7 @@ namespace Bit.Core.Utilities GlobalDomains.Add(GlobalEquivalentDomainsType.United, new List { "ua2go.com", "ual.com", "united.com", "unitedwifi.com" }); GlobalDomains.Add(GlobalEquivalentDomainsType.Yahoo, new List { "overture.com", "yahoo.com", "flickr.com" }); GlobalDomains.Add(GlobalEquivalentDomainsType.Zonelabs, new List { "zonealarm.com", "zonelabs.com" }); - GlobalDomains.Add(GlobalEquivalentDomainsType.Paypal, new List { "paypal.com", "paypal-search.com" }); + GlobalDomains.Add(GlobalEquivalentDomainsType.PayPal, new List { "paypal.com", "paypal-search.com" }); GlobalDomains.Add(GlobalEquivalentDomainsType.Avon, new List { "avon.com", "youravon.com" }); GlobalDomains.Add(GlobalEquivalentDomainsType.Diapers, new List { "diapers.com", "soap.com", "wag.com", "yoyo.com", "beautybar.com", "casa.com", "afterschool.com", "vine.com", "bookworm.com", "look.com", "vinemarket.com" }); GlobalDomains.Add(GlobalEquivalentDomainsType.Contacts, new List { "1800contacts.com", "800contacts.com" }); diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 95bfe03d42..b27395e88e 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -240,5 +240,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_Create.sql b/src/Sql/dbo/Stored Procedures/Transaction_Create.sql new file mode 100644 index 0000000000..0f9efba271 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_Create.sql @@ -0,0 +1,48 @@ +CREATE PROCEDURE [dbo].[Transaction_Create] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Amount MONEY, + @Refunded BIT, + @RefundedAmount MONEY, + @Details NVARCHAR(100), + @PaymentMethodType TINYINT, + @Gateway TINYINT, + @GatewayId VARCHAR(50), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Transaction] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Amount], + [Refunded], + [RefundedAmount], + [Details], + [PaymentMethodType], + [Gateway], + [GatewayId], + [CreationDate] + ) + VALUES + ( + @Id, + @UserId, + @OrganizationId, + @Type, + @Amount, + @Refunded, + @RefundedAmount, + @Details, + @PaymentMethodType, + @Gateway, + @GatewayId, + @CreationDate + ) +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql b/src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql new file mode 100644 index 0000000000..9bf87ef995 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[Transaction_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[Transaction] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql new file mode 100644 index 0000000000..3aca795fc3 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[Transaction_ReadByGatewayId] + @Gateway TINYINT, + @GatewayId VARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Gateway] = @Gateway + AND [GatewayId] = @GatewayId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql new file mode 100644 index 0000000000..c4426ebc27 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[Transaction_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql new file mode 100644 index 0000000000..d15c7603e1 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[Transaction_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [UserId] = NULL + AND [OrganizationId] = @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql new file mode 100644 index 0000000000..5a76fd8d07 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[Transaction_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [UserId] = @UserId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_Update.sql b/src/Sql/dbo/Stored Procedures/Transaction_Update.sql new file mode 100644 index 0000000000..fc2ac67659 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_Update.sql @@ -0,0 +1,34 @@ +CREATE PROCEDURE [dbo].[Transaction_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Amount MONEY, + @Refunded BIT, + @RefundedAmount MONEY, + @Details NVARCHAR(100), + @PaymentMethodType TINYINT, + @Gateway TINYINT, + @GatewayId VARCHAR(50), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Transaction] + SET + [UserId] = @UserId, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Amount] = @Amount, + [Refunded] = @Refunded, + [RefundedAmount] = @RefundedAmount, + [Details] = @Details, + [PaymentMethodType] = @PaymentMethodType, + [Gateway] = @Gateway, + [GatewayId] = @GatewayId, + [CreationDate] = @CreationDate + WHERE + [Id] = @Id +END \ No newline at end of file diff --git a/src/Sql/dbo/Tables/Transaction.sql b/src/Sql/dbo/Tables/Transaction.sql new file mode 100644 index 0000000000..0395bae353 --- /dev/null +++ b/src/Sql/dbo/Tables/Transaction.sql @@ -0,0 +1,28 @@ +CREATE TABLE [dbo].[Transaction] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [Type] TINYINT NOT NULL, + [Amount] MONEY NOT NULL, + [Refunded] BIT NULL, + [RefundedAmount] MONEY NULL, + [Details] NVARCHAR(100) NULL, + [PaymentMethodType] TINYINT NULL, + [Gateway] TINYINT NULL, + [GatewayId] VARCHAR(50) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_Transaction] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_Transaction_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE +); + + +GO +CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] + ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); + + +GO +CREATE NONCLUSTERED INDEX [IX_Transaction_UserId_OrganizationId_CreationDate] + ON [dbo].[Transaction]([UserId] ASC, [OrganizationId] ASC, [CreationDate] ASC); + diff --git a/src/Sql/dbo/Views/TransactionView.sql b/src/Sql/dbo/Views/TransactionView.sql new file mode 100644 index 0000000000..e81127d10d --- /dev/null +++ b/src/Sql/dbo/Views/TransactionView.sql @@ -0,0 +1,6 @@ +CREATE VIEW [dbo].[TransactionView] +AS +SELECT + * +FROM + [dbo].[Transaction] diff --git a/util/Setup/DbScripts/2019-01-31_00_Transactions.sql b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql new file mode 100644 index 0000000000..f559ac7308 --- /dev/null +++ b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql @@ -0,0 +1,247 @@ +IF OBJECT_ID('[dbo].[Transaction]') IS NULL +BEGIN + CREATE TABLE [dbo].[Transaction] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [Type] TINYINT NOT NULL, + [Amount] MONEY NOT NULL, + [Refunded] BIT NULL, + [RefundedAmount] MONEY NULL, + [Details] NVARCHAR(100) NULL, + [PaymentMethodType] TINYINT NULL, + [Gateway] TINYINT NULL, + [GatewayId] VARCHAR(50) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_Transaction] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_Transaction_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + ); + + CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] + ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); + + + CREATE NONCLUSTERED INDEX [IX_Transaction_UserId_OrganizationId_CreationDate] + ON [dbo].[Transaction]([UserId] ASC, [OrganizationId] ASC, [CreationDate] ASC); +END +GO + +IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'TransactionView') +BEGIN + DROP VIEW [dbo].[TransactionView] +END +GO + +CREATE VIEW [dbo].[TransactionView] +AS +SELECT + * +FROM + [dbo].[Transaction] +GO + +IF OBJECT_ID('[dbo].[Transaction_Create]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_Create] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_Create] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Amount MONEY, + @Refunded BIT, + @RefundedAmount MONEY, + @Details NVARCHAR(100), + @PaymentMethodType TINYINT, + @Gateway TINYINT, + @GatewayId VARCHAR(50), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Transaction] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Amount], + [Refunded], + [RefundedAmount], + [Details], + [PaymentMethodType], + [Gateway], + [GatewayId], + [CreationDate] + ) + VALUES + ( + @Id, + @UserId, + @OrganizationId, + @Type, + @Amount, + @Refunded, + @RefundedAmount, + @Details, + @PaymentMethodType, + @Gateway, + @GatewayId, + @CreationDate + ) +END +GO + +IF OBJECT_ID('[dbo].[Transaction_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[Transaction] + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[Transaction_ReadById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_ReadById] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_ReadById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[Transaction_ReadByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_ReadByOrganizationId] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [UserId] = NULL + AND [OrganizationId] = @OrganizationId +END +GO + +IF OBJECT_ID('[dbo].[Transaction_ReadByUserId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_ReadByUserId] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [UserId] = @UserId +END +GO + +IF OBJECT_ID('[dbo].[Transaction_Update]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_Update] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Amount MONEY, + @Refunded BIT, + @RefundedAmount MONEY, + @Details NVARCHAR(100), + @PaymentMethodType TINYINT, + @Gateway TINYINT, + @GatewayId VARCHAR(50), + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Transaction] + SET + [UserId] = @UserId, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Amount] = @Amount, + [Refunded] = @Refunded, + [RefundedAmount] = @RefundedAmount, + [Details] = @Details, + [PaymentMethodType] = @PaymentMethodType, + [Gateway] = @Gateway, + [GatewayId] = @GatewayId, + [CreationDate] = @CreationDate + WHERE + [Id] = @Id +END +GO + +IF OBJECT_ID('[dbo].[Transaction_ReadByGatewayId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_ReadByGatewayId] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_ReadByGatewayId] + @Gateway TINYINT, + @GatewayId VARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Gateway] = @Gateway + AND [GatewayId] = @GatewayId +END +GO diff --git a/util/Setup/Setup.csproj b/util/Setup/Setup.csproj index d48a268697..d8a77b63e1 100644 --- a/util/Setup/Setup.csproj +++ b/util/Setup/Setup.csproj @@ -16,6 +16,7 @@ +