1
0
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:
Kyle Spearrin 2019-02-20 15:17:23 -05:00
parent cdea940597
commit 312ced0e3b
6 changed files with 307 additions and 2 deletions

View File

@ -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; }

View File

@ -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();
}
}
}

View File

@ -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>();

View 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);
}
}
}
}

View File

@ -18,7 +18,8 @@
},
"billingSettings": {
"payPal": {
"production": true
"production": true,
"businessId": "4ZDA7DLUUJGMN"
}
}
}

View File

@ -59,6 +59,7 @@
"stripeWebhookSecret": "SECRET",
"payPal": {
"production": false,
"businessId": "AD3LAUZSNVPJY",
"clientId": "SECRET",
"clientSecret": "SECRET",
"webhookId": "SECRET",