diff --git a/src/Billing/Services/Implementations/PaymentFailedHandler.cs b/src/Billing/Services/Implementations/PaymentFailedHandler.cs new file mode 100644 index 0000000000..8c5362d912 --- /dev/null +++ b/src/Billing/Services/Implementations/PaymentFailedHandler.cs @@ -0,0 +1,43 @@ +using Bit.Billing.Constants; +using Microsoft.AspNetCore.Mvc; +using Stripe; +using Event = Stripe.Event; + +namespace Bit.Billing.Services.Implementations; + +public class PaymentFailedHandler : StripeWebhookHandler +{ + private readonly IStripeEventService _stripeEventService; + + public PaymentFailedHandler(IStripeEventService stripeEventService) + { + _stripeEventService = stripeEventService; + } + protected override bool CanHandle(Event parsedEvent) + { + return parsedEvent.Type.Equals(HandledStripeWebhook.PaymentSucceeded); + } + + protected override async Task ProcessEvent(Event parsedEvent) + { + await HandlePaymentFailedAsync(await _stripeEventService.GetInvoice(parsedEvent, true)); + return new OkResult(); + } + + private async Task HandlePaymentFailedAsync(Invoice invoice) + { + if (!invoice.Paid && invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) + { + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + // attempt count 4 = 11 days after initial failure + if (invoice.AttemptCount <= 3 || + !subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) + { + await AttemptToPayInvoiceAsync(invoice); + } + } + } + + +} diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs new file mode 100644 index 0000000000..3264325194 --- /dev/null +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -0,0 +1,101 @@ +using Bit.Billing.Constants; +using Bit.Core.Context; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; +using Stripe; +using Event = Stripe.Event; + +namespace Bit.Billing.Services.Implementations; + +public class PaymentSucceededHandler : StripeWebhookHandler +{ + private readonly IStripeEventService _stripeEventService; + private readonly IOrganizationService _organizationService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + + public PaymentSucceededHandler(IStripeEventService stripeEventService, + IOrganizationService organizationService, + IOrganizationRepository organizationRepository, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + IUserService userService, + IUserRepository userRepository) + { + _stripeEventService = stripeEventService; + _organizationService = organizationService; + _organizationRepository = organizationRepository; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + _userService = userService; + _userRepository = userRepository; + } + protected override bool CanHandle(Event parsedEvent) + { + return parsedEvent.Type.Equals(HandledStripeWebhook.PaymentSucceeded); + } + + protected override async Task ProcessEvent(Event parsedEvent) + { + var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); + if (invoice.Paid && invoice.BillingReason == "subscription_create") + { + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + if (subscription?.Status == StripeSubscriptionStatus.Active) + { + if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1)) + { + await Task.Delay(5000); + } + + var ids = GetIdsFromMetaData(subscription.Metadata); + // org + if (ids.Item1.HasValue) + { + if (subscription.Items.Any(i => + StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) + { + await _organizationService.EnableAsync(ids.Item1.Value, subscription.CurrentPeriodEnd); + + var organization = await _organizationRepository.GetByIdAsync(ids.Item1.Value); + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) + { + PlanName = organization?.Plan, + PlanType = organization?.PlanType, + Seats = organization?.Seats, + Storage = organization?.MaxStorageGb, + }); + } + } + // user + else if (ids.Item2.HasValue) + { + if (subscription.Items.Any(i => i.Plan.Id == PremiumPlanId)) + { + await _userService.EnablePremiumAsync(ids.Item2.Value, subscription.CurrentPeriodEnd); + + var user = await _userRepository.GetByIdAsync(ids.Item2.Value); + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) + { + PlanName = PremiumPlanId, + Storage = user?.MaxStorageGb, + }); + } + } + } + } + + return new OkResult(); + } +} diff --git a/src/Billing/Services/Implementations/StripeWebhookHandler.cs b/src/Billing/Services/Implementations/StripeWebhookHandler.cs index e95878295d..54d440f552 100644 --- a/src/Billing/Services/Implementations/StripeWebhookHandler.cs +++ b/src/Billing/Services/Implementations/StripeWebhookHandler.cs @@ -30,7 +30,10 @@ public abstract class StripeWebhookHandler } } - public Tuple GetIdsFromMetaData(IDictionary metaData) + protected abstract bool CanHandle(Event parsedEvent); + protected abstract Task ProcessEvent(Event parsedEvent); + + public static Tuple GetIdsFromMetaData(IDictionary metaData) { if (metaData == null || !metaData.Any()) { @@ -72,7 +75,125 @@ public abstract class StripeWebhookHandler public static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); - protected abstract bool CanHandle(Event parsedEvent); - protected abstract Task ProcessEvent(Event parsedEvent); + public static bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice) + { + return invoice.AmountDue > 0 && !invoice.Paid && invoice.CollectionMethod == "charge_automatically" && + invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; + } + + public async Task AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false) + { + var customerService = new CustomerService(); + var customer = await customerService.GetAsync(invoice.CustomerId); + if (customer?.Metadata?.ContainsKey("appleReceipt") ?? false) + { + return await AttemptToPayInvoiceWithAppleReceiptAsync(invoice, customer); + } + + if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) + { + return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer); + } + + if (attemptToPayWithStripe) + { + return await AttemptToPayInvoiceWithStripeAsync(invoice); + } + + return false; + } + + private async Task AttemptToPayInvoiceWithAppleReceiptAsync(Invoice invoice, Customer customer) + { + if (!customer?.Metadata?.ContainsKey("appleReceipt") ?? true) + { + return false; + } + + var originalAppleReceiptTransactionId = customer.Metadata["appleReceipt"]; + var appleReceiptRecord = await _appleIapService.GetReceiptAsync(originalAppleReceiptTransactionId); + if (string.IsNullOrWhiteSpace(appleReceiptRecord?.Item1) || !appleReceiptRecord.Item2.HasValue) + { + return false; + } + + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + var ids = GetIdsFromMetaData(subscription?.Metadata); + if (!ids.Item2.HasValue) + { + // Apple receipt is only for user subscriptions + return false; + } + + if (appleReceiptRecord.Item2.Value != ids.Item2.Value) + { + _logger.LogError("User Ids for Apple Receipt and subscription do not match: {0} != {1}.", + appleReceiptRecord.Item2.Value, ids.Item2.Value); + return false; + } + + var appleReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(appleReceiptRecord.Item1); + if (appleReceiptStatus == null) + { + // TODO: cancel sub if receipt is cancelled? + return false; + } + + var receiptExpiration = appleReceiptStatus.GetLastExpiresDate().GetValueOrDefault(DateTime.MinValue); + var invoiceDue = invoice.DueDate.GetValueOrDefault(DateTime.MinValue); + if (receiptExpiration <= invoiceDue) + { + _logger.LogWarning("Apple receipt expiration is before invoice due date. {0} <= {1}", + receiptExpiration, invoiceDue); + return false; + } + + var receiptLastTransactionId = appleReceiptStatus.GetLastTransactionId(); + var existingTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.AppStore, receiptLastTransactionId); + if (existingTransaction != null) + { + _logger.LogWarning("There is already an existing transaction for this Apple receipt.", + receiptLastTransactionId); + return false; + } + + var appleTransaction = appleReceiptStatus.BuildTransactionFromLastTransaction( + PremiumPlanAppleIapPrice, ids.Item2.Value); + appleTransaction.Type = TransactionType.Charge; + + var invoiceService = new InvoiceService(); + try + { + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + ["appleReceipt"] = appleReceiptStatus.GetOriginalTransactionId(), + ["appleReceiptTransactionId"] = receiptLastTransactionId + } + }); + + await _transactionRepository.CreateAsync(appleTransaction); + await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + } + catch (Exception e) + { + if (e.Message.Contains("Invoice is already paid")) + { + await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = invoice.Metadata + }); + } + else + { + throw; + } + } + + return true; + } } diff --git a/src/Billing/Utilities/StripeWebhookUtility.cs b/src/Billing/Utilities/StripeWebhookUtility.cs new file mode 100644 index 0000000000..cdd35a7b77 --- /dev/null +++ b/src/Billing/Utilities/StripeWebhookUtility.cs @@ -0,0 +1,6 @@ +namespace Bit.Billing.Utilities; + +public static class StripeWebhookUtility +{ + +}