mirror of
https://github.com/bitwarden/server.git
synced 2026-02-17 14:23:16 +08:00
* Create common test infrastructure project
* Add helpers to further type PlanTypes
* Enable testing of ASP.net MVC controllers
Controller properties have all kinds of validations in the background.
In general, we don't user properties on our Controllers, so the easiest
way to allow for Autofixture-based testing of our Controllers is to just
omit setting all properties on them.
* Workaround for broken MemberAutoDataAttribute
https://github.com/AutoFixture/AutoFixture/pull/1164 shows that only
the first test case is pulled for this attribute.
This is a workaround that populates the provided parameters, left to
right, using AutoFixture to populate any remaining.
* WIP: Organization sponsorship flow
* Add Attribute to use the Bit Autodata dependency chain
BitAutoDataAttribute is used to mark a Theory as autopopulating
parameters.
Extract common attribute methods to to a helper class. Cannot
inherit a common base, since both require inheriting from different
Xunit base classes to work.
* WIP: scaffolding for families for enterprise sponsorship flow
* Fix broken tests
* Create sponsorship offer (#1688)
* Initial db work (#1687)
* Add organization sponsorship databases to all providers
* Generalize create and update for database, specialize in code
* Add PlanSponsorshipType to db model
* Write valid json for test entries
* Initial scaffolding of emails (#1686)
* Initial scaffolding of emails
* Work on adding models for FamilyForEnterprise emails
* Switch verbage
* Put preliminary copy in emails
* Skip test
* Families for enterprise/stripe integrations (#1699)
* Add PlanSponsorshipType to static store
* Add sponsorship type to token and creates sponsorship
* PascalCase properties
* Require sponsorship for remove
* Create subscription sponsorship helper class
* Handle Sponsored subscription changes
* Add sponsorship id to subscription metadata
* Make sponsoring references nullable
This state indicates that a sponsorship has lapsed, but was not able to
be reverted for billing reasons
* WIP: Validate and remove subscriptions
* Update sponsorships on organization and org user delete
* Add friendly name to organization sponsorship
* Add sponsorship available boolean to orgDetails
* Add sponsorship service to DI
* Use userId to find org users
* Send f4e offer email
* Simplify names of f4e mail messages
* Fix Stripe org default tax rates
* Universal sponsorship redeem api
* Populate user in current context
* Add product type to organization details
* Use upgrade path to change sponsorship
Sponsorships need to be annual to match the GB add-on charge rate
* Use organization and auth to find organization sponsorship
* Add resend sponsorship offer api endpoint
* Fix double email send
* Fix sponsorship upgrade options
* Add is sponsored item to subscription response
* Add sponsorship validation to upcoming invoice webhook
* Add sponsorship validation to upcoming invoice webhook
* Fix organization delete sponsorship hooks
* Test org sponsorship service
* Fix sproc
* Create common test infrastructure project
* Add helpers to further type PlanTypes
* Enable testing of ASP.net MVC controllers
Controller properties have all kinds of validations in the background.
In general, we don't user properties on our Controllers, so the easiest
way to allow for Autofixture-based testing of our Controllers is to just
omit setting all properties on them.
* Workaround for broken MemberAutoDataAttribute
https://github.com/AutoFixture/AutoFixture/pull/1164 shows that only
the first test case is pulled for this attribute.
This is a workaround that populates the provided parameters, left to
right, using AutoFixture to populate any remaining.
* WIP: Organization sponsorship flow
* Add Attribute to use the Bit Autodata dependency chain
BitAutoDataAttribute is used to mark a Theory as autopopulating
parameters.
Extract common attribute methods to to a helper class. Cannot
inherit a common base, since both require inheriting from different
Xunit base classes to work.
* WIP: scaffolding for families for enterprise sponsorship flow
* Fix broken tests
* Create sponsorship offer (#1688)
* Initial db work (#1687)
* Add organization sponsorship databases to all providers
* Generalize create and update for database, specialize in code
* Add PlanSponsorshipType to db model
* Write valid json for test entries
* Initial scaffolding of emails (#1686)
* Initial scaffolding of emails
* Work on adding models for FamilyForEnterprise emails
* Switch verbage
* Put preliminary copy in emails
* Skip test
* Families for enterprise/stripe integrations (#1699)
* Add PlanSponsorshipType to static store
* Add sponsorship type to token and creates sponsorship
* PascalCase properties
* Require sponsorship for remove
* Create subscription sponsorship helper class
* Handle Sponsored subscription changes
* Add sponsorship id to subscription metadata
* Make sponsoring references nullable
This state indicates that a sponsorship has lapsed, but was not able to
be reverted for billing reasons
* WIP: Validate and remove subscriptions
* Update sponsorships on organization and org user delete
* Add friendly name to organization sponsorship
* Add sponsorship available boolean to orgDetails
* Add sponsorship service to DI
* Use userId to find org users
* Send f4e offer email
* Simplify names of f4e mail messages
* Fix Stripe org default tax rates
* Universal sponsorship redeem api
* Populate user in current context
* Add product type to organization details
* Use upgrade path to change sponsorship
Sponsorships need to be annual to match the GB add-on charge rate
* Use organization and auth to find organization sponsorship
* Add resend sponsorship offer api endpoint
* Fix double email send
* Fix sponsorship upgrade options
* Add is sponsored item to subscription response
* Add sponsorship validation to upcoming invoice webhook
* Add sponsorship validation to upcoming invoice webhook
* Fix organization delete sponsorship hooks
* Test org sponsorship service
* Fix sproc
* Fix build error
* Update emails
* Fix tests
* Skip local test
* Add newline
* Fix stripe subscription update
* Finish emails
* Skip test
* Fix unit tests
* Remove unused variable
* Fix unit tests
* Switch to handlebars ifs
* Remove ending email
* Remove reconfirmation template
* Switch naming convention
* Switch naming convention
* Fix migration
* Update copy and links
* Switch to using Guid in the method
* Remove unneeded css styles
* Add sql files to Sql.sqlproj
* Removed old comments
* Made name more verbose
* Fix SQL error
* Move unit tests to service
* Fix sp
* Revert "Move unit tests to service"
This reverts commit 1185bf3ec8.
* Do repository validation in service layer
* Fix tests
* Fix merge conflicts and remove TODO
* Remove unneeded models
* Fix spacing and formatting
* Switch Org -> Organization
* Remove single use variables
* Switch method name
* Fix Controller
* Switch to obfuscating email
* Fix unit tests
Co-authored-by: Justin Baur <admin@justinbaur.com>
1706 lines
76 KiB
C#
1706 lines
76 KiB
C#
using System;
|
|
using System.Threading.Tasks;
|
|
using Bit.Core.Models.Table;
|
|
using System.Collections.Generic;
|
|
using Bit.Core.Exceptions;
|
|
using System.Linq;
|
|
using Bit.Billing.Models;
|
|
using Bit.Core.Models.Business;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.Repositories;
|
|
using Microsoft.Extensions.Logging;
|
|
using TaxRate = Bit.Core.Models.Table.TaxRate;
|
|
using StaticStore = Bit.Core.Models.StaticStore;
|
|
|
|
namespace Bit.Core.Services
|
|
{
|
|
public class StripePaymentService : IPaymentService
|
|
{
|
|
private const string PremiumPlanId = "premium-annually";
|
|
private const string PremiumPlanAppleIapId = "premium-annually-appleiap";
|
|
private const decimal PremiumPlanAppleIapPrice = 14.99M;
|
|
private const string StoragePlanId = "storage-gb-annually";
|
|
|
|
private readonly ITransactionRepository _transactionRepository;
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IAppleIapService _appleIapService;
|
|
private readonly ILogger<StripePaymentService> _logger;
|
|
private readonly Braintree.IBraintreeGateway _btGateway;
|
|
private readonly ITaxRateRepository _taxRateRepository;
|
|
private readonly IStripeAdapter _stripeAdapter;
|
|
|
|
public StripePaymentService(
|
|
ITransactionRepository transactionRepository,
|
|
IUserRepository userRepository,
|
|
IAppleIapService appleIapService,
|
|
ILogger<StripePaymentService> logger,
|
|
ITaxRateRepository taxRateRepository,
|
|
IStripeAdapter stripeAdapter,
|
|
Braintree.IBraintreeGateway braintreeGateway)
|
|
{
|
|
_transactionRepository = transactionRepository;
|
|
_userRepository = userRepository;
|
|
_appleIapService = appleIapService;
|
|
_logger = logger;
|
|
_taxRateRepository = taxRateRepository;
|
|
_stripeAdapter = stripeAdapter;
|
|
_btGateway = braintreeGateway;
|
|
}
|
|
|
|
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
|
|
string paymentToken, StaticStore.Plan plan, short additionalStorageGb,
|
|
int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
|
|
{
|
|
Braintree.Customer braintreeCustomer = null;
|
|
string stipeCustomerSourceToken = null;
|
|
string stipeCustomerPaymentMethodId = null;
|
|
var stripeCustomerMetadata = new Dictionary<string, string>();
|
|
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
|
|
paymentMethodType == PaymentMethodType.BankAccount;
|
|
|
|
if (stripePaymentMethod && !string.IsNullOrWhiteSpace(paymentToken))
|
|
{
|
|
if (paymentToken.StartsWith("pm_"))
|
|
{
|
|
stipeCustomerPaymentMethodId = paymentToken;
|
|
}
|
|
else
|
|
{
|
|
stipeCustomerSourceToken = paymentToken;
|
|
}
|
|
}
|
|
else if (paymentMethodType == PaymentMethodType.PayPal)
|
|
{
|
|
var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
|
|
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
|
|
{
|
|
PaymentMethodNonce = paymentToken,
|
|
Email = org.BillingEmail,
|
|
Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix,
|
|
CustomFields = new Dictionary<string, string>
|
|
{
|
|
[org.BraintreeIdField()] = org.Id.ToString()
|
|
}
|
|
});
|
|
|
|
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
|
|
{
|
|
throw new GatewayException("Failed to create PayPal customer record.");
|
|
}
|
|
|
|
braintreeCustomer = customerResult.Target;
|
|
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
|
|
}
|
|
else
|
|
{
|
|
throw new GatewayException("Payment method is not supported at this time.");
|
|
}
|
|
|
|
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
|
|
{
|
|
var taxRateSearch = new TaxRate
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode
|
|
};
|
|
var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch);
|
|
|
|
// should only be one tax rate per country/zip combo
|
|
var taxRate = taxRates.FirstOrDefault();
|
|
if (taxRate != null)
|
|
{
|
|
taxInfo.StripeTaxRateId = taxRate.Id;
|
|
}
|
|
}
|
|
|
|
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
|
|
|
|
Stripe.Customer customer = null;
|
|
Stripe.Subscription subscription;
|
|
try
|
|
{
|
|
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
|
|
{
|
|
Description = org.BusinessName,
|
|
Email = org.BillingEmail,
|
|
Source = stipeCustomerSourceToken,
|
|
PaymentMethod = stipeCustomerPaymentMethodId,
|
|
Metadata = stripeCustomerMetadata,
|
|
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
|
|
{
|
|
DefaultPaymentMethod = stipeCustomerPaymentMethodId
|
|
},
|
|
Address = new Stripe.AddressOptions
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode,
|
|
// Line1 is required in Stripe's API, suggestion in Docs is to use Business Name intead.
|
|
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
|
Line2 = taxInfo.BillingAddressLine2,
|
|
City = taxInfo.BillingAddressCity,
|
|
State = taxInfo.BillingAddressState,
|
|
},
|
|
TaxIdData = !taxInfo.HasTaxId ? null : new List<Stripe.CustomerTaxIdDataOptions>
|
|
{
|
|
new Stripe.CustomerTaxIdDataOptions
|
|
{
|
|
Type = taxInfo.TaxIdType,
|
|
Value = taxInfo.TaxIdNumber,
|
|
},
|
|
},
|
|
});
|
|
subCreateOptions.AddExpand("latest_invoice.payment_intent");
|
|
subCreateOptions.Customer = customer.Id;
|
|
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
|
|
if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null)
|
|
{
|
|
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
|
|
{
|
|
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new Stripe.SubscriptionCancelOptions());
|
|
throw new GatewayException("Payment method was declined.");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (customer != null)
|
|
{
|
|
await _stripeAdapter.CustomerDeleteAsync(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;
|
|
|
|
if (subscription.Status == "incomplete" &&
|
|
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
|
|
{
|
|
org.Enabled = false;
|
|
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
|
|
}
|
|
else
|
|
{
|
|
org.Enabled = true;
|
|
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task ChangeOrganizationSponsorship(Organization org, OrganizationSponsorship sponsorship, bool applySponsorship)
|
|
{
|
|
var existingPlan = Utilities.StaticStore.GetPlan(org.PlanType);
|
|
var sponsoredPlan = sponsorship != null ?
|
|
Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) :
|
|
null;
|
|
var subscriptionUpdate = new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship);
|
|
|
|
await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, DateTime.UtcNow);
|
|
|
|
var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId);
|
|
org.ExpirationDate = sub.CurrentPeriodEnd;
|
|
|
|
}
|
|
|
|
public Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship) =>
|
|
ChangeOrganizationSponsorship(org, sponsorship, true);
|
|
|
|
public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) =>
|
|
ChangeOrganizationSponsorship(org, sponsorship, false);
|
|
|
|
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan,
|
|
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))
|
|
{
|
|
throw new BadRequestException("Organization already has a subscription.");
|
|
}
|
|
|
|
var customerOptions = new Stripe.CustomerGetOptions();
|
|
customerOptions.AddExpand("default_source");
|
|
customerOptions.AddExpand("invoice_settings.default_payment_method");
|
|
var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId, customerOptions);
|
|
if (customer == null)
|
|
{
|
|
throw new GatewayException("Could not find customer payment profile.");
|
|
}
|
|
|
|
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
|
|
{
|
|
var taxRateSearch = new TaxRate
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode
|
|
};
|
|
var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch);
|
|
|
|
// should only be one tax rate per country/zip combo
|
|
var taxRate = taxRates.FirstOrDefault();
|
|
if (taxRate != null)
|
|
{
|
|
taxInfo.StripeTaxRateId = taxRate.Id;
|
|
}
|
|
}
|
|
|
|
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
|
|
var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions);
|
|
|
|
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
|
|
stripePaymentMethod, paymentMethodType, subCreateOptions, null);
|
|
org.GatewaySubscriptionId = subscription.Id;
|
|
|
|
if (subscription.Status == "incomplete" &&
|
|
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
|
|
{
|
|
org.Enabled = false;
|
|
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
|
|
}
|
|
else
|
|
{
|
|
org.Enabled = true;
|
|
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private (bool stripePaymentMethod, PaymentMethodType PaymentMethodType) IdentifyPaymentMethod(
|
|
Stripe.Customer customer, Stripe.SubscriptionCreateOptions subCreateOptions)
|
|
{
|
|
var stripePaymentMethod = false;
|
|
var paymentMethodType = PaymentMethodType.Credit;
|
|
var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId");
|
|
if (hasBtCustomerId)
|
|
{
|
|
paymentMethodType = PaymentMethodType.PayPal;
|
|
}
|
|
else
|
|
{
|
|
if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
|
|
{
|
|
paymentMethodType = PaymentMethodType.Card;
|
|
stripePaymentMethod = true;
|
|
}
|
|
else if (customer.DefaultSource != null)
|
|
{
|
|
if (customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.SourceCard)
|
|
{
|
|
paymentMethodType = PaymentMethodType.Card;
|
|
stripePaymentMethod = true;
|
|
}
|
|
else if (customer.DefaultSource is Stripe.BankAccount || customer.DefaultSource is Stripe.SourceAchDebit)
|
|
{
|
|
paymentMethodType = PaymentMethodType.BankAccount;
|
|
stripePaymentMethod = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var paymentMethod = GetLatestCardPaymentMethod(customer.Id);
|
|
if (paymentMethod != null)
|
|
{
|
|
paymentMethodType = PaymentMethodType.Card;
|
|
stripePaymentMethod = true;
|
|
subCreateOptions.DefaultPaymentMethod = paymentMethod.Id;
|
|
}
|
|
}
|
|
}
|
|
return (stripePaymentMethod, paymentMethodType);
|
|
}
|
|
|
|
public async Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType,
|
|
string paymentToken, short additionalStorageGb, TaxInfo taxInfo)
|
|
{
|
|
if (paymentMethodType != PaymentMethodType.Credit && string.IsNullOrWhiteSpace(paymentToken))
|
|
{
|
|
throw new BadRequestException("Payment token is required.");
|
|
}
|
|
if (paymentMethodType == PaymentMethodType.Credit &&
|
|
(user.Gateway != GatewayType.Stripe || string.IsNullOrWhiteSpace(user.GatewayCustomerId)))
|
|
{
|
|
throw new BadRequestException("Your account does not have any credit available.");
|
|
}
|
|
if (paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.GoogleInApp)
|
|
{
|
|
throw new GatewayException("Payment method is not supported at this time.");
|
|
}
|
|
if ((paymentMethodType == PaymentMethodType.GoogleInApp ||
|
|
paymentMethodType == PaymentMethodType.AppleInApp) && additionalStorageGb > 0)
|
|
{
|
|
throw new BadRequestException("You cannot add storage with this payment method.");
|
|
}
|
|
|
|
var createdStripeCustomer = false;
|
|
Stripe.Customer customer = null;
|
|
Braintree.Customer braintreeCustomer = null;
|
|
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
|
|
paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.Credit;
|
|
|
|
string stipeCustomerPaymentMethodId = null;
|
|
string stipeCustomerSourceToken = null;
|
|
if (stripePaymentMethod && !string.IsNullOrWhiteSpace(paymentToken))
|
|
{
|
|
if (paymentToken.StartsWith("pm_"))
|
|
{
|
|
stipeCustomerPaymentMethodId = paymentToken;
|
|
}
|
|
else
|
|
{
|
|
stipeCustomerSourceToken = paymentToken;
|
|
}
|
|
}
|
|
|
|
if (user.Gateway == GatewayType.Stripe && !string.IsNullOrWhiteSpace(user.GatewayCustomerId))
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(paymentToken))
|
|
{
|
|
try
|
|
{
|
|
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, true, taxInfo);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
var message = e.Message.ToLowerInvariant();
|
|
if (message.Contains("apple") || message.Contains("in-app"))
|
|
{
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
try
|
|
{
|
|
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
if (customer == null && !string.IsNullOrWhiteSpace(paymentToken))
|
|
{
|
|
var stripeCustomerMetadata = new Dictionary<string, string>();
|
|
if (paymentMethodType == PaymentMethodType.PayPal)
|
|
{
|
|
var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
|
|
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
|
|
{
|
|
PaymentMethodNonce = paymentToken,
|
|
Email = user.Email,
|
|
Id = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + randomSuffix,
|
|
CustomFields = new Dictionary<string, string>
|
|
{
|
|
[user.BraintreeIdField()] = user.Id.ToString()
|
|
}
|
|
});
|
|
|
|
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
|
|
{
|
|
throw new GatewayException("Failed to create PayPal customer record.");
|
|
}
|
|
|
|
braintreeCustomer = customerResult.Target;
|
|
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
|
|
}
|
|
else if (paymentMethodType == PaymentMethodType.AppleInApp)
|
|
{
|
|
var verifiedReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
|
|
if (verifiedReceiptStatus == null)
|
|
{
|
|
throw new GatewayException("Cannot verify apple in-app purchase.");
|
|
}
|
|
var receiptOriginalTransactionId = verifiedReceiptStatus.GetOriginalTransactionId();
|
|
await VerifyAppleReceiptNotInUseAsync(receiptOriginalTransactionId, user);
|
|
await _appleIapService.SaveReceiptAsync(verifiedReceiptStatus, user.Id);
|
|
stripeCustomerMetadata.Add("appleReceipt", receiptOriginalTransactionId);
|
|
}
|
|
else if (!stripePaymentMethod)
|
|
{
|
|
throw new GatewayException("Payment method is not supported at this time.");
|
|
}
|
|
|
|
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
|
|
{
|
|
Description = user.Name,
|
|
Email = user.Email,
|
|
Metadata = stripeCustomerMetadata,
|
|
PaymentMethod = stipeCustomerPaymentMethodId,
|
|
Source = stipeCustomerSourceToken,
|
|
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
|
|
{
|
|
DefaultPaymentMethod = stipeCustomerPaymentMethodId
|
|
},
|
|
Address = new Stripe.AddressOptions
|
|
{
|
|
Line1 = string.Empty,
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode,
|
|
},
|
|
});
|
|
createdStripeCustomer = true;
|
|
}
|
|
|
|
if (customer == null)
|
|
{
|
|
throw new GatewayException("Could not set up customer payment profile.");
|
|
}
|
|
|
|
var subCreateOptions = new Stripe.SubscriptionCreateOptions
|
|
{
|
|
Customer = customer.Id,
|
|
Items = new List<Stripe.SubscriptionItemOptions>(),
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
[user.GatewayIdField()] = user.Id.ToString()
|
|
}
|
|
};
|
|
|
|
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
|
|
{
|
|
Plan = paymentMethodType == PaymentMethodType.AppleInApp ? PremiumPlanAppleIapId : PremiumPlanId,
|
|
Quantity = 1,
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
|
|
&& !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode))
|
|
{
|
|
var taxRates = await _taxRateRepository.GetByLocationAsync(
|
|
new Bit.Core.Models.Table.TaxRate()
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode
|
|
}
|
|
);
|
|
var taxRate = taxRates.FirstOrDefault();
|
|
if (taxRate != null)
|
|
{
|
|
subCreateOptions.DefaultTaxRates = new List<string>(1)
|
|
{
|
|
taxRate.Id
|
|
};
|
|
}
|
|
}
|
|
|
|
if (additionalStorageGb > 0)
|
|
{
|
|
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
|
|
{
|
|
Plan = StoragePlanId,
|
|
Quantity = additionalStorageGb
|
|
});
|
|
}
|
|
|
|
var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer,
|
|
stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer);
|
|
|
|
user.Gateway = GatewayType.Stripe;
|
|
user.GatewayCustomerId = customer.Id;
|
|
user.GatewaySubscriptionId = subscription.Id;
|
|
|
|
if (subscription.Status == "incomplete" &&
|
|
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
|
|
{
|
|
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
|
|
}
|
|
else
|
|
{
|
|
user.Premium = true;
|
|
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task<Stripe.Subscription> ChargeForNewSubscriptionAsync(ISubscriber subcriber, Stripe.Customer customer,
|
|
bool createdStripeCustomer, bool stripePaymentMethod, PaymentMethodType paymentMethodType,
|
|
Stripe.SubscriptionCreateOptions subCreateOptions, Braintree.Customer braintreeCustomer)
|
|
{
|
|
var addedCreditToStripeCustomer = false;
|
|
Braintree.Transaction braintreeTransaction = null;
|
|
Transaction appleTransaction = null;
|
|
|
|
var subInvoiceMetadata = new Dictionary<string, string>();
|
|
Stripe.Subscription subscription = null;
|
|
try
|
|
{
|
|
if (!stripePaymentMethod)
|
|
{
|
|
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
|
|
{
|
|
Customer = customer.Id,
|
|
SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items),
|
|
SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates,
|
|
});
|
|
|
|
if (previewInvoice.AmountDue > 0)
|
|
{
|
|
var appleReceiptOrigTransactionId = customer.Metadata != null &&
|
|
customer.Metadata.ContainsKey("appleReceipt") ? customer.Metadata["appleReceipt"] : null;
|
|
var braintreeCustomerId = customer.Metadata != null &&
|
|
customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null;
|
|
if (!string.IsNullOrWhiteSpace(appleReceiptOrigTransactionId))
|
|
{
|
|
if (!subcriber.IsUser())
|
|
{
|
|
throw new GatewayException("In-app purchase is only allowed for users.");
|
|
}
|
|
|
|
var appleReceipt = await _appleIapService.GetReceiptAsync(
|
|
appleReceiptOrigTransactionId);
|
|
var verifiedAppleReceipt = await _appleIapService.GetVerifiedReceiptStatusAsync(
|
|
appleReceipt.Item1);
|
|
if (verifiedAppleReceipt == null)
|
|
{
|
|
throw new GatewayException("Failed to get Apple in-app purchase receipt data.");
|
|
}
|
|
subInvoiceMetadata.Add("appleReceipt", verifiedAppleReceipt.GetOriginalTransactionId());
|
|
var lastTransactionId = verifiedAppleReceipt.GetLastTransactionId();
|
|
subInvoiceMetadata.Add("appleReceiptTransactionId", lastTransactionId);
|
|
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
|
GatewayType.AppStore, lastTransactionId);
|
|
if (existingTransaction == null)
|
|
{
|
|
appleTransaction = verifiedAppleReceipt.BuildTransactionFromLastTransaction(
|
|
PremiumPlanAppleIapPrice, subcriber.Id);
|
|
appleTransaction.Type = TransactionType.Charge;
|
|
await _transactionRepository.CreateAsync(appleTransaction);
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
|
|
{
|
|
var btInvoiceAmount = (previewInvoice.AmountDue / 100M);
|
|
var transactionResult = await _btGateway.Transaction.SaleAsync(
|
|
new Braintree.TransactionRequest
|
|
{
|
|
Amount = btInvoiceAmount,
|
|
CustomerId = braintreeCustomerId,
|
|
Options = new Braintree.TransactionOptionsRequest
|
|
{
|
|
SubmitForSettlement = true,
|
|
PayPal = new Braintree.TransactionOptionsPayPalRequest
|
|
{
|
|
CustomField = $"{subcriber.BraintreeIdField()}:{subcriber.Id}"
|
|
}
|
|
},
|
|
CustomFields = new Dictionary<string, string>
|
|
{
|
|
[subcriber.BraintreeIdField()] = subcriber.Id.ToString()
|
|
}
|
|
});
|
|
|
|
if (!transactionResult.IsSuccess())
|
|
{
|
|
throw new GatewayException("Failed to charge PayPal customer.");
|
|
}
|
|
|
|
braintreeTransaction = transactionResult.Target;
|
|
subInvoiceMetadata.Add("btTransactionId", braintreeTransaction.Id);
|
|
subInvoiceMetadata.Add("btPayPalTransactionId",
|
|
braintreeTransaction.PayPalDetails.AuthorizationId);
|
|
}
|
|
else
|
|
{
|
|
throw new GatewayException("No payment was able to be collected.");
|
|
}
|
|
|
|
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Balance = customer.Balance - previewInvoice.AmountDue
|
|
});
|
|
addedCreditToStripeCustomer = true;
|
|
}
|
|
}
|
|
else if (paymentMethodType == PaymentMethodType.Credit)
|
|
{
|
|
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
|
|
{
|
|
Customer = customer.Id,
|
|
SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items),
|
|
SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates,
|
|
});
|
|
if (previewInvoice.AmountDue > 0)
|
|
{
|
|
throw new GatewayException("Your account does not have enough credit available.");
|
|
}
|
|
}
|
|
|
|
subCreateOptions.OffSession = true;
|
|
subCreateOptions.AddExpand("latest_invoice.payment_intent");
|
|
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
|
|
if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null)
|
|
{
|
|
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
|
|
{
|
|
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new Stripe.SubscriptionCancelOptions());
|
|
throw new GatewayException("Payment method was declined.");
|
|
}
|
|
}
|
|
|
|
if (!stripePaymentMethod && subInvoiceMetadata.Any())
|
|
{
|
|
var invoices = await _stripeAdapter.InvoiceListAsync(new Stripe.InvoiceListOptions
|
|
{
|
|
Subscription = subscription.Id
|
|
});
|
|
|
|
var invoice = invoices?.FirstOrDefault();
|
|
if (invoice == null)
|
|
{
|
|
throw new GatewayException("Invoice not found.");
|
|
}
|
|
|
|
await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions
|
|
{
|
|
Metadata = subInvoiceMetadata
|
|
});
|
|
}
|
|
|
|
return subscription;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (customer != null)
|
|
{
|
|
if (createdStripeCustomer)
|
|
{
|
|
await _stripeAdapter.CustomerDeleteAsync(customer.Id);
|
|
}
|
|
else if (addedCreditToStripeCustomer || customer.Balance < 0)
|
|
{
|
|
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Balance = customer.Balance
|
|
});
|
|
}
|
|
}
|
|
if (braintreeTransaction != null)
|
|
{
|
|
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
|
|
}
|
|
if (braintreeCustomer != null)
|
|
{
|
|
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
|
|
}
|
|
if (appleTransaction != null)
|
|
{
|
|
await _transactionRepository.DeleteAsync(appleTransaction);
|
|
}
|
|
|
|
if (e is Stripe.StripeException strEx &&
|
|
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
|
|
{
|
|
throw new GatewayException("Bank account is not yet verified.");
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private List<Stripe.InvoiceSubscriptionItemOptions> ToInvoiceSubscriptionItemOptions(
|
|
List<Stripe.SubscriptionItemOptions> subItemOptions)
|
|
{
|
|
return subItemOptions.Select(si => new Stripe.InvoiceSubscriptionItemOptions
|
|
{
|
|
Plan = si.Plan,
|
|
Quantity = si.Quantity
|
|
}).ToList();
|
|
}
|
|
|
|
private async Task<string> FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber,
|
|
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate)
|
|
{
|
|
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId);
|
|
if (sub == null)
|
|
{
|
|
throw new GatewayException("Subscription not found.");
|
|
}
|
|
|
|
prorationDate ??= DateTime.UtcNow;
|
|
var collectionMethod = sub.CollectionMethod;
|
|
var daysUntilDue = sub.DaysUntilDue;
|
|
var chargeNow = collectionMethod == "charge_automatically";
|
|
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
|
|
|
|
var subUpdateOptions = new Stripe.SubscriptionUpdateOptions
|
|
{
|
|
Items = updatedItemOptions,
|
|
ProrationBehavior = "always_invoice",
|
|
DaysUntilDue = daysUntilDue ?? 1,
|
|
CollectionMethod = "send_invoice",
|
|
ProrationDate = prorationDate,
|
|
};
|
|
|
|
if (!subscriptionUpdate.UpdateNeeded(sub))
|
|
{
|
|
// No need to update subscription, quantity matches
|
|
return null;
|
|
}
|
|
|
|
var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId);
|
|
|
|
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
|
|
&& !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode))
|
|
{
|
|
var taxRates = await _taxRateRepository.GetByLocationAsync(
|
|
new Bit.Core.Models.Table.TaxRate()
|
|
{
|
|
Country = customer.Address.Country,
|
|
PostalCode = customer.Address.PostalCode
|
|
}
|
|
);
|
|
var taxRate = taxRates.FirstOrDefault();
|
|
if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id)))
|
|
{
|
|
subUpdateOptions.DefaultTaxRates = new List<string>(1)
|
|
{
|
|
taxRate.Id
|
|
};
|
|
}
|
|
}
|
|
|
|
var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions);
|
|
|
|
var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions());
|
|
if (invoice == null)
|
|
{
|
|
throw new BadRequestException("Unable to locate draft invoice for subscription update.");
|
|
}
|
|
|
|
string paymentIntentClientSecret = null;
|
|
if (invoice.AmountDue > 0 && updatedItemOptions.Any(i => i.Quantity > 0))
|
|
{
|
|
try
|
|
{
|
|
if (chargeNow)
|
|
{
|
|
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
|
|
storableSubscriber, invoice);
|
|
}
|
|
else
|
|
{
|
|
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions
|
|
{
|
|
AutoAdvance = false,
|
|
});
|
|
await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions());
|
|
paymentIntentClientSecret = null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Need to revert the subscription
|
|
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
|
|
{
|
|
Items = subscriptionUpdate.RevertItemsOptions(sub),
|
|
// This proration behavior prevents a false "credit" from
|
|
// being applied forward to the next month's invoice
|
|
ProrationBehavior = "none",
|
|
CollectionMethod = collectionMethod,
|
|
DaysUntilDue = daysUntilDue,
|
|
});
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// Change back the subscription collection method and/or days until due
|
|
if (collectionMethod != "send_invoice" || daysUntilDue == null)
|
|
{
|
|
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
|
|
{
|
|
CollectionMethod = collectionMethod,
|
|
DaysUntilDue = daysUntilDue,
|
|
});
|
|
}
|
|
|
|
return paymentIntentClientSecret;
|
|
}
|
|
|
|
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
|
|
{
|
|
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
|
|
}
|
|
|
|
public Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
|
|
string storagePlanId, DateTime? prorationDate = null)
|
|
{
|
|
return FinalizeSubscriptionChangeAsync(storableSubscriber, new StorageSubscriptionUpdate(storagePlanId, additionalStorage), prorationDate);
|
|
}
|
|
|
|
public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
|
{
|
|
await _stripeAdapter.SubscriptionCancelAsync(subscriber.GatewaySubscriptionId,
|
|
new Stripe.SubscriptionCancelOptions());
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
if (customer == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (customer.Metadata.ContainsKey("btCustomerId"))
|
|
{
|
|
var transactionRequest = new Braintree.TransactionSearchRequest()
|
|
.CustomerId.Is(customer.Metadata["btCustomerId"]);
|
|
var transactions = _btGateway.Transaction.Search(transactionRequest);
|
|
|
|
if ((transactions?.MaximumCount ?? 0) > 0)
|
|
{
|
|
var txs = transactions.Cast<Braintree.Transaction>().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 charges = await _stripeAdapter.ChargeListAsync(new Stripe.ChargeListOptions
|
|
{
|
|
Customer = subscriber.GatewayCustomerId
|
|
});
|
|
|
|
if (charges?.Data != null)
|
|
{
|
|
foreach (var charge in charges.Data.Where(c => c.Captured && !c.Refunded))
|
|
{
|
|
await _stripeAdapter.RefundCreateAsync(new Stripe.RefundCreateOptions { Charge = charge.Id });
|
|
}
|
|
}
|
|
}
|
|
|
|
await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId);
|
|
}
|
|
|
|
public async Task<string> PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Stripe.Invoice invoice)
|
|
{
|
|
var customerOptions = new Stripe.CustomerGetOptions();
|
|
customerOptions.AddExpand("default_source");
|
|
customerOptions.AddExpand("invoice_settings.default_payment_method");
|
|
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
|
|
var usingInAppPaymentMethod = customer.Metadata.ContainsKey("appleReceipt");
|
|
if (usingInAppPaymentMethod)
|
|
{
|
|
throw new BadRequestException("Cannot perform this action with in-app purchase payment method. " +
|
|
"Contact support.");
|
|
}
|
|
|
|
string paymentIntentClientSecret = null;
|
|
|
|
// Invoice them and pay now instead of waiting until Stripe does this automatically.
|
|
|
|
string cardPaymentMethodId = null;
|
|
if (!customer.Metadata.ContainsKey("btCustomerId"))
|
|
{
|
|
var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
|
|
var hasDefaultValidSource = customer.DefaultSource != null &&
|
|
(customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.BankAccount);
|
|
if (!hasDefaultCardPaymentMethod && !hasDefaultValidSource)
|
|
{
|
|
cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
|
|
if (cardPaymentMethodId == null)
|
|
{
|
|
// We're going to delete this draft invoice, it can't be paid
|
|
try
|
|
{
|
|
await _stripeAdapter.InvoiceDeleteAsync(invoice.Id);
|
|
}
|
|
catch
|
|
{
|
|
await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new Stripe.InvoiceFinalizeOptions
|
|
{
|
|
AutoAdvance = false
|
|
});
|
|
await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id);
|
|
}
|
|
throw new BadRequestException("No payment method is available.");
|
|
}
|
|
}
|
|
}
|
|
|
|
Braintree.Transaction braintreeTransaction = null;
|
|
try
|
|
{
|
|
// Finalize the invoice (from Draft) w/o auto-advance so we
|
|
// can attempt payment manually.
|
|
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new Stripe.InvoiceFinalizeOptions
|
|
{
|
|
AutoAdvance = false,
|
|
});
|
|
var invoicePayOptions = new Stripe.InvoicePayOptions
|
|
{
|
|
PaymentMethod = cardPaymentMethodId,
|
|
};
|
|
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
|
|
{
|
|
invoicePayOptions.PaidOutOfBand = true;
|
|
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 = $"{subscriber.BraintreeIdField()}:{subscriber.Id}"
|
|
}
|
|
},
|
|
CustomFields = new Dictionary<string, string>
|
|
{
|
|
[subscriber.BraintreeIdField()] = subscriber.Id.ToString()
|
|
}
|
|
});
|
|
|
|
if (!transactionResult.IsSuccess())
|
|
{
|
|
throw new GatewayException("Failed to charge PayPal customer.");
|
|
}
|
|
|
|
braintreeTransaction = transactionResult.Target;
|
|
invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
["btTransactionId"] = braintreeTransaction.Id,
|
|
["btPayPalTransactionId"] =
|
|
braintreeTransaction.PayPalDetails.AuthorizationId
|
|
},
|
|
});
|
|
invoicePayOptions.PaidOutOfBand = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
invoice = await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions);
|
|
}
|
|
catch (Stripe.StripeException e)
|
|
{
|
|
if (e.HttpStatusCode == System.Net.HttpStatusCode.PaymentRequired &&
|
|
e.StripeError?.Code == "invoice_payment_intent_requires_action")
|
|
{
|
|
// SCA required, get intent client secret
|
|
var invoiceGetOptions = new Stripe.InvoiceGetOptions();
|
|
invoiceGetOptions.AddExpand("payment_intent");
|
|
invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions);
|
|
paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret;
|
|
}
|
|
else
|
|
{
|
|
throw new GatewayException("Unable to pay invoice.");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (braintreeTransaction != null)
|
|
{
|
|
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
|
|
}
|
|
if (invoice != null)
|
|
{
|
|
if (invoice.Status == "paid")
|
|
{
|
|
// It's apparently paid, so we need to return w/o throwing an exception
|
|
return paymentIntentClientSecret;
|
|
}
|
|
|
|
invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new Stripe.InvoiceVoidOptions());
|
|
|
|
// HACK: Workaround for customer balance credit
|
|
if (invoice.StartingBalance < 0)
|
|
{
|
|
// Customer had a balance applied to this invoice. Since we can't fully trust Stripe to
|
|
// credit it back to the customer (even though their docs claim they will), we need to
|
|
// check that balance against the current customer balance and determine if it needs to be re-applied
|
|
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
|
|
|
|
// Assumption: Customer balance should now be $0, otherwise payment would not have failed.
|
|
if (customer.Balance == 0)
|
|
{
|
|
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Balance = invoice.StartingBalance
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (e is Stripe.StripeException strEx &&
|
|
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
|
|
{
|
|
throw new GatewayException("Bank account is not yet verified.");
|
|
}
|
|
|
|
// Let the caller perform any subscription change cleanup
|
|
throw;
|
|
}
|
|
return paymentIntentClientSecret;
|
|
}
|
|
|
|
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
|
|
bool skipInAppPurchaseCheck = false)
|
|
{
|
|
if (subscriber == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(subscriber));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
|
{
|
|
throw new GatewayException("No subscription.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId) && !skipInAppPurchaseCheck)
|
|
{
|
|
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
if (customer.Metadata.ContainsKey("appleReceipt"))
|
|
{
|
|
throw new BadRequestException("You are required to manage your subscription from the app store.");
|
|
}
|
|
}
|
|
|
|
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
|
if (sub == null)
|
|
{
|
|
throw new GatewayException("Subscription was not found.");
|
|
}
|
|
|
|
if (sub.CanceledAt.HasValue || sub.Status == "canceled" || sub.Status == "unpaid" ||
|
|
sub.Status == "incomplete_expired")
|
|
{
|
|
// Already canceled
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var canceledSub = endOfPeriod ?
|
|
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
|
|
new Stripe.SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) :
|
|
await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new Stripe.SubscriptionCancelOptions());
|
|
if (!canceledSub.CanceledAt.HasValue)
|
|
{
|
|
throw new GatewayException("Unable to cancel subscription.");
|
|
}
|
|
}
|
|
catch (Stripe.StripeException e)
|
|
{
|
|
if (e.Message != $"No such subscription: {subscriber.GatewaySubscriptionId}")
|
|
{
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
|
if (sub == null)
|
|
{
|
|
throw new GatewayException("Subscription was not found.");
|
|
}
|
|
|
|
if ((sub.Status != "active" && sub.Status != "trialing" && !sub.Status.StartsWith("incomplete")) ||
|
|
!sub.CanceledAt.HasValue)
|
|
{
|
|
throw new GatewayException("Subscription is not marked for cancellation.");
|
|
}
|
|
|
|
var updatedSub = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
|
|
new Stripe.SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
|
|
if (updatedSub.CanceledAt.HasValue)
|
|
{
|
|
throw new GatewayException("Unable to reinstate subscription.");
|
|
}
|
|
}
|
|
|
|
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
|
|
string paymentToken, bool allowInAppPurchases = false, TaxInfo taxInfo = null)
|
|
{
|
|
if (subscriber == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(subscriber));
|
|
}
|
|
|
|
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 createdCustomer = false;
|
|
AppleReceiptStatus appleReceiptStatus = null;
|
|
Braintree.Customer braintreeCustomer = null;
|
|
string stipeCustomerSourceToken = null;
|
|
string stipeCustomerPaymentMethodId = null;
|
|
var stripeCustomerMetadata = new Dictionary<string, string>();
|
|
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
|
|
paymentMethodType == PaymentMethodType.BankAccount;
|
|
var inAppPurchase = paymentMethodType == PaymentMethodType.AppleInApp ||
|
|
paymentMethodType == PaymentMethodType.GoogleInApp;
|
|
|
|
Stripe.Customer customer = null;
|
|
|
|
if (!allowInAppPurchases && inAppPurchase)
|
|
{
|
|
throw new GatewayException("In-app purchase payment method is not allowed.");
|
|
}
|
|
|
|
if (!subscriber.IsUser() && inAppPurchase)
|
|
{
|
|
throw new GatewayException("In-app purchase payment method is only allowed for users.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
if (customer.Metadata?.Any() ?? false)
|
|
{
|
|
stripeCustomerMetadata = customer.Metadata;
|
|
}
|
|
}
|
|
|
|
if (inAppPurchase && customer != null && customer.Balance != 0)
|
|
{
|
|
throw new GatewayException("Customer balance cannot exist when using in-app purchases.");
|
|
}
|
|
|
|
if (!inAppPurchase && customer != null && stripeCustomerMetadata.ContainsKey("appleReceipt"))
|
|
{
|
|
throw new GatewayException("Cannot change from in-app payment method. Contact support.");
|
|
}
|
|
|
|
var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId");
|
|
if (stripePaymentMethod)
|
|
{
|
|
if (paymentToken.StartsWith("pm_"))
|
|
{
|
|
stipeCustomerPaymentMethodId = paymentToken;
|
|
}
|
|
else
|
|
{
|
|
stipeCustomerSourceToken = paymentToken;
|
|
}
|
|
}
|
|
else if (paymentMethodType == PaymentMethodType.PayPal)
|
|
{
|
|
if (hadBtCustomer)
|
|
{
|
|
var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest
|
|
{
|
|
CustomerId = stripeCustomerMetadata["btCustomerId"],
|
|
PaymentMethodNonce = paymentToken
|
|
});
|
|
|
|
if (pmResult.IsSuccess())
|
|
{
|
|
var customerResult = await _btGateway.Customer.UpdateAsync(
|
|
stripeCustomerMetadata["btCustomerId"], new Braintree.CustomerRequest
|
|
{
|
|
DefaultPaymentMethodToken = pmResult.Target.Token
|
|
});
|
|
|
|
if (customerResult.IsSuccess() && customerResult.Target.PaymentMethods.Length > 0)
|
|
{
|
|
braintreeCustomer = customerResult.Target;
|
|
}
|
|
else
|
|
{
|
|
await _btGateway.PaymentMethod.DeleteAsync(pmResult.Target.Token);
|
|
hadBtCustomer = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
hadBtCustomer = false;
|
|
}
|
|
}
|
|
|
|
if (!hadBtCustomer)
|
|
{
|
|
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
|
|
{
|
|
PaymentMethodNonce = paymentToken,
|
|
Email = subscriber.BillingEmailAddress(),
|
|
Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() +
|
|
Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false),
|
|
CustomFields = new Dictionary<string, string>
|
|
{
|
|
[subscriber.BraintreeIdField()] = subscriber.Id.ToString()
|
|
}
|
|
});
|
|
|
|
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
|
|
{
|
|
throw new GatewayException("Failed to create PayPal customer record.");
|
|
}
|
|
|
|
braintreeCustomer = customerResult.Target;
|
|
}
|
|
}
|
|
else if (paymentMethodType == PaymentMethodType.AppleInApp)
|
|
{
|
|
appleReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
|
|
if (appleReceiptStatus == null)
|
|
{
|
|
throw new GatewayException("Cannot verify Apple in-app purchase.");
|
|
}
|
|
await VerifyAppleReceiptNotInUseAsync(appleReceiptStatus.GetOriginalTransactionId(), subscriber);
|
|
}
|
|
else
|
|
{
|
|
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);
|
|
}
|
|
|
|
if (appleReceiptStatus != null)
|
|
{
|
|
var originalTransactionId = appleReceiptStatus.GetOriginalTransactionId();
|
|
if (stripeCustomerMetadata.ContainsKey("appleReceipt"))
|
|
{
|
|
if (originalTransactionId != stripeCustomerMetadata["appleReceipt"])
|
|
{
|
|
var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow);
|
|
stripeCustomerMetadata.Add($"appleReceipt_{nowSec}", stripeCustomerMetadata["appleReceipt"]);
|
|
}
|
|
stripeCustomerMetadata["appleReceipt"] = originalTransactionId;
|
|
}
|
|
else
|
|
{
|
|
stripeCustomerMetadata.Add("appleReceipt", originalTransactionId);
|
|
}
|
|
await _appleIapService.SaveReceiptAsync(appleReceiptStatus, subscriber.Id);
|
|
}
|
|
|
|
try
|
|
{
|
|
if (customer == null)
|
|
{
|
|
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
|
|
{
|
|
Description = subscriber.BillingName(),
|
|
Email = subscriber.BillingEmailAddress(),
|
|
Metadata = stripeCustomerMetadata,
|
|
Source = stipeCustomerSourceToken,
|
|
PaymentMethod = stipeCustomerPaymentMethodId,
|
|
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
|
|
{
|
|
DefaultPaymentMethod = stipeCustomerPaymentMethodId
|
|
},
|
|
Address = taxInfo == null ? null : new Stripe.AddressOptions
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode,
|
|
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
|
Line2 = taxInfo.BillingAddressLine2,
|
|
City = taxInfo.BillingAddressCity,
|
|
State = taxInfo.BillingAddressState,
|
|
},
|
|
});
|
|
|
|
subscriber.Gateway = GatewayType.Stripe;
|
|
subscriber.GatewayCustomerId = customer.Id;
|
|
createdCustomer = true;
|
|
}
|
|
|
|
if (!createdCustomer)
|
|
{
|
|
string defaultSourceId = null;
|
|
string defaultPaymentMethodId = null;
|
|
if (stripePaymentMethod)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(stipeCustomerSourceToken) && paymentToken.StartsWith("btok_"))
|
|
{
|
|
var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new Stripe.BankAccountCreateOptions
|
|
{
|
|
Source = paymentToken
|
|
});
|
|
defaultSourceId = bankAccount.Id;
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(stipeCustomerPaymentMethodId))
|
|
{
|
|
await _stripeAdapter.PaymentMethodAttachAsync(stipeCustomerPaymentMethodId,
|
|
new Stripe.PaymentMethodAttachOptions { Customer = customer.Id });
|
|
defaultPaymentMethodId = stipeCustomerPaymentMethodId;
|
|
}
|
|
}
|
|
|
|
foreach (var source in customer.Sources.Where(s => s.Id != defaultSourceId))
|
|
{
|
|
if (source is Stripe.BankAccount)
|
|
{
|
|
await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
|
|
}
|
|
else if (source is Stripe.Card)
|
|
{
|
|
await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
|
|
}
|
|
}
|
|
|
|
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new Stripe.PaymentMethodListOptions
|
|
{
|
|
Customer = customer.Id,
|
|
Type = "card"
|
|
});
|
|
foreach (var cardMethod in cardPaymentMethods.Where(m => m.Id != defaultPaymentMethodId))
|
|
{
|
|
await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new Stripe.PaymentMethodDetachOptions());
|
|
}
|
|
|
|
customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Metadata = stripeCustomerMetadata,
|
|
DefaultSource = defaultSourceId,
|
|
InvoiceSettings = new Stripe.CustomerInvoiceSettingsOptions
|
|
{
|
|
DefaultPaymentMethod = defaultPaymentMethodId
|
|
},
|
|
Address = taxInfo == null ? null : new Stripe.AddressOptions
|
|
{
|
|
Country = taxInfo.BillingAddressCountry,
|
|
PostalCode = taxInfo.BillingAddressPostalCode,
|
|
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
|
Line2 = taxInfo.BillingAddressLine2,
|
|
City = taxInfo.BillingAddressCity,
|
|
State = taxInfo.BillingAddressState,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (braintreeCustomer != null && !hadBtCustomer)
|
|
{
|
|
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
return createdCustomer;
|
|
}
|
|
|
|
public async Task<bool> CreditAccountAsync(ISubscriber subscriber, decimal creditAmount)
|
|
{
|
|
Stripe.Customer customer = null;
|
|
var customerExists = subscriber.Gateway == GatewayType.Stripe &&
|
|
!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId);
|
|
if (customerExists)
|
|
{
|
|
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
}
|
|
else
|
|
{
|
|
customer = await _stripeAdapter.CustomerCreateAsync(new Stripe.CustomerCreateOptions
|
|
{
|
|
Email = subscriber.BillingEmailAddress(),
|
|
Description = subscriber.BillingName(),
|
|
});
|
|
subscriber.Gateway = GatewayType.Stripe;
|
|
subscriber.GatewayCustomerId = customer.Id;
|
|
}
|
|
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Balance = customer.Balance - (long)(creditAmount * 100)
|
|
});
|
|
return !customerExists;
|
|
}
|
|
|
|
public async Task<BillingInfo> GetBillingAsync(ISubscriber subscriber)
|
|
{
|
|
var billingInfo = new BillingInfo();
|
|
|
|
ICollection<Transaction> 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));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
Stripe.Customer customer = null;
|
|
try
|
|
{
|
|
var customerOptions = new Stripe.CustomerGetOptions();
|
|
customerOptions.AddExpand("default_source");
|
|
customerOptions.AddExpand("invoice_settings.default_payment_method");
|
|
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
|
|
}
|
|
catch (Stripe.StripeException) { }
|
|
if (customer != null)
|
|
{
|
|
billingInfo.Balance = customer.Balance / 100M;
|
|
|
|
if (customer.Metadata?.ContainsKey("appleReceipt") ?? false)
|
|
{
|
|
billingInfo.PaymentSource = new BillingInfo.BillingSource
|
|
{
|
|
Type = PaymentMethodType.AppleInApp
|
|
};
|
|
}
|
|
else if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
|
|
{
|
|
try
|
|
{
|
|
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 (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
|
|
{
|
|
billingInfo.PaymentSource = new BillingInfo.BillingSource(
|
|
customer.InvoiceSettings.DefaultPaymentMethod);
|
|
}
|
|
else if (customer.DefaultSource != null &&
|
|
(customer.DefaultSource is Stripe.Card || customer.DefaultSource is Stripe.BankAccount))
|
|
{
|
|
billingInfo.PaymentSource = new BillingInfo.BillingSource(customer.DefaultSource);
|
|
}
|
|
if (billingInfo.PaymentSource == null)
|
|
{
|
|
var paymentMethod = GetLatestCardPaymentMethod(customer.Id);
|
|
if (paymentMethod != null)
|
|
{
|
|
billingInfo.PaymentSource = new BillingInfo.BillingSource(paymentMethod);
|
|
}
|
|
}
|
|
|
|
var invoices = await _stripeAdapter.InvoiceListAsync(new Stripe.InvoiceListOptions
|
|
{
|
|
Customer = customer.Id,
|
|
Limit = 50
|
|
});
|
|
billingInfo.Invoices = invoices.Data.Where(i => i.Status != "void" && i.Status != "draft")
|
|
.OrderByDescending(i => i.Created).Select(i => new BillingInfo.BillingInvoice(i));
|
|
}
|
|
}
|
|
|
|
return billingInfo;
|
|
}
|
|
|
|
public async Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber)
|
|
{
|
|
var subscriptionInfo = new SubscriptionInfo();
|
|
|
|
if (subscriber.IsUser() && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
subscriptionInfo.UsingInAppPurchase = customer.Metadata.ContainsKey("appleReceipt");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
|
{
|
|
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
|
if (sub != null)
|
|
{
|
|
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub);
|
|
}
|
|
|
|
if (!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
try
|
|
{
|
|
var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync(
|
|
new Stripe.UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId });
|
|
if (upcomingInvoice != null)
|
|
{
|
|
subscriptionInfo.UpcomingInvoice =
|
|
new SubscriptionInfo.BillingUpcomingInvoice(upcomingInvoice);
|
|
}
|
|
}
|
|
catch (Stripe.StripeException) { }
|
|
}
|
|
}
|
|
|
|
return subscriptionInfo;
|
|
}
|
|
|
|
public async Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber)
|
|
{
|
|
if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
|
|
|
|
if (customer == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var address = customer.Address;
|
|
var taxId = customer.TaxIds?.FirstOrDefault();
|
|
|
|
// Line1 is required, so if missing we're using the subscriber name
|
|
// see: https://stripe.com/docs/api/customers/create#create_customer-address-line1
|
|
if (address != null && string.IsNullOrWhiteSpace(address.Line1))
|
|
{
|
|
address.Line1 = null;
|
|
}
|
|
|
|
return new TaxInfo
|
|
{
|
|
TaxIdNumber = taxId?.Value,
|
|
BillingAddressLine1 = address?.Line1,
|
|
BillingAddressLine2 = address?.Line2,
|
|
BillingAddressCity = address?.City,
|
|
BillingAddressState = address?.State,
|
|
BillingAddressPostalCode = address?.PostalCode,
|
|
BillingAddressCountry = address?.Country,
|
|
};
|
|
}
|
|
|
|
public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo)
|
|
{
|
|
if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
|
{
|
|
var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new Stripe.CustomerUpdateOptions
|
|
{
|
|
Address = new Stripe.AddressOptions
|
|
{
|
|
Line1 = taxInfo.BillingAddressLine1 ?? string.Empty,
|
|
Line2 = taxInfo.BillingAddressLine2,
|
|
City = taxInfo.BillingAddressCity,
|
|
State = taxInfo.BillingAddressState,
|
|
PostalCode = taxInfo.BillingAddressPostalCode,
|
|
Country = taxInfo.BillingAddressCountry,
|
|
},
|
|
});
|
|
|
|
if (!subscriber.IsUser() && customer != null)
|
|
{
|
|
var taxId = customer.TaxIds?.FirstOrDefault();
|
|
|
|
if (taxId != null)
|
|
{
|
|
await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) &&
|
|
!string.IsNullOrWhiteSpace(taxInfo.TaxIdType))
|
|
{
|
|
await _stripeAdapter.TaxIdCreateAsync(customer.Id, new Stripe.TaxIdCreateOptions
|
|
{
|
|
Type = taxInfo.TaxIdType,
|
|
Value = taxInfo.TaxIdNumber,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<TaxRate> CreateTaxRateAsync(TaxRate taxRate)
|
|
{
|
|
var stripeTaxRateOptions = new Stripe.TaxRateCreateOptions()
|
|
{
|
|
DisplayName = $"{taxRate.Country} - {taxRate.PostalCode}",
|
|
Inclusive = false,
|
|
Percentage = taxRate.Rate,
|
|
Active = true
|
|
};
|
|
var stripeTaxRate = await _stripeAdapter.TaxRateCreateAsync(stripeTaxRateOptions);
|
|
taxRate.Id = stripeTaxRate.Id;
|
|
await _taxRateRepository.CreateAsync(taxRate);
|
|
return taxRate;
|
|
}
|
|
|
|
public async Task UpdateTaxRateAsync(TaxRate taxRate)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(taxRate.Id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await ArchiveTaxRateAsync(taxRate);
|
|
await CreateTaxRateAsync(taxRate);
|
|
}
|
|
|
|
public async Task ArchiveTaxRateAsync(TaxRate taxRate)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(taxRate.Id))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var updatedStripeTaxRate = await _stripeAdapter.TaxRateUpdateAsync(
|
|
taxRate.Id,
|
|
new Stripe.TaxRateUpdateOptions() { Active = false }
|
|
);
|
|
if (!updatedStripeTaxRate.Active)
|
|
{
|
|
taxRate.Active = false;
|
|
await _taxRateRepository.ArchiveAsync(taxRate);
|
|
}
|
|
}
|
|
|
|
private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId)
|
|
{
|
|
var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(
|
|
new Stripe.PaymentMethodListOptions { Customer = customerId, Type = "card" });
|
|
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault();
|
|
}
|
|
|
|
private async Task VerifyAppleReceiptNotInUseAsync(string receiptOriginalTransactionId, ISubscriber subscriber)
|
|
{
|
|
var existingReceipt = await _appleIapService.GetReceiptAsync(receiptOriginalTransactionId);
|
|
if (existingReceipt != null && existingReceipt.Item2.HasValue && existingReceipt.Item2 != subscriber.Id)
|
|
{
|
|
var existingUser = await _userRepository.GetByIdAsync(existingReceipt.Item2.Value);
|
|
if (existingUser != null)
|
|
{
|
|
throw new GatewayException("Apple receipt already in use by another user.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|