mirror of
https://github.com/bitwarden/server.git
synced 2024-11-22 12:15:36 +01:00
paypal IPN processing for account credit
This commit is contained in:
parent
cdea940597
commit
312ced0e3b
@ -10,6 +10,7 @@
|
||||
public class PayPalSettings
|
||||
{
|
||||
public virtual bool Production { get; set; }
|
||||
public virtual string BusinessId { get; set; }
|
||||
public virtual string ClientId { get; set; }
|
||||
public virtual string ClientSecret { get; set; }
|
||||
public virtual string WebhookId { get; set; }
|
||||
|
@ -16,15 +16,18 @@ namespace Bit.Billing.Controllers
|
||||
{
|
||||
private readonly BillingSettings _billingSettings;
|
||||
private readonly PayPalClient _paypalClient;
|
||||
private readonly PayPalIpnClient _paypalIpnClient;
|
||||
private readonly ITransactionRepository _transactionRepository;
|
||||
|
||||
public PayPalController(
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
PayPalClient paypalClient,
|
||||
PayPalIpnClient paypalIpnClient,
|
||||
ITransactionRepository transactionRepository)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_paypalClient = paypalClient;
|
||||
_paypalIpnClient = paypalIpnClient;
|
||||
_transactionRepository = transactionRepository;
|
||||
}
|
||||
|
||||
@ -137,5 +140,129 @@ namespace Bit.Billing.Controllers
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
[HttpPost("ipn")]
|
||||
public async Task<IActionResult> PostIpn([FromQuery] string key)
|
||||
{
|
||||
if(key != _billingSettings.PayPal.WebhookKey)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
if(HttpContext?.Request == null)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
string body = null;
|
||||
using(var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
if(string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var verified = await _paypalIpnClient.VerifyIpnAsync(body);
|
||||
if(!verified)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var ipnTransaction = new PayPalIpnClient.IpnTransaction(body);
|
||||
if(ipnTransaction.ReceiverId != _billingSettings.PayPal.BusinessId || ipnTransaction.McCurrency != "USD")
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var ids = ipnTransaction.GetIdsFromCustom();
|
||||
if(!ids.Item1.HasValue && !ids.Item2.HasValue)
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
// Only processing credits via IPN for now
|
||||
if(!ipnTransaction.IsAccountCredit())
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
if(ipnTransaction.PaymentStatus == "Completed")
|
||||
{
|
||||
var transaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, ipnTransaction.TxnId);
|
||||
if(transaction == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction
|
||||
{
|
||||
Amount = ipnTransaction.McGross,
|
||||
CreationDate = ipnTransaction.PaymentDate,
|
||||
OrganizationId = ids.Item1,
|
||||
UserId = ids.Item2,
|
||||
Type = TransactionType.Charge,
|
||||
Gateway = GatewayType.PayPal,
|
||||
GatewayId = ipnTransaction.TxnId,
|
||||
PaymentMethodType = PaymentMethodType.PayPal,
|
||||
Details = ipnTransaction.TxnId
|
||||
});
|
||||
|
||||
if(ipnTransaction.IsAccountCredit())
|
||||
{
|
||||
// TODO: Issue Stripe credit to user/org account
|
||||
}
|
||||
}
|
||||
// Catch foreign key violations because user/org could have been deleted.
|
||||
catch(SqlException e) when(e.Number == 547) { }
|
||||
}
|
||||
}
|
||||
else if(ipnTransaction.PaymentStatus == "Refunded")
|
||||
{
|
||||
var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, ipnTransaction.TxnId);
|
||||
if(refundTransaction == null)
|
||||
{
|
||||
var parentTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal, ipnTransaction.ParentTxnId);
|
||||
if(parentTransaction == null)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
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 Core.Models.Table.Transaction
|
||||
{
|
||||
Amount = ipnTransaction.McGross,
|
||||
CreationDate = ipnTransaction.PaymentDate,
|
||||
OrganizationId = ids.Item1,
|
||||
UserId = ids.Item2,
|
||||
Type = TransactionType.Refund,
|
||||
Gateway = GatewayType.PayPal,
|
||||
GatewayId = ipnTransaction.TxnId,
|
||||
PaymentMethodType = PaymentMethodType.PayPal,
|
||||
Details = ipnTransaction.TxnId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -39,8 +39,9 @@ namespace Bit.Billing
|
||||
// Repositories
|
||||
services.AddSqlServerRepositories(globalSettings);
|
||||
|
||||
// PayPal Client
|
||||
// PayPal Clients
|
||||
services.AddSingleton<Utilities.PayPalClient>();
|
||||
services.AddSingleton<Utilities.PayPalIpnClient>();
|
||||
|
||||
// Context
|
||||
services.AddScoped<CurrentContext>();
|
||||
|
174
src/Billing/Utilities/PayPalIpnClient.cs
Normal file
174
src/Billing/Utilities/PayPalIpnClient.cs
Normal file
@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Utilities
|
||||
{
|
||||
public class PayPalIpnClient
|
||||
{
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
private readonly Uri _ipnUri;
|
||||
|
||||
public PayPalIpnClient(IOptions<BillingSettings> billingSettings)
|
||||
{
|
||||
var bSettings = billingSettings?.Value;
|
||||
_ipnUri = new Uri(bSettings.PayPal.Production ? "https://www.paypal.com/cgi-bin/webscr" :
|
||||
"https://www.sandbox.paypal.com/cgi-bin/webscr");
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyIpnAsync(string ipnBody)
|
||||
{
|
||||
if(ipnBody == null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw new Exception("Failed to verify IPN, status: " + response.StatusCode);
|
||||
}
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
if(responseContent.Equals("VERIFIED"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if(responseContent.Equals("INVALID"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
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<string>().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");
|
||||
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 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<Guid?, Guid?> 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<Guid?, Guid?>(orgId, userId);
|
||||
}
|
||||
|
||||
public bool IsAccountCredit()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(Custom) && Custom.Contains("account_credit:true");
|
||||
}
|
||||
|
||||
private string GetDictValue(IDictionary<string, string> 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)
|
||||
{
|
||||
return TimeZoneInfo.ConvertTimeToUtc(paymentDate,
|
||||
TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"));
|
||||
}
|
||||
}
|
||||
return default(DateTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -18,7 +18,8 @@
|
||||
},
|
||||
"billingSettings": {
|
||||
"payPal": {
|
||||
"production": true
|
||||
"production": true,
|
||||
"businessId": "4ZDA7DLUUJGMN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +59,7 @@
|
||||
"stripeWebhookSecret": "SECRET",
|
||||
"payPal": {
|
||||
"production": false,
|
||||
"businessId": "AD3LAUZSNVPJY",
|
||||
"clientId": "SECRET",
|
||||
"clientSecret": "SECRET",
|
||||
"webhookId": "SECRET",
|
||||
|
Loading…
Reference in New Issue
Block a user