mirror of
https://github.com/bitwarden/server.git
synced 2025-02-16 01:51:21 +01:00
Merge branch 'stripechanges'
This commit is contained in:
commit
8fad9d849f
@ -22,6 +22,7 @@ namespace Bit.Admin.Jobs
|
||||
protected async override Task ExecuteJobAsync(IJobExecutionContext context)
|
||||
{
|
||||
await _maintenanceRepository.UpdateStatisticsAsync();
|
||||
await _maintenanceRepository.DisableCipherAutoStatsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ namespace Bit.Api.Controllers
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly CurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
@ -32,6 +33,7 @@ namespace Bit.Api.Controllers
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
IPaymentService paymentService,
|
||||
CurrentContext currentContext,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
@ -39,6 +41,7 @@ namespace Bit.Api.Controllers
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_userService = userService;
|
||||
_paymentService = paymentService;
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
@ -78,8 +81,7 @@ namespace Bit.Api.Controllers
|
||||
|
||||
if(!_globalSettings.SelfHosted && organization.Gateway != null)
|
||||
{
|
||||
var paymentService = new StripePaymentService();
|
||||
var billingInfo = await paymentService.GetBillingAsync(organization);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(organization);
|
||||
if(billingInfo == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@ -110,7 +112,7 @@ namespace Bit.Api.Controllers
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await new StripeInvoiceService().GetAsync(invoiceId);
|
||||
var invoice = await new InvoiceService().GetAsync(invoiceId);
|
||||
if(invoice != null && invoice.CustomerId == organization.GatewayCustomerId &&
|
||||
!string.IsNullOrWhiteSpace(invoice.HostedInvoiceUrl))
|
||||
{
|
||||
|
@ -6,5 +6,15 @@
|
||||
public virtual string StripeWebhookKey { get; set; }
|
||||
public virtual string StripeWebhookSecret { get; set; }
|
||||
public virtual string BraintreeWebhookKey { get; set; }
|
||||
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
|
||||
|
||||
public class PayPalSettings
|
||||
{
|
||||
public virtual bool Production { get; set; }
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual string ClientSecret { get; set; }
|
||||
public virtual string WebhookId { get; set; }
|
||||
public virtual string WebhookKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
141
src/Billing/Controllers/PayPalController.cs
Normal file
141
src/Billing/Controllers/PayPalController.cs
Normal file
@ -0,0 +1,141 @@
|
||||
using Bit.Billing.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using System.Data.SqlClient;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Bit.Billing.Controllers
|
||||
{
|
||||
[Route("paypal")]
|
||||
public class PayPalController : Controller
|
||||
{
|
||||
private readonly BillingSettings _billingSettings;
|
||||
private readonly PayPalClient _paypalClient;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
|
||||
public PayPalController(
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
PayPalClient paypalClient,
|
||||
ITransactionRepository transactionRepository)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_paypalClient = paypalClient;
|
||||
_transactionRepository = transactionRepository;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromQuery] string key)
|
||||
{
|
||||
if(key != _billingSettings.PayPal.WebhookKey)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
if(HttpContext?.Request == null)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
string body = null;
|
||||
using(var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
if(body == null)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var verified = await _paypalClient.VerifyWebhookAsync(body, HttpContext.Request.Headers,
|
||||
_billingSettings.PayPal.WebhookId);
|
||||
if(!verified)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
if(body.Contains("\"PAYMENT.SALE.COMPLETED\""))
|
||||
{
|
||||
var ev = JsonConvert.DeserializeObject<PayPalClient.Event<PayPalClient.Sale>>(body);
|
||||
var sale = ev.Resource;
|
||||
var saleTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, sale.Id);
|
||||
if(saleTransaction == null)
|
||||
{
|
||||
var ids = sale.GetIdsFromCustom();
|
||||
if(ids.Item1.HasValue || ids.Item2.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction
|
||||
{
|
||||
Amount = sale.Amount.TotalAmount,
|
||||
CreationDate = sale.CreateTime,
|
||||
OrganizationId = ids.Item1,
|
||||
UserId = ids.Item2,
|
||||
Type = TransactionType.Charge,
|
||||
Gateway = GatewayType.PayPal,
|
||||
GatewayId = sale.Id,
|
||||
PaymentMethodType = PaymentMethodType.PayPal,
|
||||
Details = sale.Id
|
||||
});
|
||||
}
|
||||
// Catch foreign key violations because user/org could have been deleted.
|
||||
catch(SqlException e) when(e.Number == 547) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(body.Contains("\"PAYMENT.SALE.REFUNDED\""))
|
||||
{
|
||||
var ev = JsonConvert.DeserializeObject<PayPalClient.Event<PayPalClient.Refund>>(body);
|
||||
var refund = ev.Resource;
|
||||
var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, refund.Id);
|
||||
if(refundTransaction == null)
|
||||
{
|
||||
var saleTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, refund.SaleId);
|
||||
if(saleTransaction == null)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
if(!saleTransaction.Refunded.GetValueOrDefault() &&
|
||||
saleTransaction.RefundedAmount.GetValueOrDefault() < refund.TotalRefundedAmount.ValueAmount)
|
||||
{
|
||||
saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount;
|
||||
if(saleTransaction.RefundedAmount == saleTransaction.Amount)
|
||||
{
|
||||
saleTransaction.Refunded = true;
|
||||
}
|
||||
await _transactionRepository.ReplaceAsync(saleTransaction);
|
||||
|
||||
var ids = refund.GetIdsFromCustom();
|
||||
if(ids.Item1.HasValue || ids.Item2.HasValue)
|
||||
{
|
||||
await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction
|
||||
{
|
||||
Amount = refund.Amount.TotalAmount,
|
||||
CreationDate = refund.CreateTime,
|
||||
OrganizationId = ids.Item1,
|
||||
UserId = ids.Item2,
|
||||
Type = TransactionType.Refund,
|
||||
Gateway = GatewayType.PayPal,
|
||||
GatewayId = refund.Id,
|
||||
PaymentMethodType = PaymentMethodType.PayPal,
|
||||
Details = refund.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@ -7,6 +9,7 @@ using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SqlClient;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@ -20,14 +23,18 @@ namespace Bit.Billing.Controllers
|
||||
private readonly IHostingEnvironment _hostingEnvironment;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly Braintree.BraintreeGateway _btGateway;
|
||||
|
||||
public StripeController(
|
||||
GlobalSettings globalSettings,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITransactionRepository transactionRepository,
|
||||
IUserService userService,
|
||||
IMailService mailService)
|
||||
{
|
||||
@ -35,8 +42,18 @@ namespace Bit.Billing.Controllers
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_transactionRepository = transactionRepository;
|
||||
_userService = userService;
|
||||
_mailService = mailService;
|
||||
|
||||
_btGateway = new Braintree.BraintreeGateway
|
||||
{
|
||||
Environment = globalSettings.Braintree.Production ?
|
||||
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
|
||||
MerchantId = globalSettings.Braintree.MerchantId,
|
||||
PublicKey = globalSettings.Braintree.PublicKey,
|
||||
PrivateKey = globalSettings.Braintree.PrivateKey
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
@ -47,11 +64,11 @@ namespace Bit.Billing.Controllers
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
StripeEvent parsedEvent;
|
||||
Stripe.Event parsedEvent;
|
||||
using(var sr = new StreamReader(HttpContext.Request.Body))
|
||||
{
|
||||
var json = await sr.ReadToEndAsync();
|
||||
parsedEvent = StripeEventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"],
|
||||
parsedEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"],
|
||||
_billingSettings.StripeWebhookSecret);
|
||||
}
|
||||
|
||||
@ -60,20 +77,17 @@ namespace Bit.Billing.Controllers
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
if(_hostingEnvironment.IsProduction() && !parsedEvent.LiveMode)
|
||||
if(_hostingEnvironment.IsProduction() && !parsedEvent.Livemode)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var invUpcoming = parsedEvent.Type.Equals("invoice.upcoming");
|
||||
var subDeleted = parsedEvent.Type.Equals("customer.subscription.deleted");
|
||||
var subUpdated = parsedEvent.Type.Equals("customer.subscription.updated");
|
||||
|
||||
if(subDeleted || subUpdated)
|
||||
{
|
||||
StripeSubscription subscription = Mapper<StripeSubscription>.MapFromJson(
|
||||
parsedEvent.Data.Object.ToString());
|
||||
if(subscription == null)
|
||||
if(!(parsedEvent.Data.Object is Subscription subscription))
|
||||
{
|
||||
throw new Exception("Subscription is null.");
|
||||
}
|
||||
@ -113,16 +127,14 @@ namespace Bit.Billing.Controllers
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(invUpcoming)
|
||||
else if(parsedEvent.Type.Equals("invoice.upcoming"))
|
||||
{
|
||||
StripeInvoice invoice = Mapper<StripeInvoice>.MapFromJson(
|
||||
parsedEvent.Data.Object.ToString());
|
||||
if(invoice == null)
|
||||
if(!(parsedEvent.Data.Object is Invoice invoice))
|
||||
{
|
||||
throw new Exception("Invoice is null.");
|
||||
}
|
||||
|
||||
var subscriptionService = new StripeSubscriptionService();
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId);
|
||||
if(subscription == null)
|
||||
{
|
||||
@ -152,11 +164,153 @@ namespace Bit.Billing.Controllers
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(email) && invoice.NextPaymentAttempt.HasValue)
|
||||
{
|
||||
var items = invoice.StripeInvoiceLineItems.Select(i => i.Description).ToList();
|
||||
var items = invoice.Lines.Select(i => i.Description).ToList();
|
||||
await _mailService.SendInvoiceUpcomingAsync(email, invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value, items, ids.Item1.HasValue);
|
||||
}
|
||||
}
|
||||
else if(parsedEvent.Type.Equals("charge.succeeded"))
|
||||
{
|
||||
if(!(parsedEvent.Data.Object is Charge charge))
|
||||
{
|
||||
throw new Exception("Charge is null.");
|
||||
}
|
||||
|
||||
if(charge.InvoiceId == null)
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, charge.Id);
|
||||
if(chargeTransaction == null)
|
||||
{
|
||||
var invoiceService = new InvoiceService();
|
||||
var invoice = await invoiceService.GetAsync(charge.InvoiceId);
|
||||
if(invoice == null)
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId);
|
||||
if(subscription == null)
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
var ids = GetIdsFromMetaData(subscription.Metadata);
|
||||
if(ids.Item1.HasValue || ids.Item2.HasValue)
|
||||
{
|
||||
var tx = new Transaction
|
||||
{
|
||||
Amount = charge.Amount / 100M,
|
||||
CreationDate = charge.Created,
|
||||
OrganizationId = ids.Item1,
|
||||
UserId = ids.Item2,
|
||||
Type = TransactionType.Charge,
|
||||
Gateway = GatewayType.Stripe,
|
||||
GatewayId = charge.Id
|
||||
};
|
||||
|
||||
if(charge.Source is Card card)
|
||||
{
|
||||
tx.PaymentMethodType = PaymentMethodType.Card;
|
||||
tx.Details = $"{card.Brand}, *{card.Last4}";
|
||||
}
|
||||
else if(charge.Source is BankAccount bankAccount)
|
||||
{
|
||||
tx.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
tx.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _transactionRepository.CreateAsync(tx);
|
||||
}
|
||||
// Catch foreign key violations because user/org could have been deleted.
|
||||
catch(SqlException e) when(e.Number == 547) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(parsedEvent.Type.Equals("charge.refunded"))
|
||||
{
|
||||
if(!(parsedEvent.Data.Object is Charge charge))
|
||||
{
|
||||
throw new Exception("Charge is null.");
|
||||
}
|
||||
|
||||
var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, charge.Id);
|
||||
if(chargeTransaction == null)
|
||||
{
|
||||
throw new Exception("Cannot find refunded charge.");
|
||||
}
|
||||
|
||||
var amountRefunded = charge.AmountRefunded / 100M;
|
||||
|
||||
if(!chargeTransaction.Refunded.GetValueOrDefault() &&
|
||||
chargeTransaction.RefundedAmount.GetValueOrDefault() < amountRefunded)
|
||||
{
|
||||
chargeTransaction.RefundedAmount = amountRefunded;
|
||||
if(charge.Refunded)
|
||||
{
|
||||
chargeTransaction.Refunded = true;
|
||||
}
|
||||
await _transactionRepository.ReplaceAsync(chargeTransaction);
|
||||
|
||||
foreach(var refund in charge.Refunds)
|
||||
{
|
||||
var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, refund.Id);
|
||||
if(refundTransaction != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _transactionRepository.CreateAsync(new Transaction
|
||||
{
|
||||
Amount = refund.Amount / 100M,
|
||||
CreationDate = refund.Created,
|
||||
OrganizationId = chargeTransaction.OrganizationId,
|
||||
UserId = chargeTransaction.UserId,
|
||||
Type = TransactionType.Refund,
|
||||
Gateway = GatewayType.Stripe,
|
||||
GatewayId = refund.Id,
|
||||
PaymentMethodType = chargeTransaction.PaymentMethodType,
|
||||
Details = chargeTransaction.Details
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(parsedEvent.Type.Equals("invoice.payment_failed"))
|
||||
{
|
||||
if(!(parsedEvent.Data.Object is Invoice invoice))
|
||||
{
|
||||
throw new Exception("Invoice is null.");
|
||||
}
|
||||
|
||||
if(invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice))
|
||||
{
|
||||
await AttemptToPayInvoiceWithBraintreeAsync(invoice);
|
||||
}
|
||||
}
|
||||
else if(parsedEvent.Type.Equals("invoice.created"))
|
||||
{
|
||||
if(!(parsedEvent.Data.Object is Invoice invoice))
|
||||
{
|
||||
throw new Exception("Invoice is null.");
|
||||
}
|
||||
|
||||
if(UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice))
|
||||
{
|
||||
await AttemptToPayInvoiceWithBraintreeAsync(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
@ -204,13 +358,88 @@ namespace Bit.Billing.Controllers
|
||||
{
|
||||
switch(org.PlanType)
|
||||
{
|
||||
case Core.Enums.PlanType.FamiliesAnnually:
|
||||
case Core.Enums.PlanType.TeamsAnnually:
|
||||
case Core.Enums.PlanType.EnterpriseAnnually:
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.TeamsAnnually:
|
||||
case PlanType.EnterpriseAnnually:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> 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<string, string>
|
||||
{
|
||||
[btObjIdField] = btObjId.ToString()
|
||||
}
|
||||
});
|
||||
|
||||
if(!transactionResult.IsSuccess())
|
||||
{
|
||||
// TODO: Send payment failure email?
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoiceService = new InvoiceService();
|
||||
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["btTransactionId"] = transactionResult.Target.Id,
|
||||
["btPayPalTransactionId"] =
|
||||
transactionResult.Target.PayPalDetails?.AuthorizationId
|
||||
}
|
||||
});
|
||||
await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice)
|
||||
{
|
||||
return invoice.AmountDue > 0 && !invoice.Paid && invoice.Billing == Stripe.Billing.ChargeAutomatically &&
|
||||
invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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> billingSettings,
|
||||
GlobalSettings globalSettings,
|
||||
IUserRepository userRepository,
|
||||
IMailService mailService,
|
||||
IPaymentService paymentService,
|
||||
ILogger<PremiumRenewalRemindersJob> 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<string> { "1 × Premium Membership (Annually)" };
|
||||
|
@ -39,6 +39,9 @@ namespace Bit.Billing
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// PayPal Client
|
||||
services.AddSingleton<Utilities.PayPalClient>();
|
||||
|
||||
// Context
|
||||
services.AddScoped<CurrentContext>();
|
||||
|
||||
|
237
src/Billing/Utilities/PayPalClient.cs
Normal file
237
src/Billing/Utilities/PayPalClient.cs
Normal file
@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.Billing.Utilities
|
||||
{
|
||||
public class PayPalClient
|
||||
{
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
private readonly string _baseApiUrl;
|
||||
private readonly string _clientId;
|
||||
private readonly string _clientSecret;
|
||||
|
||||
private AuthResponse _authResponse;
|
||||
|
||||
public PayPalClient(BillingSettings billingSettings)
|
||||
{
|
||||
_baseApiUrl = _baseApiUrl = !billingSettings.PayPal.Production ? "https://api.sandbox.paypal.com/{0}" :
|
||||
"https://api.paypal.com/{0}";
|
||||
_clientId = billingSettings.PayPal.ClientId;
|
||||
_clientSecret = billingSettings.PayPal.ClientSecret;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<VerifyWebookResponse>(responseContent);
|
||||
return verifyResponse.Verified;
|
||||
}
|
||||
|
||||
private async Task<bool> 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<string, string>("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<AuthResponse>(responseContent);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public class VerifyWebookRequest
|
||||
{
|
||||
[JsonProperty("auth_algo")]
|
||||
public string AuthAlgo { get; set; }
|
||||
[JsonProperty("cert_url")]
|
||||
public string CertUrl { get; set; }
|
||||
[JsonProperty("transmission_id")]
|
||||
public string TransmissionId { get; set; }
|
||||
[JsonProperty("transmission_sig")]
|
||||
public string TransmissionSig { get; set; }
|
||||
[JsonProperty("transmission_time")]
|
||||
public string TransmissionTime { get; set; }
|
||||
[JsonProperty("webhook_event")]
|
||||
public string WebhookEvent { get; set; } = "__WEBHOOK_BODY__";
|
||||
[JsonProperty("webhook_id")]
|
||||
public string WebhookId { get; set; }
|
||||
}
|
||||
|
||||
public class VerifyWebookResponse
|
||||
{
|
||||
[JsonProperty("verification_status")]
|
||||
public string VerificationStatus { get; set; }
|
||||
public bool Verified => VerificationStatus == "SUCCESS";
|
||||
}
|
||||
|
||||
public class AuthResponse
|
||||
{
|
||||
private DateTime _created;
|
||||
|
||||
public AuthResponse()
|
||||
{
|
||||
_created = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[JsonProperty("scope")]
|
||||
public string Scope { get; set; }
|
||||
[JsonProperty("nonce")]
|
||||
public string Nonce { get; set; }
|
||||
[JsonProperty("access_token")]
|
||||
public string AccessToken { get; set; }
|
||||
[JsonProperty("token_type")]
|
||||
public string TokenType { get; set; }
|
||||
[JsonProperty("app_id")]
|
||||
public string AppId { get; set; }
|
||||
[JsonProperty("expires_in")]
|
||||
public long ExpiresIn { get; set; }
|
||||
public bool Expired => DateTime.UtcNow > _created.AddSeconds(ExpiresIn - 30);
|
||||
}
|
||||
|
||||
public class Event<T>
|
||||
{
|
||||
[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<Guid?, Guid?> 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<Guid?, Guid?>(orgId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
public class AmountInfo
|
||||
{
|
||||
[JsonProperty("total")]
|
||||
public string Total { get; set; }
|
||||
public decimal TotalAmount => Convert.ToDecimal(Total);
|
||||
}
|
||||
|
||||
public class ValueInfo
|
||||
{
|
||||
[JsonProperty("value")]
|
||||
public string Value { get; set; }
|
||||
public decimal ValueAmount => Convert.ToDecimal(Value);
|
||||
}
|
||||
}
|
||||
}
|
@ -15,5 +15,10 @@
|
||||
"braintree": {
|
||||
"production": true
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"payPal": {
|
||||
"production": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,18 +45,25 @@
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
},
|
||||
"braintree": {
|
||||
"production": false,
|
||||
"merchantId": "SECRET",
|
||||
"publicKey": "SECRET",
|
||||
"privateKey": "SECRET"
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"jobsKey": "SECRET",
|
||||
"stripeWebhookKey": "SECRET",
|
||||
"stripeWebhookSecret": "SECRET",
|
||||
"braintreeWebhookKey": "SECRET"
|
||||
},
|
||||
"braintree": {
|
||||
"production": false,
|
||||
"merchantId": "SECRET",
|
||||
"publicKey": "SECRET",
|
||||
"privateKey": "SECRET"
|
||||
"braintreeWebhookKey": "SECRET",
|
||||
"payPal": {
|
||||
"production": false,
|
||||
"clientId": "SECRET",
|
||||
"clientSecret": "SECRET",
|
||||
"webhookId": "SECRET",
|
||||
"webhookKey": "SECRET"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,7 +38,7 @@
|
||||
<PackageReference Include="AspNetCoreRateLimit" Version="2.1.0" />
|
||||
<PackageReference Include="Braintree" Version="4.6.0" />
|
||||
<PackageReference Include="Sendgrid" Version="9.10.0" />
|
||||
<PackageReference Include="Stripe.net" Version="19.10.0" />
|
||||
<PackageReference Include="Stripe.net" Version="22.8.0" />
|
||||
<PackageReference Include="U2F.Core" Version="1.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.2.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
|
@ -13,6 +13,8 @@ namespace Bit.Core.Enums
|
||||
[Display(Name = "Google Play Store")]
|
||||
PlayStore = 3,
|
||||
[Display(Name = "Coinbase")]
|
||||
Coinbase = 4
|
||||
Coinbase = 4,
|
||||
[Display(Name = "PayPal")]
|
||||
PayPal = 5,
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@
|
||||
United = 11,
|
||||
Yahoo = 12,
|
||||
Zonelabs = 13,
|
||||
Paypal = 14,
|
||||
PayPal = 14,
|
||||
Avon = 15,
|
||||
Diapers = 16,
|
||||
Contacts = 17,
|
||||
|
11
src/Core/Enums/TransactionType.cs
Normal file
11
src/Core/Enums/TransactionType.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace Bit.Core.Enums
|
||||
{
|
||||
public enum TransactionType : byte
|
||||
{
|
||||
Charge = 0,
|
||||
Credit = 1,
|
||||
PromotionalCredit = 2,
|
||||
ReferralCredit = 3,
|
||||
Refund = 4,
|
||||
}
|
||||
}
|
@ -12,10 +12,12 @@ namespace Bit.Core.Models.Api
|
||||
public BillingResponseModel(User user, BillingInfo billing, UserLicense license)
|
||||
: base("billing")
|
||||
{
|
||||
CreditAmount = billing.CreditAmount;
|
||||
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
|
||||
Subscription = billing.Subscription != null ? new BillingSubscription(billing.Subscription) : null;
|
||||
Charges = billing.Charges.Select(c => new BillingCharge(c));
|
||||
UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoice(billing.UpcomingInvoice) : null;
|
||||
Transactions = billing.Transactions?.Select(t => new BillingTransaction(t));
|
||||
Invoices = billing.Invoices?.Select(i => new BillingInvoice(i));
|
||||
UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoiceInfo(billing.UpcomingInvoice) : null;
|
||||
StorageName = user.Storage.HasValue ? Utilities.CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
|
||||
StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB
|
||||
MaxStorageGb = user.MaxStorageGb;
|
||||
@ -37,13 +39,15 @@ namespace Bit.Core.Models.Api
|
||||
}
|
||||
}
|
||||
|
||||
public decimal CreditAmount { get; set; }
|
||||
public string StorageName { get; set; }
|
||||
public double? StorageGb { get; set; }
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public BillingSource PaymentSource { get; set; }
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public BillingInvoice UpcomingInvoice { get; set; }
|
||||
public IEnumerable<BillingCharge> Charges { get; set; }
|
||||
public BillingInvoiceInfo UpcomingInvoice { get; set; }
|
||||
public IEnumerable<BillingInvoice> Invoices { get; set; }
|
||||
public IEnumerable<BillingTransaction> Transactions { get; set; }
|
||||
public UserLicense License { get; set; }
|
||||
public DateTime? Expiration { get; set; }
|
||||
}
|
||||
@ -109,9 +113,9 @@ namespace Bit.Core.Models.Api
|
||||
}
|
||||
}
|
||||
|
||||
public class BillingInvoice
|
||||
public class BillingInvoiceInfo
|
||||
{
|
||||
public BillingInvoice(BillingInfo.BillingInvoice inv)
|
||||
public BillingInvoiceInfo(BillingInfo.BillingInvoice inv)
|
||||
{
|
||||
Amount = inv.Amount;
|
||||
Date = inv.Date;
|
||||
@ -121,28 +125,44 @@ namespace Bit.Core.Models.Api
|
||||
public DateTime? Date { get; set; }
|
||||
}
|
||||
|
||||
public class BillingCharge
|
||||
public class BillingInvoice : BillingInvoiceInfo
|
||||
{
|
||||
public BillingCharge(BillingInfo.BillingCharge charge)
|
||||
public BillingInvoice(BillingInfo.BillingInvoice2 inv)
|
||||
: base(inv)
|
||||
{
|
||||
Amount = charge.Amount;
|
||||
RefundedAmount = charge.RefundedAmount;
|
||||
PaymentSource = charge.PaymentSource != null ? new BillingSource(charge.PaymentSource) : null;
|
||||
CreatedDate = charge.CreatedDate;
|
||||
FailureMessage = charge.FailureMessage;
|
||||
Refunded = charge.Refunded;
|
||||
Status = charge.Status;
|
||||
InvoiceId = charge.InvoiceId;
|
||||
Url = inv.Url;
|
||||
PdfUrl = inv.PdfUrl;
|
||||
Number = inv.Number;
|
||||
Paid = inv.Paid;
|
||||
}
|
||||
|
||||
public string Url { get; set; }
|
||||
public string PdfUrl { get; set; }
|
||||
public string Number { get; set; }
|
||||
public bool Paid { get; set; }
|
||||
}
|
||||
|
||||
public class BillingTransaction
|
||||
{
|
||||
public BillingTransaction(BillingInfo.BillingTransaction transaction)
|
||||
{
|
||||
CreatedDate = transaction.CreatedDate;
|
||||
Amount = transaction.Amount;
|
||||
Refunded = transaction.Refunded;
|
||||
RefundedAmount = transaction.RefundedAmount;
|
||||
PartiallyRefunded = transaction.PartiallyRefunded;
|
||||
Type = transaction.Type;
|
||||
PaymentMethodType = transaction.PaymentMethodType;
|
||||
Details = transaction.Details;
|
||||
}
|
||||
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public BillingSource PaymentSource { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string FailureMessage { get; set; }
|
||||
public bool Refunded { get; set; }
|
||||
public bool PartiallyRefunded => !Refunded && RefundedAmount > 0;
|
||||
public decimal RefundedAmount { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
public bool? Refunded { get; set; }
|
||||
public bool? PartiallyRefunded { get; set; }
|
||||
public decimal? RefundedAmount { get; set; }
|
||||
public TransactionType Type { get; set; }
|
||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||
public string Details { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -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<BillingCharge> Charges { get; set; }
|
||||
public BillingInvoiceInfo UpcomingInvoice { get; set; }
|
||||
public IEnumerable<BillingInvoice> Invoices { get; set; }
|
||||
public IEnumerable<BillingTransaction> Transactions { get; set; }
|
||||
public DateTime? Expiration { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Braintree;
|
||||
using Bit.Core.Models.Table;
|
||||
using Stripe;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@ -9,47 +9,47 @@ namespace Bit.Core.Models.Business
|
||||
{
|
||||
public class BillingInfo
|
||||
{
|
||||
public decimal CreditAmount { get; set; }
|
||||
public BillingSource PaymentSource { get; set; }
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public BillingInvoice UpcomingInvoice { get; set; }
|
||||
public IEnumerable<BillingCharge> Charges { get; set; } = new List<BillingCharge>();
|
||||
public IEnumerable<BillingInvoice2> Invoices { get; set; } = new List<BillingInvoice2>();
|
||||
public IEnumerable<BillingTransaction> Transactions { get; set; } = new List<BillingTransaction>();
|
||||
|
||||
public class BillingSource
|
||||
{
|
||||
public BillingSource(Source source)
|
||||
public BillingSource(IPaymentSource source)
|
||||
{
|
||||
switch(source.Type)
|
||||
if(source is BankAccount bankAccount)
|
||||
{
|
||||
case SourceType.Card:
|
||||
Type = PaymentMethodType.Card;
|
||||
Description = $"{source.Card.Brand}, *{source.Card.Last4}, " +
|
||||
string.Format("{0}/{1}",
|
||||
string.Concat(source.Card.ExpirationMonth < 10 ?
|
||||
"0" : string.Empty, source.Card.ExpirationMonth),
|
||||
source.Card.ExpirationYear);
|
||||
CardBrand = source.Card.Brand;
|
||||
break;
|
||||
case SourceType.BankAccount:
|
||||
Type = PaymentMethodType.BankAccount;
|
||||
Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4} - " +
|
||||
(source.BankAccount.Status == "verified" ? "verified" :
|
||||
source.BankAccount.Status == "errored" ? "invalid" :
|
||||
source.BankAccount.Status == "verification_failed" ? "verification failed" : "unverified");
|
||||
NeedsVerification = source.BankAccount.Status == "new" || source.BankAccount.Status == "validated";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
Type = PaymentMethodType.BankAccount;
|
||||
Description = $"{bankAccount.BankName}, *{bankAccount.Last4} - " +
|
||||
(bankAccount.Status == "verified" ? "verified" :
|
||||
bankAccount.Status == "errored" ? "invalid" :
|
||||
bankAccount.Status == "verification_failed" ? "verification failed" : "unverified");
|
||||
NeedsVerification = bankAccount.Status == "new" || bankAccount.Status == "validated";
|
||||
}
|
||||
else if(source is Card card)
|
||||
{
|
||||
Type = PaymentMethodType.Card;
|
||||
Description = $"{card.Brand}, *{card.Last4}, " +
|
||||
string.Format("{0}/{1}",
|
||||
string.Concat(card.ExpMonth < 10 ?
|
||||
"0" : string.Empty, card.ExpMonth),
|
||||
card.ExpYear);
|
||||
CardBrand = card.Brand;
|
||||
}
|
||||
}
|
||||
|
||||
public BillingSource(PaymentMethod method)
|
||||
public BillingSource(Braintree.PaymentMethod method)
|
||||
{
|
||||
if(method is PayPalAccount paypal)
|
||||
if(method is Braintree.PayPalAccount paypal)
|
||||
{
|
||||
Type = PaymentMethodType.PayPal;
|
||||
Description = paypal.Email;
|
||||
}
|
||||
else if(method is CreditCard card)
|
||||
else if(method is Braintree.CreditCard card)
|
||||
{
|
||||
Type = PaymentMethodType.Card;
|
||||
Description = $"{card.CardType.ToString()}, *{card.LastFour}, " +
|
||||
@ -59,7 +59,7 @@ namespace Bit.Core.Models.Business
|
||||
card.ExpirationYear);
|
||||
CardBrand = card.CardType.ToString();
|
||||
}
|
||||
else if(method is UsBankAccount bank)
|
||||
else if(method is Braintree.UsBankAccount bank)
|
||||
{
|
||||
Type = PaymentMethodType.BankAccount;
|
||||
Description = $"{bank.BankName}, *{bank.Last4}";
|
||||
@ -70,13 +70,13 @@ namespace Bit.Core.Models.Business
|
||||
}
|
||||
}
|
||||
|
||||
public BillingSource(UsBankAccountDetails bank)
|
||||
public BillingSource(Braintree.UsBankAccountDetails bank)
|
||||
{
|
||||
Type = PaymentMethodType.BankAccount;
|
||||
Description = $"{bank.BankName}, *{bank.Last4}";
|
||||
}
|
||||
|
||||
public BillingSource(PayPalDetails paypal)
|
||||
public BillingSource(Braintree.PayPalDetails paypal)
|
||||
{
|
||||
Type = PaymentMethodType.PayPal;
|
||||
Description = paypal.PayerEmail;
|
||||
@ -90,7 +90,7 @@ namespace Bit.Core.Models.Business
|
||||
|
||||
public class BillingSubscription
|
||||
{
|
||||
public BillingSubscription(StripeSubscription sub)
|
||||
public BillingSubscription(Subscription sub)
|
||||
{
|
||||
Status = sub.Status;
|
||||
TrialStartDate = sub.TrialStart;
|
||||
@ -106,14 +106,14 @@ namespace Bit.Core.Models.Business
|
||||
}
|
||||
}
|
||||
|
||||
public BillingSubscription(Subscription sub, Plan plan)
|
||||
public BillingSubscription(Braintree.Subscription sub, Braintree.Plan plan)
|
||||
{
|
||||
Status = sub.Status.ToString();
|
||||
|
||||
if(sub.HasTrialPeriod.GetValueOrDefault() && sub.CreatedAt.HasValue && sub.TrialDuration.HasValue)
|
||||
{
|
||||
TrialStartDate = sub.CreatedAt.Value;
|
||||
if(sub.TrialDurationUnit == SubscriptionDurationUnit.DAY)
|
||||
if(sub.TrialDurationUnit == Braintree.SubscriptionDurationUnit.DAY)
|
||||
{
|
||||
TrialEndDate = TrialStartDate.Value.AddDays(sub.TrialDuration.Value);
|
||||
}
|
||||
@ -127,7 +127,7 @@ namespace Bit.Core.Models.Business
|
||||
PeriodEndDate = sub.BillingPeriodEndDate;
|
||||
|
||||
CancelAtEndDate = !sub.NeverExpires.GetValueOrDefault();
|
||||
Cancelled = sub.Status == SubscriptionStatus.CANCELED;
|
||||
Cancelled = sub.Status == Braintree.SubscriptionStatus.CANCELED;
|
||||
if(Cancelled)
|
||||
{
|
||||
CancelledDate = sub.UpdatedAt.Value;
|
||||
@ -159,7 +159,7 @@ namespace Bit.Core.Models.Business
|
||||
|
||||
public class BillingSubscriptionItem
|
||||
{
|
||||
public BillingSubscriptionItem(StripeSubscriptionItem item)
|
||||
public BillingSubscriptionItem(SubscriptionItem item)
|
||||
{
|
||||
if(item.Plan != null)
|
||||
{
|
||||
@ -168,10 +168,10 @@ namespace Bit.Core.Models.Business
|
||||
Interval = item.Plan.Interval;
|
||||
}
|
||||
|
||||
Quantity = item.Quantity;
|
||||
Quantity = (int)item.Quantity;
|
||||
}
|
||||
|
||||
public BillingSubscriptionItem(Plan plan)
|
||||
public BillingSubscriptionItem(Braintree.Plan plan)
|
||||
{
|
||||
Name = plan.Name;
|
||||
Amount = plan.Price.GetValueOrDefault();
|
||||
@ -179,7 +179,7 @@ namespace Bit.Core.Models.Business
|
||||
Quantity = 1;
|
||||
}
|
||||
|
||||
public BillingSubscriptionItem(Plan plan, AddOn addon)
|
||||
public BillingSubscriptionItem(Braintree.Plan plan, Braintree.AddOn addon)
|
||||
{
|
||||
Name = addon.Name;
|
||||
Amount = addon.Amount.GetValueOrDefault();
|
||||
@ -196,13 +196,15 @@ namespace Bit.Core.Models.Business
|
||||
|
||||
public class BillingInvoice
|
||||
{
|
||||
public BillingInvoice(StripeInvoice inv)
|
||||
public BillingInvoice() { }
|
||||
|
||||
public BillingInvoice(Invoice inv)
|
||||
{
|
||||
Amount = inv.AmountDue / 100M;
|
||||
Date = inv.Date.Value;
|
||||
}
|
||||
|
||||
public BillingInvoice(Subscription sub)
|
||||
public BillingInvoice(Braintree.Subscription sub)
|
||||
{
|
||||
Amount = sub.NextBillAmount.GetValueOrDefault() + sub.Balance.GetValueOrDefault();
|
||||
if(Amount < 0)
|
||||
@ -218,7 +220,7 @@ namespace Bit.Core.Models.Business
|
||||
|
||||
public class BillingCharge
|
||||
{
|
||||
public BillingCharge(StripeCharge charge)
|
||||
public BillingCharge(Charge charge)
|
||||
{
|
||||
Amount = charge.Amount / 100M;
|
||||
RefundedAmount = charge.AmountRefunded / 100M;
|
||||
@ -230,7 +232,7 @@ namespace Bit.Core.Models.Business
|
||||
InvoiceId = charge.InvoiceId;
|
||||
}
|
||||
|
||||
public BillingCharge(Transaction transaction)
|
||||
public BillingCharge(Braintree.Transaction transaction)
|
||||
{
|
||||
Amount = transaction.Amount.GetValueOrDefault();
|
||||
RefundedAmount = 0; // TODO?
|
||||
@ -239,7 +241,8 @@ namespace Bit.Core.Models.Business
|
||||
{
|
||||
PaymentSource = new BillingSource(transaction.PayPalDetails);
|
||||
}
|
||||
else if(transaction.CreditCard != null && transaction.CreditCard.CardType != CreditCardCardType.UNRECOGNIZED)
|
||||
else if(transaction.CreditCard != null &&
|
||||
transaction.CreditCard.CardType != Braintree.CreditCardCardType.UNRECOGNIZED)
|
||||
{
|
||||
PaymentSource = new BillingSource(transaction.CreditCard);
|
||||
}
|
||||
@ -265,5 +268,63 @@ namespace Bit.Core.Models.Business
|
||||
public decimal RefundedAmount { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
}
|
||||
|
||||
public class BillingTransaction
|
||||
{
|
||||
public BillingTransaction(Transaction transaction)
|
||||
{
|
||||
CreatedDate = transaction.CreationDate;
|
||||
Refunded = transaction.Refunded;
|
||||
Type = transaction.Type;
|
||||
PaymentMethodType = transaction.PaymentMethodType;
|
||||
Details = transaction.Details;
|
||||
|
||||
if(transaction.RefundedAmount.HasValue)
|
||||
{
|
||||
RefundedAmount = Math.Abs(transaction.RefundedAmount.Value);
|
||||
}
|
||||
switch(transaction.Type)
|
||||
{
|
||||
case TransactionType.Charge:
|
||||
case TransactionType.Credit:
|
||||
case TransactionType.PromotionalCredit:
|
||||
case TransactionType.ReferralCredit:
|
||||
Amount = -1 * Math.Abs(transaction.Amount);
|
||||
break;
|
||||
case TransactionType.Refund:
|
||||
Amount = Math.Abs(transaction.Amount);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public bool? Refunded { get; set; }
|
||||
public bool? PartiallyRefunded => !Refunded.GetValueOrDefault() && RefundedAmount.GetValueOrDefault() > 0;
|
||||
public decimal? RefundedAmount { get; set; }
|
||||
public TransactionType Type { get; set; }
|
||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||
public string Details { get; set; }
|
||||
}
|
||||
|
||||
public class BillingInvoice2 : BillingInvoice
|
||||
{
|
||||
public BillingInvoice2(Invoice inv)
|
||||
{
|
||||
Url = inv.HostedInvoiceUrl;
|
||||
PdfUrl = inv.InvoicePdf;
|
||||
Number = inv.Number;
|
||||
Paid = inv.Paid;
|
||||
Amount = inv.Total / 100M;
|
||||
Date = inv.Date.Value;
|
||||
}
|
||||
|
||||
public string Url { get; set; }
|
||||
public string PdfUrl { get; set; }
|
||||
public string Number { get; set; }
|
||||
public bool Paid { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
using Bit.Core.Enums;
|
||||
using System;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Models.Table
|
||||
{
|
||||
public interface ISubscriber
|
||||
{
|
||||
Guid Id { get; }
|
||||
GatewayType? Gateway { get; set; }
|
||||
string GatewayCustomerId { get; set; }
|
||||
string GatewaySubscriptionId { get; set; }
|
||||
string BillingEmailAddress();
|
||||
string BillingName();
|
||||
IPaymentService GetPaymentService(GlobalSettings globalSettings);
|
||||
string BraintreeCustomerIdPrefix();
|
||||
string BraintreeIdField();
|
||||
string GatewayIdField();
|
||||
}
|
||||
}
|
||||
|
@ -63,6 +63,21 @@ namespace Bit.Core.Models.Table
|
||||
return BusinessName;
|
||||
}
|
||||
|
||||
public string BraintreeCustomerIdPrefix()
|
||||
{
|
||||
return "o";
|
||||
}
|
||||
|
||||
public string BraintreeIdField()
|
||||
{
|
||||
return "organization_id";
|
||||
}
|
||||
|
||||
public string GatewayIdField()
|
||||
{
|
||||
return "organizationId";
|
||||
}
|
||||
|
||||
public long StorageBytesRemaining()
|
||||
{
|
||||
if(!MaxStorageGb.HasValue)
|
||||
@ -84,29 +99,6 @@ namespace Bit.Core.Models.Table
|
||||
return maxStorageBytes - Storage.Value;
|
||||
}
|
||||
|
||||
public IPaymentService GetPaymentService(GlobalSettings globalSettings)
|
||||
{
|
||||
if(Gateway == null)
|
||||
{
|
||||
throw new BadRequestException("No gateway.");
|
||||
}
|
||||
|
||||
IPaymentService paymentService = null;
|
||||
switch(Gateway)
|
||||
{
|
||||
case GatewayType.Stripe:
|
||||
paymentService = new StripePaymentService();
|
||||
break;
|
||||
case GatewayType.Braintree:
|
||||
paymentService = new BraintreePaymentService(globalSettings);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported gateway.");
|
||||
}
|
||||
|
||||
return paymentService;
|
||||
}
|
||||
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(TwoFactorProviders))
|
||||
|
27
src/Core/Models/Table/Transaction.cs
Normal file
27
src/Core/Models/Table/Transaction.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Models.Table
|
||||
{
|
||||
public class Transaction : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public TransactionType Type { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public bool? Refunded { get; set; }
|
||||
public decimal? RefundedAmount { get; set; }
|
||||
public string Details { get; set; }
|
||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||
public GatewayType? Gateway { get; set; }
|
||||
public string GatewayId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
||||
}
|
@ -58,6 +58,21 @@ namespace Bit.Core.Models.Table
|
||||
return Name;
|
||||
}
|
||||
|
||||
public string BraintreeCustomerIdPrefix()
|
||||
{
|
||||
return "u";
|
||||
}
|
||||
|
||||
public string BraintreeIdField()
|
||||
{
|
||||
return "user_id";
|
||||
}
|
||||
|
||||
public string GatewayIdField()
|
||||
{
|
||||
return "userId";
|
||||
}
|
||||
|
||||
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(TwoFactorProviders))
|
||||
@ -133,29 +148,6 @@ namespace Bit.Core.Models.Table
|
||||
return maxStorageBytes - Storage.Value;
|
||||
}
|
||||
|
||||
public IPaymentService GetPaymentService(GlobalSettings globalSettings)
|
||||
{
|
||||
if(Gateway == null)
|
||||
{
|
||||
throw new BadRequestException("No gateway.");
|
||||
}
|
||||
|
||||
IPaymentService paymentService = null;
|
||||
switch(Gateway)
|
||||
{
|
||||
case GatewayType.Stripe:
|
||||
paymentService = new StripePaymentService();
|
||||
break;
|
||||
case GatewayType.Braintree:
|
||||
paymentService = new BraintreePaymentService(globalSettings);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported gateway.");
|
||||
}
|
||||
|
||||
return paymentService;
|
||||
}
|
||||
|
||||
public IdentityUser ToIdentityUser(bool twoFactorEnabled)
|
||||
{
|
||||
return new IdentityUser
|
||||
|
@ -5,6 +5,7 @@ namespace Bit.Core.Repositories
|
||||
public interface IMaintenanceRepository
|
||||
{
|
||||
Task UpdateStatisticsAsync();
|
||||
Task DisableCipherAutoStatsAsync();
|
||||
Task RebuildIndexesAsync();
|
||||
Task DeleteExpiredGrantsAsync();
|
||||
}
|
||||
|
15
src/Core/Repositories/ITransactionRepository.cs
Normal file
15
src/Core/Repositories/ITransactionRepository.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using Bit.Core.Models.Table;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Repositories
|
||||
{
|
||||
public interface ITransactionRepository : IRepository<Transaction, Guid>
|
||||
{
|
||||
Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId);
|
||||
Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId);
|
||||
Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId);
|
||||
}
|
||||
}
|
@ -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))
|
||||
|
62
src/Core/Repositories/SqlServer/TransactionRepository.cs
Normal file
62
src/Core/Repositories/SqlServer/TransactionRepository.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Bit.Core.Models.Table;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.Linq;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Repositories.SqlServer
|
||||
{
|
||||
public class TransactionRepository : Repository<Transaction, Guid>, ITransactionRepository
|
||||
{
|
||||
public TransactionRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public TransactionRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<Transaction>> GetManyByUserIdAsync(Guid userId)
|
||||
{
|
||||
using(var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<Transaction>(
|
||||
$"[{Schema}].[Transaction_ReadByUserId]",
|
||||
new { UserId = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<Transaction>> GetManyByOrganizationIdAsync(Guid organizationId)
|
||||
{
|
||||
using(var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<Transaction>(
|
||||
$"[{Schema}].[Transaction_ReadByOrganizationId]",
|
||||
new { OrganizationId = organizationId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Transaction> GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId)
|
||||
{
|
||||
using(var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<Transaction>(
|
||||
$"[{Schema}].[Transaction_ReadByGatewayId]",
|
||||
new { Gateway = gatewayType, GatewayId = gatewayId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.SingleOrDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,22 @@
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
public interface IPaymentService
|
||||
{
|
||||
Task CancelAndRecoverChargesAsync(ISubscriber subscriber);
|
||||
Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb);
|
||||
Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken,
|
||||
Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, bool premiumAccessAddon);
|
||||
Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
|
||||
short additionalStorageGb);
|
||||
Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);
|
||||
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false);
|
||||
Task ReinstateSubscriptionAsync(ISubscriber subscriber);
|
||||
Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken);
|
||||
Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
|
||||
string paymentToken);
|
||||
Task<BillingInfo.BillingInvoice> GetUpcomingInvoiceAsync(ISubscriber subscriber);
|
||||
Task<BillingInfo> GetBillingAsync(ISubscriber subscriber);
|
||||
}
|
||||
|
@ -1,368 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Models.Table;
|
||||
using Braintree;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
{
|
||||
public class BraintreePaymentService : IPaymentService
|
||||
{
|
||||
private const string PremiumPlanId = "premium-annually";
|
||||
private const string StoragePlanId = "storage-gb-annually";
|
||||
private readonly BraintreeGateway _gateway;
|
||||
|
||||
public BraintreePaymentService(
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_gateway = new BraintreeGateway
|
||||
{
|
||||
Environment = globalSettings.Braintree.Production ?
|
||||
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
|
||||
MerchantId = globalSettings.Braintree.MerchantId,
|
||||
PublicKey = globalSettings.Braintree.PublicKey,
|
||||
PrivateKey = globalSettings.Braintree.PrivateKey
|
||||
};
|
||||
}
|
||||
|
||||
public async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId)
|
||||
{
|
||||
var sub = await _gateway.Subscription.FindAsync(storableSubscriber.GatewaySubscriptionId);
|
||||
if(sub == null)
|
||||
{
|
||||
throw new GatewayException("Subscription was not found.");
|
||||
}
|
||||
|
||||
var req = new SubscriptionRequest
|
||||
{
|
||||
AddOns = new AddOnsRequest(),
|
||||
Options = new SubscriptionOptionsRequest
|
||||
{
|
||||
ProrateCharges = true,
|
||||
RevertSubscriptionOnProrationFailure = true
|
||||
}
|
||||
};
|
||||
|
||||
var storageItem = sub.AddOns?.FirstOrDefault(a => a.Id == storagePlanId);
|
||||
if(additionalStorage > 0 && storageItem == null)
|
||||
{
|
||||
req.AddOns.Add = new AddAddOnRequest[]
|
||||
{
|
||||
new AddAddOnRequest
|
||||
{
|
||||
InheritedFromId = storagePlanId,
|
||||
Quantity = additionalStorage,
|
||||
NeverExpires = true
|
||||
}
|
||||
};
|
||||
}
|
||||
else if(additionalStorage > 0 && storageItem != null)
|
||||
{
|
||||
req.AddOns.Update = new UpdateAddOnRequest[]
|
||||
{
|
||||
new UpdateAddOnRequest
|
||||
{
|
||||
ExistingId = storageItem.Id,
|
||||
Quantity = additionalStorage,
|
||||
NeverExpires = true
|
||||
}
|
||||
};
|
||||
}
|
||||
else if(additionalStorage == 0 && storageItem != null)
|
||||
{
|
||||
req.AddOns.Remove = new string[] { storageItem.Id };
|
||||
}
|
||||
|
||||
var result = await _gateway.Subscription.UpdateAsync(sub.Id, req);
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Failed to adjust storage.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber)
|
||||
{
|
||||
if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
await _gateway.Subscription.CancelAsync(subscriber.GatewaySubscriptionId);
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var transactionRequest = new TransactionSearchRequest().CustomerId.Is(subscriber.GatewayCustomerId);
|
||||
var transactions = _gateway.Transaction.Search(transactionRequest);
|
||||
|
||||
if((transactions?.MaximumCount ?? 0) > 0)
|
||||
{
|
||||
foreach(var transaction in transactions.Cast<Transaction>().Where(c => c.RefundedTransactionId == null))
|
||||
{
|
||||
await _gateway.Transaction.RefundAsync(transaction.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await _gateway.Customer.DeleteAsync(subscriber.GatewayCustomerId);
|
||||
}
|
||||
|
||||
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
|
||||
{
|
||||
if(subscriber == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(subscriber));
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
throw new GatewayException("No subscription.");
|
||||
}
|
||||
|
||||
var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId);
|
||||
if(sub == null)
|
||||
{
|
||||
throw new GatewayException("Subscription was not found.");
|
||||
}
|
||||
|
||||
if(sub.Status == SubscriptionStatus.CANCELED || sub.Status == SubscriptionStatus.EXPIRED ||
|
||||
!sub.NeverExpires.GetValueOrDefault())
|
||||
{
|
||||
throw new GatewayException("Subscription is already canceled.");
|
||||
}
|
||||
|
||||
if(endOfPeriod)
|
||||
{
|
||||
var req = new SubscriptionRequest
|
||||
{
|
||||
NeverExpires = false,
|
||||
NumberOfBillingCycles = sub.CurrentBillingCycle
|
||||
};
|
||||
|
||||
var result = await _gateway.Subscription.UpdateAsync(subscriber.GatewaySubscriptionId, req);
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Unable to cancel subscription.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await _gateway.Subscription.CancelAsync(subscriber.GatewaySubscriptionId);
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Unable to cancel subscription.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BillingInfo.BillingInvoice> 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<BillingInfo> 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<Transaction>().OrderByDescending(t => t.CreatedAt)
|
||||
.Select(t => new BillingInfo.BillingCharge(t));
|
||||
}
|
||||
}
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId);
|
||||
if(sub != null)
|
||||
{
|
||||
var plans = await _gateway.Plan.AllAsync();
|
||||
var plan = plans?.FirstOrDefault(p => p.Id == sub.PlanId);
|
||||
billingInfo.Subscription = new BillingInfo.BillingSubscription(sub, plan);
|
||||
}
|
||||
|
||||
if(!billingInfo.Subscription.Cancelled && !billingInfo.Subscription.CancelAtEndDate &&
|
||||
sub.NextBillingDate.HasValue)
|
||||
{
|
||||
billingInfo.UpcomingInvoice = new BillingInfo.BillingInvoice(sub);
|
||||
}
|
||||
}
|
||||
|
||||
return billingInfo;
|
||||
}
|
||||
|
||||
public async Task PurchasePremiumAsync(User user, string paymentToken, short additionalStorageGb)
|
||||
{
|
||||
var customerResult = await _gateway.Customer.CreateAsync(new CustomerRequest
|
||||
{
|
||||
PaymentMethodNonce = paymentToken,
|
||||
Email = user.Email
|
||||
});
|
||||
|
||||
if(!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
|
||||
{
|
||||
throw new GatewayException("Failed to create customer.");
|
||||
}
|
||||
|
||||
var subId = "u" + user.Id.ToString("N").ToLower() +
|
||||
Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
|
||||
|
||||
var subRequest = new SubscriptionRequest
|
||||
{
|
||||
Id = subId,
|
||||
PaymentMethodToken = customerResult.Target.PaymentMethods[0].Token,
|
||||
PlanId = PremiumPlanId
|
||||
};
|
||||
|
||||
if(additionalStorageGb > 0)
|
||||
{
|
||||
subRequest.AddOns = new AddOnsRequest();
|
||||
subRequest.AddOns.Add = new AddAddOnRequest[]
|
||||
{
|
||||
new AddAddOnRequest
|
||||
{
|
||||
InheritedFromId = StoragePlanId,
|
||||
Quantity = additionalStorageGb
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var subResult = await _gateway.Subscription.CreateAsync(subRequest);
|
||||
|
||||
if(!subResult.IsSuccess())
|
||||
{
|
||||
await _gateway.Customer.DeleteAsync(customerResult.Target.Id);
|
||||
throw new GatewayException("Failed to create subscription.");
|
||||
}
|
||||
|
||||
user.Gateway = Enums.GatewayType.Braintree;
|
||||
user.GatewayCustomerId = customerResult.Target.Id;
|
||||
user.GatewaySubscriptionId = subResult.Target.Id;
|
||||
user.Premium = true;
|
||||
user.PremiumExpirationDate = subResult.Target.BillingPeriodEndDate;
|
||||
}
|
||||
|
||||
public async Task ReinstateSubscriptionAsync(ISubscriber subscriber)
|
||||
{
|
||||
if(subscriber == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(subscriber));
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
throw new GatewayException("No subscription.");
|
||||
}
|
||||
|
||||
var sub = await _gateway.Subscription.FindAsync(subscriber.GatewaySubscriptionId);
|
||||
if(sub == null)
|
||||
{
|
||||
throw new GatewayException("Subscription was not found.");
|
||||
}
|
||||
|
||||
if(sub.Status != SubscriptionStatus.ACTIVE || sub.NeverExpires.GetValueOrDefault())
|
||||
{
|
||||
throw new GatewayException("Subscription is not marked for cancellation.");
|
||||
}
|
||||
|
||||
var req = new SubscriptionRequest
|
||||
{
|
||||
NeverExpires = true,
|
||||
NumberOfBillingCycles = null
|
||||
};
|
||||
|
||||
var result = await _gateway.Subscription.UpdateAsync(subscriber.GatewaySubscriptionId, req);
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Unable to reinstate subscription.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken)
|
||||
{
|
||||
if(subscriber == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(subscriber));
|
||||
}
|
||||
|
||||
if(subscriber.Gateway.HasValue && subscriber.Gateway.Value != Enums.GatewayType.Braintree)
|
||||
{
|
||||
throw new GatewayException("Switching from one payment type to another is not supported. " +
|
||||
"Contact us for assistance.");
|
||||
}
|
||||
|
||||
var updatedSubscriber = false;
|
||||
Customer customer = null;
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
{
|
||||
customer = await _gateway.Customer.FindAsync(subscriber.GatewayCustomerId);
|
||||
}
|
||||
|
||||
if(customer == null)
|
||||
{
|
||||
var result = await _gateway.Customer.CreateAsync(new CustomerRequest
|
||||
{
|
||||
Email = subscriber.BillingEmailAddress(),
|
||||
PaymentMethodNonce = paymentToken
|
||||
});
|
||||
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Cannot create customer.");
|
||||
}
|
||||
|
||||
customer = result.Target;
|
||||
subscriber.Gateway = Enums.GatewayType.Braintree;
|
||||
subscriber.GatewayCustomerId = customer.Id;
|
||||
updatedSubscriber = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if(customer.DefaultPaymentMethod != null)
|
||||
{
|
||||
var deleteResult = await _gateway.PaymentMethod.DeleteAsync(customer.DefaultPaymentMethod.Token);
|
||||
if(!deleteResult.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Cannot delete old payment method.");
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _gateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
|
||||
{
|
||||
PaymentMethodNonce = paymentToken,
|
||||
CustomerId = customer.Id
|
||||
});
|
||||
if(!result.IsSuccess())
|
||||
{
|
||||
throw new GatewayException("Cannot add new payment method.");
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSubscriber;
|
||||
}
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@ namespace Bit.Core.Services
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IInstallationRepository _installationRepository;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly StripePaymentService _stripePaymentService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public OrganizationService(
|
||||
@ -50,6 +50,7 @@ namespace Bit.Core.Services
|
||||
IEventService eventService,
|
||||
IInstallationRepository installationRepository,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IPaymentService paymentService,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -66,7 +67,7 @@ namespace Bit.Core.Services
|
||||
_eventService = eventService;
|
||||
_installationRepository = installationRepository;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_stripePaymentService = new StripePaymentService();
|
||||
_paymentService = paymentService;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
@ -78,7 +79,22 @@ namespace Bit.Core.Services
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var updated = await _stripePaymentService.UpdatePaymentMethodAsync(organization, paymentToken);
|
||||
PaymentMethodType paymentMethodType;
|
||||
if(paymentToken.StartsWith("btok_"))
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.BankAccount;
|
||||
}
|
||||
else if(paymentToken.StartsWith("tok_"))
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.Card;
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
var updated = await _paymentService.UpdatePaymentMethodAsync(organization,
|
||||
paymentMethodType, paymentToken);
|
||||
if(updated)
|
||||
{
|
||||
await ReplaceAndUpdateCache(organization);
|
||||
@ -100,7 +116,7 @@ namespace Bit.Core.Services
|
||||
eop = false;
|
||||
}
|
||||
|
||||
await _stripePaymentService.CancelSubscriptionAsync(organization, eop);
|
||||
await _paymentService.CancelSubscriptionAsync(organization, eop);
|
||||
}
|
||||
|
||||
public async Task ReinstateSubscriptionAsync(Guid organizationId)
|
||||
@ -111,7 +127,7 @@ namespace Bit.Core.Services
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _stripePaymentService.ReinstateSubscriptionAsync(organization);
|
||||
await _paymentService.ReinstateSubscriptionAsync(organization);
|
||||
}
|
||||
|
||||
public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats)
|
||||
@ -186,15 +202,15 @@ namespace Bit.Core.Services
|
||||
|
||||
// TODO: Groups?
|
||||
|
||||
var subscriptionService = new StripeSubscriptionService();
|
||||
var subscriptionService = new Stripe.SubscriptionService();
|
||||
if(string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
// They must have been on a free plan. Create new sub.
|
||||
var subCreateOptions = new StripeSubscriptionCreateOptions
|
||||
var subCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
CustomerId = organization.GatewayCustomerId,
|
||||
TrialPeriodDays = newPlan.TrialPeriodDays,
|
||||
Items = new List<StripeSubscriptionItemOption>(),
|
||||
Items = new List<SubscriptionItemOption>(),
|
||||
Metadata = new Dictionary<string, string> {
|
||||
{ "organizationId", organization.Id.ToString() }
|
||||
}
|
||||
@ -202,7 +218,7 @@ namespace Bit.Core.Services
|
||||
|
||||
if(newPlan.StripePlanId != null)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
subCreateOptions.Items.Add(new SubscriptionItemOption
|
||||
{
|
||||
PlanId = newPlan.StripePlanId,
|
||||
Quantity = 1
|
||||
@ -211,7 +227,7 @@ namespace Bit.Core.Services
|
||||
|
||||
if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
subCreateOptions.Items.Add(new SubscriptionItemOption
|
||||
{
|
||||
PlanId = newPlan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats
|
||||
@ -223,14 +239,14 @@ namespace Bit.Core.Services
|
||||
else
|
||||
{
|
||||
// Update existing sub.
|
||||
var subUpdateOptions = new StripeSubscriptionUpdateOptions
|
||||
var subUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = new List<StripeSubscriptionItemUpdateOption>()
|
||||
Items = new List<SubscriptionItemUpdateOption>()
|
||||
};
|
||||
|
||||
if(newPlan.StripePlanId != null)
|
||||
{
|
||||
subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption
|
||||
subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption
|
||||
{
|
||||
PlanId = newPlan.StripePlanId,
|
||||
Quantity = 1
|
||||
@ -239,7 +255,7 @@ namespace Bit.Core.Services
|
||||
|
||||
if(additionalSeats > 0 && newPlan.StripeSeatPlanId != null)
|
||||
{
|
||||
subUpdateOptions.Items.Add(new StripeSubscriptionItemUpdateOption
|
||||
subUpdateOptions.Items.Add(new SubscriptionItemUpdateOption
|
||||
{
|
||||
PlanId = newPlan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats
|
||||
@ -271,7 +287,7 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Plan does not allow additional storage.");
|
||||
}
|
||||
|
||||
await BillingHelpers.AdjustStorageAsync(_stripePaymentService, organization, storageAdjustmentGb,
|
||||
await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb,
|
||||
plan.StripeStoragePlanId);
|
||||
await ReplaceAndUpdateCache(organization);
|
||||
}
|
||||
@ -333,44 +349,74 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
var subscriptionItemService = new StripeSubscriptionItemService();
|
||||
var subscriptionService = new StripeSubscriptionService();
|
||||
var subscriptionItemService = new SubscriptionItemService();
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var sub = await subscriptionService.GetAsync(organization.GatewaySubscriptionId);
|
||||
if(sub == null)
|
||||
{
|
||||
throw new BadRequestException("Subscription not found.");
|
||||
}
|
||||
|
||||
Func<bool, Task<SubscriptionItem>> subUpdateAction = null;
|
||||
var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId);
|
||||
var subItemOptions = sub.Items.Where(i => i.Plan.Id != plan.StripeSeatPlanId)
|
||||
.Select(i => new InvoiceSubscriptionItemOptions
|
||||
{
|
||||
Id = i.Id,
|
||||
PlanId = i.Plan.Id,
|
||||
Quantity = i.Quantity,
|
||||
}).ToList();
|
||||
|
||||
if(additionalSeats > 0 && seatItem == null)
|
||||
{
|
||||
await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions
|
||||
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||
{
|
||||
PlanId = plan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats,
|
||||
Prorate = true,
|
||||
SubscriptionId = sub.Id
|
||||
});
|
||||
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
|
||||
new SubscriptionItemCreateOptions
|
||||
{
|
||||
PlanId = plan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats,
|
||||
Prorate = prorate,
|
||||
SubscriptionId = sub.Id
|
||||
});
|
||||
}
|
||||
else if(additionalSeats > 0 && seatItem != null)
|
||||
{
|
||||
await subscriptionItemService.UpdateAsync(seatItem.Id, new StripeSubscriptionItemUpdateOptions
|
||||
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||
{
|
||||
Id = seatItem.Id,
|
||||
PlanId = plan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats,
|
||||
Prorate = true
|
||||
});
|
||||
subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(seatItem.Id,
|
||||
new SubscriptionItemUpdateOptions
|
||||
{
|
||||
PlanId = plan.StripeSeatPlanId,
|
||||
Quantity = additionalSeats,
|
||||
Prorate = prorate
|
||||
});
|
||||
}
|
||||
else if(seatItem != null && additionalSeats == 0)
|
||||
{
|
||||
await subscriptionItemService.DeleteAsync(seatItem.Id);
|
||||
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||
{
|
||||
Id = seatItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(seatItem.Id);
|
||||
}
|
||||
|
||||
var invoicedNow = false;
|
||||
if(additionalSeats > 0)
|
||||
{
|
||||
await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500);
|
||||
invoicedNow = await (_paymentService as StripePaymentService).PreviewUpcomingInvoiceAndPayAsync(
|
||||
organization, plan.StripeSeatPlanId, subItemOptions, 500);
|
||||
}
|
||||
|
||||
await subUpdateAction(!invoicedNow);
|
||||
organization.Seats = (short?)newSeatTotal;
|
||||
await ReplaceAndUpdateCache(organization);
|
||||
}
|
||||
@ -389,7 +435,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
var bankService = new BankAccountService();
|
||||
var customerService = new StripeCustomerService();
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.GetAsync(organization.GatewayCustomerId);
|
||||
if(customer == null)
|
||||
{
|
||||
@ -397,7 +443,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
var bankAccount = customer.Sources
|
||||
.FirstOrDefault(s => s.BankAccount != null && s.BankAccount.Status != "verified")?.BankAccount;
|
||||
.FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount;
|
||||
if(bankAccount == null)
|
||||
{
|
||||
throw new GatewayException("Cannot find an unverified bank account.");
|
||||
@ -406,7 +452,7 @@ namespace Bit.Core.Services
|
||||
try
|
||||
{
|
||||
var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id,
|
||||
new BankAccountVerifyOptions { AmountOne = amount1, AmountTwo = amount2 });
|
||||
new BankAccountVerifyOptions { Amounts = new List<long> { amount1, amount2 } });
|
||||
if(result.Status != "verified")
|
||||
{
|
||||
throw new GatewayException("Unable to verify account.");
|
||||
@ -431,6 +477,11 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Plan does not allow additional storage.");
|
||||
}
|
||||
|
||||
if(signup.AdditionalStorageGb < 0)
|
||||
{
|
||||
throw new BadRequestException("You can't subtract storage!");
|
||||
}
|
||||
|
||||
if(!plan.CanBuyPremiumAccessAddon && signup.PremiumAccessAddon)
|
||||
{
|
||||
throw new BadRequestException("This plan does not allow you to buy the premium access addon.");
|
||||
@ -441,6 +492,11 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("You do not have any seats!");
|
||||
}
|
||||
|
||||
if(signup.AdditionalSeats < 0)
|
||||
{
|
||||
throw new BadRequestException("You can't subtract seats!");
|
||||
}
|
||||
|
||||
if(!plan.CanBuyAdditionalSeats && signup.AdditionalSeats > 0)
|
||||
{
|
||||
throw new BadRequestException("Plan does not allow additional users.");
|
||||
@ -453,96 +509,10 @@ namespace Bit.Core.Services
|
||||
$"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users.");
|
||||
}
|
||||
|
||||
var customerService = new StripeCustomerService();
|
||||
var subscriptionService = new StripeSubscriptionService();
|
||||
StripeCustomer customer = null;
|
||||
StripeSubscription subscription = null;
|
||||
|
||||
// Pre-generate the org id so that we can save it with the Stripe subscription..
|
||||
var newOrgId = CoreHelpers.GenerateComb();
|
||||
|
||||
if(plan.Type == PlanType.Free)
|
||||
{
|
||||
var adminCount =
|
||||
await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id);
|
||||
if(adminCount > 0)
|
||||
{
|
||||
throw new BadRequestException("You can only be an admin of one free organization.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
customer = await customerService.CreateAsync(new StripeCustomerCreateOptions
|
||||
{
|
||||
Description = signup.BusinessName,
|
||||
Email = signup.BillingEmail,
|
||||
SourceToken = signup.PaymentToken
|
||||
});
|
||||
|
||||
var subCreateOptions = new StripeSubscriptionCreateOptions
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
TrialPeriodDays = plan.TrialPeriodDays,
|
||||
Items = new List<StripeSubscriptionItemOption>(),
|
||||
Metadata = new Dictionary<string, string> {
|
||||
{ "organizationId", newOrgId.ToString() }
|
||||
}
|
||||
};
|
||||
|
||||
if(plan.StripePlanId != null)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
{
|
||||
PlanId = plan.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
|
||||
if(signup.AdditionalSeats > 0 && plan.StripeSeatPlanId != null)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
{
|
||||
PlanId = plan.StripeSeatPlanId,
|
||||
Quantity = signup.AdditionalSeats
|
||||
});
|
||||
}
|
||||
|
||||
if(signup.AdditionalStorageGb > 0)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
{
|
||||
PlanId = plan.StripeStoragePlanId,
|
||||
Quantity = signup.AdditionalStorageGb
|
||||
});
|
||||
}
|
||||
|
||||
if(signup.PremiumAccessAddon && plan.StripePremiumAccessPlanId != null)
|
||||
{
|
||||
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
|
||||
{
|
||||
PlanId = plan.StripePremiumAccessPlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
subscription = await subscriptionService.CreateAsync(subCreateOptions);
|
||||
}
|
||||
catch(StripeException)
|
||||
{
|
||||
if(customer != null)
|
||||
{
|
||||
await customerService.DeleteAsync(customer.Id);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = newOrgId,
|
||||
// Pre-generate the org id so that we can save it with the Stripe subscription..
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
Name = signup.Name,
|
||||
BillingEmail = signup.BillingEmail,
|
||||
BusinessName = signup.BusinessName,
|
||||
@ -560,16 +530,43 @@ namespace Bit.Core.Services
|
||||
SelfHost = plan.SelfHost,
|
||||
UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon,
|
||||
Plan = plan.Name,
|
||||
Gateway = plan.Type == PlanType.Free ? null : (GatewayType?)GatewayType.Stripe,
|
||||
GatewayCustomerId = customer?.Id,
|
||||
GatewaySubscriptionId = subscription?.Id,
|
||||
Gateway = null,
|
||||
Enabled = true,
|
||||
ExpirationDate = subscription?.CurrentPeriodEnd,
|
||||
LicenseKey = CoreHelpers.SecureRandomString(20),
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if(plan.Type == PlanType.Free)
|
||||
{
|
||||
var adminCount =
|
||||
await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id);
|
||||
if(adminCount > 0)
|
||||
{
|
||||
throw new BadRequestException("You can only be an admin of one free organization.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PaymentMethodType paymentMethodType;
|
||||
if(signup.PaymentToken.StartsWith("btok_"))
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.BankAccount;
|
||||
}
|
||||
else if(signup.PaymentToken.StartsWith("tok_"))
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.Card;
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentMethodType = PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
await _paymentService.PurchaseOrganizationAsync(organization, paymentMethodType,
|
||||
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
||||
signup.PremiumAccessAddon);
|
||||
}
|
||||
|
||||
return await SignUpAsync(organization, signup.Owner.Id, signup.OwnerKey, signup.CollectionName, true);
|
||||
}
|
||||
|
||||
@ -630,7 +627,8 @@ namespace Bit.Core.Services
|
||||
|
||||
var dir = $"{_globalSettings.LicenseDirectory}/organization";
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText($"{dir}/{organization.Id}.json", JsonConvert.SerializeObject(license, Formatting.Indented));
|
||||
System.IO.File.WriteAllText($"{dir}/{organization.Id}.json",
|
||||
JsonConvert.SerializeObject(license, Formatting.Indented));
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -679,7 +677,7 @@ namespace Bit.Core.Services
|
||||
{
|
||||
if(withPayment)
|
||||
{
|
||||
await _stripePaymentService.CancelAndRecoverChargesAsync(organization);
|
||||
await _paymentService.CancelAndRecoverChargesAsync(organization);
|
||||
}
|
||||
|
||||
if(organization.Id != default(Guid))
|
||||
@ -756,7 +754,8 @@ namespace Bit.Core.Services
|
||||
|
||||
var dir = $"{_globalSettings.LicenseDirectory}/organization";
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText($"{dir}/{organization.Id}.json", JsonConvert.SerializeObject(license, Formatting.Indented));
|
||||
System.IO.File.WriteAllText($"{dir}/{organization.Id}.json",
|
||||
JsonConvert.SerializeObject(license, Formatting.Indented));
|
||||
|
||||
organization.Name = license.Name;
|
||||
organization.BusinessName = license.BusinessName;
|
||||
@ -787,7 +786,7 @@ namespace Bit.Core.Services
|
||||
{
|
||||
var eop = !organization.ExpirationDate.HasValue ||
|
||||
organization.ExpirationDate.Value >= DateTime.UtcNow;
|
||||
await _stripePaymentService.CancelSubscriptionAsync(organization, eop);
|
||||
await _paymentService.CancelSubscriptionAsync(organization, eop);
|
||||
}
|
||||
catch(GatewayException) { }
|
||||
}
|
||||
@ -842,8 +841,8 @@ namespace Bit.Core.Services
|
||||
|
||||
if(updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
|
||||
{
|
||||
var customerService = new StripeCustomerService();
|
||||
await customerService.UpdateAsync(organization.GatewayCustomerId, new StripeCustomerUpdateOptions
|
||||
var customerService = new CustomerService();
|
||||
await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Email = organization.BillingEmail,
|
||||
Description = organization.BusinessName
|
||||
@ -1207,9 +1206,8 @@ namespace Bit.Core.Services
|
||||
{
|
||||
throw new BadRequestException("Invalid installation id");
|
||||
}
|
||||
|
||||
var paymentService = new StripePaymentService();
|
||||
var billingInfo = await paymentService.GetBillingAsync(organization);
|
||||
|
||||
var billingInfo = await _paymentService.GetBillingAsync(organization);
|
||||
return new OrganizationLicense(organization, billingInfo, installationId, _licensingService);
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@ namespace Bit.Core.Services
|
||||
private readonly ILicensingService _licenseService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IDataProtector _organizationServiceDataProtector;
|
||||
private readonly CurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
@ -67,6 +68,7 @@ namespace Bit.Core.Services
|
||||
IEventService eventService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IPaymentService paymentService,
|
||||
CurrentContext currentContext,
|
||||
GlobalSettings globalSettings)
|
||||
: base(
|
||||
@ -94,6 +96,7 @@ namespace Bit.Core.Services
|
||||
_licenseService = licenseService;
|
||||
_eventService = eventService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_paymentService = paymentService;
|
||||
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
|
||||
"OrganizationServiceDataProtector");
|
||||
_currentContext = currentContext;
|
||||
@ -682,6 +685,11 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Already a premium user.");
|
||||
}
|
||||
|
||||
if(additionalStorageGb < 0)
|
||||
{
|
||||
throw new BadRequestException("You can't subtract storage!");
|
||||
}
|
||||
|
||||
IPaymentService paymentService = null;
|
||||
if(_globalSettings.SelfHosted)
|
||||
{
|
||||
@ -706,16 +714,14 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
if(paymentToken.StartsWith("tok_"))
|
||||
var paymentMethodType = PaymentMethodType.Card;
|
||||
if(!paymentToken.StartsWith("tok_"))
|
||||
{
|
||||
paymentService = new StripePaymentService();
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentService = new BraintreePaymentService(_globalSettings);
|
||||
paymentMethodType = PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
await paymentService.PurchasePremiumAsync(user, paymentToken, additionalStorageGb);
|
||||
await _paymentService.PurchasePremiumAsync(user, paymentMethodType,
|
||||
paymentToken, additionalStorageGb);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -789,9 +795,8 @@ namespace Bit.Core.Services
|
||||
{
|
||||
throw new BadRequestException("Not a premium user.");
|
||||
}
|
||||
|
||||
var paymentService = user.GetPaymentService(_globalSettings);
|
||||
await BillingHelpers.AdjustStorageAsync(paymentService, user, storageAdjustmentGb, StoragePlanId);
|
||||
|
||||
await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, StoragePlanId);
|
||||
await SaveUserAsync(user);
|
||||
}
|
||||
|
||||
@ -802,17 +807,17 @@ namespace Bit.Core.Services
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
IPaymentService paymentService = null;
|
||||
PaymentMethodType paymentMethodType;
|
||||
if(paymentToken.StartsWith("tok_"))
|
||||
{
|
||||
paymentService = new StripePaymentService();
|
||||
paymentMethodType = PaymentMethodType.Card;
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentService = new BraintreePaymentService(_globalSettings);
|
||||
paymentMethodType = PaymentMethodType.PayPal;
|
||||
}
|
||||
|
||||
var updated = await paymentService.UpdatePaymentMethodAsync(user, paymentToken);
|
||||
var updated = await _paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken);
|
||||
if(updated)
|
||||
{
|
||||
await SaveUserAsync(user);
|
||||
@ -821,20 +826,18 @@ namespace Bit.Core.Services
|
||||
|
||||
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null)
|
||||
{
|
||||
var paymentService = user.GetPaymentService(_globalSettings);
|
||||
var eop = endOfPeriod.GetValueOrDefault(true);
|
||||
if(!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue &&
|
||||
user.PremiumExpirationDate.Value < DateTime.UtcNow)
|
||||
{
|
||||
eop = false;
|
||||
}
|
||||
await paymentService.CancelSubscriptionAsync(user, eop);
|
||||
await _paymentService.CancelSubscriptionAsync(user, eop);
|
||||
}
|
||||
|
||||
public async Task ReinstatePremiumAsync(User user)
|
||||
{
|
||||
var paymentService = user.GetPaymentService(_globalSettings);
|
||||
await paymentService.ReinstateSubscriptionAsync(user);
|
||||
await _paymentService.ReinstateSubscriptionAsync(user);
|
||||
}
|
||||
|
||||
public async Task DisablePremiumAsync(Guid userId, DateTime? expirationDate)
|
||||
@ -874,8 +877,7 @@ namespace Bit.Core.Services
|
||||
|
||||
if(billingInfo == null && user.Gateway != null)
|
||||
{
|
||||
var paymentService = user.GetPaymentService(_globalSettings);
|
||||
billingInfo = await paymentService.GetBillingAsync(user);
|
||||
billingInfo = await _paymentService.GetBillingAsync(user);
|
||||
}
|
||||
|
||||
return billingInfo == null ? new UserLicense(user, _licenseService) :
|
||||
|
@ -52,6 +52,7 @@ namespace Bit.Core.Utilities
|
||||
services.AddSingleton<IU2fRepository, SqlServerRepos.U2fRepository>();
|
||||
services.AddSingleton<IInstallationRepository, SqlServerRepos.InstallationRepository>();
|
||||
services.AddSingleton<IMaintenanceRepository, SqlServerRepos.MaintenanceRepository>();
|
||||
services.AddSingleton<ITransactionRepository, SqlServerRepos.TransactionRepository>();
|
||||
}
|
||||
|
||||
if(globalSettings.SelfHosted)
|
||||
@ -77,6 +78,7 @@ namespace Bit.Core.Utilities
|
||||
|
||||
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
services.AddSingleton<IPaymentService, StripePaymentService>();
|
||||
services.AddSingleton<IMailService, HandlebarsMailService>();
|
||||
services.AddSingleton<ILicensingService, LicensingService>();
|
||||
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
|
||||
|
@ -26,7 +26,7 @@ namespace Bit.Core.Utilities
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.United, new List<string> { "ua2go.com", "ual.com", "united.com", "unitedwifi.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.Yahoo, new List<string> { "overture.com", "yahoo.com", "flickr.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.Zonelabs, new List<string> { "zonealarm.com", "zonelabs.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.Paypal, new List<string> { "paypal.com", "paypal-search.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.PayPal, new List<string> { "paypal.com", "paypal-search.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.Avon, new List<string> { "avon.com", "youravon.com" });
|
||||
GlobalDomains.Add(GlobalEquivalentDomainsType.Diapers, new List<string> { "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<string> { "1800contacts.com", "800contacts.com" });
|
||||
|
@ -240,5 +240,14 @@
|
||||
<Build Include="dbo\Stored Procedures\Cipher_CreateWithCollections.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Cipher_UpdateCollections.sql" />
|
||||
<Build Include="dbo\Stored Procedures\CipherDetails_CreateWithCollections.sql" />
|
||||
<Build Include="dbo\Tables\Transaction.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_Create.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_DeleteById.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_ReadById.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_ReadByOrganizationId.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_Update.sql" />
|
||||
<Build Include="dbo\Views\TransactionView.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_ReadByUserId.sql" />
|
||||
<Build Include="dbo\Stored Procedures\Transaction_ReadByGatewayId.sql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
48
src/Sql/dbo/Stored Procedures/Transaction_Create.sql
Normal file
48
src/Sql/dbo/Stored Procedures/Transaction_Create.sql
Normal file
@ -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
|
12
src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql
Normal file
12
src/Sql/dbo/Stored Procedures/Transaction_DeleteById.sql
Normal file
@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[Transaction_DeleteById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[Transaction]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -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
|
13
src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql
Normal file
13
src/Sql/dbo/Stored Procedures/Transaction_ReadById.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
@ -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
|
13
src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql
Normal file
13
src/Sql/dbo/Stored Procedures/Transaction_ReadByUserId.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadByUserId]
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[UserId] = @UserId
|
||||
END
|
34
src/Sql/dbo/Stored Procedures/Transaction_Update.sql
Normal file
34
src/Sql/dbo/Stored Procedures/Transaction_Update.sql
Normal file
@ -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
|
28
src/Sql/dbo/Tables/Transaction.sql
Normal file
28
src/Sql/dbo/Tables/Transaction.sql
Normal file
@ -0,0 +1,28 @@
|
||||
CREATE TABLE [dbo].[Transaction] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Amount] MONEY NOT NULL,
|
||||
[Refunded] BIT NULL,
|
||||
[RefundedAmount] MONEY NULL,
|
||||
[Details] NVARCHAR(100) NULL,
|
||||
[PaymentMethodType] TINYINT NULL,
|
||||
[Gateway] TINYINT NULL,
|
||||
[GatewayId] VARCHAR(50) NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_Transaction] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_Transaction_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
GO
|
||||
CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId]
|
||||
ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC);
|
||||
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_Transaction_UserId_OrganizationId_CreationDate]
|
||||
ON [dbo].[Transaction]([UserId] ASC, [OrganizationId] ASC, [CreationDate] ASC);
|
||||
|
6
src/Sql/dbo/Views/TransactionView.sql
Normal file
6
src/Sql/dbo/Views/TransactionView.sql
Normal file
@ -0,0 +1,6 @@
|
||||
CREATE VIEW [dbo].[TransactionView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[Transaction]
|
247
util/Setup/DbScripts/2019-01-31_00_Transactions.sql
Normal file
247
util/Setup/DbScripts/2019-01-31_00_Transactions.sql
Normal file
@ -0,0 +1,247 @@
|
||||
IF OBJECT_ID('[dbo].[Transaction]') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[Transaction] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[Type] TINYINT NOT NULL,
|
||||
[Amount] MONEY NOT NULL,
|
||||
[Refunded] BIT NULL,
|
||||
[RefundedAmount] MONEY NULL,
|
||||
[Details] NVARCHAR(100) NULL,
|
||||
[PaymentMethodType] TINYINT NULL,
|
||||
[Gateway] TINYINT NULL,
|
||||
[GatewayId] VARCHAR(50) NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_Transaction] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_Transaction_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId]
|
||||
ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC);
|
||||
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_Transaction_UserId_OrganizationId_CreationDate]
|
||||
ON [dbo].[Transaction]([UserId] ASC, [OrganizationId] ASC, [CreationDate] ASC);
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS(SELECT * FROM sys.views WHERE [Name] = 'TransactionView')
|
||||
BEGIN
|
||||
DROP VIEW [dbo].[TransactionView]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE VIEW [dbo].[TransactionView]
|
||||
AS
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[Transaction]
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_Create]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_Create]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_Create]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Amount MONEY,
|
||||
@Refunded BIT,
|
||||
@RefundedAmount MONEY,
|
||||
@Details NVARCHAR(100),
|
||||
@PaymentMethodType TINYINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayId VARCHAR(50),
|
||||
@CreationDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[Transaction]
|
||||
(
|
||||
[Id],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[Type],
|
||||
[Amount],
|
||||
[Refunded],
|
||||
[RefundedAmount],
|
||||
[Details],
|
||||
[PaymentMethodType],
|
||||
[Gateway],
|
||||
[GatewayId],
|
||||
[CreationDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@UserId,
|
||||
@OrganizationId,
|
||||
@Type,
|
||||
@Amount,
|
||||
@Refunded,
|
||||
@RefundedAmount,
|
||||
@Details,
|
||||
@PaymentMethodType,
|
||||
@Gateway,
|
||||
@GatewayId,
|
||||
@CreationDate
|
||||
)
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_DeleteById]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_DeleteById]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_DeleteById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[Transaction]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_ReadById]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_ReadById]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadById]
|
||||
@Id UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_ReadByOrganizationId]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_ReadByOrganizationId]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadByOrganizationId]
|
||||
@OrganizationId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[UserId] = NULL
|
||||
AND [OrganizationId] = @OrganizationId
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_ReadByUserId]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_ReadByUserId]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadByUserId]
|
||||
@UserId UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[UserId] = @UserId
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_Update]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_Update]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@Type TINYINT,
|
||||
@Amount MONEY,
|
||||
@Refunded BIT,
|
||||
@RefundedAmount MONEY,
|
||||
@Details NVARCHAR(100),
|
||||
@PaymentMethodType TINYINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayId VARCHAR(50),
|
||||
@CreationDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[Transaction]
|
||||
SET
|
||||
[UserId] = @UserId,
|
||||
[OrganizationId] = @OrganizationId,
|
||||
[Type] = @Type,
|
||||
[Amount] = @Amount,
|
||||
[Refunded] = @Refunded,
|
||||
[RefundedAmount] = @RefundedAmount,
|
||||
[Details] = @Details,
|
||||
[PaymentMethodType] = @PaymentMethodType,
|
||||
[Gateway] = @Gateway,
|
||||
[GatewayId] = @GatewayId,
|
||||
[CreationDate] = @CreationDate
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('[dbo].[Transaction_ReadByGatewayId]') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE [dbo].[Transaction_ReadByGatewayId]
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE [dbo].[Transaction_ReadByGatewayId]
|
||||
@Gateway TINYINT,
|
||||
@GatewayId VARCHAR(50)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
[dbo].[TransactionView]
|
||||
WHERE
|
||||
[Gateway] = @Gateway
|
||||
AND [GatewayId] = @GatewayId
|
||||
END
|
||||
GO
|
@ -16,6 +16,7 @@
|
||||
<None Remove="DbScripts\2018-09-25_00_OrgPurge.sql" />
|
||||
<None Remove="DbScripts\2018-10-17_00_ManagerRole.sql" />
|
||||
<None Remove="DbScripts\2018-12-19_00_OrgUserTwoFactorEnabled.sql" />
|
||||
<None Remove="DbScripts\2019-01-31_00_Transactions.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
Loading…
Reference in New Issue
Block a user