mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-25183] Update the BitPay purchasing procedure (#6396)
* Revise BitPay controller * Run dotnet format * Kyle's feedback * Run dotnet format * Temporary logging * Whoops * Undo temporary logging
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,4 +234,6 @@ bitwarden_license/src/Sso/Sso.zip
|
|||||||
/identity.json
|
/identity.json
|
||||||
/api.json
|
/api.json
|
||||||
/api.public.json
|
/api.public.json
|
||||||
|
|
||||||
|
# Serena
|
||||||
.serena/
|
.serena/
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
using Bit.Api.Models.Request;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Stripe;
|
|
||||||
|
|
||||||
namespace Bit.Api.Controllers;
|
|
||||||
|
|
||||||
public class MiscController : Controller
|
|
||||||
{
|
|
||||||
private readonly BitPayClient _bitPayClient;
|
|
||||||
private readonly GlobalSettings _globalSettings;
|
|
||||||
|
|
||||||
public MiscController(
|
|
||||||
BitPayClient bitPayClient,
|
|
||||||
GlobalSettings globalSettings)
|
|
||||||
{
|
|
||||||
_bitPayClient = bitPayClient;
|
|
||||||
_globalSettings = globalSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize("Application")]
|
|
||||||
[HttpPost("~/bitpay-invoice")]
|
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
|
||||||
public async Task<string> PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model)
|
|
||||||
{
|
|
||||||
var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings));
|
|
||||||
return invoice.Url;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize("Application")]
|
|
||||||
[HttpPost("~/setup-payment")]
|
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
|
||||||
public async Task<string> PostSetupPayment()
|
|
||||||
{
|
|
||||||
var options = new SetupIntentCreateOptions
|
|
||||||
{
|
|
||||||
Usage = "off_session"
|
|
||||||
};
|
|
||||||
var service = new SetupIntentService();
|
|
||||||
var setupIntent = await service.CreateAsync(options);
|
|
||||||
return setupIntent.ClientSecret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
|
|
||||||
namespace Bit.Api.Models.Request;
|
|
||||||
|
|
||||||
public class BitPayInvoiceRequestModel : IValidatableObject
|
|
||||||
{
|
|
||||||
public Guid? UserId { get; set; }
|
|
||||||
public Guid? OrganizationId { get; set; }
|
|
||||||
public Guid? ProviderId { get; set; }
|
|
||||||
public bool Credit { get; set; }
|
|
||||||
[Required]
|
|
||||||
public decimal? Amount { get; set; }
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings)
|
|
||||||
{
|
|
||||||
var inv = new BitPayLight.Models.Invoice.Invoice
|
|
||||||
{
|
|
||||||
Price = Convert.ToDouble(Amount.Value),
|
|
||||||
Currency = "USD",
|
|
||||||
RedirectUrl = ReturnUrl,
|
|
||||||
Buyer = new BitPayLight.Models.Invoice.Buyer
|
|
||||||
{
|
|
||||||
Email = Email,
|
|
||||||
Name = Name
|
|
||||||
},
|
|
||||||
NotificationUrl = globalSettings.BitPay.NotificationUrl,
|
|
||||||
FullNotifications = true,
|
|
||||||
ExtendedNotifications = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var posData = string.Empty;
|
|
||||||
if (UserId.HasValue)
|
|
||||||
{
|
|
||||||
posData = "userId:" + UserId.Value;
|
|
||||||
}
|
|
||||||
else if (OrganizationId.HasValue)
|
|
||||||
{
|
|
||||||
posData = "organizationId:" + OrganizationId.Value;
|
|
||||||
}
|
|
||||||
else if (ProviderId.HasValue)
|
|
||||||
{
|
|
||||||
posData = "providerId:" + ProviderId.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Credit)
|
|
||||||
{
|
|
||||||
posData += ",accountCredit:1";
|
|
||||||
inv.ItemDesc = "Bitwarden Account Credit";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
inv.ItemDesc = "Bitwarden";
|
|
||||||
}
|
|
||||||
|
|
||||||
inv.PosData = posData;
|
|
||||||
return inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue)
|
|
||||||
{
|
|
||||||
yield return new ValidationResult("User, Organization or Provider is required.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,9 +94,6 @@ public class Startup
|
|||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddDistributedCache(globalSettings);
|
services.AddDistributedCache(globalSettings);
|
||||||
|
|
||||||
// BitPay
|
|
||||||
services.AddSingleton<BitPayClient>();
|
|
||||||
|
|
||||||
if (!globalSettings.SelfHosted)
|
if (!globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
services.AddIpRateLimiting(globalSettings);
|
services.AddIpRateLimiting(globalSettings);
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
"bitPay": {
|
"bitPay": {
|
||||||
"production": false,
|
"production": false,
|
||||||
"token": "SECRET",
|
"token": "SECRET",
|
||||||
"notificationUrl": "https://bitwarden.com/SECRET"
|
"notificationUrl": "https://bitwarden.com/SECRET",
|
||||||
|
"webhookKey": "SECRET"
|
||||||
},
|
},
|
||||||
"amazon": {
|
"amazon": {
|
||||||
"accessKeyId": "SECRET",
|
"accessKeyId": "SECRET",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ public class BillingSettings
|
|||||||
public virtual string JobsKey { get; set; }
|
public virtual string JobsKey { get; set; }
|
||||||
public virtual string StripeWebhookKey { get; set; }
|
public virtual string StripeWebhookKey { get; set; }
|
||||||
public virtual string StripeWebhookSecret20250827Basil { get; set; }
|
public virtual string StripeWebhookSecret20250827Basil { get; set; }
|
||||||
public virtual string BitPayWebhookKey { get; set; }
|
|
||||||
public virtual string AppleWebhookKey { get; set; }
|
public virtual string AppleWebhookKey { get; set; }
|
||||||
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
|
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
|
||||||
public virtual string FreshsalesApiKey { get; set; }
|
public virtual string FreshsalesApiKey { get; set; }
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Bit.Billing.Constants;
|
|
||||||
|
|
||||||
public static class BitPayInvoiceStatus
|
|
||||||
{
|
|
||||||
public const string Confirmed = "confirmed";
|
|
||||||
public const string Complete = "complete";
|
|
||||||
}
|
|
||||||
@@ -1,125 +1,79 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using System.Globalization;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Globalization;
|
|
||||||
using Bit.Billing.Constants;
|
|
||||||
using Bit.Billing.Models;
|
using Bit.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using BitPayLight.Models.Invoice;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Bit.Billing.Controllers;
|
namespace Bit.Billing.Controllers;
|
||||||
|
|
||||||
|
using static BitPayConstants;
|
||||||
|
using static StripeConstants;
|
||||||
|
|
||||||
[Route("bitpay")]
|
[Route("bitpay")]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
public class BitPayController : Controller
|
public class BitPayController(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
IBitPayClient bitPayClient,
|
||||||
|
ITransactionRepository transactionRepository,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IMailService mailService,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
ILogger<BitPayController> logger,
|
||||||
|
IPremiumUserBillingService premiumUserBillingService)
|
||||||
|
: Controller
|
||||||
{
|
{
|
||||||
private readonly BillingSettings _billingSettings;
|
|
||||||
private readonly BitPayClient _bitPayClient;
|
|
||||||
private readonly ITransactionRepository _transactionRepository;
|
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly IProviderRepository _providerRepository;
|
|
||||||
private readonly IMailService _mailService;
|
|
||||||
private readonly IPaymentService _paymentService;
|
|
||||||
private readonly ILogger<BitPayController> _logger;
|
|
||||||
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
|
||||||
|
|
||||||
public BitPayController(
|
|
||||||
IOptions<BillingSettings> billingSettings,
|
|
||||||
BitPayClient bitPayClient,
|
|
||||||
ITransactionRepository transactionRepository,
|
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
IProviderRepository providerRepository,
|
|
||||||
IMailService mailService,
|
|
||||||
IPaymentService paymentService,
|
|
||||||
ILogger<BitPayController> logger,
|
|
||||||
IPremiumUserBillingService premiumUserBillingService)
|
|
||||||
{
|
|
||||||
_billingSettings = billingSettings?.Value;
|
|
||||||
_bitPayClient = bitPayClient;
|
|
||||||
_transactionRepository = transactionRepository;
|
|
||||||
_organizationRepository = organizationRepository;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_providerRepository = providerRepository;
|
|
||||||
_mailService = mailService;
|
|
||||||
_paymentService = paymentService;
|
|
||||||
_logger = logger;
|
|
||||||
_premiumUserBillingService = premiumUserBillingService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("ipn")]
|
[HttpPost("ipn")]
|
||||||
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)
|
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)
|
||||||
{
|
{
|
||||||
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey))
|
if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey))
|
||||||
{
|
{
|
||||||
return new BadRequestResult();
|
return new BadRequestObjectResult("Invalid key");
|
||||||
}
|
|
||||||
if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) ||
|
|
||||||
string.IsNullOrWhiteSpace(model.Event?.Name))
|
|
||||||
{
|
|
||||||
return new BadRequestResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed)
|
var invoice = await bitPayClient.GetInvoice(model.Data.Id);
|
||||||
{
|
|
||||||
// Only processing confirmed invoice events for now.
|
|
||||||
return new OkResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id);
|
|
||||||
if (invoice == null)
|
|
||||||
{
|
|
||||||
// Request forged...?
|
|
||||||
_logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id);
|
|
||||||
return new BadRequestResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id);
|
|
||||||
return new BadRequestResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invoice.Currency != "USD")
|
if (invoice.Currency != "USD")
|
||||||
{
|
{
|
||||||
// Only process USD payments
|
logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency);
|
||||||
_logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id);
|
return new BadRequestObjectResult("Cannot process non-USD payments");
|
||||||
return new OkResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (organizationId, userId, providerId) = GetIdsFromPosData(invoice);
|
var (organizationId, userId, providerId) = GetIdsFromPosData(invoice);
|
||||||
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
|
if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit))
|
||||||
{
|
{
|
||||||
return new OkResult();
|
logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData);
|
||||||
|
return new BadRequestObjectResult("Invalid POS data");
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAccountCredit = IsAccountCredit(invoice);
|
if (invoice.Status != InvoiceStatuses.Complete)
|
||||||
if (!isAccountCredit)
|
|
||||||
{
|
{
|
||||||
// Only processing credits
|
logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}",
|
||||||
_logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id);
|
invoice.Id, invoice.Status);
|
||||||
return new OkResult();
|
return new OkObjectResult("Waiting for invoice to be completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
|
var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
|
||||||
if (transaction != null)
|
if (existingTransaction != null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id);
|
logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id);
|
||||||
return new OkResult();
|
return new OkObjectResult("Invoice already processed");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tx = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Amount = Convert.ToDecimal(invoice.Price),
|
Amount = Convert.ToDecimal(invoice.Price),
|
||||||
CreationDate = GetTransactionDate(invoice),
|
CreationDate = GetTransactionDate(invoice),
|
||||||
@@ -132,50 +86,47 @@ public class BitPayController : Controller
|
|||||||
PaymentMethodType = PaymentMethodType.BitPay,
|
PaymentMethodType = PaymentMethodType.BitPay,
|
||||||
Details = $"{invoice.Currency}, BitPay {invoice.Id}"
|
Details = $"{invoice.Currency}, BitPay {invoice.Id}"
|
||||||
};
|
};
|
||||||
await _transactionRepository.CreateAsync(tx);
|
|
||||||
|
|
||||||
string billingEmail = null;
|
await transactionRepository.CreateAsync(transaction);
|
||||||
if (tx.OrganizationId.HasValue)
|
|
||||||
|
var billingEmail = "";
|
||||||
|
if (transaction.OrganizationId.HasValue)
|
||||||
{
|
{
|
||||||
var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value);
|
var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value);
|
||||||
if (org != null)
|
if (organization != null)
|
||||||
{
|
{
|
||||||
billingEmail = org.BillingEmailAddress();
|
billingEmail = organization.BillingEmailAddress();
|
||||||
if (await _paymentService.CreditAccountAsync(org, tx.Amount))
|
if (await paymentService.CreditAccountAsync(organization, transaction.Amount))
|
||||||
{
|
{
|
||||||
await _organizationRepository.ReplaceAsync(org);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (tx.UserId.HasValue)
|
else if (transaction.UserId.HasValue)
|
||||||
{
|
{
|
||||||
var user = await _userRepository.GetByIdAsync(tx.UserId.Value);
|
var user = await userRepository.GetByIdAsync(transaction.UserId.Value);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
billingEmail = user.BillingEmailAddress();
|
billingEmail = user.BillingEmailAddress();
|
||||||
await _premiumUserBillingService.Credit(user, tx.Amount);
|
await premiumUserBillingService.Credit(user, transaction.Amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (tx.ProviderId.HasValue)
|
else if (transaction.ProviderId.HasValue)
|
||||||
{
|
{
|
||||||
var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value);
|
var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value);
|
||||||
if (provider != null)
|
if (provider != null)
|
||||||
{
|
{
|
||||||
billingEmail = provider.BillingEmailAddress();
|
billingEmail = provider.BillingEmailAddress();
|
||||||
if (await _paymentService.CreditAccountAsync(provider, tx.Amount))
|
if (await paymentService.CreditAccountAsync(provider, transaction.Amount))
|
||||||
{
|
{
|
||||||
await _providerRepository.ReplaceAsync(provider);
|
await providerRepository.ReplaceAsync(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(billingEmail))
|
if (!string.IsNullOrWhiteSpace(billingEmail))
|
||||||
{
|
{
|
||||||
await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount);
|
await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Catch foreign key violations because user/org could have been deleted.
|
// Catch foreign key violations because user/org could have been deleted.
|
||||||
@@ -186,58 +137,34 @@ public class BitPayController : Controller
|
|||||||
return new OkResult();
|
return new OkResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice)
|
private static DateTime GetTransactionDate(Invoice invoice)
|
||||||
{
|
{
|
||||||
return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1");
|
var transactions = invoice.Transactions?.Where(transaction =>
|
||||||
|
transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) &&
|
||||||
|
transaction.Confirmations != "0").ToList();
|
||||||
|
|
||||||
|
return transactions?.Count == 1
|
||||||
|
? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
|
||||||
|
: CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice)
|
public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice)
|
||||||
{
|
{
|
||||||
var transactions = invoice.Transactions?.Where(t => t.Type == null &&
|
if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':'))
|
||||||
!string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0");
|
|
||||||
if (transactions != null && transactions.Count() == 1)
|
|
||||||
{
|
{
|
||||||
return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture,
|
return new ValueTuple<Guid?, Guid?, Guid?>(null, null, null);
|
||||||
DateTimeStyles.RoundtripKind);
|
|
||||||
}
|
|
||||||
return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Tuple<Guid?, Guid?, Guid?> GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice)
|
|
||||||
{
|
|
||||||
Guid? orgId = null;
|
|
||||||
Guid? userId = null;
|
|
||||||
Guid? providerId = null;
|
|
||||||
|
|
||||||
if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':'))
|
|
||||||
{
|
|
||||||
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mainParts = invoice.PosData.Split(',');
|
var ids = invoice.PosData
|
||||||
foreach (var mainPart in mainParts)
|
.Split(',')
|
||||||
{
|
.Select(part => part.Split(':'))
|
||||||
var parts = mainPart.Split(':');
|
.Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _))
|
||||||
|
.ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1]));
|
||||||
|
|
||||||
if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id))
|
return new ValueTuple<Guid?, Guid?, Guid?>(
|
||||||
{
|
ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null,
|
||||||
continue;
|
ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null,
|
||||||
}
|
ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null
|
||||||
|
);
|
||||||
switch (parts[0])
|
|
||||||
{
|
|
||||||
case "userId":
|
|
||||||
userId = id;
|
|
||||||
break;
|
|
||||||
case "organizationId":
|
|
||||||
orgId = id;
|
|
||||||
break;
|
|
||||||
case "providerId":
|
|
||||||
providerId = id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Tuple<Guid?, Guid?, Guid?>(orgId, userId, providerId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ public class Startup
|
|||||||
// Repositories
|
// Repositories
|
||||||
services.AddDatabaseRepositories(globalSettings);
|
services.AddDatabaseRepositories(globalSettings);
|
||||||
|
|
||||||
// BitPay Client
|
|
||||||
services.AddSingleton<BitPayClient>();
|
|
||||||
|
|
||||||
// PayPal IPN Client
|
// PayPal IPN Client
|
||||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||||
|
|
||||||
|
|||||||
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal file
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Bit.Core.Billing.Constants;
|
||||||
|
|
||||||
|
public static class BitPayConstants
|
||||||
|
{
|
||||||
|
public static class InvoiceStatuses
|
||||||
|
{
|
||||||
|
public const string Complete = "complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PosDataKeys
|
||||||
|
{
|
||||||
|
public const string AccountCredit = "accountCredit:1";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.Billing.Commands;
|
using Bit.Core.Billing.Commands;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Payment.Clients;
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Bit.Core.Billing.Payment.Commands;
|
namespace Bit.Core.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
using static BitPayConstants;
|
||||||
|
|
||||||
public interface ICreateBitPayInvoiceForCreditCommand
|
public interface ICreateBitPayInvoiceForCreditCommand
|
||||||
{
|
{
|
||||||
Task<BillingCommandResult<string>> Run(
|
Task<BillingCommandResult<string>> Run(
|
||||||
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
|
|||||||
{
|
{
|
||||||
var (name, email, posData) = GetSubscriberInformation(subscriber);
|
var (name, email, posData) = GetSubscriberInformation(subscriber);
|
||||||
|
|
||||||
|
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
|
||||||
|
|
||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Buyer = new Buyer { Email = email, Name = name },
|
Buyer = new Buyer { Email = email, Name = name },
|
||||||
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
|
|||||||
ExtendedNotifications = true,
|
ExtendedNotifications = true,
|
||||||
FullNotifications = true,
|
FullNotifications = true,
|
||||||
ItemDesc = "Bitwarden",
|
ItemDesc = "Bitwarden",
|
||||||
NotificationUrl = globalSettings.BitPay.NotificationUrl,
|
NotificationUrl = notificationUrl,
|
||||||
PosData = posData,
|
PosData = posData,
|
||||||
Price = Convert.ToDouble(amount),
|
Price = Convert.ToDouble(amount),
|
||||||
RedirectUrl = redirectUrl
|
RedirectUrl = redirectUrl
|
||||||
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
|
|||||||
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
|
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
|
||||||
ISubscriber subscriber) => subscriber switch
|
ISubscriber subscriber) => subscriber switch
|
||||||
{
|
{
|
||||||
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"),
|
User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"),
|
||||||
Organization organization => (organization.Name, organization.BillingEmail,
|
Organization organization => (organization.Name, organization.BillingEmail,
|
||||||
$"organizationId:{organization.Id},accountCredit:1"),
|
$"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
|
||||||
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
|
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
|
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public bool Production { get; set; }
|
public bool Production { get; set; }
|
||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
public string NotificationUrl { get; set; }
|
public string NotificationUrl { get; set; }
|
||||||
|
public string WebhookKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InstallationSettings : IInstallationSettings
|
public class InstallationSettings : IInstallationSettings
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
|
|
||||||
namespace Bit.Core.Utilities;
|
|
||||||
|
|
||||||
public class BitPayClient
|
|
||||||
{
|
|
||||||
private readonly BitPayLight.BitPay _bpClient;
|
|
||||||
|
|
||||||
public BitPayClient(GlobalSettings globalSettings)
|
|
||||||
{
|
|
||||||
if (CoreHelpers.SettingHasValue(globalSettings.BitPay.Token))
|
|
||||||
{
|
|
||||||
_bpClient = new BitPayLight.BitPay(globalSettings.BitPay.Token,
|
|
||||||
globalSettings.BitPay.Production ? BitPayLight.Env.Prod : BitPayLight.Env.Test);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<BitPayLight.Models.Invoice.Invoice> GetInvoiceAsync(string id)
|
|
||||||
{
|
|
||||||
return _bpClient.GetInvoice(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<BitPayLight.Models.Invoice.Invoice> CreateInvoiceAsync(BitPayLight.Models.Invoice.Invoice invoice)
|
|
||||||
{
|
|
||||||
return _bpClient.CreateInvoice(invoice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal file
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
using Bit.Billing.Controllers;
|
||||||
|
using Bit.Billing.Models;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
using BitPayLight.Models.Invoice;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
using Transaction = Bit.Core.Entities.Transaction;
|
||||||
|
|
||||||
|
namespace Bit.Billing.Test.Controllers;
|
||||||
|
|
||||||
|
using static BitPayConstants;
|
||||||
|
|
||||||
|
public class BitPayControllerTests
|
||||||
|
{
|
||||||
|
private readonly GlobalSettings _globalSettings = new();
|
||||||
|
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
|
||||||
|
private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>();
|
||||||
|
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||||
|
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||||
|
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||||
|
private readonly IMailService _mailService = Substitute.For<IMailService>();
|
||||||
|
private readonly IPaymentService _paymentService = Substitute.For<IPaymentService>();
|
||||||
|
|
||||||
|
private readonly IPremiumUserBillingService _premiumUserBillingService =
|
||||||
|
Substitute.For<IPremiumUserBillingService>();
|
||||||
|
|
||||||
|
private const string _validWebhookKey = "valid-webhook-key";
|
||||||
|
private const string _invalidWebhookKey = "invalid-webhook-key";
|
||||||
|
|
||||||
|
public BitPayControllerTests()
|
||||||
|
{
|
||||||
|
var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey };
|
||||||
|
_globalSettings.BitPay = bitPaySettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BitPayController CreateController() => new(
|
||||||
|
_globalSettings,
|
||||||
|
_bitPayClient,
|
||||||
|
_transactionRepository,
|
||||||
|
_organizationRepository,
|
||||||
|
_userRepository,
|
||||||
|
_providerRepository,
|
||||||
|
_mailService,
|
||||||
|
_paymentService,
|
||||||
|
Substitute.For<ILogger<BitPayController>>(),
|
||||||
|
_premiumUserBillingService);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_InvalidKey_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _invalidWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid key", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_NullKey_ThrowsException()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => controller.PostIpn(eventModel, null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_EmptyKey_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, string.Empty);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid key", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_NonUsdCurrency_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(currency: "EUR");
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Cannot process non-USD payments", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_NullPosData_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(posData: null!);
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid POS data", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_EmptyPosData_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(posData: "");
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid POS data", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000");
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid POS data", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_PosDataWithoutValidId_BadRequest()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit);
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Invalid POS data", badRequestResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_IncompleteInvoice_Ok()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice(status: "paid");
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||||
|
Assert.Equal("Waiting for invoice to be completed", okResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_ExistingTransaction_Ok()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var invoice = CreateValidInvoice();
|
||||||
|
var existingTransaction = new Transaction { GatewayId = invoice.Id };
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
var okResult = Assert.IsType<OkObjectResult>(result);
|
||||||
|
Assert.Equal("Invoice already processed", okResult.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_ValidOrganizationTransaction_Success()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var organizationId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
|
||||||
|
var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" };
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
|
||||||
|
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||||
|
_paymentService.CreditAccountAsync(organization, Arg.Any<decimal>()).Returns(true);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
Assert.IsType<OkResult>(result);
|
||||||
|
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
|
||||||
|
t.OrganizationId == organizationId &&
|
||||||
|
t.Type == TransactionType.Credit &&
|
||||||
|
t.Gateway == GatewayType.BitPay &&
|
||||||
|
t.PaymentMethodType == PaymentMethodType.BitPay));
|
||||||
|
await _organizationRepository.Received(1).ReplaceAsync(organization);
|
||||||
|
await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_ValidUserTransaction_Success()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
|
||||||
|
var user = new User { Id = userId, Email = "user@example.com" };
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
|
||||||
|
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
Assert.IsType<OkResult>(result);
|
||||||
|
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
|
||||||
|
t.UserId == userId &&
|
||||||
|
t.Type == TransactionType.Credit &&
|
||||||
|
t.Gateway == GatewayType.BitPay &&
|
||||||
|
t.PaymentMethodType == PaymentMethodType.BitPay));
|
||||||
|
await _premiumUserBillingService.Received(1).Credit(user, 100.00m);
|
||||||
|
await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostIpn_ValidProviderTransaction_Success()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var eventModel = CreateValidEventModel();
|
||||||
|
var providerId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
|
||||||
|
var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" };
|
||||||
|
|
||||||
|
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
|
||||||
|
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
|
||||||
|
_providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider));
|
||||||
|
_paymentService.CreditAccountAsync(provider, Arg.Any<decimal>()).Returns(true);
|
||||||
|
|
||||||
|
var result = await controller.PostIpn(eventModel, _validWebhookKey);
|
||||||
|
|
||||||
|
Assert.IsType<OkResult>(result);
|
||||||
|
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
|
||||||
|
t.ProviderId == providerId &&
|
||||||
|
t.Type == TransactionType.Credit &&
|
||||||
|
t.Gateway == GatewayType.BitPay &&
|
||||||
|
t.PaymentMethodType == PaymentMethodType.BitPay));
|
||||||
|
await _providerRepository.Received(1).ReplaceAsync(provider);
|
||||||
|
await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var organizationId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Equal(organizationId, result.OrganizationId);
|
||||||
|
Assert.Null(result.UserId);
|
||||||
|
Assert.Null(result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Null(result.OrganizationId);
|
||||||
|
Assert.Equal(userId, result.UserId);
|
||||||
|
Assert.Null(result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var providerId = Guid.NewGuid();
|
||||||
|
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Null(result.OrganizationId);
|
||||||
|
Assert.Null(result.UserId);
|
||||||
|
Assert.Equal(providerId, result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_InvalidGuid_ReturnsNull()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}");
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Null(result.OrganizationId);
|
||||||
|
Assert.Null(result.UserId);
|
||||||
|
Assert.Null(result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_NullPosData_ReturnsNull()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var invoice = CreateValidInvoice(posData: null!);
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Null(result.OrganizationId);
|
||||||
|
Assert.Null(result.UserId);
|
||||||
|
Assert.Null(result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetIdsFromPosData_EmptyPosData_ReturnsNull()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var invoice = CreateValidInvoice(posData: "");
|
||||||
|
|
||||||
|
var result = controller.GetIdsFromPosData(invoice);
|
||||||
|
|
||||||
|
Assert.Null(result.OrganizationId);
|
||||||
|
Assert.Null(result.UserId);
|
||||||
|
Assert.Null(result.ProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id")
|
||||||
|
{
|
||||||
|
return new BitPayEventModel
|
||||||
|
{
|
||||||
|
Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" },
|
||||||
|
Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete",
|
||||||
|
string currency = "USD", decimal price = 100.00m,
|
||||||
|
string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1")
|
||||||
|
{
|
||||||
|
return new Invoice
|
||||||
|
{
|
||||||
|
Id = invoiceId,
|
||||||
|
Status = status,
|
||||||
|
Currency = currency,
|
||||||
|
Price = (double)price,
|
||||||
|
PosData = posData,
|
||||||
|
CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
Transactions =
|
||||||
|
[
|
||||||
|
new InvoiceTransaction
|
||||||
|
{
|
||||||
|
Type = null,
|
||||||
|
Confirmations = "1",
|
||||||
|
ReceivedTime = DateTime.UtcNow.ToString("O")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Payment.Clients;
|
using Bit.Core.Billing.Payment.Clients;
|
||||||
using Bit.Core.Billing.Payment.Commands;
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
@@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice;
|
|||||||
|
|
||||||
namespace Bit.Core.Test.Billing.Payment.Commands;
|
namespace Bit.Core.Test.Billing.Payment.Commands;
|
||||||
|
|
||||||
|
using static BitPayConstants;
|
||||||
|
|
||||||
public class CreateBitPayInvoiceForCreditCommandTests
|
public class CreateBitPayInvoiceForCreditCommandTests
|
||||||
{
|
{
|
||||||
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
|
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
|
||||||
private readonly GlobalSettings _globalSettings = new()
|
private readonly GlobalSettings _globalSettings = new()
|
||||||
{
|
{
|
||||||
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" }
|
BitPay = new GlobalSettings.BitPaySettings
|
||||||
|
{
|
||||||
|
NotificationUrl = "https://example.com/bitpay/notification",
|
||||||
|
WebhookKey = "test-webhook-key"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
private const string _redirectUrl = "https://bitwarden.com/redirect";
|
private const string _redirectUrl = "https://bitwarden.com/redirect";
|
||||||
private readonly CreateBitPayInvoiceForCreditCommand _command;
|
private readonly CreateBitPayInvoiceForCreditCommand _command;
|
||||||
@@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
|||||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||||
options.Buyer.Email == user.Email &&
|
options.Buyer.Email == user.Email &&
|
||||||
options.Buyer.Name == user.Email &&
|
options.Buyer.Name == user.Email &&
|
||||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||||
options.PosData == $"userId:{user.Id},accountCredit:1" &&
|
options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" &&
|
||||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||||
options.Price == Convert.ToDouble(10M) &&
|
options.Price == Convert.ToDouble(10M) &&
|
||||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||||
@@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
|||||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||||
options.Buyer.Email == organization.BillingEmail &&
|
options.Buyer.Email == organization.BillingEmail &&
|
||||||
options.Buyer.Name == organization.Name &&
|
options.Buyer.Name == organization.Name &&
|
||||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||||
options.PosData == $"organizationId:{organization.Id},accountCredit:1" &&
|
options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" &&
|
||||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||||
options.Price == Convert.ToDouble(10M) &&
|
options.Price == Convert.ToDouble(10M) &&
|
||||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||||
@@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
|||||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||||
options.Buyer.Email == provider.BillingEmail &&
|
options.Buyer.Email == provider.BillingEmail &&
|
||||||
options.Buyer.Name == provider.Name &&
|
options.Buyer.Name == provider.Name &&
|
||||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||||
options.PosData == $"providerId:{provider.Id},accountCredit:1" &&
|
options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" &&
|
||||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||||
options.Price == Convert.ToDouble(10M) &&
|
options.Price == Convert.ToDouble(10M) &&
|
||||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||||
|
|||||||
Reference in New Issue
Block a user