diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index c0d3a2700..1621ee239 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -1,5 +1,6 @@ using System.Text; -using Bit.Billing.Utilities; +using Bit.Billing.Models; +using Bit.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -15,220 +16,256 @@ namespace Bit.Billing.Controllers; public class PayPalController : Controller { private readonly BillingSettings _billingSettings; - private readonly PayPalIpnClient _paypalIpnClient; - private readonly ITransactionRepository _transactionRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; - private readonly IMailService _mailService; - private readonly IPaymentService _paymentService; private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPaymentService _paymentService; + private readonly IPayPalIPNClient _payPalIPNClient; + private readonly ITransactionRepository _transactionRepository; + private readonly IUserRepository _userRepository; public PayPalController( IOptions billingSettings, - PayPalIpnClient paypalIpnClient, - ITransactionRepository transactionRepository, - IOrganizationRepository organizationRepository, - IUserRepository userRepository, + ILogger logger, IMailService mailService, + IOrganizationRepository organizationRepository, IPaymentService paymentService, - ILogger logger) + IPayPalIPNClient payPalIPNClient, + ITransactionRepository transactionRepository, + IUserRepository userRepository) { _billingSettings = billingSettings?.Value; - _paypalIpnClient = paypalIpnClient; - _transactionRepository = transactionRepository; - _organizationRepository = organizationRepository; - _userRepository = userRepository; - _mailService = mailService; - _paymentService = paymentService; _logger = logger; + _mailService = mailService; + _organizationRepository = organizationRepository; + _paymentService = paymentService; + _payPalIPNClient = payPalIPNClient; + _transactionRepository = transactionRepository; + _userRepository = userRepository; } [HttpPost("ipn")] public async Task PostIpn() { - _logger.LogDebug("PayPal webhook has been hit."); - if (HttpContext?.Request?.Query == null) + var key = HttpContext.Request.Query.ContainsKey("key") + ? HttpContext.Request.Query["key"].ToString() + : null; + + if (string.IsNullOrEmpty(key)) { - return new BadRequestResult(); + _logger.LogError("PayPal IPN: Key is missing"); + return BadRequest(); } - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.PayPal.WebhookKey)) { - _logger.LogWarning("PayPal webhook key is incorrect or does not exist."); - return new BadRequestResult(); + _logger.LogError("PayPal IPN: Key is incorrect"); + return BadRequest(); } - string body = null; - using (var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) + using var streamReader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8); + + var requestContent = await streamReader.ReadToEndAsync(); + + if (string.IsNullOrEmpty(requestContent)) { - body = await reader.ReadToEndAsync(); + _logger.LogError("PayPal IPN: Request body is null or empty"); + return BadRequest(); } - if (string.IsNullOrWhiteSpace(body)) + var transactionModel = new PayPalIPNTransactionModel(requestContent); + + var entityId = transactionModel.UserId ?? transactionModel.OrganizationId; + + if (!entityId.HasValue) { - return new BadRequestResult(); + _logger.LogError("PayPal IPN ({Id}): 'custom' did not contain a User ID or Organization ID", transactionModel.TransactionId); + return BadRequest(); } - var verified = await _paypalIpnClient.VerifyIpnAsync(body); + var verified = await _payPalIPNClient.VerifyIPN(entityId.Value, requestContent); + if (!verified) { - _logger.LogWarning("Unverified IPN received."); - return new BadRequestResult(); + _logger.LogError("PayPal IPN ({Id}): Verification failed", transactionModel.TransactionId); + return BadRequest(); } - var ipnTransaction = new PayPalIpnClient.IpnTransaction(body); - if (ipnTransaction.TxnType != "web_accept" && ipnTransaction.TxnType != "merch_pmt" && - ipnTransaction.PaymentStatus != "Refunded") + if (transactionModel.TransactionType != "web_accept" && + transactionModel.TransactionType != "merch_pmt" && + transactionModel.PaymentStatus != "Refunded") { - // Only processing billing agreement payments, buy now button payments, and refunds for now. - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction type ({Type}) not supported for payments", + transactionModel.TransactionId, + transactionModel.TransactionType); + + return Ok(); } - if (ipnTransaction.ReceiverId != _billingSettings.PayPal.BusinessId) + if (transactionModel.ReceiverId != _billingSettings.PayPal.BusinessId) { - _logger.LogWarning("Receiver was not proper business id. " + ipnTransaction.ReceiverId); - return new BadRequestResult(); + _logger.LogWarning( + "PayPal IPN ({Id}): Receiver ID ({ReceiverId}) does not match Bitwarden business ID ({BusinessId})", + transactionModel.TransactionId, + transactionModel.ReceiverId, + _billingSettings.PayPal.BusinessId); + + return Ok(); } - if (ipnTransaction.PaymentStatus == "Refunded" && ipnTransaction.ParentTxnId == null) + if (transactionModel.PaymentStatus == "Refunded" && string.IsNullOrEmpty(transactionModel.ParentTransactionId)) { - // Refunds require parent transaction - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Parent transaction ID is required for refund", transactionModel.TransactionId); + return Ok(); } - if (ipnTransaction.PaymentType == "echeck" && ipnTransaction.PaymentStatus != "Refunded") + if (transactionModel.PaymentType == "echeck" && transactionModel.PaymentStatus != "Refunded") { - // Not accepting eChecks, unless it is a refund - _logger.LogWarning("Got an eCheck payment. " + ipnTransaction.TxnId); - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction was an eCheck payment", transactionModel.TransactionId); + return Ok(); } - if (ipnTransaction.McCurrency != "USD") + if (transactionModel.MerchantCurrency != "USD") { - // Only process USD payments - _logger.LogWarning("Received a payment not in USD. " + ipnTransaction.TxnId); - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction was not in USD ({Currency})", + transactionModel.TransactionId, + transactionModel.MerchantCurrency); + + return Ok(); } - var ids = ipnTransaction.GetIdsFromCustom(); - if (!ids.Item1.HasValue && !ids.Item2.HasValue) + switch (transactionModel.PaymentStatus) { - return new OkResult(); - } - - if (ipnTransaction.PaymentStatus == "Completed") - { - var transaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.TxnId); - if (transaction != null) - { - _logger.LogWarning("Already processed this completed transaction. #" + ipnTransaction.TxnId); - return new OkResult(); - } - - var isAccountCredit = ipnTransaction.IsAccountCredit(); - try - { - var tx = new Transaction + case "Completed": { - Amount = ipnTransaction.McGross, - CreationDate = ipnTransaction.PaymentDate, - OrganizationId = ids.Item1, - UserId = ids.Item2, - Type = isAccountCredit ? TransactionType.Credit : TransactionType.Charge, - Gateway = GatewayType.PayPal, - GatewayId = ipnTransaction.TxnId, - PaymentMethodType = PaymentMethodType.PayPal, - Details = ipnTransaction.TxnId - }; - await _transactionRepository.CreateAsync(tx); + var existingTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.TransactionId); - if (isAccountCredit) - { - string billingEmail = null; - if (tx.OrganizationId.HasValue) + if (existingTransaction != null) { - var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); - if (org != null) + _logger.LogWarning("PayPal IPN ({Id}): Already processed this completed transaction", transactionModel.TransactionId); + return Ok(); + } + + try + { + var transaction = new Transaction { - billingEmail = org.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(org, tx.Amount)) - { - await _organizationRepository.ReplaceAsync(org); - } + Amount = transactionModel.MerchantGross, + CreationDate = transactionModel.PaymentDate, + OrganizationId = transactionModel.OrganizationId, + UserId = transactionModel.UserId, + Type = transactionModel.IsAccountCredit ? TransactionType.Credit : TransactionType.Charge, + Gateway = GatewayType.PayPal, + GatewayId = transactionModel.TransactionId, + PaymentMethodType = PaymentMethodType.PayPal, + Details = transactionModel.TransactionId + }; + + await _transactionRepository.CreateAsync(transaction); + + if (transactionModel.IsAccountCredit) + { + await ApplyCreditAsync(transaction); } } - else + // Catch foreign key violations because user/org could have been deleted. + catch (SqlException sqlException) when (sqlException.Number == 547) { - var user = await _userRepository.GetByIdAsync(tx.UserId.Value); - if (user != null) + _logger.LogError("PayPal IPN ({Id}): SQL Exception | {Message}", transactionModel.TransactionId, sqlException.Message); + } + + break; + } + case "Refunded" or "Reversed": + { + var existingTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.TransactionId); + + if (existingTransaction != null) + { + _logger.LogWarning("PayPal IPN ({Id}): Already processed this refunded transaction", transactionModel.TransactionId); + return Ok(); + } + + var parentTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.ParentTransactionId); + + if (parentTransaction == null) + { + _logger.LogError("PayPal IPN ({Id}): Could not find parent transaction", transactionModel.TransactionId); + return BadRequest(); + } + + var refundAmount = Math.Abs(transactionModel.MerchantGross); + + var remainingAmount = parentTransaction.Amount - parentTransaction.RefundedAmount.GetValueOrDefault(); + + if (refundAmount > 0 && !parentTransaction.Refunded.GetValueOrDefault() && remainingAmount >= refundAmount) + { + parentTransaction.RefundedAmount = parentTransaction.RefundedAmount.GetValueOrDefault() + refundAmount; + + if (parentTransaction.RefundedAmount == parentTransaction.Amount) { - billingEmail = user.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(user, tx.Amount)) - { - await _userRepository.ReplaceAsync(user); - } + parentTransaction.Refunded = true; } + + await _transactionRepository.ReplaceAsync(parentTransaction); + + await _transactionRepository.CreateAsync(new Transaction + { + Amount = refundAmount, + CreationDate = transactionModel.PaymentDate, + OrganizationId = transactionModel.OrganizationId, + UserId = transactionModel.UserId, + Type = TransactionType.Refund, + Gateway = GatewayType.PayPal, + GatewayId = transactionModel.TransactionId, + PaymentMethodType = PaymentMethodType.PayPal, + Details = transactionModel.TransactionId + }); } - if (!string.IsNullOrWhiteSpace(billingEmail)) - { - await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); - } + break; } - } - // Catch foreign key violations because user/org could have been deleted. - catch (SqlException e) when (e.Number == 547) { } } - else if (ipnTransaction.PaymentStatus == "Refunded" || ipnTransaction.PaymentStatus == "Reversed") + + return Ok(); + } + + private async Task ApplyCreditAsync(Transaction transaction) + { + string billingEmail = null; + + if (transaction.OrganizationId.HasValue) { - var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.TxnId); - if (refundTransaction != null) + var organization = await _organizationRepository.GetByIdAsync(transaction.OrganizationId.Value); + + if (await _paymentService.CreditAccountAsync(organization, transaction.Amount)) { - _logger.LogWarning("Already processed this refunded transaction. #" + ipnTransaction.TxnId); - return new OkResult(); + await _organizationRepository.ReplaceAsync(organization); + + billingEmail = organization.BillingEmailAddress(); } + } + else if (transaction.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(transaction.UserId.Value); - var parentTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.ParentTxnId); - if (parentTransaction == null) + if (await _paymentService.CreditAccountAsync(user, transaction.Amount)) { - _logger.LogWarning("Parent transaction was not found. " + ipnTransaction.TxnId); - return new BadRequestResult(); - } + await _userRepository.ReplaceAsync(user); - var refundAmount = System.Math.Abs(ipnTransaction.McGross); - var remainingAmount = parentTransaction.Amount - - parentTransaction.RefundedAmount.GetValueOrDefault(); - if (refundAmount > 0 && !parentTransaction.Refunded.GetValueOrDefault() && - remainingAmount >= refundAmount) - { - parentTransaction.RefundedAmount = - parentTransaction.RefundedAmount.GetValueOrDefault() + refundAmount; - if (parentTransaction.RefundedAmount == parentTransaction.Amount) - { - parentTransaction.Refunded = true; - } - - await _transactionRepository.ReplaceAsync(parentTransaction); - await _transactionRepository.CreateAsync(new Transaction - { - Amount = refundAmount, - CreationDate = ipnTransaction.PaymentDate, - OrganizationId = ids.Item1, - UserId = ids.Item2, - Type = TransactionType.Refund, - Gateway = GatewayType.PayPal, - GatewayId = ipnTransaction.TxnId, - PaymentMethodType = PaymentMethodType.PayPal, - Details = ipnTransaction.TxnId - }); + billingEmail = user.BillingEmailAddress(); } } - return new OkResult(); + if (!string.IsNullOrEmpty(billingEmail)) + { + await _mailService.SendAddedCreditAsync(billingEmail, transaction.Amount); + } } } diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs new file mode 100644 index 000000000..c2d9f4657 --- /dev/null +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Web; + +namespace Bit.Billing.Models; + +public class PayPalIPNTransactionModel +{ + public string TransactionId { get; } + public string TransactionType { get; } + public string ParentTransactionId { get; } + public string PaymentStatus { get; } + public string PaymentType { get; } + public decimal MerchantGross { get; } + public string MerchantCurrency { get; } + public string ReceiverId { get; } + public DateTime PaymentDate { get; } + public Guid? UserId { get; } + public Guid? OrganizationId { get; } + public bool IsAccountCredit { get; } + + public PayPalIPNTransactionModel(string formData) + { + var queryString = HttpUtility.ParseQueryString(formData); + + var data = queryString + .AllKeys + .ToDictionary(key => key, key => queryString[key]); + + TransactionId = Extract(data, "txn_id"); + TransactionType = Extract(data, "txn_type"); + ParentTransactionId = Extract(data, "parent_txn_id"); + PaymentStatus = Extract(data, "payment_status"); + PaymentType = Extract(data, "payment_type"); + + var merchantGross = Extract(data, "mc_gross"); + if (!string.IsNullOrEmpty(merchantGross)) + { + MerchantGross = decimal.Parse(merchantGross); + } + + MerchantCurrency = Extract(data, "mc_currency"); + ReceiverId = Extract(data, "receiver_id"); + + var paymentDate = Extract(data, "payment_date"); + PaymentDate = ToUTCDateTime(paymentDate); + + var custom = Extract(data, "custom"); + + if (string.IsNullOrEmpty(custom)) + { + return; + } + + var metadata = custom.Split(',') + .Where(field => !string.IsNullOrEmpty(field) && field.Contains(':')) + .Select(field => field.Split(':')) + .ToDictionary(parts => parts[0], parts => parts[1]); + + if (metadata.TryGetValue("user_id", out var userIdStr) && + Guid.TryParse(userIdStr, out var userId)) + { + UserId = userId; + } + + if (metadata.TryGetValue("organization_id", out var organizationIdStr) && + Guid.TryParse(organizationIdStr, out var organizationId)) + { + OrganizationId = organizationId; + } + + IsAccountCredit = custom.Contains("account_credit:1"); + } + + private static string Extract(IReadOnlyDictionary data, string key) + { + var success = data.TryGetValue(key, out var value); + return success ? value : null; + } + + private static DateTime ToUTCDateTime(string input) + { + if (string.IsNullOrEmpty(input)) + { + return default; + } + + var success = DateTime.TryParseExact(input, + new[] + { + "HH:mm:ss dd MMM yyyy PDT", + "HH:mm:ss dd MMM yyyy PST", + "HH:mm:ss dd MMM, yyyy PST", + "HH:mm:ss dd MMM, yyyy PDT", + "HH:mm:ss MMM dd, yyyy PST", + "HH:mm:ss MMM dd, yyyy PDT" + }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime); + + if (!success) + { + return default; + } + + var pacificTime = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time") + : TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); + + return TimeZoneInfo.ConvertTimeToUtc(dateTime, pacificTime); + } +} diff --git a/src/Billing/Services/IPayPalIPNClient.cs b/src/Billing/Services/IPayPalIPNClient.cs new file mode 100644 index 000000000..3b3d4cede --- /dev/null +++ b/src/Billing/Services/IPayPalIPNClient.cs @@ -0,0 +1,6 @@ +namespace Bit.Billing.Services; + +public interface IPayPalIPNClient +{ + Task VerifyIPN(Guid entityId, string formData); +} diff --git a/src/Billing/Services/Implementations/PayPalIPNClient.cs b/src/Billing/Services/Implementations/PayPalIPNClient.cs new file mode 100644 index 000000000..f0f20499b --- /dev/null +++ b/src/Billing/Services/Implementations/PayPalIPNClient.cs @@ -0,0 +1,86 @@ +using System.Text; +using Microsoft.Extensions.Options; + +namespace Bit.Billing.Services.Implementations; + +public class PayPalIPNClient : IPayPalIPNClient +{ + private readonly HttpClient _httpClient; + private readonly Uri _ipnEndpoint; + private readonly ILogger _logger; + + public PayPalIPNClient( + IOptions billingSettings, + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _ipnEndpoint = new Uri(billingSettings.Value.PayPal.Production + ? "https://www.paypal.com/cgi-bin/webscr" + : "https://www.sandbox.paypal.com/cgi-bin/webscr"); + _logger = logger; + } + + public async Task VerifyIPN(Guid entityId, string formData) + { + LogInfo(entityId, $"Verifying IPN against {_ipnEndpoint}"); + + if (string.IsNullOrEmpty(formData)) + { + throw new ArgumentNullException(nameof(formData)); + } + + var requestMessage = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = _ipnEndpoint }; + + var requestContent = string.Concat("cmd=_notify-validate&", formData); + + LogInfo(entityId, $"Request Content: {requestContent}"); + + requestMessage.Content = new StringContent(requestContent, Encoding.UTF8, "application/x-www-form-urlencoded"); + + var response = await _httpClient.SendAsync(requestMessage); + + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return responseContent switch + { + "VERIFIED" => Verified(), + "INVALID" => Invalid(), + _ => Unhandled(responseContent) + }; + } + + LogError(entityId, $"Unsuccessful Response | Status Code: {response.StatusCode} | Content: {responseContent}"); + + return false; + + bool Verified() + { + LogInfo(entityId, "Verified"); + return true; + } + + bool Invalid() + { + LogError(entityId, "Verification Invalid"); + return false; + } + + bool Unhandled(string content) + { + LogWarning(entityId, $"Unhandled Response Content: {content}"); + return false; + } + } + + private void LogInfo(Guid entityId, string message) + => _logger.LogInformation("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); + + private void LogWarning(Guid entityId, string message) + => _logger.LogWarning("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); + + private void LogError(Guid entityId, string message) + => _logger.LogError("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cbde05ce0..f4436f6c5 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -43,12 +43,12 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - // PayPal Client - services.AddSingleton(); - // BitPay Client services.AddSingleton(); + // PayPal IPN Client + services.AddHttpClient(); + // Context services.AddScoped(); diff --git a/src/Billing/Utilities/PayPalIpnClient.cs b/src/Billing/Utilities/PayPalIpnClient.cs deleted file mode 100644 index 0534faf76..000000000 --- a/src/Billing/Utilities/PayPalIpnClient.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Globalization; -using System.Runtime.InteropServices; -using System.Text; -using System.Web; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Utilities; - -public class PayPalIpnClient -{ - private readonly HttpClient _httpClient = new HttpClient(); - private readonly Uri _ipnUri; - private readonly ILogger _logger; - - public PayPalIpnClient(IOptions billingSettings, ILogger logger) - { - var bSettings = billingSettings?.Value; - _logger = logger; - _ipnUri = new Uri(bSettings.PayPal.Production ? "https://www.paypal.com/cgi-bin/webscr" : - "https://www.sandbox.paypal.com/cgi-bin/webscr"); - } - - public async Task VerifyIpnAsync(string ipnBody) - { - _logger.LogInformation("Verifying IPN with PayPal at {Timestamp}: {VerificationUri}", DateTime.UtcNow, _ipnUri); - if (ipnBody == null) - { - _logger.LogError("No IPN body."); - throw new ArgumentException("No IPN body."); - } - - var request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = _ipnUri }; - var cmdIpnBody = string.Concat("cmd=_notify-validate&", ipnBody); - request.Content = new StringContent(cmdIpnBody, Encoding.UTF8, "application/x-www-form-urlencoded"); - var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - _logger.LogError("Failed to receive a successful response from PayPal IPN verification service. Response: {Response}", response); - throw new Exception("Failed to verify IPN, status: " + response.StatusCode); - } - - var responseContent = await response.Content.ReadAsStringAsync(); - if (responseContent.Equals("VERIFIED")) - { - return true; - } - - if (responseContent.Equals("INVALID")) - { - _logger.LogWarning("Received an INVALID response from PayPal: {ResponseContent}", responseContent); - return false; - } - - _logger.LogError("Failed to verify IPN: {ResponseContent}", responseContent); - throw new Exception("Failed to verify IPN."); - } - - public class IpnTransaction - { - private string[] _dateFormats = new string[] - { - "HH:mm:ss dd MMM yyyy PDT", "HH:mm:ss dd MMM yyyy PST", "HH:mm:ss dd MMM, yyyy PST", - "HH:mm:ss dd MMM, yyyy PDT","HH:mm:ss MMM dd, yyyy PST", "HH:mm:ss MMM dd, yyyy PDT" - }; - - public IpnTransaction(string ipnFormData) - { - if (string.IsNullOrWhiteSpace(ipnFormData)) - { - return; - } - - var qsData = HttpUtility.ParseQueryString(ipnFormData); - var dataDict = qsData.Keys.Cast().ToDictionary(k => k, v => qsData[v].ToString()); - - TxnId = GetDictValue(dataDict, "txn_id"); - TxnType = GetDictValue(dataDict, "txn_type"); - ParentTxnId = GetDictValue(dataDict, "parent_txn_id"); - PaymentStatus = GetDictValue(dataDict, "payment_status"); - PaymentType = GetDictValue(dataDict, "payment_type"); - McCurrency = GetDictValue(dataDict, "mc_currency"); - Custom = GetDictValue(dataDict, "custom"); - ItemName = GetDictValue(dataDict, "item_name"); - ItemNumber = GetDictValue(dataDict, "item_number"); - PayerId = GetDictValue(dataDict, "payer_id"); - PayerEmail = GetDictValue(dataDict, "payer_email"); - ReceiverId = GetDictValue(dataDict, "receiver_id"); - ReceiverEmail = GetDictValue(dataDict, "receiver_email"); - - PaymentDate = ConvertDate(GetDictValue(dataDict, "payment_date")); - - var mcGrossString = GetDictValue(dataDict, "mc_gross"); - if (!string.IsNullOrWhiteSpace(mcGrossString) && decimal.TryParse(mcGrossString, out var mcGross)) - { - McGross = mcGross; - } - var mcFeeString = GetDictValue(dataDict, "mc_fee"); - if (!string.IsNullOrWhiteSpace(mcFeeString) && decimal.TryParse(mcFeeString, out var mcFee)) - { - McFee = mcFee; - } - } - - public string TxnId { get; set; } - public string TxnType { get; set; } - public string ParentTxnId { get; set; } - public string PaymentStatus { get; set; } - public string PaymentType { get; set; } - public decimal McGross { get; set; } - public decimal McFee { get; set; } - public string McCurrency { get; set; } - public string Custom { get; set; } - public string ItemName { get; set; } - public string ItemNumber { get; set; } - public string PayerId { get; set; } - public string PayerEmail { get; set; } - public string ReceiverId { get; set; } - public string ReceiverEmail { get; set; } - public DateTime PaymentDate { get; set; } - - public Tuple GetIdsFromCustom() - { - Guid? orgId = null; - Guid? userId = null; - - if (!string.IsNullOrWhiteSpace(Custom) && Custom.Contains(":")) - { - var mainParts = Custom.Split(','); - foreach (var mainPart in mainParts) - { - var parts = mainPart.Split(':'); - if (parts.Length > 1 && Guid.TryParse(parts[1], out var id)) - { - if (parts[0] == "user_id") - { - userId = id; - } - else if (parts[0] == "organization_id") - { - orgId = id; - } - } - } - } - - return new Tuple(orgId, userId); - } - - public bool IsAccountCredit() - { - return !string.IsNullOrWhiteSpace(Custom) && Custom.Contains("account_credit:1"); - } - - private string GetDictValue(IDictionary dict, string key) - { - return dict.ContainsKey(key) ? dict[key] : null; - } - - private DateTime ConvertDate(string dateString) - { - if (!string.IsNullOrWhiteSpace(dateString)) - { - var parsed = DateTime.TryParseExact(dateString, _dateFormats, - CultureInfo.InvariantCulture, DateTimeStyles.None, out var paymentDate); - if (parsed) - { - var pacificTime = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time") : - TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); - return TimeZoneInfo.ConvertTimeToUtc(paymentDate, pacificTime); - } - } - return default(DateTime); - } - } -} diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index 302f590ad..0bd8368f4 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -5,8 +5,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -44,6 +46,33 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs new file mode 100644 index 000000000..7d4bfc36b --- /dev/null +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -0,0 +1,644 @@ +using System.Text; +using Bit.Billing.Controllers; +using Bit.Billing.Services; +using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Divergic.Logging.Xunit; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using Xunit.Abstractions; +using Transaction = Bit.Core.Entities.Transaction; + +namespace Bit.Billing.Test.Controllers; + +public class PayPalControllerTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + private readonly IOptions _billingSettings = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IPaymentService _paymentService = Substitute.For(); + private readonly IPayPalIPNClient _payPalIPNClient = Substitute.For(); + private readonly ITransactionRepository _transactionRepository = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private const string _defaultWebhookKey = "webhook-key"; + + public PayPalControllerTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task PostIpn_NullKey_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + var controller = ConfigureControllerContextWith(logger, null, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Key is missing"); + } + + [Fact] + public async Task PostIpn_IncorrectKey_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = "INCORRECT" } + }); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Key is incorrect"); + } + + [Fact] + public async Task PostIpn_EmptyIPNBody_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Request body is null or empty"); + } + + [Fact] + public async Task PostIpn_IPNHasNoEntityId_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.TransactionMissingEntityIds); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): 'custom' did not contain a User ID or Organization ID"); + } + + [Fact] + public async Task PostIpn_Unverified_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(false); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): Verification failed"); + } + + [Fact] + public async Task PostIpn_OtherTransactionType_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.UnsupportedTransactionType); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction type (other) not supported for payments"); + } + + [Fact] + public async Task PostIpn_MismatchedReceiverID_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "INCORRECT" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Receiver ID (NHDYKLQ3L4LWL) does not match Bitwarden business ID (INCORRECT)"); + } + + [Fact] + public async Task PostIpn_RefundMissingParent_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.RefundMissingParentTransaction); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Parent transaction ID is required for refund"); + } + + [Fact] + public async Task PostIpn_eCheckPayment_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.ECheckPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction was an eCheck payment"); + } + + [Fact] + public async Task PostIpn_NonUSD_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.NonUSDPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction was not in USD (CAD)"); + } + + [Fact] + public async Task PostIpn_Completed_ExistingTransaction_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").Returns(new Transaction()); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Already processed this completed transaction"); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received().CreateAsync(Arg.Any()); + + await _paymentService.DidNotReceiveWithAnyArgs().CreditAccountAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_CreditsOrganizationAccount_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPaymentForOrganizationCredit); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + const string billingEmail = "billing@organization.com"; + + var organization = new Organization { BillingEmail = billingEmail }; + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _paymentService.CreditAccountAsync(organization, 48M).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.OrganizationId == organizationId && + transaction.Amount == 48M)); + + await _paymentService.Received(1).CreditAccountAsync(organization, 48M); + + await _organizationRepository.Received(1).ReplaceAsync(organization); + + await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_CreditsUserAccount_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var userId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPaymentForUserCredit); + + _payPalIPNClient.VerifyIPN(userId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + const string billingEmail = "billing@user.com"; + + var user = new User { Email = billingEmail }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + _paymentService.CreditAccountAsync(user, 48M).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.UserId == userId && + transaction.Amount == 48M)); + + await _paymentService.Received(1).CreditAccountAsync(user, 48M); + + await _userRepository.Received(1).ReplaceAsync(user); + + await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); + } + + [Fact] + public async Task PostIpn_Refunded_ExistingTransaction_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").Returns(new Transaction()); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Already processed this refunded transaction"); + + await _transactionRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + + await _transactionRepository.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task PostIpn_Refunded_MissingParentTransaction_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "PARENT").ReturnsNull(); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): Could not find parent transaction"); + + await _transactionRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + + await _transactionRepository.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task PostIpn_Refunded_ReplacesParent_CreatesTransaction_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + var parentTransaction = new Transaction + { + GatewayId = "PARENT", + Amount = 48M, + RefundedAmount = 0, + Refunded = false + }; + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "PARENT").Returns(parentTransaction); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).ReplaceAsync(Arg.Is(transaction => + transaction.GatewayId == "PARENT" && + transaction.RefundedAmount == 48M && + transaction.Refunded == true)); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.Amount == 48M && + transaction.OrganizationId == organizationId && + transaction.Type == TransactionType.Refund)); + } + + private PayPalController ConfigureControllerContextWith( + ILogger logger, + string webhookKey, + string ipnBody) + { + var controller = new PayPalController( + _billingSettings, + logger, + _mailService, + _organizationRepository, + _paymentService, + _payPalIPNClient, + _transactionRepository, + _userRepository); + + var httpContext = new DefaultHttpContext(); + + if (!string.IsNullOrEmpty(webhookKey)) + { + httpContext.Request.Query = new QueryCollection(new Dictionary + { + { "key", new StringValues(webhookKey) } + }); + } + + if (!string.IsNullOrEmpty(ipnBody)) + { + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(ipnBody)); + + httpContext.Request.Body = memoryStream; + httpContext.Request.ContentLength = memoryStream.Length; + } + + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + return controller; + } + + private static void HasStatusCode(IActionResult result, int statusCode) + { + var statusCodeActionResult = (IStatusCodeActionResult)result; + + statusCodeActionResult.StatusCode.Should().Be(statusCode); + } + + private static void Logged(ICacheLogger logger, LogLevel logLevel, string message) + { + logger.Last.Should().NotBeNull(); + logger.Last!.LogLevel.Should().Be(logLevel); + logger.Last!.Message.Should().Be(message); + } + + private static void LoggedError(ICacheLogger logger, string message) + => Logged(logger, LogLevel.Error, message); + + private static void LoggedWarning(ICacheLogger logger, string message) + => Logged(logger, LogLevel.Warning, message); +} diff --git a/test/Billing.Test/Resources/IPN/echeck-payment.txt b/test/Billing.Test/Resources/IPN/echeck-payment.txt new file mode 100644 index 000000000..40294f014 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/echeck-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=echeck& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/non-usd-payment.txt b/test/Billing.Test/Resources/IPN/non-usd-payment.txt new file mode 100644 index 000000000..593308f97 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/non-usd-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=CAD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=CAD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt b/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt new file mode 100644 index 000000000..f3228c24d --- /dev/null +++ b/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Refunded& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt b/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt new file mode 100644 index 000000000..7ea976adc --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS%2Caccount_credit%3A1& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt b/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt new file mode 100644 index 000000000..714d143ba --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=user_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS%2Caccount_credit%3A1& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment.txt b/test/Billing.Test/Resources/IPN/successful-payment.txt new file mode 100644 index 000000000..3192fedd6 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-refund.txt b/test/Billing.Test/Resources/IPN/successful-refund.txt new file mode 100644 index 000000000..d77093d33 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-refund.txt @@ -0,0 +1,41 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Refunded& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134& +parent_txn_id=PARENT diff --git a/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt b/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt new file mode 100644 index 000000000..5156ff448 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=region%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt b/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt new file mode 100644 index 000000000..8d261d982 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=other& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Services/PayPalIPNClientTests.cs b/test/Billing.Test/Services/PayPalIPNClientTests.cs new file mode 100644 index 000000000..cbf2997c0 --- /dev/null +++ b/test/Billing.Test/Services/PayPalIPNClientTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; + +namespace Bit.Billing.Test.Services; + +public class PayPalIPNClientTests +{ + private readonly Uri _endpoint = new("https://www.sandbox.paypal.com/cgi-bin/webscr"); + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + + private readonly IOptions _billingSettings = Substitute.For>(); + private readonly ILogger _logger = Substitute.For>(); + + private readonly IPayPalIPNClient _payPalIPNClient; + + public PayPalIPNClientTests() + { + var httpClient = new HttpClient(_mockHttpMessageHandler) + { + BaseAddress = _endpoint + }; + + _payPalIPNClient = new PayPalIPNClient( + _billingSettings, + httpClient, + _logger); + } + + [Fact] + public async Task VerifyIPN_FormDataNull_ThrowsArgumentNullException() + => await Assert.ThrowsAsync(() => _payPalIPNClient.VerifyIPN(Guid.NewGuid(), null)); + + [Fact] + public async Task VerifyIPN_Unauthorized_ReturnsFalse() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond(HttpStatusCode.Unauthorized); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.False(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } + + [Fact] + public async Task VerifyIPN_OK_Invalid_ReturnsFalse() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond("application/text", "INVALID"); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.False(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } + + [Fact] + public async Task VerifyIPN_OK_Verified_ReturnsTrue() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond("application/text", "VERIFIED"); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.True(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } +} diff --git a/test/Billing.Test/Utilities/PayPalTestIPN.cs b/test/Billing.Test/Utilities/PayPalTestIPN.cs new file mode 100644 index 000000000..2697851a8 --- /dev/null +++ b/test/Billing.Test/Utilities/PayPalTestIPN.cs @@ -0,0 +1,37 @@ +namespace Bit.Billing.Test.Utilities; + +public enum IPNBody +{ + SuccessfulPayment, + ECheckPayment, + TransactionMissingEntityIds, + NonUSDPayment, + SuccessfulPaymentForOrganizationCredit, + UnsupportedTransactionType, + SuccessfulRefund, + RefundMissingParentTransaction, + SuccessfulPaymentForUserCredit +} + +public static class PayPalTestIPN +{ + public static async Task GetAsync(IPNBody ipnBody) + { + var fileName = ipnBody switch + { + IPNBody.ECheckPayment => "echeck-payment.txt", + IPNBody.NonUSDPayment => "non-usd-payment.txt", + IPNBody.RefundMissingParentTransaction => "refund-missing-parent-transaction.txt", + IPNBody.SuccessfulPayment => "successful-payment.txt", + IPNBody.SuccessfulPaymentForOrganizationCredit => "successful-payment-org-credit.txt", + IPNBody.SuccessfulRefund => "successful-refund.txt", + IPNBody.SuccessfulPaymentForUserCredit => "successful-payment-user-credit.txt", + IPNBody.TransactionMissingEntityIds => "transaction-missing-entity-ids.txt", + IPNBody.UnsupportedTransactionType => "unsupported-transaction-type.txt" + }; + + var content = await EmbeddedResourceReader.ReadAsync("IPN", fileName); + + return content.Replace("\n", string.Empty); + } +}