diff --git a/src/Billing/Controllers/PaypalController.cs b/src/Billing/Controllers/PaypalController.cs index 458ba9fa86..73f04b4e33 100644 --- a/src/Billing/Controllers/PaypalController.cs +++ b/src/Billing/Controllers/PaypalController.cs @@ -1,4 +1,6 @@ using Bit.Billing.Utilities; +using Bit.Core.Enums; +using Bit.Core.Repositories; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -13,13 +15,16 @@ namespace Bit.Billing.Controllers { private readonly BillingSettings _billingSettings; private readonly PaypalClient _paypalClient; + private readonly ITransactionRepository _transactionRepository; public PaypalController( IOptions billingSettings, - PaypalClient paypalClient) + PaypalClient paypalClient, + ITransactionRepository transactionRepository) { _billingSettings = billingSettings?.Value; _paypalClient = paypalClient; + _transactionRepository = transactionRepository; } [HttpPost("webhook")] @@ -48,8 +53,66 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - var webhook = JsonConvert.DeserializeObject(body); - // TODO: process webhook + if(body.Contains("\"PAYMENT.SALE.COMPLETED\"")) + { + var ev = JsonConvert.DeserializeObject>(body); + var sale = ev.Resource; + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, sale.Id); + if(saleTransaction == null) + { + var ids = sale.GetIdsFromCustom(); + if(ids.Item1.HasValue || ids.Item2.HasValue) + { + await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + { + Amount = sale.Amount.TotalAmount, + CreationDate = sale.CreateTime, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = sale.GetCreditFromCustom() ? TransactionType.Credit : TransactionType.Charge, + Gateway = GatewayType.PayPal, + GatewayId = sale.Id, + PaymentMethodType = PaymentMethodType.PayPal + }); + } + } + } + else if(body.Contains("\"PAYMENT.SALE.REFUNDED\"")) + { + var ev = JsonConvert.DeserializeObject>(body); + var refund = ev.Resource; + var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.Id); + if(refundTransaction == null) + { + var ids = refund.GetIdsFromCustom(); + if(ids.Item1.HasValue || ids.Item2.HasValue) + { + await _transactionRepository.CreateAsync(new Core.Models.Table.Transaction + { + Amount = refund.Amount.TotalAmount, + CreationDate = refund.CreateTime, + OrganizationId = ids.Item1, + UserId = ids.Item2, + Type = TransactionType.Refund, + Gateway = GatewayType.PayPal, + GatewayId = refund.Id, + PaymentMethodType = PaymentMethodType.PayPal + }); + } + + var saleTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, refund.SaleId); + if(saleTransaction != null) + { + saleTransaction.Refunded = true; + saleTransaction.RefundedAmount = refund.TotalRefundedAmount.ValueAmount; + await _transactionRepository.ReplaceAsync(saleTransaction); + } + } + } + return new OkResult(); } } diff --git a/src/Billing/Utilities/PaypalClient.cs b/src/Billing/Utilities/PaypalClient.cs index b40900d09e..95281baf17 100644 --- a/src/Billing/Utilities/PaypalClient.cs +++ b/src/Billing/Utilities/PaypalClient.cs @@ -156,5 +156,87 @@ namespace Bit.Billing.Utilities public long ExpiresIn { get; set; } public bool Expired => DateTime.UtcNow > _created.AddSeconds(ExpiresIn - 30); } + + public class Event + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("event_type")] + public string EventType { get; set; } + [JsonProperty("resource_type")] + public string ResourceType { get; set; } + [JsonProperty("create_time")] + public DateTime CreateTime { get; set; } + public T Resource { get; set; } + } + + public class Refund : Sale + { + [JsonProperty("total_refunded_amount")] + public ValueInfo TotalRefundedAmount { get; set; } + [JsonProperty("sale_id")] + public string SaleId { get; set; } + } + + public class Sale + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("state")] + public string State { get; set; } + [JsonProperty("amount")] + public AmountInfo Amount { get; set; } + [JsonProperty("parent_payment")] + public string ParentPayment { get; set; } + [JsonProperty("custom")] + public string Custom { get; set; } + [JsonProperty("create_time")] + public DateTime CreateTime { get; set; } + [JsonProperty("update_time")] + public DateTime UpdateTime { get; set; } + + public Tuple GetIdsFromCustom() + { + Guid? orgId = null; + Guid? userId = null; + + if(!string.IsNullOrWhiteSpace(Custom) && Custom.Contains(":")) + { + var parts = Custom.Split(':'); + if(parts.Length > 1 && Guid.TryParse(parts[1], out var id)) + { + if(parts[0] == "user_id") + { + userId = id; + } + else if(parts[0] == "organization_id") + { + orgId = id; + } + } + } + + return new Tuple(orgId, userId); + } + + public bool GetCreditFromCustom() + { + return Custom.Contains("credit:true"); + } + } + + public class AmountInfo + { + [JsonProperty("total")] + public string Total { get; set; } + public decimal TotalAmount => Convert.ToDecimal(Total); + } + + public class ValueInfo + { + [JsonProperty("value")] + public string Value { get; set; } + public decimal ValueAmount => Convert.ToDecimal(Value); + } } } diff --git a/src/Core/Enums/GatewayType.cs b/src/Core/Enums/GatewayType.cs index 5a9dcdbd76..a8ceff36d0 100644 --- a/src/Core/Enums/GatewayType.cs +++ b/src/Core/Enums/GatewayType.cs @@ -13,6 +13,8 @@ namespace Bit.Core.Enums [Display(Name = "Google Play Store")] PlayStore = 3, [Display(Name = "Coinbase")] - Coinbase = 4 + Coinbase = 4, + [Display(Name = "PayPal")] + PayPal = 1, } } diff --git a/src/Core/Enums/TransactionType.cs b/src/Core/Enums/TransactionType.cs index 40ec69f31e..45baa68c06 100644 --- a/src/Core/Enums/TransactionType.cs +++ b/src/Core/Enums/TransactionType.cs @@ -5,6 +5,7 @@ Charge = 0, Credit = 1, PromotionalCredit = 2, - ReferralCredit = 3 + ReferralCredit = 3, + Refund = 4, } } diff --git a/src/Core/Models/Table/Transaction.cs b/src/Core/Models/Table/Transaction.cs index 2e6e6c6d91..637f40f403 100644 --- a/src/Core/Models/Table/Transaction.cs +++ b/src/Core/Models/Table/Transaction.cs @@ -17,7 +17,7 @@ namespace Bit.Core.Models.Table public PaymentMethodType? PaymentMethodType { get; set; } public GatewayType? Gateway { get; set; } public string GatewayId { get; set; } - public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + public DateTime CreationDate { get; set; } = DateTime.UtcNow; public void SetNewId() { diff --git a/src/Core/Repositories/ITransactionRepository.cs b/src/Core/Repositories/ITransactionRepository.cs index d1248cbe86..242dcfa3ce 100644 --- a/src/Core/Repositories/ITransactionRepository.cs +++ b/src/Core/Repositories/ITransactionRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Models.Table; using System.Collections.Generic; using System.Threading.Tasks; +using Bit.Core.Enums; namespace Bit.Core.Repositories { @@ -9,5 +10,6 @@ namespace Bit.Core.Repositories { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId); } } diff --git a/src/Core/Repositories/SqlServer/TransactionRepository.cs b/src/Core/Repositories/SqlServer/TransactionRepository.cs index 0299c8288b..85f2ed35b5 100644 --- a/src/Core/Repositories/SqlServer/TransactionRepository.cs +++ b/src/Core/Repositories/SqlServer/TransactionRepository.cs @@ -6,6 +6,7 @@ using Dapper; using System.Data; using System.Data.SqlClient; using System.Linq; +using Bit.Core.Enums; namespace Bit.Core.Repositories.SqlServer { @@ -44,5 +45,18 @@ namespace Bit.Core.Repositories.SqlServer return results.ToList(); } } + + public async Task GetByGatewayIdAsync(GatewayType gatewayType, string gatewayId) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[Transaction_ReadByGatewayId]", + new { Gateway = gatewayType, GatewayId = gatewayId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } } } diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 4886a29e50..b27395e88e 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -248,5 +248,6 @@ + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql b/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql new file mode 100644 index 0000000000..3aca795fc3 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Transaction_ReadByGatewayId.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[Transaction_ReadByGatewayId] + @Gateway TINYINT, + @GatewayId VARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Gateway] = @Gateway + AND [GatewayId] = @GatewayId +END \ No newline at end of file diff --git a/src/Sql/dbo/Tables/Transaction.sql b/src/Sql/dbo/Tables/Transaction.sql index 6f30cf6b39..0395bae353 100644 --- a/src/Sql/dbo/Tables/Transaction.sql +++ b/src/Sql/dbo/Tables/Transaction.sql @@ -18,7 +18,7 @@ GO -CREATE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] +CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); diff --git a/util/Setup/DbScripts/2019-01-31_00_Transactions.sql b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql index 6e864c80f0..f559ac7308 100644 --- a/util/Setup/DbScripts/2019-01-31_00_Transactions.sql +++ b/util/Setup/DbScripts/2019-01-31_00_Transactions.sql @@ -18,7 +18,7 @@ BEGIN CONSTRAINT [FK_Transaction_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE ); - CREATE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] + CREATE UNIQUE NONCLUSTERED INDEX [IX_Transaction_Gateway_GatewayId] ON [dbo].[Transaction]([Gateway] ASC, [GatewayId] ASC); @@ -222,3 +222,26 @@ BEGIN [Id] = @Id END GO + +IF OBJECT_ID('[dbo].[Transaction_ReadByGatewayId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Transaction_ReadByGatewayId] +END +GO + +CREATE PROCEDURE [dbo].[Transaction_ReadByGatewayId] + @Gateway TINYINT, + @GatewayId VARCHAR(50) +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[TransactionView] + WHERE + [Gateway] = @Gateway + AND [GatewayId] = @GatewayId +END +GO