From e54a381dba79826de3ce602099ec84547236e43e Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 29 Jan 2019 13:12:11 -0500 Subject: [PATCH 01/31] setup: process paypal with stripe subscription --- .../Controllers/OrganizationsController.cs | 2 +- src/Core/Models/Table/Organization.cs | 2 +- src/Core/Models/Table/User.cs | 2 +- src/Core/Services/IPaymentService.cs | 4 +- .../BraintreePaymentService.cs | 4 +- .../Implementations/OrganizationService.cs | 4 +- .../Implementations/StripePaymentService.cs | 145 +++++++++++++++++- .../Services/Implementations/UserService.cs | 14 +- 8 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 340b079086..5ab83bc30b 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -78,7 +78,7 @@ namespace Bit.Api.Controllers if(!_globalSettings.SelfHosted && organization.Gateway != null) { - var paymentService = new StripePaymentService(); + var paymentService = new StripePaymentService(_globalSettings); var billingInfo = await paymentService.GetBillingAsync(organization); if(billingInfo == null) { diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index f398d3908d..be7722bdf0 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -95,7 +95,7 @@ namespace Bit.Core.Models.Table switch(Gateway) { case GatewayType.Stripe: - paymentService = new StripePaymentService(); + paymentService = new StripePaymentService(globalSettings); break; case GatewayType.Braintree: paymentService = new BraintreePaymentService(globalSettings); diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index ce0a73319e..d7c7d72099 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -144,7 +144,7 @@ namespace Bit.Core.Models.Table switch(Gateway) { case GatewayType.Stripe: - paymentService = new StripePaymentService(); + paymentService = new StripePaymentService(globalSettings); break; case GatewayType.Braintree: paymentService = new BraintreePaymentService(globalSettings); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 41da719bf9..8d23ddd410 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,13 +1,15 @@ 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 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); diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index 43ec65ae01..eb8a132704 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -5,6 +5,7 @@ using Bit.Core.Models.Table; using Braintree; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Enums; namespace Bit.Core.Services { @@ -214,7 +215,8 @@ namespace Bit.Core.Services return billingInfo; } - public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) + public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + short additionalStorageGb) { var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest { diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 98e886907f..1388ae2b28 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -66,7 +66,7 @@ namespace Bit.Core.Services _eventService = eventService; _installationRepository = installationRepository; _applicationCacheService = applicationCacheService; - _stripePaymentService = new StripePaymentService(); + _stripePaymentService = new StripePaymentService(globalSettings); _globalSettings = globalSettings; } @@ -1208,7 +1208,7 @@ namespace Bit.Core.Services throw new BadRequestException("Invalid installation id"); } - var paymentService = new StripePaymentService(); + var paymentService = new StripePaymentService(_globalSettings); 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..1f34da4239 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 Braintree; +using Bit.Core.Enums; namespace Bit.Core.Services { @@ -13,21 +15,68 @@ namespace Bit.Core.Services { private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; + private readonly BraintreeGateway _btGateway; - public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb) + public StripePaymentService( + GlobalSettings globalSettings) { + _btGateway = 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 PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + short additionalStorageGb) + { + Customer braintreeCustomer = null; + StripeBilling? stripeSubscriptionBilling = null; + string stipeCustomerSourceToken = null; + var stripeCustomerMetadata = new Dictionary(); + + if(paymentMethodType == PaymentMethodType.PayPal) + { + stripeSubscriptionBilling = StripeBilling.SendInvoice; + var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); + var customerResult = await _btGateway.Customer.CreateAsync(new CustomerRequest + { + PaymentMethodNonce = paymentToken, + Email = user.Email, + Id = "u" + user.Id.ToString("N").ToLower() + randomSuffix + }); + + 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 if(paymentMethodType == PaymentMethodType.Card || paymentMethodType == PaymentMethodType.BankAccount) + { + stipeCustomerSourceToken = paymentToken; + } + var customerService = new StripeCustomerService(); var customer = await customerService.CreateAsync(new StripeCustomerCreateOptions { Description = user.Name, Email = user.Email, - SourceToken = paymentToken + SourceToken = stipeCustomerSourceToken, + Metadata = stripeCustomerMetadata }); var subCreateOptions = new StripeSubscriptionCreateOptions { CustomerId = customer.Id, Items = new List(), + Billing = stripeSubscriptionBilling, + DaysUntilDue = stripeSubscriptionBilling != null ? 1 : 0, Metadata = new Dictionary { ["userId"] = user.Id.ToString() @@ -54,14 +103,70 @@ namespace Bit.Core.Services { var subscriptionService = new StripeSubscriptionService(); subscription = await subscriptionService.CreateAsync(subCreateOptions); + + if(stripeSubscriptionBilling == StripeBilling.SendInvoice) + { + var invoiceService = new StripeInvoiceService(); + var invoices = await invoiceService.ListAsync(new StripeInvoiceListOptions + { + SubscriptionId = subscription.Id + }); + + var invoice = invoices?.FirstOrDefault(i => i.AmountDue > 0); + if(invoice == null) + { + throw new GatewayException("Invoice not found."); + } + + if(braintreeCustomer != null) + { + var btInvoiceAmount = (invoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync(new TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = braintreeCustomer.Id + }); + + if(!transactionResult.IsSuccess() || transactionResult.Target.Amount != btInvoiceAmount) + { + throw new GatewayException("Failed to charge PayPal customer."); + } + + var invoiceItemService = new StripeInvoiceItemService(); + await invoiceItemService.CreateAsync(new StripeInvoiceItemCreateOptions + { + Currency = "USD", + CustomerId = customer.Id, + InvoiceId = invoice.Id, + Amount = -1 * invoice.AmountDue, + Description = $"PayPal Credit, Transaction ID " + + transactionResult.Target.PayPalDetails.AuthorizationId, + Metadata = new Dictionary + { + ["btTransactionId"] = transactionResult.Target.Id, + ["btPayPalTransactionId"] = transactionResult.Target.PayPalDetails.AuthorizationId + } + }); + } + else + { + throw new GatewayException("No payment was able to be collected."); + } + + await invoiceService.PayAsync(invoice.Id, new StripeInvoicePayOptions { }); + } } - catch(StripeException) + catch(Exception e) { await customerService.DeleteAsync(customer.Id); - throw; + 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; @@ -169,6 +274,36 @@ namespace Bit.Core.Services if(invoice.AmountDue > 0) { + var customerService = new StripeCustomerService(); + var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer != null) + { + if(customer.Metadata.ContainsKey("btCustomerId")) + { + var invoiceAmount = (invoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync(new TransactionRequest + { + Amount = invoiceAmount, + CustomerId = customer.Metadata["btCustomerId"] + }); + + if(!transactionResult.IsSuccess() || transactionResult.Target.Amount != invoiceAmount) + { + await invoiceService.UpdateAsync(invoice.Id, new StripeInvoiceUpdateOptions + { + Closed = true + }); + throw new GatewayException("Failed to charge PayPal customer."); + } + + await customerService.UpdateAsync(customer.Id, new StripeCustomerUpdateOptions + { + AccountBalance = customer.AccountBalance - invoice.AmountDue, + Metadata = customer.Metadata + }); + } + } + await invoiceService.PayAsync(invoice.Id, new StripeInvoicePayOptions()); } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 0250d29278..03553401f1 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -706,16 +706,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 new StripePaymentService(_globalSettings).PurchasePremiumAsync(user, paymentMethodType, + paymentToken, additionalStorageGb); } else { @@ -805,7 +803,7 @@ namespace Bit.Core.Services IPaymentService paymentService = null; if(paymentToken.StartsWith("tok_")) { - paymentService = new StripePaymentService(); + paymentService = new StripePaymentService(_globalSettings); } else { From a34ca4700d34805e47475e060e274bf0371844cf Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 29 Jan 2019 14:41:37 -0500 Subject: [PATCH 02/31] upgrade stripe lib and breaking changes --- .../Controllers/OrganizationsController.cs | 2 +- src/Billing/Controllers/StripeController.cs | 16 +- src/Core/Core.csproj | 2 +- src/Core/Models/Business/BillingInfo.cs | 79 ++++---- .../Implementations/OrganizationService.cs | 64 +++---- .../Implementations/StripePaymentService.cs | 170 +++++++++--------- 6 files changed, 163 insertions(+), 170 deletions(-) diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 5ab83bc30b..cea2162d9c 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -110,7 +110,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/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index b30357cb12..df39e02532 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -47,11 +47,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,7 +60,7 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - if(_hostingEnvironment.IsProduction() && !parsedEvent.LiveMode) + if(_hostingEnvironment.IsProduction() && !parsedEvent.Livemode) { return new BadRequestResult(); } @@ -71,8 +71,7 @@ namespace Bit.Billing.Controllers if(subDeleted || subUpdated) { - StripeSubscription subscription = Mapper.MapFromJson( - parsedEvent.Data.Object.ToString()); + var subscription = parsedEvent.Data.Object as Subscription; if(subscription == null) { throw new Exception("Subscription is null."); @@ -115,14 +114,13 @@ namespace Bit.Billing.Controllers } else if(invUpcoming) { - StripeInvoice invoice = Mapper.MapFromJson( - parsedEvent.Data.Object.ToString()); + var invoice = parsedEvent.Data.Object as Invoice; if(invoice == null) { 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,7 +150,7 @@ 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); } 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/Models/Business/BillingInfo.cs b/src/Core/Models/Business/BillingInfo.cs index 1e1d5d9bf8..8cbd164114 100644 --- a/src/Core/Models/Business/BillingInfo.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -1,5 +1,4 @@ using Bit.Core.Enums; -using Braintree; using Stripe; using System; using System.Collections.Generic; @@ -16,40 +15,37 @@ namespace Bit.Core.Models.Business 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 +55,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 +66,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 +86,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 +102,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 +123,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 +155,7 @@ namespace Bit.Core.Models.Business public class BillingSubscriptionItem { - public BillingSubscriptionItem(StripeSubscriptionItem item) + public BillingSubscriptionItem(SubscriptionItem item) { if(item.Plan != null) { @@ -168,10 +164,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 +175,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 +192,13 @@ namespace Bit.Core.Models.Business public class BillingInvoice { - public BillingInvoice(StripeInvoice inv) + 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 +214,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 +226,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 +235,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); } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 1388ae2b28..8c2159f6af 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -186,15 +186,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 +202,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 +211,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 +223,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 +239,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 @@ -333,8 +333,8 @@ 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) { @@ -344,7 +344,7 @@ namespace Bit.Core.Services var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId); if(additionalSeats > 0 && seatItem == null) { - await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + await subscriptionItemService.CreateAsync(new SubscriptionItemCreateOptions { PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, @@ -354,7 +354,7 @@ namespace Bit.Core.Services } else if(additionalSeats > 0 && seatItem != null) { - await subscriptionItemService.UpdateAsync(seatItem.Id, new StripeSubscriptionItemUpdateOptions + await subscriptionItemService.UpdateAsync(seatItem.Id, new SubscriptionItemUpdateOptions { PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, @@ -389,7 +389,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 +397,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 +406,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."); @@ -453,10 +453,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; + var customerService = new CustomerService(); + var subscriptionService = new SubscriptionService(); + Customer customer = null; + Subscription subscription = null; // Pre-generate the org id so that we can save it with the Stripe subscription.. var newOrgId = CoreHelpers.GenerateComb(); @@ -472,18 +472,18 @@ namespace Bit.Core.Services } else { - customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + customer = await customerService.CreateAsync(new CustomerCreateOptions { Description = signup.BusinessName, Email = signup.BillingEmail, SourceToken = signup.PaymentToken }); - var subCreateOptions = new StripeSubscriptionCreateOptions + var subCreateOptions = new SubscriptionCreateOptions { CustomerId = customer.Id, TrialPeriodDays = plan.TrialPeriodDays, - Items = new List(), + Items = new List(), Metadata = new Dictionary { { "organizationId", newOrgId.ToString() } } @@ -491,7 +491,7 @@ namespace Bit.Core.Services if(plan.StripePlanId != null) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = plan.StripePlanId, Quantity = 1 @@ -500,7 +500,7 @@ namespace Bit.Core.Services if(signup.AdditionalSeats > 0 && plan.StripeSeatPlanId != null) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = plan.StripeSeatPlanId, Quantity = signup.AdditionalSeats @@ -509,7 +509,7 @@ namespace Bit.Core.Services if(signup.AdditionalStorageGb > 0) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = plan.StripeStoragePlanId, Quantity = signup.AdditionalStorageGb @@ -518,7 +518,7 @@ namespace Bit.Core.Services if(signup.PremiumAccessAddon && plan.StripePremiumAccessPlanId != null) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = plan.StripePremiumAccessPlanId, Quantity = 1 @@ -630,7 +630,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; } @@ -756,7 +757,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; @@ -842,8 +844,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 diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 1f34da4239..225d404f5a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using Bit.Core.Exceptions; using System.Linq; using Bit.Core.Models.Business; -using Braintree; using Bit.Core.Enums; namespace Bit.Core.Services @@ -15,12 +14,12 @@ namespace Bit.Core.Services { private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; - private readonly BraintreeGateway _btGateway; + private readonly Braintree.BraintreeGateway _btGateway; public StripePaymentService( GlobalSettings globalSettings) { - _btGateway = new BraintreeGateway + _btGateway = new Braintree.BraintreeGateway { Environment = globalSettings.Braintree.Production ? Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX, @@ -33,16 +32,16 @@ namespace Bit.Core.Services public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb) { - Customer braintreeCustomer = null; - StripeBilling? stripeSubscriptionBilling = null; + Braintree.Customer braintreeCustomer = null; + Billing? stripeSubscriptionBilling = null; string stipeCustomerSourceToken = null; var stripeCustomerMetadata = new Dictionary(); if(paymentMethodType == PaymentMethodType.PayPal) { - stripeSubscriptionBilling = StripeBilling.SendInvoice; + stripeSubscriptionBilling = Billing.SendInvoice; var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); - var customerResult = await _btGateway.Customer.CreateAsync(new CustomerRequest + var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest { PaymentMethodNonce = paymentToken, Email = user.Email, @@ -62,8 +61,8 @@ namespace Bit.Core.Services stipeCustomerSourceToken = paymentToken; } - var customerService = new StripeCustomerService(); - var customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + var customerService = new CustomerService(); + var customer = await customerService.CreateAsync(new CustomerCreateOptions { Description = user.Name, Email = user.Email, @@ -71,19 +70,19 @@ namespace Bit.Core.Services Metadata = stripeCustomerMetadata }); - var subCreateOptions = new StripeSubscriptionCreateOptions + var subCreateOptions = new SubscriptionCreateOptions { CustomerId = customer.Id, - Items = new List(), + Items = new List(), Billing = stripeSubscriptionBilling, - DaysUntilDue = stripeSubscriptionBilling != null ? 1 : 0, + DaysUntilDue = stripeSubscriptionBilling != null ? 1 : (long?)null, Metadata = new Dictionary { ["userId"] = user.Id.ToString() } }; - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = PremiumPlanId, Quantity = 1 @@ -91,23 +90,23 @@ namespace Bit.Core.Services if(additionalStorageGb > 0) { - subCreateOptions.Items.Add(new StripeSubscriptionItemOption + subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = StoragePlanId, Quantity = additionalStorageGb }); } - StripeSubscription subscription = null; + Subscription subscription = null; try { - var subscriptionService = new StripeSubscriptionService(); + var subscriptionService = new SubscriptionService(); subscription = await subscriptionService.CreateAsync(subCreateOptions); - if(stripeSubscriptionBilling == StripeBilling.SendInvoice) + if(stripeSubscriptionBilling == Billing.SendInvoice) { - var invoiceService = new StripeInvoiceService(); - var invoices = await invoiceService.ListAsync(new StripeInvoiceListOptions + var invoiceService = new InvoiceService(); + var invoices = await invoiceService.ListAsync(new InvoiceListOptions { SubscriptionId = subscription.Id }); @@ -121,7 +120,7 @@ namespace Bit.Core.Services if(braintreeCustomer != null) { var btInvoiceAmount = (invoice.AmountDue / 100M); - var transactionResult = await _btGateway.Transaction.SaleAsync(new TransactionRequest + var transactionResult = await _btGateway.Transaction.SaleAsync(new Braintree.TransactionRequest { Amount = btInvoiceAmount, CustomerId = braintreeCustomer.Id @@ -132,8 +131,8 @@ namespace Bit.Core.Services throw new GatewayException("Failed to charge PayPal customer."); } - var invoiceItemService = new StripeInvoiceItemService(); - await invoiceItemService.CreateAsync(new StripeInvoiceItemCreateOptions + var invoiceItemService = new InvoiceItemService(); + await invoiceItemService.CreateAsync(new InvoiceItemCreateOptions { Currency = "USD", CustomerId = customer.Id, @@ -153,7 +152,7 @@ namespace Bit.Core.Services throw new GatewayException("No payment was able to be collected."); } - await invoiceService.PayAsync(invoice.Id, new StripeInvoicePayOptions { }); + await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { }); } } catch(Exception e) @@ -176,8 +175,8 @@ namespace Bit.Core.Services 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) { @@ -187,7 +186,7 @@ namespace Bit.Core.Services var storageItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId); if(additionalStorage > 0 && storageItem == null) { - await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions + await subscriptionItemService.CreateAsync(new SubscriptionItemCreateOptions { PlanId = storagePlanId, Quantity = additionalStorage, @@ -197,7 +196,7 @@ namespace Bit.Core.Services } else if(additionalStorage > 0 && storageItem != null) { - await subscriptionItemService.UpdateAsync(storageItem.Id, new StripeSubscriptionItemUpdateOptions + await subscriptionItemService.UpdateAsync(storageItem.Id, new SubscriptionItemUpdateOptions { PlanId = storagePlanId, Quantity = additionalStorage, @@ -219,9 +218,9 @@ namespace Bit.Core.Services { 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)) @@ -229,36 +228,36 @@ namespace Bit.Core.Services return; } - var chargeService = new StripeChargeService(); - var charges = await chargeService.ListAsync(new StripeChargeListOptions + var chargeService = new ChargeService(); + var charges = await chargeService.ListAsync(new ChargeListOptions { CustomerId = subscriber.GatewayCustomerId }); if(charges?.Data != null) { - var refundService = new StripeRefundService(); + var refundService = new RefundService(); foreach(var charge in charges.Data.Where(c => !c.Refunded)) { - await refundService.CreateAsync(charge.Id); + await refundService.CreateAsync(new RefundCreateOptions { ChargeId = charge.Id }); } } - var customerService = new StripeCustomerService(); + var customerService = new CustomerService(); await customerService.DeleteAsync(subscriber.GatewayCustomerId); } public async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId, int prorateThreshold = 500) { - var invoiceService = new StripeInvoiceService(); - var upcomingPreview = await invoiceService.UpcomingAsync(subscriber.GatewayCustomerId, - new StripeUpcomingInvoiceOptions - { - SubscriptionId = subscriber.GatewaySubscriptionId - }); + var invoiceService = new InvoiceService(); + var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions + { + CustomerId = subscriber.GatewayCustomerId, + SubscriptionId = subscriber.GatewaySubscriptionId + }); - var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data? + var prorationAmount = upcomingPreview.Lines?.Data? .TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount); if(prorationAmount.GetValueOrDefault() >= prorateThreshold) { @@ -266,37 +265,38 @@ namespace Bit.Core.Services { // 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 - }); + var invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions + { + CustomerId = subscriber.GatewayCustomerId, + SubscriptionId = subscriber.GatewaySubscriptionId + }); if(invoice.AmountDue > 0) { - var customerService = new StripeCustomerService(); + var customerService = new CustomerService(); var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); if(customer != null) { if(customer.Metadata.ContainsKey("btCustomerId")) { var invoiceAmount = (invoice.AmountDue / 100M); - var transactionResult = await _btGateway.Transaction.SaleAsync(new TransactionRequest - { - Amount = invoiceAmount, - CustomerId = customer.Metadata["btCustomerId"] - }); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = invoiceAmount, + CustomerId = customer.Metadata["btCustomerId"] + }); if(!transactionResult.IsSuccess() || transactionResult.Target.Amount != invoiceAmount) { - await invoiceService.UpdateAsync(invoice.Id, new StripeInvoiceUpdateOptions + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions { Closed = true }); throw new GatewayException("Failed to charge PayPal customer."); } - await customerService.UpdateAsync(customer.Id, new StripeCustomerUpdateOptions + await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions { AccountBalance = customer.AccountBalance - invoice.AmountDue, Metadata = customer.Metadata @@ -304,7 +304,7 @@ namespace Bit.Core.Services } } - await invoiceService.PayAsync(invoice.Id, new StripeInvoicePayOptions()); + await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions()); } } catch(StripeException) { } @@ -323,7 +323,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) { @@ -340,8 +340,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."); @@ -368,7 +368,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) { @@ -381,7 +381,7 @@ 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."); @@ -403,10 +403,10 @@ namespace Bit.Core.Services var updatedSubscriber = false; - 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)) { @@ -415,7 +415,7 @@ namespace Bit.Core.Services if(customer == null) { - customer = await customerService.CreateAsync(new StripeCustomerCreateOptions + customer = await customerService.CreateAsync(new CustomerCreateOptions { Description = subscriber.BillingName(), Email = subscriber.BillingEmailAddress(), @@ -437,7 +437,7 @@ namespace Bit.Core.Services } else { - await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions + await cardService.CreateAsync(customer.Id, new CardCreateOptions { SourceToken = paymentToken }); @@ -446,11 +446,11 @@ namespace Bit.Core.Services if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) { var source = customer.Sources.FirstOrDefault(s => s.Id == customer.DefaultSourceId); - if(source.BankAccount != null) + if(source is BankAccount) { await bankSerice.DeleteAsync(customer.Id, customer.DefaultSourceId); } - else if(source.Card != null) + else if(source is Card) { await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); } @@ -464,8 +464,8 @@ namespace Bit.Core.Services { 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) { @@ -473,7 +473,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); @@ -489,10 +492,10 @@ 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(); + var customerService = new CustomerService(); + var subscriptionService = new SubscriptionService(); + var chargeService = new ChargeService(); + var invoiceService = new InvoiceService(); if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { @@ -501,18 +504,10 @@ namespace Bit.Core.Services { if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) { - if(customer.DefaultSourceId.StartsWith("card_")) + if(customer.DefaultSourceId.StartsWith("card_") || customer.DefaultSourceId.StartsWith("ba_")) { - var source = customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId); - if(source != null) - { - billingInfo.PaymentSource = new BillingInfo.BillingSource(source); - } - } - else if(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); @@ -520,7 +515,7 @@ namespace Bit.Core.Services } } - var charges = await chargeService.ListAsync(new StripeChargeListOptions + var charges = await chargeService.ListAsync(new ChargeListOptions { CustomerId = customer.Id, Limit = 20 @@ -542,7 +537,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); From abb1751bfe6572343d7b03a337fd628549028d6e Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 29 Jan 2019 17:44:31 -0500 Subject: [PATCH 03/31] stripe invoice handling. return credit amount. --- .../Api/Response/BillingResponseModel.cs | 2 + src/Core/Models/Business/BillingInfo.cs | 1 + .../Implementations/StripePaymentService.cs | 136 ++++++++++++------ 3 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/Core/Models/Api/Response/BillingResponseModel.cs b/src/Core/Models/Api/Response/BillingResponseModel.cs index 1a3e01225f..b9d44a057f 100644 --- a/src/Core/Models/Api/Response/BillingResponseModel.cs +++ b/src/Core/Models/Api/Response/BillingResponseModel.cs @@ -12,6 +12,7 @@ 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)); @@ -37,6 +38,7 @@ 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; } diff --git a/src/Core/Models/Business/BillingInfo.cs b/src/Core/Models/Business/BillingInfo.cs index 8cbd164114..ebfbadca09 100644 --- a/src/Core/Models/Business/BillingInfo.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -8,6 +8,7 @@ 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; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 225d404f5a..fa65417358 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -105,6 +105,7 @@ namespace Bit.Core.Services if(stripeSubscriptionBilling == Billing.SendInvoice) { + var invoicePayOptions = new InvoicePayOptions(); var invoiceService = new InvoiceService(); var invoices = await invoiceService.ListAsync(new InvoiceListOptions { @@ -119,40 +120,54 @@ namespace Bit.Core.Services if(braintreeCustomer != null) { - var btInvoiceAmount = (invoice.AmountDue / 100M); - var transactionResult = await _btGateway.Transaction.SaleAsync(new Braintree.TransactionRequest + invoicePayOptions.PaidOutOfBand = true; + Braintree.Transaction braintreeTransaction = null; + try { - Amount = btInvoiceAmount, - CustomerId = braintreeCustomer.Id - }); + var btInvoiceAmount = (invoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = braintreeCustomer.Id, + Options = new Braintree.TransactionOptionsRequest { SubmitForSettlement = true } + }); - if(!transactionResult.IsSuccess() || transactionResult.Target.Amount != btInvoiceAmount) - { - throw new GatewayException("Failed to charge PayPal customer."); - } - - var invoiceItemService = new InvoiceItemService(); - await invoiceItemService.CreateAsync(new InvoiceItemCreateOptions - { - Currency = "USD", - CustomerId = customer.Id, - InvoiceId = invoice.Id, - Amount = -1 * invoice.AmountDue, - Description = $"PayPal Credit, Transaction ID " + - transactionResult.Target.PayPalDetails.AuthorizationId, - Metadata = new Dictionary + if(!transactionResult.IsSuccess()) { - ["btTransactionId"] = transactionResult.Target.Id, - ["btPayPalTransactionId"] = transactionResult.Target.PayPalDetails.AuthorizationId + throw new GatewayException("Failed to charge PayPal customer."); } - }); + + braintreeTransaction = transactionResult.Target; + if(transactionResult.Target.Amount != btInvoiceAmount) + { + throw new GatewayException("PayPal charge mismatch."); + } + + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + ["btTransactionId"] = braintreeTransaction.Id, + ["btPayPalTransactionId"] = braintreeTransaction.PayPalDetails.AuthorizationId + } + }); + } + catch(Exception e) + { + if(braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } + throw e; + } } else { throw new GatewayException("No payment was able to be collected."); } - await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { }); + await invoiceService.PayAsync(invoice.Id, invoicePayOptions); } } catch(Exception e) @@ -271,41 +286,65 @@ namespace Bit.Core.Services SubscriptionId = subscriber.GatewaySubscriptionId }); + var invoicePayOptions = new InvoicePayOptions(); if(invoice.AmountDue > 0) { var customerService = new CustomerService(); var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); if(customer != null) { + Braintree.Transaction braintreeTransaction = null; if(customer.Metadata.ContainsKey("btCustomerId")) { - var invoiceAmount = (invoice.AmountDue / 100M); - var transactionResult = await _btGateway.Transaction.SaleAsync( - new Braintree.TransactionRequest - { - Amount = invoiceAmount, - CustomerId = customer.Metadata["btCustomerId"] - }); - - if(!transactionResult.IsSuccess() || transactionResult.Target.Amount != invoiceAmount) + invoicePayOptions.PaidOutOfBand = true; + try { + 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 + } + }); + + if(!transactionResult.IsSuccess()) + { + throw new GatewayException("Failed to charge PayPal customer."); + } + + braintreeTransaction = transactionResult.Target; + if(transactionResult.Target.Amount != btInvoiceAmount) + { + throw new GatewayException("PayPal charge mismatch."); + } + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions { - Closed = true + Metadata = new Dictionary + { + ["btTransactionId"] = braintreeTransaction.Id, + ["btPayPalTransactionId"] = + braintreeTransaction.PayPalDetails.AuthorizationId + } }); - throw new GatewayException("Failed to charge PayPal customer."); } - - await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions + catch(Exception e) { - AccountBalance = customer.AccountBalance - invoice.AmountDue, - Metadata = customer.Metadata - }); + if(braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } + throw e; + } } } - - await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions()); } + + await invoiceService.PayAsync(invoice.Id, invoicePayOptions); } catch(StripeException) { } } @@ -502,7 +541,18 @@ namespace Bit.Core.Services 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) + { + var braintreeCustomer = await _btGateway.Customer.FindAsync(customer.Metadata["btCustomerId"]); + if(braintreeCustomer?.DefaultPaymentMethod != null) + { + billingInfo.PaymentSource = new BillingInfo.BillingSource( + braintreeCustomer.DefaultPaymentMethod); + } + } + else if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) { if(customer.DefaultSourceId.StartsWith("card_") || customer.DefaultSourceId.StartsWith("ba_")) { From d236bdd40800443f91f37d5fcaccc0208304268d Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 30 Jan 2019 16:27:20 -0500 Subject: [PATCH 04/31] rework paypal payment to use customer balance --- .../Implementations/StripePaymentService.cs | 131 +++++++++--------- 1 file changed, 69 insertions(+), 62 deletions(-) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index fa65417358..bd244902c9 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -32,14 +32,22 @@ namespace Bit.Core.Services 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; - Billing? stripeSubscriptionBilling = null; string stipeCustomerSourceToken = null; var stripeCustomerMetadata = new Dictionary(); + var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card || + paymentMethodType == PaymentMethodType.BankAccount; - if(paymentMethodType == PaymentMethodType.PayPal) + if(stripePaymentMethod) + { + stipeCustomerSourceToken = paymentToken; + } + else if(paymentMethodType == PaymentMethodType.PayPal) { - stripeSubscriptionBilling = Billing.SendInvoice; var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest { @@ -56,12 +64,7 @@ namespace Bit.Core.Services braintreeCustomer = customerResult.Target; stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); } - else if(paymentMethodType == PaymentMethodType.Card || paymentMethodType == PaymentMethodType.BankAccount) - { - stipeCustomerSourceToken = paymentToken; - } - var customerService = new CustomerService(); var customer = await customerService.CreateAsync(new CustomerCreateOptions { Description = user.Name, @@ -69,13 +72,11 @@ namespace Bit.Core.Services SourceToken = stipeCustomerSourceToken, Metadata = stripeCustomerMetadata }); - + var subCreateOptions = new SubscriptionCreateOptions { CustomerId = customer.Id, Items = new List(), - Billing = stripeSubscriptionBilling, - DaysUntilDue = stripeSubscriptionBilling != null ? 1 : (long?)null, Metadata = new Dictionary { ["userId"] = user.Id.ToString() @@ -85,7 +86,7 @@ namespace Bit.Core.Services subCreateOptions.Items.Add(new SubscriptionItemOption { PlanId = PremiumPlanId, - Quantity = 1 + Quantity = 1, }); if(additionalStorageGb > 0) @@ -97,82 +98,78 @@ namespace Bit.Core.Services }); } + var subInvoiceMetadata = new Dictionary(); Subscription subscription = null; try { - var subscriptionService = new SubscriptionService(); - subscription = await subscriptionService.CreateAsync(subCreateOptions); - - if(stripeSubscriptionBilling == Billing.SendInvoice) + if(!stripePaymentMethod) { - var invoicePayOptions = new InvoicePayOptions(); - var invoiceService = new InvoiceService(); - var invoices = await invoiceService.ListAsync(new InvoiceListOptions + var previewInvoice = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions { - SubscriptionId = subscription.Id + CustomerId = customer.Id, + SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items) }); - var invoice = invoices?.FirstOrDefault(i => i.AmountDue > 0); - if(invoice == null) + await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions { - throw new GatewayException("Invoice not found."); - } + AccountBalance = -1 * previewInvoice.AmountDue + }); if(braintreeCustomer != null) { - invoicePayOptions.PaidOutOfBand = true; - Braintree.Transaction braintreeTransaction = null; - try - { - var btInvoiceAmount = (invoice.AmountDue / 100M); - var transactionResult = await _btGateway.Transaction.SaleAsync( - new Braintree.TransactionRequest - { - Amount = btInvoiceAmount, - CustomerId = braintreeCustomer.Id, - Options = new Braintree.TransactionOptionsRequest { SubmitForSettlement = true } - }); - - if(!transactionResult.IsSuccess()) + var btInvoiceAmount = (previewInvoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest { - throw new GatewayException("Failed to charge PayPal customer."); - } - - braintreeTransaction = transactionResult.Target; - if(transactionResult.Target.Amount != btInvoiceAmount) - { - throw new GatewayException("PayPal charge mismatch."); - } - - await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions - { - Metadata = new Dictionary - { - ["btTransactionId"] = braintreeTransaction.Id, - ["btPayPalTransactionId"] = braintreeTransaction.PayPalDetails.AuthorizationId - } + Amount = btInvoiceAmount, + CustomerId = braintreeCustomer.Id, + Options = new Braintree.TransactionOptionsRequest { SubmitForSettlement = true } }); - } - catch(Exception e) + + if(!transactionResult.IsSuccess()) { - if(braintreeTransaction != null) - { - await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); - } - throw e; + throw new GatewayException("Failed to charge PayPal customer."); } + + subInvoiceMetadata.Add("btTransactionId", braintreeTransaction.Id); + subInvoiceMetadata.Add("btPayPalTransactionId", + braintreeTransaction.PayPalDetails.AuthorizationId); } else { throw new GatewayException("No payment was able to be collected."); } + } - await invoiceService.PayAsync(invoice.Id, invoicePayOptions); + 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(Exception e) { await customerService.DeleteAsync(customer.Id); + if(braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } if(braintreeCustomer != null) { await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); @@ -187,6 +184,16 @@ namespace Bit.Core.Services 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) { From 87ee144eddb5a3ee2fe6fb05b9b67100b987f3b2 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 00:41:13 -0500 Subject: [PATCH 05/31] preview and pay to invoice prior to sub change --- .../Implementations/OrganizationService.cs | 46 +++- .../Implementations/StripePaymentService.cs | 223 ++++++++++++------ 2 files changed, 190 insertions(+), 79 deletions(-) diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 8c2159f6af..2af6bdefc4 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -340,37 +340,67 @@ namespace Bit.Core.Services { 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 SubscriptionItemCreateOptions + 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 SubscriptionItemUpdateOptions + 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 _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync( + organization, plan.StripeSeatPlanId, subItemOptions, 500); } + await subUpdateAction(!invoicedNow); organization.Seats = (short?)newSeatTotal; await ReplaceAndUpdateCache(organization); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index bd244902c9..0e766c2ff5 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -72,7 +72,7 @@ namespace Bit.Core.Services SourceToken = stipeCustomerSourceToken, Metadata = stripeCustomerMetadata }); - + var subCreateOptions = new SubscriptionCreateOptions { CustomerId = customer.Id, @@ -205,35 +205,66 @@ namespace Bit.Core.Services 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 SubscriptionItemCreateOptions + 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 SubscriptionItemUpdateOptions + 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) @@ -269,92 +300,142 @@ namespace Bit.Core.Services 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 InvoiceService(); + var invoiceItemService = new InvoiceItemService(); + + 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 + SubscriptionId = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions }); - var prorationAmount = upcomingPreview.Lines?.Data? - .TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount); - if(prorationAmount.GetValueOrDefault() >= prorateThreshold) + 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(new InvoiceCreateOptions + foreach(var ii in itemsForInvoice) { - CustomerId = subscriber.GatewayCustomerId, - SubscriptionId = subscriber.GatewaySubscriptionId + 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(); - if(invoice.AmountDue > 0) + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer != null) { - var customerService = new CustomerService(); - var customer = await customerService.GetAsync(subscriber.GatewayCustomerId); - if(customer != null) + if(customer.Metadata.ContainsKey("btCustomerId")) { - Braintree.Transaction braintreeTransaction = 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 + } + }); + + if(!transactionResult.IsSuccess()) { - invoicePayOptions.PaidOutOfBand = true; - try - { - 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 - } - }); - - if(!transactionResult.IsSuccess()) - { - throw new GatewayException("Failed to charge PayPal customer."); - } - - braintreeTransaction = transactionResult.Target; - if(transactionResult.Target.Amount != btInvoiceAmount) - { - throw new GatewayException("PayPal charge mismatch."); - } - - await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions - { - Metadata = new Dictionary - { - ["btTransactionId"] = braintreeTransaction.Id, - ["btPayPalTransactionId"] = - braintreeTransaction.PayPalDetails.AuthorizationId - } - }); - } - catch(Exception e) - { - if(braintreeTransaction != null) - { - await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); - } - throw e; - } + 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(StripeException) { } + 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; + } } + return invoiceNow; } public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) From fca1ee4253416cbd353a4e99983d420922902063 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 09:00:44 -0500 Subject: [PATCH 06/31] CancelAndRecoverChargesAsync for braintree --- .../Implementations/StripePaymentService.cs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 0e766c2ff5..9109f91f1f 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -14,6 +14,7 @@ namespace Bit.Core.Services { private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; + private readonly Braintree.BraintreeGateway _btGateway; public StripePaymentService( @@ -281,22 +282,48 @@ namespace Bit.Core.Services return; } - var chargeService = new ChargeService(); - var charges = await chargeService.ListAsync(new ChargeListOptions + 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 RefundService(); - 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(new RefundCreateOptions { ChargeId = 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 CustomerService(); await customerService.DeleteAsync(subscriber.GatewayCustomerId); } From 952d624d7219ed1c26cab0b571752294ac9dded2 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 12:11:30 -0500 Subject: [PATCH 07/31] change payment methods between stripe and paypal --- src/Core/Models/Table/ISubscriber.cs | 5 +- src/Core/Models/Table/Organization.cs | 5 + src/Core/Models/Table/User.cs | 5 + src/Core/Services/IPaymentService.cs | 5 +- .../BraintreePaymentService.cs | 12 +- .../Implementations/OrganizationService.cs | 19 +- .../Implementations/StripePaymentService.cs | 164 +++++++++++++----- .../Services/Implementations/UserService.cs | 9 +- 8 files changed, 172 insertions(+), 52 deletions(-) diff --git a/src/Core/Models/Table/ISubscriber.cs b/src/Core/Models/Table/ISubscriber.cs index 84f2a6aff5..9b2ffdbdbc 100644 --- a/src/Core/Models/Table/ISubscriber.cs +++ b/src/Core/Models/Table/ISubscriber.cs @@ -1,15 +1,18 @@ -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(); + string BraintreeCustomerIdPrefix(); IPaymentService GetPaymentService(GlobalSettings globalSettings); } } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index be7722bdf0..09d56ac4a1 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -63,6 +63,11 @@ namespace Bit.Core.Models.Table return BusinessName; } + public string BraintreeCustomerIdPrefix() + { + return "o"; + } + public long StorageBytesRemaining() { if(!MaxStorageGb.HasValue) diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index d7c7d72099..2850254c06 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -58,6 +58,11 @@ namespace Bit.Core.Models.Table return Name; } + public string BraintreeCustomerIdPrefix() + { + return "u"; + } + public Dictionary GetTwoFactorProviders() { if(string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 8d23ddd410..d45e378b39 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -8,12 +8,13 @@ namespace Bit.Core.Services public interface IPaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); - Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + 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 index eb8a132704..8c1a805ab3 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -215,7 +215,7 @@ namespace Bit.Core.Services return billingInfo; } - public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, + public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb) { var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest @@ -303,14 +303,20 @@ namespace Bit.Core.Services } } - 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.Braintree) + if(paymentMethodType != PaymentMethodType.PayPal) + { + throw new GatewayException("Payment method not allowed"); + } + + if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != GatewayType.Braintree) { throw new GatewayException("Switching from one payment type to another is not supported. " + "Contact us for assistance."); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 2af6bdefc4..067810c22f 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -78,7 +78,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 _stripePaymentService.UpdatePaymentMethodAsync(organization, + paymentMethodType, paymentToken); if(updated) { await ReplaceAndUpdateCache(organization); @@ -340,7 +355,7 @@ namespace Bit.Core.Services { 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) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 9109f91f1f..3a23e857df 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -65,6 +65,10 @@ namespace Bit.Core.Services 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 { @@ -542,20 +546,26 @@ namespace Bit.Core.Services } } - 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 CardService(); var bankSerice = new BankAccountService(); @@ -565,53 +575,122 @@ namespace Bit.Core.Services if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { customer = await customerService.GetAsync(subscriber.GatewayCustomerId); + if(customer.Metadata?.Any() ?? false) + { + stripeCustomerMetadata = customer.Metadata; + } } - if(customer == null) + if(stripeCustomerMetadata.ContainsKey("btCustomerId")) { - customer = await customerService.CreateAsync(new CustomerCreateOptions + var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow); + stripeCustomerMetadata.Add($"btCustomerId_{nowSec}", stripeCustomerMetadata["btCustomerId"]); + stripeCustomerMetadata["btCustomerId"] = null; + } + + 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 { - Description = subscriber.BillingName(), + PaymentMethodNonce = paymentToken, Email = subscriber.BillingEmailAddress(), - SourceToken = paymentToken + Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + randomSuffix }); - subscriber.Gateway = Enums.GatewayType.Stripe; - subscriber.GatewayCustomerId = customer.Id; - updatedSubscriber = true; - } - else - { - if(paymentToken.StartsWith("btok_")) + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) { - await bankSerice.CreateAsync(customer.Id, new BankAccountCreateOptions - { - SourceToken = paymentToken - }); + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; + if(stripeCustomerMetadata.ContainsKey("btCustomerId")) + { + stripeCustomerMetadata["btCustomerId"] = braintreeCustomer.Id; } else { - await cardService.CreateAsync(customer.Id, new CardCreateOptions - { - SourceToken = paymentToken - }); - } - - if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId)) - { - var source = customer.Sources.FirstOrDefault(s => s.Id == customer.DefaultSourceId); - if(source is BankAccount) - { - await bankSerice.DeleteAsync(customer.Id, customer.DefaultSourceId); - } - else if(source is Card) - { - await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId); - } + stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); } } + else + { + throw new GatewayException("Payment method is not supported at this time."); + } - return updatedSubscriber; + 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) + { + await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); + } + throw e; + } + + return createdCustomer; } public async Task GetUpcomingInvoiceAsync(ISubscriber subscriber) @@ -660,12 +739,17 @@ namespace Bit.Core.Services if(customer.Metadata?.ContainsKey("btCustomerId") ?? false) { - var braintreeCustomer = await _btGateway.Customer.FindAsync(customer.Metadata["btCustomerId"]); - if(braintreeCustomer?.DefaultPaymentMethod != null) + try { - billingInfo.PaymentSource = new BillingInfo.BillingSource( - braintreeCustomer.DefaultPaymentMethod); + var braintreeCustomer = await _btGateway.Customer.FindAsync( + customer.Metadata["btCustomerId"]); + if(braintreeCustomer?.DefaultPaymentMethod != null) + { + billingInfo.PaymentSource = new BillingInfo.BillingSource( + braintreeCustomer.DefaultPaymentMethod); + } } + catch(Braintree.Exceptions.NotFoundException) { } } else if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null) { diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 03553401f1..af15171f7f 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -800,17 +800,18 @@ namespace Bit.Core.Services throw new BadRequestException("Invalid token."); } - IPaymentService paymentService = null; + PaymentMethodType paymentMethodType; + var paymentService = new StripePaymentService(_globalSettings); if(paymentToken.StartsWith("tok_")) { - paymentService = new StripePaymentService(_globalSettings); + 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); From 9f876d9bff1432076b2f8fce6dfc4b71b148abd2 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 14:25:46 -0500 Subject: [PATCH 08/31] purchase org with paypal support --- .../Implementations/OrganizationService.cs | 135 ++++++------------ .../Implementations/StripePaymentService.cs | 120 ++++++++++++++++ .../Services/Implementations/UserService.cs | 5 + 3 files changed, 168 insertions(+), 92 deletions(-) diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 067810c22f..a7308ffec0 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -476,6 +476,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."); @@ -486,6 +491,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."); @@ -498,96 +508,10 @@ namespace Bit.Core.Services $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); } - var customerService = new CustomerService(); - var subscriptionService = new SubscriptionService(); - Customer customer = null; - Subscription 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 CustomerCreateOptions - { - Description = signup.BusinessName, - Email = signup.BillingEmail, - SourceToken = signup.PaymentToken - }); - - var subCreateOptions = new SubscriptionCreateOptions - { - CustomerId = customer.Id, - TrialPeriodDays = plan.TrialPeriodDays, - Items = new List(), - Metadata = new Dictionary { - { "organizationId", newOrgId.ToString() } - } - }; - - if(plan.StripePlanId != null) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - PlanId = plan.StripePlanId, - Quantity = 1 - }); - } - - if(signup.AdditionalSeats > 0 && plan.StripeSeatPlanId != null) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - PlanId = plan.StripeSeatPlanId, - Quantity = signup.AdditionalSeats - }); - } - - if(signup.AdditionalStorageGb > 0) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - PlanId = plan.StripeStoragePlanId, - Quantity = signup.AdditionalStorageGb - }); - } - - if(signup.PremiumAccessAddon && plan.StripePremiumAccessPlanId != null) - { - subCreateOptions.Items.Add(new SubscriptionItemOption - { - 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, @@ -605,16 +529,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 _stripePaymentService.PurchaseOrganizationAsync(organization, paymentMethodType, + signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon); + } + return await SignUpAsync(organization, signup.Owner.Id, signup.OwnerKey, signup.CollectionName, true); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 3a23e857df..18d4fb280c 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -30,6 +30,126 @@ namespace Bit.Core.Services }; } + 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 = "o" + org.Id.ToString("N").ToLower() + randomSuffix + }); + + 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 + { + ["organizationId"] = org.Id.ToString() + } + }; + + 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) { diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index af15171f7f..9201cf45bf 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -682,6 +682,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) { From 25f3b76e6be9292744204f0d6359687fa667fb3b Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 16:45:01 -0500 Subject: [PATCH 09/31] added transactions table --- src/Core/Enums/TransactionType.cs | 10 + src/Core/Models/Table/Transaction.cs | 27 +++ .../Repositories/ITransactionRepository.cs | 13 + .../SqlServer/TransactionRepository.cs | 48 ++++ .../Utilities/ServiceCollectionExtensions.cs | 1 + src/Sql/Sql.sqlproj | 11 + .../Stored Procedures/Transaction_Create.sql | 48 ++++ .../Transaction_DeleteById.sql | 12 + .../Transaction_ReadById.sql | 13 + .../Transaction_ReadByOrganizationId.sql | 14 ++ .../Transaction_ReadByUserId.sql | 13 + .../Stored Procedures/Transaction_Update.sql | 34 +++ src/Sql/dbo/Tables/Transaction.sql | 28 +++ src/Sql/dbo/Views/TransactionView.sql | 6 + .../DbScripts/2019-01-31_00_Transactions.sql | 224 ++++++++++++++++++ util/Setup/Setup.csproj | 1 + 16 files changed, 503 insertions(+) create mode 100644 src/Core/Enums/TransactionType.cs create mode 100644 src/Core/Models/Table/Transaction.cs create mode 100644 src/Core/Repositories/ITransactionRepository.cs create mode 100644 src/Core/Repositories/SqlServer/TransactionRepository.cs create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_Create.sql create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_Update.sql create mode 100644 src/Sql/dbo/Tables/Transaction.sql create mode 100644 src/Sql/dbo/Views/TransactionView.sql create mode 100644 util/Setup/DbScripts/2019-01-31_00_Transactions.sql diff --git a/src/Core/Enums/TransactionType.cs b/src/Core/Enums/TransactionType.cs new file mode 100644 index 0000000000..40ec69f31e --- /dev/null +++ b/src/Core/Enums/TransactionType.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Enums +{ + public enum TransactionType : byte + { + Charge = 0, + Credit = 1, + PromotionalCredit = 2, + ReferralCredit = 3 + } +} diff --git a/src/Core/Models/Table/Transaction.cs b/src/Core/Models/Table/Transaction.cs new file mode 100644 index 0000000000..2e6e6c6d91 --- /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; internal set; } = DateTime.UtcNow; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + } +} diff --git a/src/Core/Repositories/ITransactionRepository.cs b/src/Core/Repositories/ITransactionRepository.cs new file mode 100644 index 0000000000..d1248cbe86 --- /dev/null +++ b/src/Core/Repositories/ITransactionRepository.cs @@ -0,0 +1,13 @@ +using System; +using Bit.Core.Models.Table; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Bit.Core.Repositories +{ + public interface ITransactionRepository : IRepository + { + Task> GetManyByUserIdAsync(Guid userId); + Task> GetManyByOrganizationIdAsync(Guid organizationId); + } +} diff --git a/src/Core/Repositories/SqlServer/TransactionRepository.cs b/src/Core/Repositories/SqlServer/TransactionRepository.cs new file mode 100644 index 0000000000..0299c8288b --- /dev/null +++ b/src/Core/Repositories/SqlServer/TransactionRepository.cs @@ -0,0 +1,48 @@ +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; + +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(); + } + } + } +} diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 6dac10bb0c..2de1ba284d 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) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 95bfe03d42..34fa0f76da 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -240,5 +240,16 @@ + + + + + + + + + + + \ 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_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..6f30cf6b39 --- /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 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..6e864c80f0 --- /dev/null +++ b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql @@ -0,0 +1,224 @@ +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 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 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 @@ + From 24fbec6c0eea7bb8e5efdea6d8669baa1a0ad67f Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 16:55:04 -0500 Subject: [PATCH 10/31] fix ambig. transaction refs --- .../Services/Implementations/BraintreePaymentService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index 8c1a805ab3..96d5de8b68 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -100,7 +100,8 @@ namespace Bit.Core.Services if((transactions?.MaximumCount ?? 0) > 0) { - foreach(var transaction in transactions.Cast().Where(c => c.RefundedTransactionId == null)) + var txs = transactions.Cast().Where(c => c.RefundedTransactionId == null); + foreach(var transaction in txs) { await _gateway.Transaction.RefundAsync(transaction.Id); } @@ -190,8 +191,8 @@ namespace Bit.Core.Services 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)); + billingInfo.Charges = transactions?.Cast() + .OrderByDescending(t => t.CreatedAt).Select(t => new BillingInfo.BillingCharge(t)); } } From 11f353050fa16b59ec5def5f049623b3c81947a4 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 31 Jan 2019 17:02:08 -0500 Subject: [PATCH 11/31] fix sql proj file --- src/Sql/Sql.sqlproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 34fa0f76da..4886a29e50 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -249,7 +249,4 @@ - - - \ No newline at end of file From 9882815e4abef1eda79dfa5f6b5713880ce65511 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 09:18:34 -0500 Subject: [PATCH 12/31] custom id fields for paypal --- src/Core/Models/Table/ISubscriber.cs | 2 ++ src/Core/Models/Table/Organization.cs | 10 ++++++ src/Core/Models/Table/User.cs | 10 ++++++ .../BraintreePaymentService.cs | 2 +- .../Implementations/StripePaymentService.cs | 32 +++++++++++++++---- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Core/Models/Table/ISubscriber.cs b/src/Core/Models/Table/ISubscriber.cs index 9b2ffdbdbc..302025881d 100644 --- a/src/Core/Models/Table/ISubscriber.cs +++ b/src/Core/Models/Table/ISubscriber.cs @@ -13,6 +13,8 @@ namespace Bit.Core.Models.Table string BillingEmailAddress(); string BillingName(); string BraintreeCustomerIdPrefix(); + string BraintreeIdField(); + string GatewayIdField(); IPaymentService GetPaymentService(GlobalSettings globalSettings); } } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index 09d56ac4a1..ed9a0dad7a 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -68,6 +68,16 @@ namespace Bit.Core.Models.Table return "o"; } + public string BraintreeIdField() + { + return "organization_id"; + } + + public string GatewayIdField() + { + return "organizationId"; + } + public long StorageBytesRemaining() { if(!MaxStorageGb.HasValue) diff --git a/src/Core/Models/Table/User.cs b/src/Core/Models/Table/User.cs index 2850254c06..2c99e89a93 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -63,6 +63,16 @@ namespace Bit.Core.Models.Table return "u"; } + public string BraintreeIdField() + { + return "user_id"; + } + + public string GatewayIdField() + { + return "userId"; + } + public Dictionary GetTwoFactorProviders() { if(string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index 96d5de8b68..344c7db1de 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -230,7 +230,7 @@ namespace Bit.Core.Services throw new GatewayException("Failed to create customer."); } - var subId = "u" + user.Id.ToString("N").ToLower() + + var subId = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false); var subRequest = new SubscriptionRequest diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 18d4fb280c..a51ca29d4d 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -54,7 +54,7 @@ namespace Bit.Core.Services { PaymentMethodNonce = paymentToken, Email = org.BillingEmail, - Id = "o" + org.Id.ToString("N").ToLower() + randomSuffix + Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix }); if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) @@ -76,7 +76,7 @@ namespace Bit.Core.Services Items = new List(), Metadata = new Dictionary { - ["organizationId"] = org.Id.ToString() + [org.GatewayIdField()] = org.Id.ToString() } }; @@ -174,7 +174,7 @@ namespace Bit.Core.Services { PaymentMethodNonce = paymentToken, Email = user.Email, - Id = "u" + user.Id.ToString("N").ToLower() + randomSuffix + Id = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + randomSuffix }); if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) @@ -204,7 +204,7 @@ namespace Bit.Core.Services Items = new List(), Metadata = new Dictionary { - ["userId"] = user.Id.ToString() + [user.GatewayIdField()] = user.Id.ToString() } }; @@ -248,7 +248,18 @@ namespace Bit.Core.Services { Amount = btInvoiceAmount, CustomerId = braintreeCustomer.Id, - Options = new Braintree.TransactionOptionsRequest { SubmitForSettlement = true } + 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()) @@ -256,6 +267,7 @@ namespace Bit.Core.Services throw new GatewayException("Failed to charge PayPal customer."); } + braintreeTransaction = transactionResult.Target; subInvoiceMetadata.Add("btTransactionId", braintreeTransaction.Id); subInvoiceMetadata.Add("btPayPalTransactionId", braintreeTransaction.PayPalDetails.AuthorizationId); @@ -525,7 +537,15 @@ namespace Bit.Core.Services CustomerId = customer.Metadata["btCustomerId"], Options = new Braintree.TransactionOptionsRequest { - SubmitForSettlement = true + SubmitForSettlement = true, + PayPal = new Braintree.TransactionOptionsPayPalRequest + { + CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id}" + } + }, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() } }); From f3b5068aba2f14708fc99024b0b8489ad2fe14f0 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 17:16:28 -0500 Subject: [PATCH 13/31] paypal client and stub out webhook --- src/Billing/BillingSettings.cs | 9 ++ src/Billing/Controllers/PaypalController.cs | 56 +++++++ src/Billing/Startup.cs | 3 + src/Billing/Utilities/PaypalClient.cs | 160 ++++++++++++++++++++ src/Billing/appsettings.Production.json | 5 + src/Billing/appsettings.json | 20 ++- 6 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 src/Billing/Controllers/PaypalController.cs create mode 100644 src/Billing/Utilities/PaypalClient.cs diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index e0b378bdc6..851cf1c2e2 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -6,5 +6,14 @@ 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; } + } } } diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PaypalController.cs new file mode 100644 index 0000000000..458ba9fa86 --- /dev/null +++ b/src/Billing/Controllers/PaypalController.cs @@ -0,0 +1,56 @@ +using Bit.Billing.Utilities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +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; + + public PaypalController( + IOptions billingSettings, + PaypalClient paypalClient) + { + _billingSettings = billingSettings?.Value; + _paypalClient = paypalClient; + } + + [HttpPost("webhook")] + public async Task PostWebhook([FromQuery] string key) + { + 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(); + } + + var webhook = JsonConvert.DeserializeObject(body); + // TODO: process webhook + return new OkResult(); + } + } +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index fc5716916e..871d507d57 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..b40900d09e --- /dev/null +++ b/src/Billing/Utilities/PaypalClient.cs @@ -0,0 +1,160 @@ +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); + } + } +} diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 5ea6892d03..add96fc3bc 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..3f34debdab 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -45,18 +45,24 @@ "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" + } } } From 44630e9728e7504eeb2be6f8be3fe5630e1cf274 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 22:22:08 -0500 Subject: [PATCH 14/31] handle transactions on paypal webhook --- src/Billing/Controllers/PaypalController.cs | 69 +++++++++++++++- src/Billing/Utilities/PaypalClient.cs | 82 +++++++++++++++++++ src/Core/Enums/GatewayType.cs | 4 +- src/Core/Enums/TransactionType.cs | 3 +- src/Core/Models/Table/Transaction.cs | 2 +- .../Repositories/ITransactionRepository.cs | 2 + .../SqlServer/TransactionRepository.cs | 14 ++++ src/Sql/Sql.sqlproj | 1 + .../Transaction_ReadByGatewayId.sql | 15 ++++ src/Sql/dbo/Tables/Transaction.sql | 2 +- .../DbScripts/2019-01-31_00_Transactions.sql | 25 +++++- 11 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PaypalController.cs index 458ba9fa86..73f04b4e33 100644 --- a/src/Billing/Controllers/PaypalController.cs +++ b/src/Billing/Controllers/PaypalController.cs @@ -1,4 +1,6 @@ using Bit.Billing.Utilities; +using Bit.Core.Enums; +using Bit.Core.Repositories; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -13,13 +15,16 @@ namespace Bit.Billing.Controllers { private readonly BillingSettings _billingSettings; private readonly PaypalClient _paypalClient; + private readonly ITransactionRepository _transactionRepository; public PaypalController( IOptions billingSettings, - PaypalClient paypalClient) + PaypalClient paypalClient, + ITransactionRepository transactionRepository) { _billingSettings = billingSettings?.Value; _paypalClient = paypalClient; + _transactionRepository = transactionRepository; } [HttpPost("webhook")] @@ -48,8 +53,66 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - var webhook = JsonConvert.DeserializeObject(body); - // TODO: process webhook + 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) + { + await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + { + Amount = sale.Amount.TotalAmount, + CreationDate = sale.CreateTime, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = sale.GetCreditFromCustom() ? TransactionType.Credit : TransactionType.Charge, + Gateway = GatewayType.PayPal, + GatewayId = sale.Id, + PaymentMethodType = PaymentMethodType.PayPal + }); + } + } + } + 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 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 + }); + } + + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.SaleId); + if(saleTransaction != null) + { + saleTransaction.Refunded = true; + saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; + await _transactionRepository.ReplaceAsync(saleTransaction); + } + } + } + return new OkResult(); } } diff --git a/src/Billing/Utilities/PaypalClient.cs b/src/Billing/Utilities/PaypalClient.cs index b40900d09e..95281baf17 100644 --- a/src/Billing/Utilities/PaypalClient.cs +++ b/src/Billing/Utilities/PaypalClient.cs @@ -156,5 +156,87 @@ namespace Bit.Billing.Utilities 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 bool GetCreditFromCustom() + { + return Custom.Contains("credit:true"); + } + } + + 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/Core/Enums/GatewayType.cs b/src/Core/Enums/GatewayType.cs index 5a9dcdbd76..a8ceff36d0 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 = 1, } } diff --git a/src/Core/Enums/TransactionType.cs b/src/Core/Enums/TransactionType.cs index 40ec69f31e..45baa68c06 100644 --- a/src/Core/Enums/TransactionType.cs +++ b/src/Core/Enums/TransactionType.cs @@ -5,6 +5,7 @@ Charge = 0, Credit = 1, PromotionalCredit = 2, - ReferralCredit = 3 + ReferralCredit = 3, + Refund = 4, } } diff --git a/src/Core/Models/Table/Transaction.cs b/src/Core/Models/Table/Transaction.cs index 2e6e6c6d91..637f40f403 100644 --- a/src/Core/Models/Table/Transaction.cs +++ b/src/Core/Models/Table/Transaction.cs @@ -17,7 +17,7 @@ namespace Bit.Core.Models.Table public PaymentMethodType? PaymentMethodType { get; set; } public GatewayType? Gateway { get; set; } public string GatewayId { get; set; } - public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public void SetNewId() { diff --git a/src/Core/Repositories/ITransactionRepository.cs b/src/Core/Repositories/ITransactionRepository.cs index d1248cbe86..242dcfa3ce 100644 --- a/src/Core/Repositories/ITransactionRepository.cs +++ b/src/Core/Repositories/ITransactionRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Models.Table; using System.Collections.Generic; using System.Threading.Tasks; +using Bit.Core.Enums; namespace Bit.Core.Repositories { @@ -9,5 +10,6 @@ namespace Bit.Core.Repositories { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId); } } diff --git a/src/Core/Repositories/SqlServer/TransactionRepository.cs b/src/Core/Repositories/SqlServer/TransactionRepository.cs index 0299c8288b..85f2ed35b5 100644 --- a/src/Core/Repositories/SqlServer/TransactionRepository.cs +++ b/src/Core/Repositories/SqlServer/TransactionRepository.cs @@ -6,6 +6,7 @@ using Dapper; using System.Data; using System.Data.SqlClient; using System.Linq; +using Bit.Core.Enums; namespace Bit.Core.Repositories.SqlServer { @@ -44,5 +45,18 @@ namespace Bit.Core.Repositories.SqlServer 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/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 4886a29e50..b27395e88e 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -248,5 +248,6 @@ + \ 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/Tables/Transaction.sql b/src/Sql/dbo/Tables/Transaction.sql index 6f30cf6b39..0395bae353 100644 --- a/src/Sql/dbo/Tables/Transaction.sql +++ b/src/Sql/dbo/Tables/Transaction.sql @@ -18,7 +18,7 @@ GO -CREATE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] +CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); diff --git a/util/Setup/DbScripts/2019-01-31_00_Transactions.sql b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql index 6e864c80f0..f559ac7308 100644 --- a/util/Setup/DbScripts/2019-01-31_00_Transactions.sql +++ b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql @@ -18,7 +18,7 @@ BEGIN CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE ); - CREATE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] + CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); @@ -222,3 +222,26 @@ BEGIN [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 From a5044b6e6ced04d9510d573f9c071d4e2d3d48d9 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 22:25:34 -0500 Subject: [PATCH 15/31] rename to PayPal --- src/Billing/BillingSettings.cs | 4 ++-- src/Billing/Controllers/PaypalController.cs | 14 +++++++------- src/Billing/Startup.cs | 4 ++-- src/Billing/Utilities/PaypalClient.cs | 10 +++++----- src/Billing/appsettings.Production.json | 2 +- src/Billing/appsettings.json | 2 +- src/Core/Enums/GlobalEquivalentDomainsType.cs | 2 +- src/Core/Utilities/StaticStore.cs | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 851cf1c2e2..ed7672ed76 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -6,9 +6,9 @@ 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 virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); - public class PaypalSettings + public class PayPalSettings { public virtual bool Production { get; set; } public virtual string ClientId { get; set; } diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PaypalController.cs index 73f04b4e33..ddf1501791 100644 --- a/src/Billing/Controllers/PaypalController.cs +++ b/src/Billing/Controllers/PaypalController.cs @@ -11,15 +11,15 @@ using System.Threading.Tasks; namespace Bit.Billing.Controllers { [Route("paypal")] - public class PaypalController : Controller + public class PayPalController : Controller { private readonly BillingSettings _billingSettings; - private readonly PaypalClient _paypalClient; + private readonly PayPalClient _paypalClient; private readonly ITransactionRepository _transactionRepository; - public PaypalController( + public PayPalController( IOptions billingSettings, - PaypalClient paypalClient, + PayPalClient paypalClient, ITransactionRepository transactionRepository) { _billingSettings = billingSettings?.Value; @@ -47,7 +47,7 @@ namespace Bit.Billing.Controllers } var verified = await _paypalClient.VerifyWebhookAsync(body, HttpContext.Request.Headers, - _billingSettings.Paypal.WebhookId); + _billingSettings.PayPal.WebhookId); if(!verified) { return new BadRequestResult(); @@ -55,7 +55,7 @@ namespace Bit.Billing.Controllers if(body.Contains("\"PAYMENT.SALE.COMPLETED\"")) { - var ev = JsonConvert.DeserializeObject>(body); + var ev = JsonConvert.DeserializeObject>(body); var sale = ev.Resource; var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( GatewayType.PayPal, sale.Id); @@ -80,7 +80,7 @@ namespace Bit.Billing.Controllers } else if(body.Contains("\"PAYMENT.SALE.REFUNDED\"")) { - var ev = JsonConvert.DeserializeObject>(body); + var ev = JsonConvert.DeserializeObject>(body); var refund = ev.Resource; var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( GatewayType.PayPal, refund.Id); diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 871d507d57..6e85974981 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -39,8 +39,8 @@ namespace Bit.Billing // Repositories services.AddSqlServerRepositories(globalSettings); - // Paypal Client - services.AddSingleton(); + // PayPal Client + services.AddSingleton(); // Context services.AddScoped(); diff --git a/src/Billing/Utilities/PaypalClient.cs b/src/Billing/Utilities/PaypalClient.cs index 95281baf17..df40df9360 100644 --- a/src/Billing/Utilities/PaypalClient.cs +++ b/src/Billing/Utilities/PaypalClient.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json; namespace Bit.Billing.Utilities { - public class PaypalClient + public class PayPalClient { private readonly HttpClient _httpClient = new HttpClient(); private readonly string _baseApiUrl; @@ -18,12 +18,12 @@ namespace Bit.Billing.Utilities private AuthResponse _authResponse; - public PaypalClient(BillingSettings billingSettings) + public PayPalClient(BillingSettings billingSettings) { - _baseApiUrl = _baseApiUrl = !billingSettings.Paypal.Production ? "https://api.sandbox.paypal.com/{0}" : + _baseApiUrl = _baseApiUrl = !billingSettings.PayPal.Production ? "https://api.sandbox.paypal.com/{0}" : "https://api.paypal.com/{0}"; - _clientId = billingSettings.Paypal.ClientId; - _clientSecret = billingSettings.Paypal.ClientSecret; + _clientId = billingSettings.PayPal.ClientId; + _clientSecret = billingSettings.PayPal.ClientSecret; } public async Task VerifyWebhookAsync(string webhookJson, IHeaderDictionary headers, string webhookId) diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index add96fc3bc..370128a2e4 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -17,7 +17,7 @@ } }, "billingSettings": { - "paypal": { + "payPal": { "production": false } } diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 3f34debdab..b07ca558fc 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -58,7 +58,7 @@ "stripeWebhookKey": "SECRET", "stripeWebhookSecret": "SECRET", "braintreeWebhookKey": "SECRET", - "paypal": { + "payPal": { "production": false, "clientId": "SECRET", "clientSecret": "SECRET", 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/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" }); From 1283932f4ef7aa216fb323249ec7f39d8734615b Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 22:26:58 -0500 Subject: [PATCH 16/31] rename for case change --- .../Controllers/{PaypalController.cs => PayPalController2.cs} | 0 src/Billing/Utilities/{PaypalClient.cs => PayPalClient2.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/Billing/Controllers/{PaypalController.cs => PayPalController2.cs} (100%) rename src/Billing/Utilities/{PaypalClient.cs => PayPalClient2.cs} (100%) diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PayPalController2.cs similarity index 100% rename from src/Billing/Controllers/PaypalController.cs rename to src/Billing/Controllers/PayPalController2.cs diff --git a/src/Billing/Utilities/PaypalClient.cs b/src/Billing/Utilities/PayPalClient2.cs similarity index 100% rename from src/Billing/Utilities/PaypalClient.cs rename to src/Billing/Utilities/PayPalClient2.cs From 1bf1d83b2682cbde5c00ee2c4c1586fed3d91cef Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 22:27:13 -0500 Subject: [PATCH 17/31] drop 2 --- .../Controllers/{PayPalController2.cs => PayPalController.cs} | 0 src/Billing/Utilities/{PayPalClient2.cs => PayPalClient.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/Billing/Controllers/{PayPalController2.cs => PayPalController.cs} (100%) rename src/Billing/Utilities/{PayPalClient2.cs => PayPalClient.cs} (100%) diff --git a/src/Billing/Controllers/PayPalController2.cs b/src/Billing/Controllers/PayPalController.cs similarity index 100% rename from src/Billing/Controllers/PayPalController2.cs rename to src/Billing/Controllers/PayPalController.cs diff --git a/src/Billing/Utilities/PayPalClient2.cs b/src/Billing/Utilities/PayPalClient.cs similarity index 100% rename from src/Billing/Utilities/PayPalClient2.cs rename to src/Billing/Utilities/PayPalClient.cs From 4ade9a229bcf63d895a0a21bcd616401b472708f Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 23:04:51 -0500 Subject: [PATCH 18/31] stripe charge hooks and fixes to pp refunds --- src/Billing/Controllers/PayPalController.cs | 29 +++-- src/Billing/Controllers/StripeController.cs | 118 +++++++++++++++++++- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index ddf1501791..65240efee9 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -73,7 +73,8 @@ namespace Bit.Billing.Controllers Type = sale.GetCreditFromCustom() ? TransactionType.Credit : TransactionType.Charge, Gateway = GatewayType.PayPal, GatewayId = sale.Id, - PaymentMethodType = PaymentMethodType.PayPal + PaymentMethodType = PaymentMethodType.PayPal, + Details = sale.Id }); } } @@ -86,6 +87,20 @@ namespace Bit.Billing.Controllers GatewayType.PayPal, refund.Id); if(refundTransaction == null) { + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.SaleId); + if(saleTransaction == null) + { + return new BadRequestResult(); + } + + 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) { @@ -98,18 +113,10 @@ namespace Bit.Billing.Controllers Type = TransactionType.Refund, Gateway = GatewayType.PayPal, GatewayId = refund.Id, - PaymentMethodType = PaymentMethodType.PayPal + PaymentMethodType = PaymentMethodType.PayPal, + Details = refund.Id }); } - - var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, refund.SaleId); - if(saleTransaction != null) - { - saleTransaction.Refunded = true; - saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; - await _transactionRepository.ReplaceAsync(saleTransaction); - } } } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index df39e02532..0bccd4723a 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Table; +using Bit.Core.Enums; +using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Hosting; @@ -20,6 +21,7 @@ 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; @@ -28,6 +30,7 @@ namespace Bit.Billing.Controllers IHostingEnvironment hostingEnvironment, IOrganizationService organizationService, IOrganizationRepository organizationRepository, + ITransactionRepository transactionRepository, IUserService userService, IMailService mailService) { @@ -35,6 +38,7 @@ namespace Bit.Billing.Controllers _hostingEnvironment = hostingEnvironment; _organizationService = organizationService; _organizationRepository = organizationRepository; + _transactionRepository = transactionRepository; _userService = userService; _mailService = mailService; } @@ -65,7 +69,6 @@ namespace Bit.Billing.Controllers 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"); @@ -112,7 +115,7 @@ namespace Bit.Billing.Controllers } } } - else if(invUpcoming) + else if(parsedEvent.Type.Equals("invoice.upcoming")) { var invoice = parsedEvent.Data.Object as Invoice; if(invoice == null) @@ -155,6 +158,115 @@ namespace Bit.Billing.Controllers invoice.NextPaymentAttempt.Value, items, ids.Item1.HasValue); } } + else if(parsedEvent.Type.Equals("charge.succeeded")) + { + var charge = parsedEvent.Data.Object as Charge; + if(charge == null) + { + 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(); + } + + await _transactionRepository.CreateAsync(tx); + } + } + } + else if(parsedEvent.Type.Equals("charge.refunded")) + { + var charge = parsedEvent.Data.Object as Charge; + if(charge == null) + { + 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."); + } + + chargeTransaction.RefundedAmount = charge.AmountRefunded / 100M; + 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 + }); + } + } return new OkResult(); } From fb3cdba99e50b7bc67b1c30786fa59fdcf02ef4d Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 23:29:12 -0500 Subject: [PATCH 19/31] check to make sure not already refunded --- src/Billing/Controllers/PayPalController.cs | 44 +++++++++--------- src/Billing/Controllers/StripeController.cs | 50 ++++++++++++--------- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 65240efee9..0403d02735 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -94,28 +94,32 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; - if(saleTransaction.RefundedAmount == saleTransaction.Amount) + if(!saleTransaction.Refunded.GetValueOrDefault() && + saleTransaction.RefundedAmount.GetValueOrDefault() < refund.TotalRefundedAmount.ValueAmount) { - 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 + saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; + if(saleTransaction.RefundedAmount == saleTransaction.Amount) { - 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 - }); + 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 + }); + } } } } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 0bccd4723a..69dd9ae332 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -237,34 +237,40 @@ namespace Bit.Billing.Controllers throw new Exception("Cannot find refunded charge."); } - chargeTransaction.RefundedAmount = charge.AmountRefunded / 100M; - if(charge.Refunded) - { - chargeTransaction.Refunded = true; - } - await _transactionRepository.ReplaceAsync(chargeTransaction); + var amountRefunded = charge.AmountRefunded / 100M; - foreach(var refund in charge.Refunds) + if(!chargeTransaction.Refunded.GetValueOrDefault() && + chargeTransaction.RefundedAmount.GetValueOrDefault() < amountRefunded) { - var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.Stripe, refund.Id); - if(refundTransaction != null) + chargeTransaction.RefundedAmount = amountRefunded; + if(charge.Refunded) { - continue; + chargeTransaction.Refunded = true; } + await _transactionRepository.ReplaceAsync(chargeTransaction); - await _transactionRepository.CreateAsync(new Transaction + foreach(var refund in charge.Refunds) { - 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 - }); + 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 + }); + } } } From 9cb7a0caaf2d22082e20671200e6bc58c5d6ced1 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 1 Feb 2019 23:29:34 -0500 Subject: [PATCH 20/31] remove credit --- src/Billing/Controllers/PayPalController.cs | 2 +- src/Billing/Utilities/PayPalClient.cs | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 0403d02735..2acf1f2709 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -70,7 +70,7 @@ namespace Bit.Billing.Controllers CreationDate = sale.CreateTime, OrganizationId = ids.Item1, UserId = ids.Item2, - Type = sale.GetCreditFromCustom() ? TransactionType.Credit : TransactionType.Charge, + Type = TransactionType.Charge, Gateway = GatewayType.PayPal, GatewayId = sale.Id, PaymentMethodType = PaymentMethodType.PayPal, diff --git a/src/Billing/Utilities/PayPalClient.cs b/src/Billing/Utilities/PayPalClient.cs index df40df9360..0e203df57c 100644 --- a/src/Billing/Utilities/PayPalClient.cs +++ b/src/Billing/Utilities/PayPalClient.cs @@ -218,11 +218,6 @@ namespace Bit.Billing.Utilities return new Tuple(orgId, userId); } - - public bool GetCreditFromCustom() - { - return Custom.Contains("credit:true"); - } } public class AmountInfo From 0e4e3b22d1915e14c4d17d8c8a2e510ba9007dd7 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 2 Feb 2019 16:42:40 -0500 Subject: [PATCH 21/31] reuse btCustomerId when changing from pp => new pp --- .../Implementations/StripePaymentService.cs | 83 +++++++++++++------ 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index a51ca29d4d..007abcd86a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -721,40 +721,61 @@ namespace Bit.Core.Services } } - if(stripeCustomerMetadata.ContainsKey("btCustomerId")) - { - var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow); - stripeCustomerMetadata.Add($"btCustomerId_{nowSec}", stripeCustomerMetadata["btCustomerId"]); - stripeCustomerMetadata["btCustomerId"] = null; - } - + var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId"); 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 + if(hadBtCustomer) { - PaymentMethodNonce = paymentToken, - Email = subscriber.BillingEmailAddress(), - Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + randomSuffix - }); + var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest + { + CustomerId = stripeCustomerMetadata["btCustomerId"], + PaymentMethodNonce = paymentToken + }); - if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) - { - throw new GatewayException("Failed to create PayPal customer record."); + 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; + } } - braintreeCustomer = customerResult.Target; - if(stripeCustomerMetadata.ContainsKey("btCustomerId")) + if(!hadBtCustomer) { - stripeCustomerMetadata["btCustomerId"] = braintreeCustomer.Id; - } - else - { - stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); + 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) + }); + + if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) + { + throw new GatewayException("Failed to create PayPal customer record."); + } + + braintreeCustomer = customerResult.Target; } } else @@ -762,6 +783,20 @@ namespace Bit.Core.Services throw new GatewayException("Payment method is not supported at this time."); } + 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) @@ -823,7 +858,7 @@ namespace Bit.Core.Services } catch(Exception e) { - if(braintreeCustomer != null) + if(braintreeCustomer != null && !hadBtCustomer) { await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); } From 98bd42dde1044159a031c0b42cdcf74b83708662 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 2 Feb 2019 23:04:14 -0500 Subject: [PATCH 22/31] fix enums --- src/Billing/Controllers/StripeController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 69dd9ae332..b965c02f38 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -320,9 +320,9 @@ 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; From 856e331ef3fe07eaf80cfe640f7a95d106842c92 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 2 Feb 2019 23:04:44 -0500 Subject: [PATCH 23/31] custom ids to braintree customers --- .../Implementations/BraintreePaymentService.cs | 13 +++++++++++-- .../Implementations/StripePaymentService.cs | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs index 344c7db1de..c205235fd7 100644 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ b/src/Core/Services/Implementations/BraintreePaymentService.cs @@ -6,6 +6,7 @@ using Braintree; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Enums; +using System.Collections.Generic; namespace Bit.Core.Services { @@ -222,7 +223,11 @@ namespace Bit.Core.Services var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest { PaymentMethodNonce = paymentToken, - Email = user.Email + Email = user.Email, + CustomFields = new Dictionary + { + [user.BraintreeIdField()] = user.Id.ToString() + } }); if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) @@ -336,7 +341,11 @@ namespace Bit.Core.Services var result = await _gateway.Customer.CreateAsync(new CustomerRequest { Email = subscriber.BillingEmailAddress(), - PaymentMethodNonce = paymentToken + PaymentMethodNonce = paymentToken, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() + } }); if(!result.IsSuccess()) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 007abcd86a..501deb46fe 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -54,7 +54,11 @@ namespace Bit.Core.Services { PaymentMethodNonce = paymentToken, Email = org.BillingEmail, - Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix + 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) @@ -174,7 +178,11 @@ namespace Bit.Core.Services { PaymentMethodNonce = paymentToken, Email = user.Email, - Id = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + randomSuffix + 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) @@ -767,7 +775,11 @@ namespace Bit.Core.Services PaymentMethodNonce = paymentToken, Email = subscriber.BillingEmailAddress(), Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + - Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false) + Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false), + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() + } }); if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) From 9e14af3223ea9860f929bcb2978903cc39bc364e Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sun, 3 Feb 2019 00:00:21 -0500 Subject: [PATCH 24/31] attempt to pay invoices via braintree --- src/Billing/Controllers/StripeController.cs | 115 ++++++++++++++++++-- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index b965c02f38..0803e785d3 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core; +using Bit.Core.Enums; using Bit.Core.Models.Table; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,8 +25,10 @@ namespace Bit.Billing.Controllers 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, @@ -41,6 +44,15 @@ namespace Bit.Billing.Controllers _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")] @@ -74,8 +86,7 @@ namespace Bit.Billing.Controllers if(subDeleted || subUpdated) { - var subscription = parsedEvent.Data.Object as Subscription; - if(subscription == null) + if(!(parsedEvent.Data.Object is Subscription subscription)) { throw new Exception("Subscription is null."); } @@ -117,8 +128,7 @@ namespace Bit.Billing.Controllers } else if(parsedEvent.Type.Equals("invoice.upcoming")) { - var invoice = parsedEvent.Data.Object as Invoice; - if(invoice == null) + if(!(parsedEvent.Data.Object is Invoice invoice)) { throw new Exception("Invoice is null."); } @@ -160,8 +170,7 @@ namespace Bit.Billing.Controllers } else if(parsedEvent.Type.Equals("charge.succeeded")) { - var charge = parsedEvent.Data.Object as Charge; - if(charge == null) + if(!(parsedEvent.Data.Object is Charge charge)) { throw new Exception("Charge is null."); } @@ -224,8 +233,7 @@ namespace Bit.Billing.Controllers } else if(parsedEvent.Type.Equals("charge.refunded")) { - var charge = parsedEvent.Data.Object as Charge; - if(charge == null) + if(!(parsedEvent.Data.Object is Charge charge)) { throw new Exception("Charge is null."); } @@ -273,6 +281,30 @@ namespace Bit.Billing.Controllers } } } + 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(); } @@ -328,5 +360,70 @@ namespace Bit.Billing.Controllers 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()) + { + return false; + } + + 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 }); + 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; + } } } From 7675478daa53b9bdb6188f58069403cb0bde8c13 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sun, 3 Feb 2019 00:05:35 -0500 Subject: [PATCH 25/31] refund if something screws up --- src/Billing/Controllers/StripeController.cs | 27 ++++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 0803e785d3..44d6133498 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -406,17 +406,26 @@ namespace Bit.Billing.Controllers return false; } - var invoiceService = new InvoiceService(); - await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + try { - Metadata = new Dictionary + var invoiceService = new InvoiceService(); + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions { - ["btTransactionId"] = transactionResult.Target.Id, - ["btPayPalTransactionId"] = - transactionResult.Target.PayPalDetails?.AuthorizationId - } - }); - await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + 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; } From 22c049c9c5154233125bbb977dec002f158aac0d Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sun, 3 Feb 2019 22:39:53 -0500 Subject: [PATCH 26/31] disable autostats for cipher table --- src/Admin/Jobs/DatabaseUpdateStatisticsJob.cs | 1 + src/Core/Repositories/IMaintenanceRepository.cs | 1 + .../Repositories/SqlServer/MaintenanceRepository.cs | 11 +++++++++++ 3 files changed, 13 insertions(+) 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/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/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)) From 1dc22f61d1c09f50b473802fc8e2a1f9f5e05aab Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 7 Feb 2019 17:28:09 -0500 Subject: [PATCH 27/31] catch sql FK violations on webhook tx creation --- src/Billing/Controllers/PayPalController.cs | 28 +++++++++++++-------- src/Billing/Controllers/StripeController.cs | 8 +++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 2acf1f2709..c5771fa046 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -4,6 +4,7 @@ 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; @@ -64,18 +65,23 @@ namespace Bit.Billing.Controllers var ids = sale.GetIdsFromCustom(); if(ids.Item1.HasValue || ids.Item2.HasValue) { - await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + try { - 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 - }); + 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) { } } } } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 44d6133498..d05d0dadaf 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -9,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; @@ -227,7 +228,12 @@ namespace Bit.Billing.Controllers return new OkResult(); } - await _transactionRepository.CreateAsync(tx); + try + { + await _transactionRepository.CreateAsync(tx); + } + // Catch foreign key violations because user/org could have been deleted. + catch(SqlException e) when(e.Number == 547) { } } } } From f837c1708e708c674b3d71185c45384f2a3b924e Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 8 Feb 2019 14:28:36 -0500 Subject: [PATCH 28/31] paypal webhook key --- src/Billing/BillingSettings.cs | 1 + src/Billing/Controllers/PayPalController.cs | 5 +++++ src/Billing/appsettings.json | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index ed7672ed76..f072672afe 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -14,6 +14,7 @@ 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 index c5771fa046..2f456b54ea 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -31,6 +31,11 @@ namespace Bit.Billing.Controllers [HttpPost("webhook")] public async Task PostWebhook([FromQuery] string key) { + if(key != _billingSettings.PayPal.WebhookKey) + { + return new BadRequestResult(); + } + if(HttpContext?.Request == null) { return new BadRequestResult(); diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index b07ca558fc..ebd5714491 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -62,7 +62,8 @@ "production": false, "clientId": "SECRET", "clientSecret": "SECRET", - "webhookId": "SECRET" + "webhookId": "SECRET", + "webhookKey": "SECRET" } } } From a97a6216d7bb06e50f2f10cfbfa7f1ed0b554f64 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 8 Feb 2019 23:24:48 -0500 Subject: [PATCH 29/31] return invoices and transactions on billing api --- src/Core/Enums/GatewayType.cs | 2 +- .../Api/Response/BillingResponseModel.cs | 64 ++++++++++++------- .../Api/Response/OrganizationResponseModel.cs | 10 +-- src/Core/Models/Business/BillingInfo.cs | 63 ++++++++++++++++++ .../Implementations/StripePaymentService.cs | 8 +++ 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/src/Core/Enums/GatewayType.cs b/src/Core/Enums/GatewayType.cs index a8ceff36d0..bb0e88f365 100644 --- a/src/Core/Enums/GatewayType.cs +++ b/src/Core/Enums/GatewayType.cs @@ -15,6 +15,6 @@ namespace Bit.Core.Enums [Display(Name = "Coinbase")] Coinbase = 4, [Display(Name = "PayPal")] - PayPal = 1, + PayPal = 5, } } diff --git a/src/Core/Models/Api/Response/BillingResponseModel.cs b/src/Core/Models/Api/Response/BillingResponseModel.cs index b9d44a057f..efc2bda096 100644 --- a/src/Core/Models/Api/Response/BillingResponseModel.cs +++ b/src/Core/Models/Api/Response/BillingResponseModel.cs @@ -15,8 +15,9 @@ namespace Bit.Core.Models.Api 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; @@ -44,8 +45,9 @@ namespace Bit.Core.Models.Api 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; } } @@ -111,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; @@ -123,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 ebfbadca09..ac4c6c0672 100644 --- a/src/Core/Models/Business/BillingInfo.cs +++ b/src/Core/Models/Business/BillingInfo.cs @@ -1,4 +1,5 @@ using Bit.Core.Enums; +using Bit.Core.Models.Table; using Stripe; using System; using System.Collections.Generic; @@ -13,6 +14,8 @@ namespace Bit.Core.Models.Business 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 { @@ -193,6 +196,8 @@ namespace Bit.Core.Models.Business public class BillingInvoice { + public BillingInvoice() { } + public BillingInvoice(Invoice inv) { Amount = inv.AmountDue / 100M; @@ -263,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/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 501deb46fe..64f9e1a2e0 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -958,6 +958,14 @@ namespace Bit.Core.Services }); 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)); } } From d568b86e1e23b9c935832c33bb59a64016d3c277 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 8 Feb 2019 23:53:09 -0500 Subject: [PATCH 30/31] inject stripepaymentservice --- src/Api/Controllers/AccountsController.cs | 6 +- .../Controllers/OrganizationsController.cs | 6 +- .../Jobs/PremiumRenewalRemindersJob.cs | 6 +- src/Core/Models/Table/ISubscriber.cs | 1 - src/Core/Models/Table/Organization.cs | 23 -- src/Core/Models/Table/User.cs | 23 -- src/Core/Services/IPaymentService.cs | 2 + .../BraintreePaymentService.cs | 386 ------------------ .../Implementations/OrganizationService.cs | 26 +- .../Implementations/StripePaymentService.cs | 20 + .../Services/Implementations/UserService.cs | 22 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + 12 files changed, 58 insertions(+), 464 deletions(-) delete mode 100644 src/Core/Services/Implementations/BraintreePaymentService.cs 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 cea2162d9c..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(_globalSettings); - var billingInfo = await paymentService.GetBillingAsync(organization); + var billingInfo = await _paymentService.GetBillingAsync(organization); if(billingInfo == null) { throw new NotFoundException(); 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/Core/Models/Table/ISubscriber.cs b/src/Core/Models/Table/ISubscriber.cs index 302025881d..fd22d7d099 100644 --- a/src/Core/Models/Table/ISubscriber.cs +++ b/src/Core/Models/Table/ISubscriber.cs @@ -15,6 +15,5 @@ namespace Bit.Core.Models.Table string BraintreeCustomerIdPrefix(); string BraintreeIdField(); string GatewayIdField(); - IPaymentService GetPaymentService(GlobalSettings globalSettings); } } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index ed9a0dad7a..f9aa28229f 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -99,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(globalSettings); - 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/User.cs b/src/Core/Models/Table/User.cs index 2c99e89a93..bd2b75455f 100644 --- a/src/Core/Models/Table/User.cs +++ b/src/Core/Models/Table/User.cs @@ -148,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(globalSettings); - 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/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index d45e378b39..633d6faddd 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -8,6 +8,8 @@ namespace Bit.Core.Services public interface IPaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); + 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); diff --git a/src/Core/Services/Implementations/BraintreePaymentService.cs b/src/Core/Services/Implementations/BraintreePaymentService.cs deleted file mode 100644 index c205235fd7..0000000000 --- a/src/Core/Services/Implementations/BraintreePaymentService.cs +++ /dev/null @@ -1,386 +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; -using Bit.Core.Enums; -using System.Collections.Generic; - -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) - { - var txs = transactions.Cast().Where(c => c.RefundedTransactionId == null); - foreach(var transaction in txs) - { - 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, PaymentMethodType paymentMethodType, string paymentToken, - short additionalStorageGb) - { - var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest - { - PaymentMethodNonce = paymentToken, - Email = user.Email, - CustomFields = new Dictionary - { - [user.BraintreeIdField()] = user.Id.ToString() - } - }); - - if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) - { - throw new GatewayException("Failed to create customer."); - } - - var subId = user.BraintreeCustomerIdPrefix() + 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, PaymentMethodType paymentMethodType, - string paymentToken) - { - if(subscriber == null) - { - throw new ArgumentNullException(nameof(subscriber)); - } - - if(paymentMethodType != PaymentMethodType.PayPal) - { - throw new GatewayException("Payment method not allowed"); - } - - if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != 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, - CustomFields = new Dictionary - { - [subscriber.BraintreeIdField()] = subscriber.Id.ToString() - } - }); - - 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 a7308ffec0..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(globalSettings); + _paymentService = paymentService; _globalSettings = globalSettings; } @@ -92,7 +93,7 @@ namespace Bit.Core.Services paymentMethodType = PaymentMethodType.PayPal; } - var updated = await _stripePaymentService.UpdatePaymentMethodAsync(organization, + var updated = await _paymentService.UpdatePaymentMethodAsync(organization, paymentMethodType, paymentToken); if(updated) { @@ -115,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) @@ -126,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) @@ -286,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); } @@ -411,7 +412,7 @@ namespace Bit.Core.Services var invoicedNow = false; if(additionalSeats > 0) { - invoicedNow = await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync( + invoicedNow = await (_paymentService as StripePaymentService).PreviewUpcomingInvoiceAndPayAsync( organization, plan.StripeSeatPlanId, subItemOptions, 500); } @@ -561,7 +562,7 @@ namespace Bit.Core.Services paymentMethodType = PaymentMethodType.PayPal; } - await _stripePaymentService.PurchaseOrganizationAsync(organization, paymentMethodType, + await _paymentService.PurchaseOrganizationAsync(organization, paymentMethodType, signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, signup.PremiumAccessAddon); } @@ -676,7 +677,7 @@ namespace Bit.Core.Services { if(withPayment) { - await _stripePaymentService.CancelAndRecoverChargesAsync(organization); + await _paymentService.CancelAndRecoverChargesAsync(organization); } if(organization.Id != default(Guid)) @@ -785,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) { } } @@ -1205,9 +1206,8 @@ namespace Bit.Core.Services { throw new BadRequestException("Invalid installation id"); } - - var paymentService = new StripePaymentService(_globalSettings); - 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 64f9e1a2e0..7ca6dc85b9 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -7,6 +7,7 @@ using Bit.Core.Exceptions; using System.Linq; using Bit.Core.Models.Business; using Bit.Core.Enums; +using Bit.Core.Repositories; namespace Bit.Core.Services { @@ -15,9 +16,11 @@ namespace Bit.Core.Services private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; + private readonly ITransactionRepository _transactionRepository; private readonly Braintree.BraintreeGateway _btGateway; public StripePaymentService( + ITransactionRepository transactionRepository, GlobalSettings globalSettings) { _btGateway = new Braintree.BraintreeGateway @@ -28,6 +31,7 @@ namespace Bit.Core.Services PublicKey = globalSettings.Braintree.PublicKey, PrivateKey = globalSettings.Braintree.PrivateKey }; + _transactionRepository = transactionRepository; } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, @@ -912,6 +916,22 @@ namespace Bit.Core.Services public async Task GetBillingAsync(ISubscriber subscriber) { var billingInfo = new BillingInfo(); + + 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(); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 9201cf45bf..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; @@ -717,7 +720,7 @@ namespace Bit.Core.Services paymentMethodType = PaymentMethodType.PayPal; } - await new StripePaymentService(_globalSettings).PurchasePremiumAsync(user, paymentMethodType, + await _paymentService.PurchasePremiumAsync(user, paymentMethodType, paymentToken, additionalStorageGb); } else @@ -792,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); } @@ -806,7 +808,6 @@ namespace Bit.Core.Services } PaymentMethodType paymentMethodType; - var paymentService = new StripePaymentService(_globalSettings); if(paymentToken.StartsWith("tok_")) { paymentMethodType = PaymentMethodType.Card; @@ -816,7 +817,7 @@ namespace Bit.Core.Services paymentMethodType = PaymentMethodType.PayPal; } - var updated = await paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken); + var updated = await _paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken); if(updated) { await SaveUserAsync(user); @@ -825,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) @@ -878,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 2de1ba284d..93f3b4fd9e 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ namespace Bit.Core.Utilities public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 7ad899c7282c35b9a14f74fa0227a36f409b6c3a Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Sat, 9 Feb 2019 18:12:52 -0500 Subject: [PATCH 31/31] todo for payment failure email --- src/Billing/Controllers/StripeController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index d05d0dadaf..b42d205920 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -409,6 +409,7 @@ namespace Bit.Billing.Controllers if(!transactionResult.IsSuccess()) { + // TODO: Send payment failure email? return false; }