From 490cd8dcf217f5fb41c6d4a934b85b4aa00da55c Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 14:49:45 +0100 Subject: [PATCH 1/2] feat: add payment providers Adyen, PayU, Przelewy24, Tpay, HotPay, PayNow, Revolut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7 nowych bibliotek implementujących IPaymentProvider - Adyen — oficjalny SDK (Adyen v14), Sessions API, HMAC-SHA256 webhook - PayU — OAuth2 + REST API v2.1, MD5/SHA256 signature - Przelewy24 — REST API v1, SHA384 CRC verification - Tpay — OAuth2 + REST API, SHA256 webhook - HotPay — hash SHA256, redirect flow - PayNow (mBank) — REST API v2, HMAC-SHA256 Signature header - Revolut — REST API 1.0, HMAC-SHA256 webhook (Revolut-Signature) - MultiProviderPaymentTest.cs — smoke testy dla wszystkich 7 (channels, providers list, invalid webhook → Rejected) - TailoredApps.Shared.sln + test csproj zaktualizowane - appsettings.json z placeholder config dla każdego providera --- TailoredApps.Shared.sln | 42 +++ .../AdyenProvider.cs | 237 +++++++++++++ ...Apps.Shared.Payments.Provider.Adyen.csproj | 25 ++ .../HotPayProvider.cs | 169 +++++++++ ...pps.Shared.Payments.Provider.HotPay.csproj | 27 ++ .../PayNowProvider.cs | 222 ++++++++++++ ...pps.Shared.Payments.Provider.PayNow.csproj | 27 ++ .../PayUProvider.cs | 331 ++++++++++++++++++ ...dApps.Shared.Payments.Provider.PayU.csproj | 27 ++ .../Przelewy24Provider.cs | 290 +++++++++++++++ ...Shared.Payments.Provider.Przelewy24.csproj | 27 ++ .../RevolutProvider.cs | 207 +++++++++++ ...ps.Shared.Payments.Provider.Revolut.csproj | 27 ++ ...dApps.Shared.Payments.Provider.Tpay.csproj | 27 ++ .../TpayProvider.cs | 263 ++++++++++++++ .../MultiProviderPaymentTest.cs | 206 +++++++++++ .../TailoredApps.Shared.Payments.Tests.csproj | 7 + .../appsettings.json | 54 +++ 18 files changed, 2215 insertions(+) create mode 100644 src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs create mode 100644 src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj create mode 100644 src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs create mode 100644 tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs diff --git a/TailoredApps.Shared.sln b/TailoredApps.Shared.sln index cfe6efb..65adc97 100644 --- a/TailoredApps.Shared.sln +++ b/TailoredApps.Shared.sln @@ -35,6 +35,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payment EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Stripe", "src\TailoredApps.Shared.Payments.Provider.Stripe\TailoredApps.Shared.Payments.Provider.Stripe.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Adyen", "src\TailoredApps.Shared.Payments.Provider.Adyen\TailoredApps.Shared.Payments.Provider.Adyen.csproj", "{B1C2D3E4-F5A6-7890-BCDE-FA2345678901}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.PayU", "src\TailoredApps.Shared.Payments.Provider.PayU\TailoredApps.Shared.Payments.Provider.PayU.csproj", "{C2D3E4F5-A6B7-8901-CDEF-AB3456789012}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Przelewy24", "src\TailoredApps.Shared.Payments.Provider.Przelewy24\TailoredApps.Shared.Payments.Provider.Przelewy24.csproj", "{D3E4F5A6-B7C8-9012-DEF0-BC4567890123}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Tpay", "src\TailoredApps.Shared.Payments.Provider.Tpay\TailoredApps.Shared.Payments.Provider.Tpay.csproj", "{E4F5A6B7-C8D9-0123-EF01-CD5678901234}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.HotPay", "src\TailoredApps.Shared.Payments.Provider.HotPay\TailoredApps.Shared.Payments.Provider.HotPay.csproj", "{F5A6B7C8-D9E0-1234-F012-DE6789012345}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.PayNow", "src\TailoredApps.Shared.Payments.Provider.PayNow\TailoredApps.Shared.Payments.Provider.PayNow.csproj", "{A6B7C8D9-E0F1-2345-0123-EF7890123456}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Revolut", "src\TailoredApps.Shared.Payments.Provider.Revolut\TailoredApps.Shared.Payments.Provider.Revolut.csproj", "{B7C8D9E0-F1A2-3456-1234-F08901234567}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore", "src\TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore\TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore.csproj", "{5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.MediatR.ML", "src\TailoredApps.Shared.MediatR.ML\TailoredApps.Shared.MediatR.ML.csproj", "{C454266F-AF6D-450B-B899-9359180BAE94}" @@ -127,6 +141,34 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Release|Any CPU.Build.0 = Release|Any CPU + {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Release|Any CPU.Build.0 = Release|Any CPU + {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Release|Any CPU.Build.0 = Release|Any CPU + {B7C8D9E0-F1A2-3456-1234-F08901234567}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7C8D9E0-F1A2-3456-1234-F08901234567}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7C8D9E0-F1A2-3456-1234-F08901234567}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7C8D9E0-F1A2-3456-1234-F08901234567}.Release|Any CPU.Build.0 = Release|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Debug|Any CPU.Build.0 = Debug|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs new file mode 100644 index 0000000..7957559 --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs @@ -0,0 +1,237 @@ +using System.Security.Cryptography; +using System.Text; +using Adyen; +using Adyen.Model.Checkout; +using Adyen.Service.Checkout; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Environment = Adyen.Model.Enum.Environment; + +namespace TailoredApps.Shared.Payments.Provider.Adyen; + +// ─── Options ───────────────────────────────────────────────────────────────── + +/// Konfiguracja Adyen. Sekcja: Payments:Providers:Adyen. +public class AdyenServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:Adyen"; + public string ApiKey { get; set; } = string.Empty; + public string MerchantAccount { get; set; } = string.Empty; + public string ClientKey { get; set; } = string.Empty; + public string ReturnUrl { get; set; } = string.Empty; + /// HMAC klucz do weryfikacji powiadomień webhooka. + public string NotificationHmacKey { get; set; } = string.Empty; + /// True = sandbox (test), False = live. + public bool IsTest { get; set; } = true; +} + +// ─── Interface ──────────────────────────────────────────────────────────────── + +/// Abstrakcja nad Adyen Checkout API. +public interface IAdyenServiceCaller +{ + Task CreateSessionAsync(PaymentRequest request); + Task GetPaymentStatusAsync(string pspReference); + bool VerifyNotificationHmac(string payload, string hmacSignature); +} + +// ─── Caller ─────────────────────────────────────────────────────────────────── + +/// Implementacja opakowująca oficjalny Adyen SDK. +public class AdyenServiceCaller : IAdyenServiceCaller +{ + private readonly AdyenServiceOptions options; + + public AdyenServiceCaller(IOptions options) + { + this.options = options.Value; + } + + private Client CreateClient() + { + var config = new Config + { + XApiKey = options.ApiKey, + Environment = options.IsTest ? Environment.Test : Environment.Live, + }; + return new Client(config); + } + + public async Task CreateSessionAsync(PaymentRequest request) + { + var client = CreateClient(); + var checkout = new PaymentsService(client); + + var amount = new Adyen.Model.Checkout.Amount + { + Value = (long)(request.Amount * 100), + Currency = request.Currency.ToUpperInvariant(), + }; + + var sessionRequest = new CreateCheckoutSessionRequest( + merchantAccount: options.MerchantAccount, + amount: amount, + returnUrl: options.ReturnUrl, + reference: request.AdditionalData ?? Guid.NewGuid().ToString("N") + ) + { + ShopperEmail = request.Email, + ShopperReference = request.Email, + CountryCode = request.Country ?? "PL", + }; + + return await checkout.SessionsAsync(sessionRequest); + } + + public async Task GetPaymentStatusAsync(string pspReference) + { + var client = CreateClient(); + var checkout = new PaymentsService(client); + var details = new PaymentDetailsRequest(details: new PaymentCompletionDetails(), paymentData: pspReference); + return await checkout.PaymentsDetailsAsync(details); + } + + public bool VerifyNotificationHmac(string payload, string hmacSignature) + { + try + { + var keyBytes = Convert.FromHexString(options.NotificationHmacKey); + var dataBytes = Encoding.UTF8.GetBytes(payload); + var computed = Convert.ToBase64String(HMACSHA256.HashData(keyBytes, dataBytes)); + return string.Equals(computed, hmacSignature, StringComparison.Ordinal); + } + catch { return false; } + } +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +/// Implementacja dla Adyen. +public class AdyenProvider : IPaymentProvider +{ + private readonly IAdyenServiceCaller caller; + private readonly AdyenServiceOptions options; + + public AdyenProvider(IAdyenServiceCaller caller, IOptions options) + { + this.caller = caller; + this.options = options.Value; + } + + public string Key => "Adyen"; + public string Name => "Adyen"; + public string Description => "Globalny operator płatności Adyen — karty, BLIK, iDEAL i inne."; + public string Url => "https://www.adyen.com"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = currency.ToUpperInvariant() switch + { + "PLN" => + [ + new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "blik", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "onlineBanking_PL", Name = "Przelew online", Description = "Polskie banki", PaymentModel = PaymentModel.OneTime }, + ], + "EUR" => + [ + new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "ideal", Name = "iDEAL", Description = "Przelew iDEAL", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "sepadirectdebit", Name = "SEPA Direct Debit", Description = "SEPA", PaymentModel = PaymentModel.OneTime }, + ], + _ => + [ + new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + ], + }; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var session = await caller.CreateSessionAsync(request); + return new PaymentResponse + { + PaymentUniqueId = session.Id, + RedirectUrl = session.Url, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public async Task GetStatus(string paymentId) + { + var details = await caller.GetPaymentStatusAsync(paymentId); + var status = details.ResultCode?.ToString() switch + { + "Authorised" => PaymentStatusEnum.Finished, + "Refused" => PaymentStatusEnum.Rejected, + "Cancelled" => PaymentStatusEnum.Rejected, + "Pending" => PaymentStatusEnum.Processing, + "Received" => PaymentStatusEnum.Processing, + _ => PaymentStatusEnum.Created, + }; + return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; + } + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + var hmac = payload.QueryParameters.TryGetValue("HmacSignature", out var h) ? h.ToString() : string.Empty; + + if (!caller.VerifyNotificationHmac(body, hmac)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid HMAC" }); + + // Parsuj eventCode z JSON + var status = PaymentStatusEnum.Processing; + try + { + var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("eventCode", out var ev)) + { + status = ev.GetString() switch + { + "AUTHORISATION" => PaymentStatusEnum.Finished, + "CANCELLATION" => PaymentStatusEnum.Rejected, + "REFUND" => PaymentStatusEnum.Rejected, + "AUTHORISATION_FAILED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Processing, + }; + } + } + catch { /* ignore */ } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +// ─── DI ─────────────────────────────────────────────────────────────────────── + +/// Rozszerzenia DI dla Adyen. +public static class AdyenProviderExtensions +{ + public static void RegisterAdyenProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddTransient(); + } +} + +/// Wczytuje opcje Adyen z konfiguracji. +public class AdyenConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public AdyenConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(AdyenServiceOptions options) + { + var s = configuration.GetSection(AdyenServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.ApiKey = s.ApiKey; + options.MerchantAccount = s.MerchantAccount; + options.ClientKey = s.ClientKey; + options.ReturnUrl = s.ReturnUrl; + options.NotificationHmacKey = s.NotificationHmacKey; + options.IsTest = s.IsTest; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj b/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj new file mode 100644 index 0000000..af39e01 --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj @@ -0,0 +1,25 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs b/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs new file mode 100644 index 0000000..6a1b9bf --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs @@ -0,0 +1,169 @@ +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.HotPay; + +public class HotPayServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:HotPay"; + public string SecretHash { get; set; } = string.Empty; + public string ServiceUrl { get; set; } = "https://platnosci.hotpay.pl"; + public string ReturnUrl { get; set; } = string.Empty; + public string PaymentUrl { get; set; } = string.Empty; + public string NotifyUrl { get; set; } = string.Empty; +} + +file class HotPayRequest +{ + [JsonPropertyName("SEKRET")] public string Secret { get; set; } = string.Empty; + [JsonPropertyName("KWOTA")] public string Amount { get; set; } = string.Empty; + [JsonPropertyName("NAZWA_USLUGI")] public string ServiceName { get; set; } = string.Empty; + [JsonPropertyName("IDENTYFIKATOR_PLATNOSCI")] public string PaymentId { get; set; } = string.Empty; + [JsonPropertyName("ADRES_WWW")] public string ReturnUrl { get; set; } = string.Empty; + [JsonPropertyName("EMAIL")] public string? Email { get; set; } + [JsonPropertyName("HASH")] public string Hash { get; set; } = string.Empty; +} + +file class HotPayResponse +{ + [JsonPropertyName("STATUS")] public string? Status { get; set; } + [JsonPropertyName("PRZEKIERUJ_DO")] public string? RedirectUrl { get; set; } + [JsonPropertyName("ID_PLATNOSCI")] public string? PaymentId { get; set; } +} + +public interface IHotPayServiceCaller +{ + Task InitPaymentAsync(PaymentRequest request, string paymentId); + bool VerifyNotification(string hash, string kwota, string idPlatnosci, string status); +} + +public class HotPayServiceCaller : IHotPayServiceCaller +{ + private readonly HotPayServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public HotPayServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + public async Task InitPaymentAsync(PaymentRequest request, string paymentId) + { + var amount = request.Amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); + var hashData = $"{options.SecretHash};{amount};{request.Title ?? "Order"};{paymentId};{options.ReturnUrl}"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hashData))).ToLowerInvariant(); + + var body = new HotPayRequest + { + Secret = options.SecretHash, + Amount = amount, + ServiceName = request.Title ?? request.Description ?? "Order", + PaymentId = paymentId, + ReturnUrl = options.ReturnUrl, + Email = request.Email, + Hash = hash, + }; + + using var client = httpClientFactory.CreateClient("HotPay"); + var json = JsonSerializer.Serialize(body); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await client.PostAsync(options.ServiceUrl, content); + var responseJson = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseJson); + } + + public bool VerifyNotification(string hash, string kwota, string idPlatnosci, string status) + { + var data = $"{options.SecretHash};{kwota};{idPlatnosci};{status}"; + var computed = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant(); + return string.Equals(computed, hash, StringComparison.OrdinalIgnoreCase); + } +} + +public class HotPayProvider : IPaymentProvider +{ + private readonly IHotPayServiceCaller caller; + + public HotPayProvider(IHotPayServiceCaller caller) => this.caller = caller; + + public string Key => "HotPay"; + public string Name => "HotPay"; + public string Description => "Operator płatności HotPay — BLIK, karty, przelewy."; + public string Url => "https://hotpay.pl"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "blik", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "transfer", Name = "Przelew online", Description = "Przelew bankowy", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var paymentId = Guid.NewGuid().ToString("N"); + var result = await caller.InitPaymentAsync(request, paymentId); + + return new PaymentResponse + { + PaymentUniqueId = result?.PaymentId ?? paymentId, + RedirectUrl = result?.RedirectUrl, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public Task GetStatus(string paymentId) + => Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing }); + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var qs = payload.QueryParameters; + var hash = qs.TryGetValue("HASH", out var h) ? h.ToString() : string.Empty; + var kwota = qs.TryGetValue("KWOTA", out var k) ? k.ToString() : string.Empty; + var idPlatnosci = qs.TryGetValue("ID_PLATNOSCI", out var i) ? i.ToString() : string.Empty; + var status = qs.TryGetValue("STATUS", out var s) ? s.ToString() : string.Empty; + + if (!caller.VerifyNotification(hash, kwota, idPlatnosci, status)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid hash" }); + + var payStatus = status == "SUCCESS" ? PaymentStatusEnum.Finished : PaymentStatusEnum.Rejected; + return Task.FromResult(new PaymentResponse { PaymentUniqueId = idPlatnosci, PaymentStatus = payStatus, ResponseObject = "OK" }); + } +} + +public static class HotPayProviderExtensions +{ + public static void RegisterHotPayProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("HotPay"); + services.AddTransient(); + } +} + +public class HotPayConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public HotPayConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(HotPayServiceOptions options) + { + var s = configuration.GetSection(HotPayServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.SecretHash = s.SecretHash; + options.ServiceUrl = s.ServiceUrl; + options.ReturnUrl = s.ReturnUrl; + options.PaymentUrl = s.PaymentUrl; + options.NotifyUrl = s.NotifyUrl; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj b/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs b/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs new file mode 100644 index 0000000..f23765c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs @@ -0,0 +1,222 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.PayNow; + +public class PayNowServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:PayNow"; + public string ApiKey { get; set; } = string.Empty; + public string SignatureKey { get; set; } = string.Empty; + public string ApiUrl { get; set; } = "https://api.paynow.pl"; + public string ReturnUrl { get; set; } = string.Empty; + public string ContinueUrl { get; set; } = string.Empty; +} + +file class PayNowPaymentRequest +{ + [JsonPropertyName("amount")] public long Amount { get; set; } + [JsonPropertyName("currency")] public string Currency { get; set; } = "PLN"; + [JsonPropertyName("externalId")] public string ExternalId { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("buyer")] public PayNowBuyer? Buyer { get; set; } + [JsonPropertyName("continueUrl")] public string? ContinueUrl { get; set; } + [JsonPropertyName("returnUrl")] public string? ReturnUrl { get; set; } +} + +file class PayNowBuyer +{ + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; +} + +file class PayNowPaymentResponse +{ + [JsonPropertyName("paymentId")] public string? PaymentId { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + [JsonPropertyName("redirectUrl")] public string? RedirectUrl { get; set; } +} + +file class PayNowStatusResponse +{ + [JsonPropertyName("status")] public string? Status { get; set; } +} + +public interface IPayNowServiceCaller +{ + Task CreatePaymentAsync(PaymentRequest request); + Task GetPaymentStatusAsync(string paymentId); + bool VerifySignature(string body, string signature); +} + +public class PayNowServiceCaller : IPayNowServiceCaller +{ + private readonly PayNowServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public PayNowServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + private HttpClient CreateClient() + { + var client = httpClientFactory.CreateClient("PayNow"); + client.DefaultRequestHeaders.Add("Api-Key", options.ApiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + public async Task CreatePaymentAsync(PaymentRequest request) + { + using var client = CreateClient(); + var idempotencyKey = Guid.NewGuid().ToString(); + client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + + var body = new PayNowPaymentRequest + { + Amount = (long)(request.Amount * 100), + Currency = request.Currency.ToUpperInvariant(), + ExternalId = request.AdditionalData ?? Guid.NewGuid().ToString("N"), + Description = request.Title ?? request.Description ?? "Order", + Buyer = new PayNowBuyer { Email = request.Email ?? string.Empty }, + ContinueUrl = options.ContinueUrl, + ReturnUrl = options.ReturnUrl, + }; + + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{options.ApiUrl}/v2/payments", content); + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + public async Task GetPaymentStatusAsync(string paymentId) + { + using var client = CreateClient(); + var response = await client.GetAsync($"{options.ApiUrl}/v2/payments/{paymentId}/status"); + if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; + var json = await response.Content.ReadAsStringAsync(); + var status = JsonSerializer.Deserialize(json)?.Status; + return status switch + { + "CONFIRMED" => PaymentStatusEnum.Finished, + "PENDING" => PaymentStatusEnum.Processing, + "PROCESSING" => PaymentStatusEnum.Processing, + "NEW" => PaymentStatusEnum.Created, + "ERROR" => PaymentStatusEnum.Rejected, + "REJECTED" => PaymentStatusEnum.Rejected, + "ABANDONED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Created, + }; + } + + public bool VerifySignature(string body, string signature) + { + var keyBytes = Encoding.UTF8.GetBytes(options.SignatureKey); + var dataBytes = Encoding.UTF8.GetBytes(body); + var computed = Convert.ToBase64String(HMACSHA256.HashData(keyBytes, dataBytes)); + return string.Equals(computed, signature, StringComparison.Ordinal); + } +} + +public class PayNowProvider : IPaymentProvider +{ + private readonly IPayNowServiceCaller caller; + + public PayNowProvider(IPayNowServiceCaller caller) => this.caller = caller; + + public string Key => "PayNow"; + public string Name => "PayNow"; + public string Description => "Operator płatności PayNow (mBank) — BLIK, karty, przelewy."; + public string Url => "https://paynow.pl"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "BLIK", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "CARD", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "PBL", Name = "Przelew bankowy", Description = "Pay-by-link", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "TRANSFER", Name = "Przelew tradycyjny",Description = "Przelew bankowy", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var payment = await caller.CreatePaymentAsync(request); + return new PaymentResponse + { + PaymentUniqueId = payment?.PaymentId, + RedirectUrl = payment?.RedirectUrl, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public async Task GetStatus(string paymentId) + { + var status = await caller.GetPaymentStatusAsync(paymentId); + return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; + } + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + var sig = payload.QueryParameters.TryGetValue("Signature", out var s) ? s.ToString() : string.Empty; + + if (!caller.VerifySignature(body, sig)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); + + var status = PaymentStatusEnum.Processing; + try + { + var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("status", out var st)) + status = st.GetString() switch + { + "CONFIRMED" => PaymentStatusEnum.Finished, + "ERROR" => PaymentStatusEnum.Rejected, + "REJECTED" => PaymentStatusEnum.Rejected, + "ABANDONED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Processing, + }; + } + catch { /* ignore */ } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +public static class PayNowProviderExtensions +{ + public static void RegisterPayNowProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("PayNow"); + services.AddTransient(); + } +} + +public class PayNowConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public PayNowConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(PayNowServiceOptions options) + { + var s = configuration.GetSection(PayNowServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.ApiKey = s.ApiKey; + options.SignatureKey = s.SignatureKey; + options.ApiUrl = s.ApiUrl; + options.ReturnUrl = s.ReturnUrl; + options.ContinueUrl = s.ContinueUrl; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj b/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs b/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs new file mode 100644 index 0000000..d0e1430 --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs @@ -0,0 +1,331 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.PayU; + +// ─── Options ──────────────────────────────────────────────────────────────── + +/// Konfiguracja providera PayU. Sekcja: Payments:Providers:PayU. +public class PayUServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:PayU"; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + /// POS ID (merchantPosId) z panelu PayU. + public string PosId { get; set; } = string.Empty; + /// Klucz MD5 do weryfikacji powiadomień. + public string SignatureKey { get; set; } = string.Empty; + public string ServiceUrl { get; set; } = "https://secure.payu.com"; + public string NotifyUrl { get; set; } = string.Empty; + public string ContinueUrl { get; set; } = string.Empty; +} + +// ─── Internal models ───────────────────────────────────────────────────────── + +file class PayUTokenResponse +{ + [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; + [JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty; +} + +file class PayUOrderRequest +{ + [JsonPropertyName("merchantPosId")] public string MerchantPosId { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("currencyCode")] public string CurrencyCode { get; set; } = string.Empty; + [JsonPropertyName("totalAmount")] public long TotalAmount { get; set; } + [JsonPropertyName("notifyUrl")] public string NotifyUrl { get; set; } = string.Empty; + [JsonPropertyName("continueUrl")] public string ContinueUrl { get; set; } = string.Empty; + [JsonPropertyName("customerIp")] public string CustomerIp { get; set; } = "127.0.0.1"; + [JsonPropertyName("buyer")] public PayUBuyer? Buyer { get; set; } + [JsonPropertyName("products")] public List Products { get; set; } = []; + [JsonPropertyName("payMethods")] public PayUPayMethods? PayMethods { get; set; } +} + +file class PayUBuyer +{ + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; + [JsonPropertyName("firstName")] public string FirstName { get; set; } = string.Empty; + [JsonPropertyName("lastName")] public string LastName { get; set; } = string.Empty; +} + +file class PayUProduct +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("unitPrice")] public long UnitPrice { get; set; } + [JsonPropertyName("quantity")] public int Quantity { get; set; } = 1; +} + +file class PayUPayMethods +{ + [JsonPropertyName("payMethod")] public PayUPayMethod? PayMethod { get; set; } +} + +file class PayUPayMethod +{ + [JsonPropertyName("type")] public string Type { get; set; } = "PBL"; + [JsonPropertyName("value")] public string Value { get; set; } = string.Empty; +} + +file class PayUOrderResponse +{ + [JsonPropertyName("orderId")] public string? OrderId { get; set; } + [JsonPropertyName("redirectUri")] public string? RedirectUri { get; set; } + [JsonPropertyName("status")] public PayUStatus? Status { get; set; } +} + +file class PayUStatusResponse +{ + [JsonPropertyName("orders")] public List? Orders { get; set; } +} + +file class PayUOrder +{ + [JsonPropertyName("orderId")] public string? OrderId { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } +} + +file class PayUStatus +{ + [JsonPropertyName("statusCode")] public string? StatusCode { get; set; } +} + +// ─── Service Caller Interface ──────────────────────────────────────────────── + +/// Abstrakcja nad PayU REST API. +public interface IPayUServiceCaller +{ + Task GetAccessTokenAsync(); + Task CreateOrderAsync(string accessToken, PaymentRequest request); + Task GetOrderStatusAsync(string accessToken, string orderId); + bool VerifyNotification(string body, string signatureHeader); +} + +// ─── Service Caller ────────────────────────────────────────────────────────── + +/// Implementacja . +public class PayUServiceCaller : IPayUServiceCaller +{ + private readonly PayUServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public PayUServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + public async Task GetAccessTokenAsync() + { + using var client = httpClientFactory.CreateClient("PayU"); + var content = new FormUrlEncodedContent([ + new("grant_type", "client_credentials"), + new("client_id", options.ClientId), + new("client_secret", options.ClientSecret), + ]); + var response = await client.PostAsync($"{options.ServiceUrl}/pl/standard/user/oauth/authorize", content); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var token = JsonSerializer.Deserialize(json); + return token?.AccessToken ?? string.Empty; + } + + public async Task CreateOrderAsync(string accessToken, PaymentRequest request) + { + using var client = httpClientFactory.CreateClient("PayU"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var order = new PayUOrderRequest + { + MerchantPosId = options.PosId, + Description = request.Title ?? request.Description ?? "Order", + CurrencyCode = request.Currency.ToUpperInvariant(), + TotalAmount = (long)(request.Amount * 100), + NotifyUrl = options.NotifyUrl, + ContinueUrl = options.ContinueUrl, + Buyer = new PayUBuyer + { + Email = request.Email ?? string.Empty, + FirstName = request.FirstName ?? string.Empty, + LastName = request.Surname ?? string.Empty, + }, + Products = [new PayUProduct { Name = request.Title ?? "Product", UnitPrice = (long)(request.Amount * 100) }], + }; + + if (!string.IsNullOrWhiteSpace(request.PaymentChannel)) + order.PayMethods = new PayUPayMethods { PayMethod = new PayUPayMethod { Value = request.PaymentChannel } }; + + var json = JsonSerializer.Serialize(order); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + // PayU zwraca 302 redirect — nie podążaj za nim + var handler = new HttpClientHandler { AllowAutoRedirect = false }; + using var redirectClient = new HttpClient(handler); + redirectClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + redirectClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var response = await redirectClient.PostAsync($"{options.ServiceUrl}/api/v2_1/orders", content); + var responseJson = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseJson); + } + + public async Task GetOrderStatusAsync(string accessToken, string orderId) + { + using var client = httpClientFactory.CreateClient("PayU"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var response = await client.GetAsync($"{options.ServiceUrl}/api/v2_1/orders/{orderId}"); + if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; + var json = await response.Content.ReadAsStringAsync(); + var status = JsonSerializer.Deserialize(json); + return status?.Orders?.FirstOrDefault()?.Status switch + { + "COMPLETED" => PaymentStatusEnum.Finished, + "PENDING" => PaymentStatusEnum.Processing, + "WAITING_FOR_CONFIRMATION" => PaymentStatusEnum.Processing, + "CANCELED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Created, + }; + } + + public bool VerifyNotification(string body, string signatureHeader) + { + // Parsuj: signature=;algorithm=MD5;sender=PAYU + var parts = signatureHeader.Split(';') + .Select(p => p.Split('=')) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + + if (!parts.TryGetValue("signature", out var signature)) return false; + + var algorithm = parts.GetValueOrDefault("algorithm", "MD5"); + var input = body + options.SignatureKey; + + string computed; + if (algorithm.Equals("SHA", StringComparison.OrdinalIgnoreCase) || algorithm.Equals("SHA256", StringComparison.OrdinalIgnoreCase)) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + computed = Convert.ToHexString(bytes).ToLowerInvariant(); + } + else + { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); + computed = Convert.ToHexString(bytes).ToLowerInvariant(); + } + + return string.Equals(computed, signature, StringComparison.OrdinalIgnoreCase); + } +} + +// ─── Provider ──────────────────────────────────────────────────────────────── + +/// Implementacja dla PayU. +public class PayUProvider : IPaymentProvider +{ + private readonly IPayUServiceCaller caller; + + public PayUProvider(IPayUServiceCaller caller) => this.caller = caller; + + public string Key => "PayU"; + public string Name => "PayU"; + public string Description => "Operator płatności online PayU — BLIK, karty, przelewy."; + public string Url => "https://payu.com"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "c", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "BLIK", Name = "BLIK", Description = "Szybka płatność BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "m", Name = "Przelew online", Description = "Przelew bankowy online", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var token = await caller.GetAccessTokenAsync(); + var order = await caller.CreateOrderAsync(token, request); + return new PaymentResponse + { + PaymentUniqueId = order?.OrderId, + RedirectUrl = order?.RedirectUri, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public async Task GetStatus(string paymentId) + { + var token = await caller.GetAccessTokenAsync(); + var status = await caller.GetOrderStatusAsync(token, paymentId); + return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; + } + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + var sig = payload.QueryParameters.TryGetValue("OpenPayU-Signature", out var s) ? s.ToString() : string.Empty; + var valid = caller.VerifyNotification(body, sig); + if (!valid) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); + + // Parsuj status z body (JSON) + var status = PaymentStatusEnum.Processing; + try + { + var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("order", out var order) && + order.TryGetProperty("status", out var s2)) + { + status = s2.GetString() switch + { + "COMPLETED" => PaymentStatusEnum.Finished, + "CANCELED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Processing, + }; + } + } + catch { /* ignore parse errors */ } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +// ─── DI Extensions ─────────────────────────────────────────────────────────── + +/// Rozszerzenia DI dla PayU. +public static class PayUProviderExtensions +{ + public static void RegisterPayUProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("PayU"); + services.AddTransient(); + } +} + +/// Wczytuje opcje PayU z konfiguracji. +public class PayUConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public PayUConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(PayUServiceOptions options) + { + var section = configuration.GetSection(PayUServiceOptions.ConfigurationKey).Get(); + if (section is null) return; + options.ClientId = section.ClientId; + options.ClientSecret = section.ClientSecret; + options.PosId = section.PosId; + options.SignatureKey = section.SignatureKey; + options.ServiceUrl = section.ServiceUrl; + options.NotifyUrl = section.NotifyUrl; + options.ContinueUrl = section.ContinueUrl; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj b/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs new file mode 100644 index 0000000..996f171 --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs @@ -0,0 +1,290 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.Przelewy24; + +// ─── Options ───────────────────────────────────────────────────────────────── + +/// Konfiguracja Przelewy24. Sekcja: Payments:Providers:Przelewy24. +public class Przelewy24ServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:Przelewy24"; + public int MerchantId { get; set; } + public int PosId { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string CrcKey { get; set; } = string.Empty; + public string ServiceUrl { get; set; } = "https://secure.przelewy24.pl"; + public string ReturnUrl { get; set; } = string.Empty; + public string NotifyUrl { get; set; } = string.Empty; +} + +// ─── Internal models ───────────────────────────────────────────────────────── + +file class P24RegisterRequest +{ + [JsonPropertyName("merchantId")] public int MerchantId { get; set; } + [JsonPropertyName("posId")] public int PosId { get; set; } + [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; + [JsonPropertyName("amount")] public long Amount { get; set; } + [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; + [JsonPropertyName("urlReturn")] public string UrlReturn { get; set; } = string.Empty; + [JsonPropertyName("urlStatus")] public string UrlStatus { get; set; } = string.Empty; + [JsonPropertyName("sign")] public string Sign { get; set; } = string.Empty; + [JsonPropertyName("encoding")] public string Encoding { get; set; } = "UTF-8"; +} + +file class P24RegisterResponse +{ + [JsonPropertyName("data")] public P24RegisterData? Data { get; set; } +} + +file class P24RegisterData +{ + [JsonPropertyName("token")] public string? Token { get; set; } +} + +file class P24VerifyRequest +{ + [JsonPropertyName("merchantId")] public int MerchantId { get; set; } + [JsonPropertyName("posId")] public int PosId { get; set; } + [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; + [JsonPropertyName("amount")] public long Amount { get; set; } + [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; + [JsonPropertyName("orderId")] public int OrderId { get; set; } + [JsonPropertyName("sign")] public string Sign { get; set; } = string.Empty; +} + +// ─── Interface ──────────────────────────────────────────────────────────────── + +/// Abstrakcja nad Przelewy24 REST API. +public interface IPrzelewy24ServiceCaller +{ + Task<(string? token, string? error)> RegisterTransactionAsync(PaymentRequest request, string sessionId); + Task VerifyTransactionAsync(string sessionId, long amount, string currency, int orderId); + string ComputeSign(string sessionId, int merchantId, long amount, string currency); + bool VerifyNotification(string body); +} + +// ─── Caller ─────────────────────────────────────────────────────────────────── + +/// Implementacja . +public class Przelewy24ServiceCaller : IPrzelewy24ServiceCaller +{ + private readonly Przelewy24ServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public Przelewy24ServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + private HttpClient CreateClient() + { + var client = httpClientFactory.CreateClient("Przelewy24"); + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.PosId}:{options.ApiKey}")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + public async Task<(string? token, string? error)> RegisterTransactionAsync(PaymentRequest request, string sessionId) + { + using var client = CreateClient(); + var amount = (long)(request.Amount * 100); + var sign = ComputeSign(sessionId, options.MerchantId, amount, request.Currency); + + var body = new P24RegisterRequest + { + MerchantId = options.MerchantId, + PosId = options.PosId, + SessionId = sessionId, + Amount = amount, + Currency = request.Currency.ToUpperInvariant(), + Description = request.Title ?? request.Description ?? "Order", + Email = request.Email ?? string.Empty, + UrlReturn = options.ReturnUrl, + UrlStatus = options.NotifyUrl, + Sign = sign, + }; + + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{options.ServiceUrl}/api/v1/transaction/register", content); + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return (result?.Data?.Token, response.IsSuccessStatusCode ? null : json); + } + + public async Task VerifyTransactionAsync(string sessionId, long amount, string currency, int orderId) + { + using var client = CreateClient(); + var sign = ComputeVerifySign(sessionId, orderId, options.MerchantId, amount, currency); + var body = new P24VerifyRequest + { + MerchantId = options.MerchantId, + PosId = options.PosId, + SessionId = sessionId, + Amount = amount, + Currency = currency.ToUpperInvariant(), + OrderId = orderId, + Sign = sign, + }; + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PutAsync($"{options.ServiceUrl}/api/v1/transaction/verify", content); + return response.IsSuccessStatusCode ? PaymentStatusEnum.Finished : PaymentStatusEnum.Rejected; + } + + public string ComputeSign(string sessionId, int merchantId, long amount, string currency) + { + var json = JsonSerializer.Serialize(new { sessionId, merchantId, amount, currency, crc = options.CrcKey }); + var bytes = SHA384.HashData(Encoding.UTF8.GetBytes(json)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private string ComputeVerifySign(string sessionId, int orderId, int merchantId, long amount, string currency) + { + var json = JsonSerializer.Serialize(new { sessionId, orderId, merchantId, amount, currency, crc = options.CrcKey }); + var bytes = SHA384.HashData(Encoding.UTF8.GetBytes(json)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public bool VerifyNotification(string body) + { + try + { + var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("sign", out var signEl)) return false; + var receivedSign = signEl.GetString() ?? string.Empty; + + doc.RootElement.TryGetProperty("sessionId", out var sid); + doc.RootElement.TryGetProperty("orderId", out var oid); + doc.RootElement.TryGetProperty("merchantId", out var mid); + doc.RootElement.TryGetProperty("amount", out var amt); + doc.RootElement.TryGetProperty("currency", out var cur); + + var json = JsonSerializer.Serialize(new + { + sessionId = sid.GetString(), + orderId = oid.GetInt32(), + merchantId = mid.GetInt32(), + amount = amt.GetInt64(), + currency = cur.GetString(), + crc = options.CrcKey, + }); + var expected = Convert.ToHexString(SHA384.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant(); + return string.Equals(expected, receivedSign, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +/// Implementacja dla Przelewy24. +public class Przelewy24Provider : IPaymentProvider +{ + private readonly IPrzelewy24ServiceCaller caller; + private readonly Przelewy24ServiceOptions options; + + public Przelewy24Provider(IPrzelewy24ServiceCaller caller, IOptions options) + { + this.caller = caller; + this.options = options.Value; + } + + public string Key => "Przelewy24"; + public string Name => "Przelewy24"; + public string Description => "Operator płatności online Przelewy24 — przelewy, BLIK, karty."; + public string Url => "https://przelewy24.pl"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "online_transfer", Name = "Przelew online", Description = "Wszystkie banki", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "blik", Name = "BLIK", Description = "Płatność BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var sessionId = Guid.NewGuid().ToString("N"); + var (token, error) = await caller.RegisterTransactionAsync(request, sessionId); + + if (token is null) + return new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = error }; + + return new PaymentResponse + { + PaymentUniqueId = sessionId, + RedirectUrl = $"{options.ServiceUrl}/trnRequest/{token}", + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public Task GetStatus(string paymentId) + => Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing }); + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + if (!caller.VerifyNotification(body)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); + + PaymentStatusEnum status; + try + { + var doc = JsonDocument.Parse(body); + status = doc.RootElement.TryGetProperty("error", out _) + ? PaymentStatusEnum.Rejected + : PaymentStatusEnum.Finished; + } + catch { status = PaymentStatusEnum.Processing; } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +// ─── DI ─────────────────────────────────────────────────────────────────────── + +/// Rozszerzenia DI dla Przelewy24. +public static class Przelewy24ProviderExtensions +{ + public static void RegisterPrzelewy24Provider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("Przelewy24"); + services.AddTransient(); + } +} + +/// Wczytuje opcje Przelewy24 z konfiguracji. +public class Przelewy24ConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public Przelewy24ConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(Przelewy24ServiceOptions options) + { + var s = configuration.GetSection(Przelewy24ServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.MerchantId = s.MerchantId; + options.PosId = s.PosId; + options.ApiKey = s.ApiKey; + options.CrcKey = s.CrcKey; + options.ServiceUrl = s.ServiceUrl; + options.ReturnUrl = s.ReturnUrl; + options.NotifyUrl = s.NotifyUrl; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs new file mode 100644 index 0000000..36c827d --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs @@ -0,0 +1,207 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.Revolut; + +public class RevolutServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:Revolut"; + public string ApiKey { get; set; } = string.Empty; + public string ApiUrl { get; set; } = "https://merchant.revolut.com/api"; + public string ReturnUrl { get; set; } = string.Empty; + public string WebhookSecret { get; set; } = string.Empty; +} + +file class RevolutOrderRequest +{ + [JsonPropertyName("amount")] public long Amount { get; set; } + [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("merchant_order_ext_ref")] public string? ExternalRef { get; set; } + [JsonPropertyName("email")] public string? Email { get; set; } +} + +file class RevolutOrderResponse +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("checkout_url")] public string? CheckoutUrl { get; set; } + [JsonPropertyName("state")] public string? State { get; set; } +} + +public interface IRevolutServiceCaller +{ + Task CreateOrderAsync(PaymentRequest request); + Task GetOrderAsync(string orderId); + bool VerifyWebhookSignature(string payload, string timestamp, string signature); +} + +public class RevolutServiceCaller : IRevolutServiceCaller +{ + private readonly RevolutServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public RevolutServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + private HttpClient CreateClient() + { + var client = httpClientFactory.CreateClient("Revolut"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return client; + } + + public async Task CreateOrderAsync(PaymentRequest request) + { + using var client = CreateClient(); + var body = new RevolutOrderRequest + { + Amount = (long)(request.Amount * 100), + Currency = request.Currency.ToUpperInvariant(), + Description = request.Title ?? request.Description, + ExternalRef = request.AdditionalData ?? Guid.NewGuid().ToString("N"), + Email = request.Email, + }; + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{options.ApiUrl}/1.0/orders", content); + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + public async Task GetOrderAsync(string orderId) + { + using var client = CreateClient(); + var response = await client.GetAsync($"{options.ApiUrl}/1.0/orders/{orderId}"); + if (!response.IsSuccessStatusCode) return null; + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + /// + /// Weryfikuje podpis webhooka Revolut. + /// Format: HMAC-SHA256("v1:{timestamp}.{payload}", webhookSecret). + /// Nagłówek Revolut-Signature: v1= + /// + public bool VerifyWebhookSignature(string payload, string timestamp, string signature) + { + var signedPayload = $"v1:{timestamp}.{payload}"; + var keyBytes = Encoding.UTF8.GetBytes(options.WebhookSecret); + var dataBytes = Encoding.UTF8.GetBytes(signedPayload); + var computed = Convert.ToHexString(HMACSHA256.HashData(keyBytes, dataBytes)).ToLowerInvariant(); + // Nagłówek: "v1=" + var receivedHex = signature.StartsWith("v1=") ? signature.Substring(3) : signature; + return string.Equals(computed, receivedHex, StringComparison.OrdinalIgnoreCase); + } +} + +public class RevolutProvider : IPaymentProvider +{ + private readonly IRevolutServiceCaller caller; + + public RevolutProvider(IRevolutServiceCaller caller) => this.caller = caller; + + public string Key => "Revolut"; + public string Name => "Revolut"; + public string Description => "Globalny operator płatności Revolut — karty, Revolut Pay."; + public string Url => "https://revolut.com/business"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard, Amex", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "revolut_pay", Name = "Revolut Pay", Description = "Płatność Revolut Pay", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var order = await caller.CreateOrderAsync(request); + return new PaymentResponse + { + PaymentUniqueId = order?.Id, + RedirectUrl = order?.CheckoutUrl, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public async Task GetStatus(string paymentId) + { + var order = await caller.GetOrderAsync(paymentId); + var status = order?.State switch + { + "completed" => PaymentStatusEnum.Finished, + "processing" => PaymentStatusEnum.Processing, + "authorised" => PaymentStatusEnum.Processing, + "failed" => PaymentStatusEnum.Rejected, + "cancelled" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Created, + }; + return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; + } + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + var timestamp = payload.QueryParameters.TryGetValue("Revolut-Request-Timestamp", out var t) ? t.ToString() : string.Empty; + var signature = payload.QueryParameters.TryGetValue("Revolut-Signature", out var s) ? s.ToString() : string.Empty; + + if (!caller.VerifyWebhookSignature(body, timestamp, signature)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); + + var status = PaymentStatusEnum.Processing; + try + { + var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("event", out var ev)) + status = ev.GetString() switch + { + "ORDER_COMPLETED" => PaymentStatusEnum.Finished, + "ORDER_AUTHORISED" => PaymentStatusEnum.Processing, + "ORDER_CANCELLED" => PaymentStatusEnum.Rejected, + "PAYMENT_DECLINED" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Processing, + }; + } + catch { /* ignore */ } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +public static class RevolutProviderExtensions +{ + public static void RegisterRevolutProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("Revolut"); + services.AddTransient(); + } +} + +public class RevolutConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public RevolutConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(RevolutServiceOptions options) + { + var s = configuration.GetSection(RevolutServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.ApiKey = s.ApiKey; + options.ApiUrl = s.ApiUrl; + options.ReturnUrl = s.ReturnUrl; + options.WebhookSecret = s.WebhookSecret; + } +} diff --git a/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj b/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj b/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj new file mode 100644 index 0000000..c0e5a9c --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj @@ -0,0 +1,27 @@ + + + build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) + net8.0;net9.0;net10.0 + True + True + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs new file mode 100644 index 0000000..fc9d7f6 --- /dev/null +++ b/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs @@ -0,0 +1,263 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace TailoredApps.Shared.Payments.Provider.Tpay; + +public class TpayServiceOptions +{ + public static string ConfigurationKey => "Payments:Providers:Tpay"; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string MerchantId { get; set; } = string.Empty; + public string ApiUrl { get; set; } = "https://api.tpay.com"; + public string ReturnUrl { get; set; } = string.Empty; + public string NotifyUrl { get; set; } = string.Empty; + public string SecurityCode { get; set; } = string.Empty; +} + +file class TpayTokenResponse +{ + [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; +} + +file class TpayTransactionRequest +{ + [JsonPropertyName("amount")] public decimal Amount { get; set; } + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("hiddenDescription")] public string? HiddenDescription { get; set; } + [JsonPropertyName("lang")] public string Lang { get; set; } = "pl"; + [JsonPropertyName("pay")] public TpayPay Pay { get; set; } = new(); + [JsonPropertyName("payer")] public TpayPayer Payer { get; set; } = new(); + [JsonPropertyName("callbacks")] public TpayCallbacks Callbacks { get; set; } = new(); +} + +file class TpayPay +{ + [JsonPropertyName("groupId")] public int? GroupId { get; set; } + [JsonPropertyName("channel")] public string? Channel { get; set; } +} + +file class TpayPayer +{ + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; +} + +file class TpayCallbacks +{ + [JsonPropertyName("payerUrls")] public TpayPayerUrls PayerUrls { get; set; } = new(); + [JsonPropertyName("notification")] public TpayNotification Notification { get; set; } = new(); +} + +file class TpayPayerUrls +{ + [JsonPropertyName("success")] public string Success { get; set; } = string.Empty; + [JsonPropertyName("error")] public string Error { get; set; } = string.Empty; +} + +file class TpayNotification +{ + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; + [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; +} + +file class TpayTransactionResponse +{ + [JsonPropertyName("transactionId")] public string? TransactionId { get; set; } + [JsonPropertyName("transactionPaymentUrl")] public string? PaymentUrl { get; set; } + [JsonPropertyName("title")] public string? Title { get; set; } +} + +file class TpayStatusResponse +{ + [JsonPropertyName("status")] public string? Status { get; set; } +} + +public interface ITpayServiceCaller +{ + Task GetAccessTokenAsync(); + Task CreateTransactionAsync(string token, PaymentRequest request); + Task GetTransactionStatusAsync(string token, string transactionId); + bool VerifyNotification(string body, string signature); +} + +public class TpayServiceCaller : ITpayServiceCaller +{ + private readonly TpayServiceOptions options; + private readonly IHttpClientFactory httpClientFactory; + + public TpayServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) + { + this.options = options.Value; + this.httpClientFactory = httpClientFactory; + } + + public async Task GetAccessTokenAsync() + { + using var client = httpClientFactory.CreateClient("Tpay"); + var content = new FormUrlEncodedContent([ + new("grant_type", "client_credentials"), + new("client_id", options.ClientId), + new("client_secret", options.ClientSecret), + ]); + var response = await client.PostAsync($"{options.ApiUrl}/oauth/auth", content); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json)?.AccessToken ?? string.Empty; + } + + public async Task CreateTransactionAsync(string token, PaymentRequest request) + { + using var client = httpClientFactory.CreateClient("Tpay"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var body = new TpayTransactionRequest + { + Amount = request.Amount, + Description = request.Title ?? request.Description ?? "Order", + Payer = new TpayPayer { Email = request.Email ?? string.Empty, Name = $"{request.FirstName} {request.Surname}".Trim() }, + Callbacks = new TpayCallbacks + { + PayerUrls = new TpayPayerUrls { Success = options.ReturnUrl, Error = options.ReturnUrl }, + Notification = new TpayNotification { Url = options.NotifyUrl, Email = request.Email ?? string.Empty }, + }, + }; + + if (!string.IsNullOrWhiteSpace(request.PaymentChannel)) + body.Pay = new TpayPay { Channel = request.PaymentChannel }; + + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{options.ApiUrl}/transactions", content); + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + public async Task GetTransactionStatusAsync(string token, string transactionId) + { + using var client = httpClientFactory.CreateClient("Tpay"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await client.GetAsync($"{options.ApiUrl}/transactions/{transactionId}"); + if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; + var json = await response.Content.ReadAsStringAsync(); + var status = JsonSerializer.Deserialize(json)?.Status; + return status switch + { + "correct" => PaymentStatusEnum.Finished, + "pending" => PaymentStatusEnum.Processing, + "error" => PaymentStatusEnum.Rejected, + "chargeback" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Created, + }; + } + + public bool VerifyNotification(string body, string signature) + { + var input = body + options.SecurityCode; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + var computed = Convert.ToHexString(hash).ToLowerInvariant(); + return string.Equals(computed, signature, StringComparison.OrdinalIgnoreCase); + } +} + +public class TpayProvider : IPaymentProvider +{ + private readonly ITpayServiceCaller caller; + + public TpayProvider(ITpayServiceCaller caller) => this.caller = caller; + + public string Key => "Tpay"; + public string Name => "Tpay"; + public string Description => "Operator płatności Tpay — przelewy, BLIK, karty."; + public string Url => "https://tpay.com"; + + public Task> GetPaymentChannels(string currency) + { + ICollection channels = + [ + new PaymentChannel { Id = "150", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "103", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, + new PaymentChannel { Id = "21", Name = "Przelew online", Description = "mTransfer, iPKO", PaymentModel = PaymentModel.OneTime }, + ]; + return Task.FromResult(channels); + } + + public async Task RequestPayment(PaymentRequest request) + { + var token = await caller.GetAccessTokenAsync(); + var tx = await caller.CreateTransactionAsync(token, request); + return new PaymentResponse + { + PaymentUniqueId = tx?.TransactionId, + RedirectUrl = tx?.PaymentUrl, + PaymentStatus = PaymentStatusEnum.Created, + }; + } + + public async Task GetStatus(string paymentId) + { + var token = await caller.GetAccessTokenAsync(); + var status = await caller.GetTransactionStatusAsync(token, paymentId); + return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; + } + + public Task TransactionStatusChange(TransactionStatusChangePayload payload) + { + var body = payload.Payload?.ToString() ?? string.Empty; + var sig = payload.QueryParameters.TryGetValue("X-Signature", out var s) ? s.ToString() : string.Empty; + + if (!caller.VerifyNotification(body, sig)) + return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); + + var status = PaymentStatusEnum.Processing; + try + { + var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("tr_status", out var st)) + status = st.GetString() switch + { + "TRUE" => PaymentStatusEnum.Finished, + "FALSE" => PaymentStatusEnum.Rejected, + _ => PaymentStatusEnum.Processing, + }; + } + catch { /* ignore */ } + + return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); + } +} + +public static class TpayProviderExtensions +{ + public static void RegisterTpayProvider(this IServiceCollection services) + { + services.AddOptions(); + services.ConfigureOptions(); + services.AddHttpClient("Tpay"); + services.AddTransient(); + } +} + +public class TpayConfigureOptions : IConfigureOptions +{ + private readonly IConfiguration configuration; + public TpayConfigureOptions(IConfiguration configuration) => this.configuration = configuration; + public void Configure(TpayServiceOptions options) + { + var s = configuration.GetSection(TpayServiceOptions.ConfigurationKey).Get(); + if (s is null) return; + options.ClientId = s.ClientId; + options.ClientSecret = s.ClientSecret; + options.MerchantId = s.MerchantId; + options.ApiUrl = s.ApiUrl; + options.ReturnUrl = s.ReturnUrl; + options.NotifyUrl = s.NotifyUrl; + options.SecurityCode = s.SecurityCode; + } +} diff --git a/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs b/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs new file mode 100644 index 0000000..e6e74e9 --- /dev/null +++ b/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; +using TailoredApps.Shared.Payments; +using TailoredApps.Shared.Payments.Provider.Adyen; +using TailoredApps.Shared.Payments.Provider.HotPay; +using TailoredApps.Shared.Payments.Provider.PayNow; +using TailoredApps.Shared.Payments.Provider.PayU; +using TailoredApps.Shared.Payments.Provider.Przelewy24; +using TailoredApps.Shared.Payments.Provider.Revolut; +using TailoredApps.Shared.Payments.Provider.Tpay; +using Xunit; + +namespace TailoredApps.Shared.Payments.Tests; + +/// +/// Smoke testy dla 7 nowych providerów płatności. +/// Testy jednostkowe — nie wymagają połączenia sieciowego. +/// +public class MultiProviderPaymentTest +{ + private static IHost BuildHost() => + Host.CreateDefaultBuilder() + .ConfigureAppConfiguration(cfg => cfg.AddJsonFile("appsettings.json", optional: true)) + .ConfigureServices((_, services) => + { + // Wszystkie 7 providerów + services.RegisterAdyenProvider(); + services.RegisterPayUProvider(); + services.RegisterPrzelewy24Provider(); + services.RegisterTpayProvider(); + services.RegisterHotPayProvider(); + services.RegisterPayNowProvider(); + services.RegisterRevolutProvider(); + + services.AddPayments() + .RegisterPaymentProvider() + .RegisterPaymentProvider() + .RegisterPaymentProvider() + .RegisterPaymentProvider() + .RegisterPaymentProvider() + .RegisterPaymentProvider() + .RegisterPaymentProvider(); + }) + .Build(); + + // ─── Provider list ──────────────────────────────────────────────────────── + + [Fact] + public async Task AllProviders_AreRegistered() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var providers = await service.GetProviders(); + var ids = providers.Select(p => p.Id).ToList(); + + Assert.Contains("Adyen", ids); + Assert.Contains("PayU", ids); + Assert.Contains("Przelewy24", ids); + Assert.Contains("Tpay", ids); + Assert.Contains("HotPay", ids); + Assert.Contains("PayNow", ids); + Assert.Contains("Revolut", ids); + } + + // ─── GetChannels ────────────────────────────────────────────────────────── + + [Theory] + [InlineData("Adyen", "PLN")] + [InlineData("PayU", "PLN")] + [InlineData("Przelewy24", "PLN")] + [InlineData("Tpay", "PLN")] + [InlineData("HotPay", "PLN")] + [InlineData("PayNow", "PLN")] + [InlineData("Revolut", "PLN")] + public async Task GetChannels_PLN_ReturnsNonEmptyList(string providerKey, string currency) + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var channels = await service.GetChannels(providerKey, currency); + Assert.NotEmpty(channels); + } + + [Fact] + public async Task Adyen_GetChannels_EUR_ReturnsIdeal() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var channels = await service.GetChannels("Adyen", "EUR"); + Assert.Contains(channels, c => c.Id == "ideal"); + } + + [Fact] + public async Task Revolut_GetChannels_ContainsRevolutPay() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var channels = await service.GetChannels("Revolut", "PLN"); + Assert.Contains(channels, c => c.Id == "revolut_pay"); + } + + [Fact] + public async Task PayNow_GetChannels_ContainsBlikAndCard() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var channels = await service.GetChannels("PayNow", "PLN"); + Assert.Contains(channels, c => c.Id == "BLIK"); + Assert.Contains(channels, c => c.Id == "CARD"); + } + + // ─── Invalid webhook → Rejected ────────────────────────────────────────── + + [Theory] + [InlineData("PayU")] + [InlineData("Przelewy24")] + [InlineData("Tpay")] + [InlineData("PayNow")] + [InlineData("Revolut")] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected(string providerKey) + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + + var result = await service.TransactionStatusChange(providerKey, new TransactionStatusChangePayload + { + ProviderId = providerKey, + Payload = """{"status":"CONFIRMED","orderId":"test_123"}""", + QueryParameters = new Dictionary + { + { "OpenPayU-Signature", new StringValues("invalidsignature") }, + { "Signature", new StringValues("invalidsignature") }, + { "X-Signature", new StringValues("invalidsignature") }, + { "Revolut-Signature", new StringValues("v1=invalidsignature") }, + { "Revolut-Request-Timestamp",new StringValues("1234567890") }, + }, + }); + + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task HotPay_TransactionStatusChange_InvalidHash_ReturnsRejected() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + + var result = await service.TransactionStatusChange("HotPay", new TransactionStatusChangePayload + { + ProviderId = "HotPay", + Payload = string.Empty, + QueryParameters = new Dictionary + { + { "HASH", new StringValues("invalidhash") }, + { "KWOTA", new StringValues("9.99") }, + { "ID_PLATNOSCI", new StringValues("test_123") }, + { "STATUS", new StringValues("SUCCESS") }, + }, + }); + + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + // ─── Integration tests (Skip) ───────────────────────────────────────────── + + [Fact(Skip = "Integration — requires API credentials in appsettings.json")] + public async Task PayU_RegisterPayment_CreatesOrder() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var result = await service.RegisterPayment(new PaymentRequest + { + PaymentProvider = "PayU", + PaymentChannel = "c", + PaymentModel = PaymentModel.OneTime, + Title = "Test order", + Currency = "PLN", + Amount = 1.00m, + Email = "test@example.com", + }); + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.NotEmpty(result.RedirectUrl!); + } + + [Fact(Skip = "Integration — requires API credentials in appsettings.json")] + public async Task Revolut_RegisterPayment_CreatesOrder() + { + var host = BuildHost(); + var service = host.Services.GetRequiredService(); + var result = await service.RegisterPayment(new PaymentRequest + { + PaymentProvider = "Revolut", + PaymentChannel = "card", + PaymentModel = PaymentModel.OneTime, + Title = "Test order", + Currency = "PLN", + Amount = 1.00m, + Email = "test@example.com", + }); + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + } +} diff --git a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj index 79ceb45..eac5f76 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj +++ b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj @@ -22,6 +22,13 @@ + + + + + + + diff --git a/tests/TailoredApps.Shared.Payments.Tests/appsettings.json b/tests/TailoredApps.Shared.Payments.Tests/appsettings.json index b90b291..46599bc 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/appsettings.json +++ b/tests/TailoredApps.Shared.Payments.Tests/appsettings.json @@ -13,6 +13,60 @@ "WebhookSecret": "whsec_REPLACE_ME", "SuccessUrl": "https://integrationtest.tailoredapps.pl/payment/success?session={CHECKOUT_SESSION_ID}", "CancelUrl": "https://integrationtest.tailoredapps.pl/payment/cancel" + }, + "Adyen": { + "ApiKey": "REPLACE_ME", + "MerchantAccount": "REPLACE_ME", + "ClientKey": "test_REPLACE_ME", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "NotificationHmacKey": "REPLACE_ME", + "IsTest": true + }, + "PayU": { + "ClientId": "REPLACE_ME", + "ClientSecret": "REPLACE_ME", + "PosId": "REPLACE_ME", + "SignatureKey": "REPLACE_ME", + "ServiceUrl": "https://secure.snd.payu.com", + "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/payu", + "ContinueUrl": "https://integrationtest.tailoredapps.pl/payment/return" + }, + "Przelewy24": { + "MerchantId": 0, + "PosId": 0, + "ApiKey": "REPLACE_ME", + "CrcKey": "REPLACE_ME", + "ServiceUrl": "https://sandbox.przelewy24.pl", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/p24" + }, + "Tpay": { + "ClientId": "REPLACE_ME", + "ClientSecret": "REPLACE_ME", + "MerchantId": "REPLACE_ME", + "ApiUrl": "https://openapi.sandbox.tpay.com", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/tpay", + "SecurityCode": "REPLACE_ME" + }, + "HotPay": { + "SecretHash": "REPLACE_ME", + "ServiceUrl": "https://platnosci.hotpay.pl", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/hotpay" + }, + "PayNow": { + "ApiKey": "REPLACE_ME", + "SignatureKey": "REPLACE_ME", + "ApiUrl": "https://api.sandbox.paynow.pl", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "ContinueUrl": "https://integrationtest.tailoredapps.pl/payment/return" + }, + "Revolut": { + "ApiKey": "REPLACE_ME", + "ApiUrl": "https://sandbox-merchant.revolut.com/api", + "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", + "WebhookSecret": "REPLACE_ME" } } } From 7b63656e49f8be17016b8df941d6f14664d02ebc Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 14:54:41 +0100 Subject: [PATCH 2/2] Revert "feat: add payment providers Adyen, PayU, Przelewy24, Tpay, HotPay, PayNow, Revolut" This reverts commit 490cd8dcf217f5fb41c6d4a934b85b4aa00da55c. --- TailoredApps.Shared.sln | 42 --- .../AdyenProvider.cs | 237 ------------- ...Apps.Shared.Payments.Provider.Adyen.csproj | 25 -- .../HotPayProvider.cs | 169 --------- ...pps.Shared.Payments.Provider.HotPay.csproj | 27 -- .../PayNowProvider.cs | 222 ------------ ...pps.Shared.Payments.Provider.PayNow.csproj | 27 -- .../PayUProvider.cs | 331 ------------------ ...dApps.Shared.Payments.Provider.PayU.csproj | 27 -- .../Przelewy24Provider.cs | 290 --------------- ...Shared.Payments.Provider.Przelewy24.csproj | 27 -- .../RevolutProvider.cs | 207 ----------- ...ps.Shared.Payments.Provider.Revolut.csproj | 27 -- ...dApps.Shared.Payments.Provider.Tpay.csproj | 27 -- .../TpayProvider.cs | 263 -------------- .../MultiProviderPaymentTest.cs | 206 ----------- .../TailoredApps.Shared.Payments.Tests.csproj | 7 - .../appsettings.json | 54 --- 18 files changed, 2215 deletions(-) delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj delete mode 100644 src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs delete mode 100644 tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs diff --git a/TailoredApps.Shared.sln b/TailoredApps.Shared.sln index 65adc97..cfe6efb 100644 --- a/TailoredApps.Shared.sln +++ b/TailoredApps.Shared.sln @@ -35,20 +35,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payment EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Stripe", "src\TailoredApps.Shared.Payments.Provider.Stripe\TailoredApps.Shared.Payments.Provider.Stripe.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Adyen", "src\TailoredApps.Shared.Payments.Provider.Adyen\TailoredApps.Shared.Payments.Provider.Adyen.csproj", "{B1C2D3E4-F5A6-7890-BCDE-FA2345678901}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.PayU", "src\TailoredApps.Shared.Payments.Provider.PayU\TailoredApps.Shared.Payments.Provider.PayU.csproj", "{C2D3E4F5-A6B7-8901-CDEF-AB3456789012}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Przelewy24", "src\TailoredApps.Shared.Payments.Provider.Przelewy24\TailoredApps.Shared.Payments.Provider.Przelewy24.csproj", "{D3E4F5A6-B7C8-9012-DEF0-BC4567890123}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Tpay", "src\TailoredApps.Shared.Payments.Provider.Tpay\TailoredApps.Shared.Payments.Provider.Tpay.csproj", "{E4F5A6B7-C8D9-0123-EF01-CD5678901234}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.HotPay", "src\TailoredApps.Shared.Payments.Provider.HotPay\TailoredApps.Shared.Payments.Provider.HotPay.csproj", "{F5A6B7C8-D9E0-1234-F012-DE6789012345}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.PayNow", "src\TailoredApps.Shared.Payments.Provider.PayNow\TailoredApps.Shared.Payments.Provider.PayNow.csproj", "{A6B7C8D9-E0F1-2345-0123-EF7890123456}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.Payments.Provider.Revolut", "src\TailoredApps.Shared.Payments.Provider.Revolut\TailoredApps.Shared.Payments.Provider.Revolut.csproj", "{B7C8D9E0-F1A2-3456-1234-F08901234567}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore", "src\TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore\TailoredApps.Shared.EntityFramework.UnitOfWork.WebApiCore.csproj", "{5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TailoredApps.Shared.MediatR.ML", "src\TailoredApps.Shared.MediatR.ML\TailoredApps.Shared.MediatR.ML.csproj", "{C454266F-AF6D-450B-B899-9359180BAE94}" @@ -141,34 +127,6 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU - {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B1C2D3E4-F5A6-7890-BCDE-FA2345678901}.Release|Any CPU.Build.0 = Release|Any CPU - {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C2D3E4F5-A6B7-8901-CDEF-AB3456789012}.Release|Any CPU.Build.0 = Release|Any CPU - {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D3E4F5A6-B7C8-9012-DEF0-BC4567890123}.Release|Any CPU.Build.0 = Release|Any CPU - {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4F5A6B7-C8D9-0123-EF01-CD5678901234}.Release|Any CPU.Build.0 = Release|Any CPU - {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F5A6B7C8-D9E0-1234-F012-DE6789012345}.Release|Any CPU.Build.0 = Release|Any CPU - {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A6B7C8D9-E0F1-2345-0123-EF7890123456}.Release|Any CPU.Build.0 = Release|Any CPU - {B7C8D9E0-F1A2-3456-1234-F08901234567}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7C8D9E0-F1A2-3456-1234-F08901234567}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7C8D9E0-F1A2-3456-1234-F08901234567}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7C8D9E0-F1A2-3456-1234-F08901234567}.Release|Any CPU.Build.0 = Release|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Debug|Any CPU.Build.0 = Debug|Any CPU {5C6FFD3B-B9E3-47D8-8A77-5973198C7B16}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs deleted file mode 100644 index 7957559..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Adyen; -using Adyen.Model.Checkout; -using Adyen.Service.Checkout; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Environment = Adyen.Model.Enum.Environment; - -namespace TailoredApps.Shared.Payments.Provider.Adyen; - -// ─── Options ───────────────────────────────────────────────────────────────── - -/// Konfiguracja Adyen. Sekcja: Payments:Providers:Adyen. -public class AdyenServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:Adyen"; - public string ApiKey { get; set; } = string.Empty; - public string MerchantAccount { get; set; } = string.Empty; - public string ClientKey { get; set; } = string.Empty; - public string ReturnUrl { get; set; } = string.Empty; - /// HMAC klucz do weryfikacji powiadomień webhooka. - public string NotificationHmacKey { get; set; } = string.Empty; - /// True = sandbox (test), False = live. - public bool IsTest { get; set; } = true; -} - -// ─── Interface ──────────────────────────────────────────────────────────────── - -/// Abstrakcja nad Adyen Checkout API. -public interface IAdyenServiceCaller -{ - Task CreateSessionAsync(PaymentRequest request); - Task GetPaymentStatusAsync(string pspReference); - bool VerifyNotificationHmac(string payload, string hmacSignature); -} - -// ─── Caller ─────────────────────────────────────────────────────────────────── - -/// Implementacja opakowująca oficjalny Adyen SDK. -public class AdyenServiceCaller : IAdyenServiceCaller -{ - private readonly AdyenServiceOptions options; - - public AdyenServiceCaller(IOptions options) - { - this.options = options.Value; - } - - private Client CreateClient() - { - var config = new Config - { - XApiKey = options.ApiKey, - Environment = options.IsTest ? Environment.Test : Environment.Live, - }; - return new Client(config); - } - - public async Task CreateSessionAsync(PaymentRequest request) - { - var client = CreateClient(); - var checkout = new PaymentsService(client); - - var amount = new Adyen.Model.Checkout.Amount - { - Value = (long)(request.Amount * 100), - Currency = request.Currency.ToUpperInvariant(), - }; - - var sessionRequest = new CreateCheckoutSessionRequest( - merchantAccount: options.MerchantAccount, - amount: amount, - returnUrl: options.ReturnUrl, - reference: request.AdditionalData ?? Guid.NewGuid().ToString("N") - ) - { - ShopperEmail = request.Email, - ShopperReference = request.Email, - CountryCode = request.Country ?? "PL", - }; - - return await checkout.SessionsAsync(sessionRequest); - } - - public async Task GetPaymentStatusAsync(string pspReference) - { - var client = CreateClient(); - var checkout = new PaymentsService(client); - var details = new PaymentDetailsRequest(details: new PaymentCompletionDetails(), paymentData: pspReference); - return await checkout.PaymentsDetailsAsync(details); - } - - public bool VerifyNotificationHmac(string payload, string hmacSignature) - { - try - { - var keyBytes = Convert.FromHexString(options.NotificationHmacKey); - var dataBytes = Encoding.UTF8.GetBytes(payload); - var computed = Convert.ToBase64String(HMACSHA256.HashData(keyBytes, dataBytes)); - return string.Equals(computed, hmacSignature, StringComparison.Ordinal); - } - catch { return false; } - } -} - -// ─── Provider ───────────────────────────────────────────────────────────────── - -/// Implementacja dla Adyen. -public class AdyenProvider : IPaymentProvider -{ - private readonly IAdyenServiceCaller caller; - private readonly AdyenServiceOptions options; - - public AdyenProvider(IAdyenServiceCaller caller, IOptions options) - { - this.caller = caller; - this.options = options.Value; - } - - public string Key => "Adyen"; - public string Name => "Adyen"; - public string Description => "Globalny operator płatności Adyen — karty, BLIK, iDEAL i inne."; - public string Url => "https://www.adyen.com"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = currency.ToUpperInvariant() switch - { - "PLN" => - [ - new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "blik", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "onlineBanking_PL", Name = "Przelew online", Description = "Polskie banki", PaymentModel = PaymentModel.OneTime }, - ], - "EUR" => - [ - new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "ideal", Name = "iDEAL", Description = "Przelew iDEAL", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "sepadirectdebit", Name = "SEPA Direct Debit", Description = "SEPA", PaymentModel = PaymentModel.OneTime }, - ], - _ => - [ - new PaymentChannel { Id = "scheme", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - ], - }; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var session = await caller.CreateSessionAsync(request); - return new PaymentResponse - { - PaymentUniqueId = session.Id, - RedirectUrl = session.Url, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public async Task GetStatus(string paymentId) - { - var details = await caller.GetPaymentStatusAsync(paymentId); - var status = details.ResultCode?.ToString() switch - { - "Authorised" => PaymentStatusEnum.Finished, - "Refused" => PaymentStatusEnum.Rejected, - "Cancelled" => PaymentStatusEnum.Rejected, - "Pending" => PaymentStatusEnum.Processing, - "Received" => PaymentStatusEnum.Processing, - _ => PaymentStatusEnum.Created, - }; - return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; - } - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - var hmac = payload.QueryParameters.TryGetValue("HmacSignature", out var h) ? h.ToString() : string.Empty; - - if (!caller.VerifyNotificationHmac(body, hmac)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid HMAC" }); - - // Parsuj eventCode z JSON - var status = PaymentStatusEnum.Processing; - try - { - var doc = System.Text.Json.JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("eventCode", out var ev)) - { - status = ev.GetString() switch - { - "AUTHORISATION" => PaymentStatusEnum.Finished, - "CANCELLATION" => PaymentStatusEnum.Rejected, - "REFUND" => PaymentStatusEnum.Rejected, - "AUTHORISATION_FAILED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Processing, - }; - } - } - catch { /* ignore */ } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -// ─── DI ─────────────────────────────────────────────────────────────────────── - -/// Rozszerzenia DI dla Adyen. -public static class AdyenProviderExtensions -{ - public static void RegisterAdyenProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddTransient(); - } -} - -/// Wczytuje opcje Adyen z konfiguracji. -public class AdyenConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public AdyenConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(AdyenServiceOptions options) - { - var s = configuration.GetSection(AdyenServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.ApiKey = s.ApiKey; - options.MerchantAccount = s.MerchantAccount; - options.ClientKey = s.ClientKey; - options.ReturnUrl = s.ReturnUrl; - options.NotificationHmacKey = s.NotificationHmacKey; - options.IsTest = s.IsTest; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj b/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj deleted file mode 100644 index af39e01..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Adyen/TailoredApps.Shared.Payments.Provider.Adyen.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs b/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs deleted file mode 100644 index 6a1b9bf..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.HotPay/HotPayProvider.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.HotPay; - -public class HotPayServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:HotPay"; - public string SecretHash { get; set; } = string.Empty; - public string ServiceUrl { get; set; } = "https://platnosci.hotpay.pl"; - public string ReturnUrl { get; set; } = string.Empty; - public string PaymentUrl { get; set; } = string.Empty; - public string NotifyUrl { get; set; } = string.Empty; -} - -file class HotPayRequest -{ - [JsonPropertyName("SEKRET")] public string Secret { get; set; } = string.Empty; - [JsonPropertyName("KWOTA")] public string Amount { get; set; } = string.Empty; - [JsonPropertyName("NAZWA_USLUGI")] public string ServiceName { get; set; } = string.Empty; - [JsonPropertyName("IDENTYFIKATOR_PLATNOSCI")] public string PaymentId { get; set; } = string.Empty; - [JsonPropertyName("ADRES_WWW")] public string ReturnUrl { get; set; } = string.Empty; - [JsonPropertyName("EMAIL")] public string? Email { get; set; } - [JsonPropertyName("HASH")] public string Hash { get; set; } = string.Empty; -} - -file class HotPayResponse -{ - [JsonPropertyName("STATUS")] public string? Status { get; set; } - [JsonPropertyName("PRZEKIERUJ_DO")] public string? RedirectUrl { get; set; } - [JsonPropertyName("ID_PLATNOSCI")] public string? PaymentId { get; set; } -} - -public interface IHotPayServiceCaller -{ - Task InitPaymentAsync(PaymentRequest request, string paymentId); - bool VerifyNotification(string hash, string kwota, string idPlatnosci, string status); -} - -public class HotPayServiceCaller : IHotPayServiceCaller -{ - private readonly HotPayServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public HotPayServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - public async Task InitPaymentAsync(PaymentRequest request, string paymentId) - { - var amount = request.Amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); - var hashData = $"{options.SecretHash};{amount};{request.Title ?? "Order"};{paymentId};{options.ReturnUrl}"; - var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hashData))).ToLowerInvariant(); - - var body = new HotPayRequest - { - Secret = options.SecretHash, - Amount = amount, - ServiceName = request.Title ?? request.Description ?? "Order", - PaymentId = paymentId, - ReturnUrl = options.ReturnUrl, - Email = request.Email, - Hash = hash, - }; - - using var client = httpClientFactory.CreateClient("HotPay"); - var json = JsonSerializer.Serialize(body); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await client.PostAsync(options.ServiceUrl, content); - var responseJson = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(responseJson); - } - - public bool VerifyNotification(string hash, string kwota, string idPlatnosci, string status) - { - var data = $"{options.SecretHash};{kwota};{idPlatnosci};{status}"; - var computed = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant(); - return string.Equals(computed, hash, StringComparison.OrdinalIgnoreCase); - } -} - -public class HotPayProvider : IPaymentProvider -{ - private readonly IHotPayServiceCaller caller; - - public HotPayProvider(IHotPayServiceCaller caller) => this.caller = caller; - - public string Key => "HotPay"; - public string Name => "HotPay"; - public string Description => "Operator płatności HotPay — BLIK, karty, przelewy."; - public string Url => "https://hotpay.pl"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "blik", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "transfer", Name = "Przelew online", Description = "Przelew bankowy", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var paymentId = Guid.NewGuid().ToString("N"); - var result = await caller.InitPaymentAsync(request, paymentId); - - return new PaymentResponse - { - PaymentUniqueId = result?.PaymentId ?? paymentId, - RedirectUrl = result?.RedirectUrl, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public Task GetStatus(string paymentId) - => Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing }); - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var qs = payload.QueryParameters; - var hash = qs.TryGetValue("HASH", out var h) ? h.ToString() : string.Empty; - var kwota = qs.TryGetValue("KWOTA", out var k) ? k.ToString() : string.Empty; - var idPlatnosci = qs.TryGetValue("ID_PLATNOSCI", out var i) ? i.ToString() : string.Empty; - var status = qs.TryGetValue("STATUS", out var s) ? s.ToString() : string.Empty; - - if (!caller.VerifyNotification(hash, kwota, idPlatnosci, status)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid hash" }); - - var payStatus = status == "SUCCESS" ? PaymentStatusEnum.Finished : PaymentStatusEnum.Rejected; - return Task.FromResult(new PaymentResponse { PaymentUniqueId = idPlatnosci, PaymentStatus = payStatus, ResponseObject = "OK" }); - } -} - -public static class HotPayProviderExtensions -{ - public static void RegisterHotPayProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("HotPay"); - services.AddTransient(); - } -} - -public class HotPayConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public HotPayConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(HotPayServiceOptions options) - { - var s = configuration.GetSection(HotPayServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.SecretHash = s.SecretHash; - options.ServiceUrl = s.ServiceUrl; - options.ReturnUrl = s.ReturnUrl; - options.PaymentUrl = s.PaymentUrl; - options.NotifyUrl = s.NotifyUrl; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj b/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.HotPay/TailoredApps.Shared.Payments.Provider.HotPay.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs b/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs deleted file mode 100644 index f23765c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.PayNow/PayNowProvider.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.PayNow; - -public class PayNowServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:PayNow"; - public string ApiKey { get; set; } = string.Empty; - public string SignatureKey { get; set; } = string.Empty; - public string ApiUrl { get; set; } = "https://api.paynow.pl"; - public string ReturnUrl { get; set; } = string.Empty; - public string ContinueUrl { get; set; } = string.Empty; -} - -file class PayNowPaymentRequest -{ - [JsonPropertyName("amount")] public long Amount { get; set; } - [JsonPropertyName("currency")] public string Currency { get; set; } = "PLN"; - [JsonPropertyName("externalId")] public string ExternalId { get; set; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; - [JsonPropertyName("buyer")] public PayNowBuyer? Buyer { get; set; } - [JsonPropertyName("continueUrl")] public string? ContinueUrl { get; set; } - [JsonPropertyName("returnUrl")] public string? ReturnUrl { get; set; } -} - -file class PayNowBuyer -{ - [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; -} - -file class PayNowPaymentResponse -{ - [JsonPropertyName("paymentId")] public string? PaymentId { get; set; } - [JsonPropertyName("status")] public string? Status { get; set; } - [JsonPropertyName("redirectUrl")] public string? RedirectUrl { get; set; } -} - -file class PayNowStatusResponse -{ - [JsonPropertyName("status")] public string? Status { get; set; } -} - -public interface IPayNowServiceCaller -{ - Task CreatePaymentAsync(PaymentRequest request); - Task GetPaymentStatusAsync(string paymentId); - bool VerifySignature(string body, string signature); -} - -public class PayNowServiceCaller : IPayNowServiceCaller -{ - private readonly PayNowServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public PayNowServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - private HttpClient CreateClient() - { - var client = httpClientFactory.CreateClient("PayNow"); - client.DefaultRequestHeaders.Add("Api-Key", options.ApiKey); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - return client; - } - - public async Task CreatePaymentAsync(PaymentRequest request) - { - using var client = CreateClient(); - var idempotencyKey = Guid.NewGuid().ToString(); - client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); - - var body = new PayNowPaymentRequest - { - Amount = (long)(request.Amount * 100), - Currency = request.Currency.ToUpperInvariant(), - ExternalId = request.AdditionalData ?? Guid.NewGuid().ToString("N"), - Description = request.Title ?? request.Description ?? "Order", - Buyer = new PayNowBuyer { Email = request.Email ?? string.Empty }, - ContinueUrl = options.ContinueUrl, - ReturnUrl = options.ReturnUrl, - }; - - var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); - var response = await client.PostAsync($"{options.ApiUrl}/v2/payments", content); - var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); - } - - public async Task GetPaymentStatusAsync(string paymentId) - { - using var client = CreateClient(); - var response = await client.GetAsync($"{options.ApiUrl}/v2/payments/{paymentId}/status"); - if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; - var json = await response.Content.ReadAsStringAsync(); - var status = JsonSerializer.Deserialize(json)?.Status; - return status switch - { - "CONFIRMED" => PaymentStatusEnum.Finished, - "PENDING" => PaymentStatusEnum.Processing, - "PROCESSING" => PaymentStatusEnum.Processing, - "NEW" => PaymentStatusEnum.Created, - "ERROR" => PaymentStatusEnum.Rejected, - "REJECTED" => PaymentStatusEnum.Rejected, - "ABANDONED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Created, - }; - } - - public bool VerifySignature(string body, string signature) - { - var keyBytes = Encoding.UTF8.GetBytes(options.SignatureKey); - var dataBytes = Encoding.UTF8.GetBytes(body); - var computed = Convert.ToBase64String(HMACSHA256.HashData(keyBytes, dataBytes)); - return string.Equals(computed, signature, StringComparison.Ordinal); - } -} - -public class PayNowProvider : IPaymentProvider -{ - private readonly IPayNowServiceCaller caller; - - public PayNowProvider(IPayNowServiceCaller caller) => this.caller = caller; - - public string Key => "PayNow"; - public string Name => "PayNow"; - public string Description => "Operator płatności PayNow (mBank) — BLIK, karty, przelewy."; - public string Url => "https://paynow.pl"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "BLIK", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "CARD", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "PBL", Name = "Przelew bankowy", Description = "Pay-by-link", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "TRANSFER", Name = "Przelew tradycyjny",Description = "Przelew bankowy", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var payment = await caller.CreatePaymentAsync(request); - return new PaymentResponse - { - PaymentUniqueId = payment?.PaymentId, - RedirectUrl = payment?.RedirectUrl, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public async Task GetStatus(string paymentId) - { - var status = await caller.GetPaymentStatusAsync(paymentId); - return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; - } - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - var sig = payload.QueryParameters.TryGetValue("Signature", out var s) ? s.ToString() : string.Empty; - - if (!caller.VerifySignature(body, sig)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); - - var status = PaymentStatusEnum.Processing; - try - { - var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("status", out var st)) - status = st.GetString() switch - { - "CONFIRMED" => PaymentStatusEnum.Finished, - "ERROR" => PaymentStatusEnum.Rejected, - "REJECTED" => PaymentStatusEnum.Rejected, - "ABANDONED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Processing, - }; - } - catch { /* ignore */ } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -public static class PayNowProviderExtensions -{ - public static void RegisterPayNowProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("PayNow"); - services.AddTransient(); - } -} - -public class PayNowConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public PayNowConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(PayNowServiceOptions options) - { - var s = configuration.GetSection(PayNowServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.ApiKey = s.ApiKey; - options.SignatureKey = s.SignatureKey; - options.ApiUrl = s.ApiUrl; - options.ReturnUrl = s.ReturnUrl; - options.ContinueUrl = s.ContinueUrl; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj b/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.PayNow/TailoredApps.Shared.Payments.Provider.PayNow.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs b/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs deleted file mode 100644 index d0e1430..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs +++ /dev/null @@ -1,331 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.PayU; - -// ─── Options ──────────────────────────────────────────────────────────────── - -/// Konfiguracja providera PayU. Sekcja: Payments:Providers:PayU. -public class PayUServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:PayU"; - public string ClientId { get; set; } = string.Empty; - public string ClientSecret { get; set; } = string.Empty; - /// POS ID (merchantPosId) z panelu PayU. - public string PosId { get; set; } = string.Empty; - /// Klucz MD5 do weryfikacji powiadomień. - public string SignatureKey { get; set; } = string.Empty; - public string ServiceUrl { get; set; } = "https://secure.payu.com"; - public string NotifyUrl { get; set; } = string.Empty; - public string ContinueUrl { get; set; } = string.Empty; -} - -// ─── Internal models ───────────────────────────────────────────────────────── - -file class PayUTokenResponse -{ - [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; - [JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty; -} - -file class PayUOrderRequest -{ - [JsonPropertyName("merchantPosId")] public string MerchantPosId { get; set; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; - [JsonPropertyName("currencyCode")] public string CurrencyCode { get; set; } = string.Empty; - [JsonPropertyName("totalAmount")] public long TotalAmount { get; set; } - [JsonPropertyName("notifyUrl")] public string NotifyUrl { get; set; } = string.Empty; - [JsonPropertyName("continueUrl")] public string ContinueUrl { get; set; } = string.Empty; - [JsonPropertyName("customerIp")] public string CustomerIp { get; set; } = "127.0.0.1"; - [JsonPropertyName("buyer")] public PayUBuyer? Buyer { get; set; } - [JsonPropertyName("products")] public List Products { get; set; } = []; - [JsonPropertyName("payMethods")] public PayUPayMethods? PayMethods { get; set; } -} - -file class PayUBuyer -{ - [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; - [JsonPropertyName("firstName")] public string FirstName { get; set; } = string.Empty; - [JsonPropertyName("lastName")] public string LastName { get; set; } = string.Empty; -} - -file class PayUProduct -{ - [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; - [JsonPropertyName("unitPrice")] public long UnitPrice { get; set; } - [JsonPropertyName("quantity")] public int Quantity { get; set; } = 1; -} - -file class PayUPayMethods -{ - [JsonPropertyName("payMethod")] public PayUPayMethod? PayMethod { get; set; } -} - -file class PayUPayMethod -{ - [JsonPropertyName("type")] public string Type { get; set; } = "PBL"; - [JsonPropertyName("value")] public string Value { get; set; } = string.Empty; -} - -file class PayUOrderResponse -{ - [JsonPropertyName("orderId")] public string? OrderId { get; set; } - [JsonPropertyName("redirectUri")] public string? RedirectUri { get; set; } - [JsonPropertyName("status")] public PayUStatus? Status { get; set; } -} - -file class PayUStatusResponse -{ - [JsonPropertyName("orders")] public List? Orders { get; set; } -} - -file class PayUOrder -{ - [JsonPropertyName("orderId")] public string? OrderId { get; set; } - [JsonPropertyName("status")] public string? Status { get; set; } -} - -file class PayUStatus -{ - [JsonPropertyName("statusCode")] public string? StatusCode { get; set; } -} - -// ─── Service Caller Interface ──────────────────────────────────────────────── - -/// Abstrakcja nad PayU REST API. -public interface IPayUServiceCaller -{ - Task GetAccessTokenAsync(); - Task CreateOrderAsync(string accessToken, PaymentRequest request); - Task GetOrderStatusAsync(string accessToken, string orderId); - bool VerifyNotification(string body, string signatureHeader); -} - -// ─── Service Caller ────────────────────────────────────────────────────────── - -/// Implementacja . -public class PayUServiceCaller : IPayUServiceCaller -{ - private readonly PayUServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public PayUServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - public async Task GetAccessTokenAsync() - { - using var client = httpClientFactory.CreateClient("PayU"); - var content = new FormUrlEncodedContent([ - new("grant_type", "client_credentials"), - new("client_id", options.ClientId), - new("client_secret", options.ClientSecret), - ]); - var response = await client.PostAsync($"{options.ServiceUrl}/pl/standard/user/oauth/authorize", content); - response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); - var token = JsonSerializer.Deserialize(json); - return token?.AccessToken ?? string.Empty; - } - - public async Task CreateOrderAsync(string accessToken, PaymentRequest request) - { - using var client = httpClientFactory.CreateClient("PayU"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - var order = new PayUOrderRequest - { - MerchantPosId = options.PosId, - Description = request.Title ?? request.Description ?? "Order", - CurrencyCode = request.Currency.ToUpperInvariant(), - TotalAmount = (long)(request.Amount * 100), - NotifyUrl = options.NotifyUrl, - ContinueUrl = options.ContinueUrl, - Buyer = new PayUBuyer - { - Email = request.Email ?? string.Empty, - FirstName = request.FirstName ?? string.Empty, - LastName = request.Surname ?? string.Empty, - }, - Products = [new PayUProduct { Name = request.Title ?? "Product", UnitPrice = (long)(request.Amount * 100) }], - }; - - if (!string.IsNullOrWhiteSpace(request.PaymentChannel)) - order.PayMethods = new PayUPayMethods { PayMethod = new PayUPayMethod { Value = request.PaymentChannel } }; - - var json = JsonSerializer.Serialize(order); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - // PayU zwraca 302 redirect — nie podążaj za nim - var handler = new HttpClientHandler { AllowAutoRedirect = false }; - using var redirectClient = new HttpClient(handler); - redirectClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - redirectClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - var response = await redirectClient.PostAsync($"{options.ServiceUrl}/api/v2_1/orders", content); - var responseJson = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(responseJson); - } - - public async Task GetOrderStatusAsync(string accessToken, string orderId) - { - using var client = httpClientFactory.CreateClient("PayU"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - var response = await client.GetAsync($"{options.ServiceUrl}/api/v2_1/orders/{orderId}"); - if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; - var json = await response.Content.ReadAsStringAsync(); - var status = JsonSerializer.Deserialize(json); - return status?.Orders?.FirstOrDefault()?.Status switch - { - "COMPLETED" => PaymentStatusEnum.Finished, - "PENDING" => PaymentStatusEnum.Processing, - "WAITING_FOR_CONFIRMATION" => PaymentStatusEnum.Processing, - "CANCELED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Created, - }; - } - - public bool VerifyNotification(string body, string signatureHeader) - { - // Parsuj: signature=;algorithm=MD5;sender=PAYU - var parts = signatureHeader.Split(';') - .Select(p => p.Split('=')) - .Where(p => p.Length == 2) - .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); - - if (!parts.TryGetValue("signature", out var signature)) return false; - - var algorithm = parts.GetValueOrDefault("algorithm", "MD5"); - var input = body + options.SignatureKey; - - string computed; - if (algorithm.Equals("SHA", StringComparison.OrdinalIgnoreCase) || algorithm.Equals("SHA256", StringComparison.OrdinalIgnoreCase)) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - computed = Convert.ToHexString(bytes).ToLowerInvariant(); - } - else - { - var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); - computed = Convert.ToHexString(bytes).ToLowerInvariant(); - } - - return string.Equals(computed, signature, StringComparison.OrdinalIgnoreCase); - } -} - -// ─── Provider ──────────────────────────────────────────────────────────────── - -/// Implementacja dla PayU. -public class PayUProvider : IPaymentProvider -{ - private readonly IPayUServiceCaller caller; - - public PayUProvider(IPayUServiceCaller caller) => this.caller = caller; - - public string Key => "PayU"; - public string Name => "PayU"; - public string Description => "Operator płatności online PayU — BLIK, karty, przelewy."; - public string Url => "https://payu.com"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "c", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "BLIK", Name = "BLIK", Description = "Szybka płatność BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "m", Name = "Przelew online", Description = "Przelew bankowy online", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var token = await caller.GetAccessTokenAsync(); - var order = await caller.CreateOrderAsync(token, request); - return new PaymentResponse - { - PaymentUniqueId = order?.OrderId, - RedirectUrl = order?.RedirectUri, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public async Task GetStatus(string paymentId) - { - var token = await caller.GetAccessTokenAsync(); - var status = await caller.GetOrderStatusAsync(token, paymentId); - return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; - } - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - var sig = payload.QueryParameters.TryGetValue("OpenPayU-Signature", out var s) ? s.ToString() : string.Empty; - var valid = caller.VerifyNotification(body, sig); - if (!valid) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); - - // Parsuj status z body (JSON) - var status = PaymentStatusEnum.Processing; - try - { - var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("order", out var order) && - order.TryGetProperty("status", out var s2)) - { - status = s2.GetString() switch - { - "COMPLETED" => PaymentStatusEnum.Finished, - "CANCELED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Processing, - }; - } - } - catch { /* ignore parse errors */ } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -// ─── DI Extensions ─────────────────────────────────────────────────────────── - -/// Rozszerzenia DI dla PayU. -public static class PayUProviderExtensions -{ - public static void RegisterPayUProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("PayU"); - services.AddTransient(); - } -} - -/// Wczytuje opcje PayU z konfiguracji. -public class PayUConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public PayUConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(PayUServiceOptions options) - { - var section = configuration.GetSection(PayUServiceOptions.ConfigurationKey).Get(); - if (section is null) return; - options.ClientId = section.ClientId; - options.ClientSecret = section.ClientSecret; - options.PosId = section.PosId; - options.SignatureKey = section.SignatureKey; - options.ServiceUrl = section.ServiceUrl; - options.NotifyUrl = section.NotifyUrl; - options.ContinueUrl = section.ContinueUrl; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj b/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.PayU/TailoredApps.Shared.Payments.Provider.PayU.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs deleted file mode 100644 index 996f171..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/Przelewy24Provider.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.Przelewy24; - -// ─── Options ───────────────────────────────────────────────────────────────── - -/// Konfiguracja Przelewy24. Sekcja: Payments:Providers:Przelewy24. -public class Przelewy24ServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:Przelewy24"; - public int MerchantId { get; set; } - public int PosId { get; set; } - public string ApiKey { get; set; } = string.Empty; - public string CrcKey { get; set; } = string.Empty; - public string ServiceUrl { get; set; } = "https://secure.przelewy24.pl"; - public string ReturnUrl { get; set; } = string.Empty; - public string NotifyUrl { get; set; } = string.Empty; -} - -// ─── Internal models ───────────────────────────────────────────────────────── - -file class P24RegisterRequest -{ - [JsonPropertyName("merchantId")] public int MerchantId { get; set; } - [JsonPropertyName("posId")] public int PosId { get; set; } - [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; - [JsonPropertyName("amount")] public long Amount { get; set; } - [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; - [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; - [JsonPropertyName("urlReturn")] public string UrlReturn { get; set; } = string.Empty; - [JsonPropertyName("urlStatus")] public string UrlStatus { get; set; } = string.Empty; - [JsonPropertyName("sign")] public string Sign { get; set; } = string.Empty; - [JsonPropertyName("encoding")] public string Encoding { get; set; } = "UTF-8"; -} - -file class P24RegisterResponse -{ - [JsonPropertyName("data")] public P24RegisterData? Data { get; set; } -} - -file class P24RegisterData -{ - [JsonPropertyName("token")] public string? Token { get; set; } -} - -file class P24VerifyRequest -{ - [JsonPropertyName("merchantId")] public int MerchantId { get; set; } - [JsonPropertyName("posId")] public int PosId { get; set; } - [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; - [JsonPropertyName("amount")] public long Amount { get; set; } - [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; - [JsonPropertyName("orderId")] public int OrderId { get; set; } - [JsonPropertyName("sign")] public string Sign { get; set; } = string.Empty; -} - -// ─── Interface ──────────────────────────────────────────────────────────────── - -/// Abstrakcja nad Przelewy24 REST API. -public interface IPrzelewy24ServiceCaller -{ - Task<(string? token, string? error)> RegisterTransactionAsync(PaymentRequest request, string sessionId); - Task VerifyTransactionAsync(string sessionId, long amount, string currency, int orderId); - string ComputeSign(string sessionId, int merchantId, long amount, string currency); - bool VerifyNotification(string body); -} - -// ─── Caller ─────────────────────────────────────────────────────────────────── - -/// Implementacja . -public class Przelewy24ServiceCaller : IPrzelewy24ServiceCaller -{ - private readonly Przelewy24ServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public Przelewy24ServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - private HttpClient CreateClient() - { - var client = httpClientFactory.CreateClient("Przelewy24"); - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.PosId}:{options.ApiKey}")); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - return client; - } - - public async Task<(string? token, string? error)> RegisterTransactionAsync(PaymentRequest request, string sessionId) - { - using var client = CreateClient(); - var amount = (long)(request.Amount * 100); - var sign = ComputeSign(sessionId, options.MerchantId, amount, request.Currency); - - var body = new P24RegisterRequest - { - MerchantId = options.MerchantId, - PosId = options.PosId, - SessionId = sessionId, - Amount = amount, - Currency = request.Currency.ToUpperInvariant(), - Description = request.Title ?? request.Description ?? "Order", - Email = request.Email ?? string.Empty, - UrlReturn = options.ReturnUrl, - UrlStatus = options.NotifyUrl, - Sign = sign, - }; - - var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); - var response = await client.PostAsync($"{options.ServiceUrl}/api/v1/transaction/register", content); - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json); - return (result?.Data?.Token, response.IsSuccessStatusCode ? null : json); - } - - public async Task VerifyTransactionAsync(string sessionId, long amount, string currency, int orderId) - { - using var client = CreateClient(); - var sign = ComputeVerifySign(sessionId, orderId, options.MerchantId, amount, currency); - var body = new P24VerifyRequest - { - MerchantId = options.MerchantId, - PosId = options.PosId, - SessionId = sessionId, - Amount = amount, - Currency = currency.ToUpperInvariant(), - OrderId = orderId, - Sign = sign, - }; - var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); - var response = await client.PutAsync($"{options.ServiceUrl}/api/v1/transaction/verify", content); - return response.IsSuccessStatusCode ? PaymentStatusEnum.Finished : PaymentStatusEnum.Rejected; - } - - public string ComputeSign(string sessionId, int merchantId, long amount, string currency) - { - var json = JsonSerializer.Serialize(new { sessionId, merchantId, amount, currency, crc = options.CrcKey }); - var bytes = SHA384.HashData(Encoding.UTF8.GetBytes(json)); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } - - private string ComputeVerifySign(string sessionId, int orderId, int merchantId, long amount, string currency) - { - var json = JsonSerializer.Serialize(new { sessionId, orderId, merchantId, amount, currency, crc = options.CrcKey }); - var bytes = SHA384.HashData(Encoding.UTF8.GetBytes(json)); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } - - public bool VerifyNotification(string body) - { - try - { - var doc = JsonDocument.Parse(body); - if (!doc.RootElement.TryGetProperty("sign", out var signEl)) return false; - var receivedSign = signEl.GetString() ?? string.Empty; - - doc.RootElement.TryGetProperty("sessionId", out var sid); - doc.RootElement.TryGetProperty("orderId", out var oid); - doc.RootElement.TryGetProperty("merchantId", out var mid); - doc.RootElement.TryGetProperty("amount", out var amt); - doc.RootElement.TryGetProperty("currency", out var cur); - - var json = JsonSerializer.Serialize(new - { - sessionId = sid.GetString(), - orderId = oid.GetInt32(), - merchantId = mid.GetInt32(), - amount = amt.GetInt64(), - currency = cur.GetString(), - crc = options.CrcKey, - }); - var expected = Convert.ToHexString(SHA384.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant(); - return string.Equals(expected, receivedSign, StringComparison.OrdinalIgnoreCase); - } - catch { return false; } - } -} - -// ─── Provider ───────────────────────────────────────────────────────────────── - -/// Implementacja dla Przelewy24. -public class Przelewy24Provider : IPaymentProvider -{ - private readonly IPrzelewy24ServiceCaller caller; - private readonly Przelewy24ServiceOptions options; - - public Przelewy24Provider(IPrzelewy24ServiceCaller caller, IOptions options) - { - this.caller = caller; - this.options = options.Value; - } - - public string Key => "Przelewy24"; - public string Name => "Przelewy24"; - public string Description => "Operator płatności online Przelewy24 — przelewy, BLIK, karty."; - public string Url => "https://przelewy24.pl"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "online_transfer", Name = "Przelew online", Description = "Wszystkie banki", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "blik", Name = "BLIK", Description = "Płatność BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var sessionId = Guid.NewGuid().ToString("N"); - var (token, error) = await caller.RegisterTransactionAsync(request, sessionId); - - if (token is null) - return new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = error }; - - return new PaymentResponse - { - PaymentUniqueId = sessionId, - RedirectUrl = $"{options.ServiceUrl}/trnRequest/{token}", - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public Task GetStatus(string paymentId) - => Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing }); - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - if (!caller.VerifyNotification(body)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); - - PaymentStatusEnum status; - try - { - var doc = JsonDocument.Parse(body); - status = doc.RootElement.TryGetProperty("error", out _) - ? PaymentStatusEnum.Rejected - : PaymentStatusEnum.Finished; - } - catch { status = PaymentStatusEnum.Processing; } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -// ─── DI ─────────────────────────────────────────────────────────────────────── - -/// Rozszerzenia DI dla Przelewy24. -public static class Przelewy24ProviderExtensions -{ - public static void RegisterPrzelewy24Provider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("Przelewy24"); - services.AddTransient(); - } -} - -/// Wczytuje opcje Przelewy24 z konfiguracji. -public class Przelewy24ConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public Przelewy24ConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(Przelewy24ServiceOptions options) - { - var s = configuration.GetSection(Przelewy24ServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.MerchantId = s.MerchantId; - options.PosId = s.PosId; - options.ApiKey = s.ApiKey; - options.CrcKey = s.CrcKey; - options.ServiceUrl = s.ServiceUrl; - options.ReturnUrl = s.ReturnUrl; - options.NotifyUrl = s.NotifyUrl; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj b/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Przelewy24/TailoredApps.Shared.Payments.Provider.Przelewy24.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs deleted file mode 100644 index 36c827d..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Revolut/RevolutProvider.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.Revolut; - -public class RevolutServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:Revolut"; - public string ApiKey { get; set; } = string.Empty; - public string ApiUrl { get; set; } = "https://merchant.revolut.com/api"; - public string ReturnUrl { get; set; } = string.Empty; - public string WebhookSecret { get; set; } = string.Empty; -} - -file class RevolutOrderRequest -{ - [JsonPropertyName("amount")] public long Amount { get; set; } - [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; - [JsonPropertyName("description")] public string? Description { get; set; } - [JsonPropertyName("merchant_order_ext_ref")] public string? ExternalRef { get; set; } - [JsonPropertyName("email")] public string? Email { get; set; } -} - -file class RevolutOrderResponse -{ - [JsonPropertyName("id")] public string? Id { get; set; } - [JsonPropertyName("checkout_url")] public string? CheckoutUrl { get; set; } - [JsonPropertyName("state")] public string? State { get; set; } -} - -public interface IRevolutServiceCaller -{ - Task CreateOrderAsync(PaymentRequest request); - Task GetOrderAsync(string orderId); - bool VerifyWebhookSignature(string payload, string timestamp, string signature); -} - -public class RevolutServiceCaller : IRevolutServiceCaller -{ - private readonly RevolutServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public RevolutServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - private HttpClient CreateClient() - { - var client = httpClientFactory.CreateClient("Revolut"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - return client; - } - - public async Task CreateOrderAsync(PaymentRequest request) - { - using var client = CreateClient(); - var body = new RevolutOrderRequest - { - Amount = (long)(request.Amount * 100), - Currency = request.Currency.ToUpperInvariant(), - Description = request.Title ?? request.Description, - ExternalRef = request.AdditionalData ?? Guid.NewGuid().ToString("N"), - Email = request.Email, - }; - var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); - var response = await client.PostAsync($"{options.ApiUrl}/1.0/orders", content); - var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); - } - - public async Task GetOrderAsync(string orderId) - { - using var client = CreateClient(); - var response = await client.GetAsync($"{options.ApiUrl}/1.0/orders/{orderId}"); - if (!response.IsSuccessStatusCode) return null; - var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); - } - - /// - /// Weryfikuje podpis webhooka Revolut. - /// Format: HMAC-SHA256("v1:{timestamp}.{payload}", webhookSecret). - /// Nagłówek Revolut-Signature: v1= - /// - public bool VerifyWebhookSignature(string payload, string timestamp, string signature) - { - var signedPayload = $"v1:{timestamp}.{payload}"; - var keyBytes = Encoding.UTF8.GetBytes(options.WebhookSecret); - var dataBytes = Encoding.UTF8.GetBytes(signedPayload); - var computed = Convert.ToHexString(HMACSHA256.HashData(keyBytes, dataBytes)).ToLowerInvariant(); - // Nagłówek: "v1=" - var receivedHex = signature.StartsWith("v1=") ? signature.Substring(3) : signature; - return string.Equals(computed, receivedHex, StringComparison.OrdinalIgnoreCase); - } -} - -public class RevolutProvider : IPaymentProvider -{ - private readonly IRevolutServiceCaller caller; - - public RevolutProvider(IRevolutServiceCaller caller) => this.caller = caller; - - public string Key => "Revolut"; - public string Name => "Revolut"; - public string Description => "Globalny operator płatności Revolut — karty, Revolut Pay."; - public string Url => "https://revolut.com/business"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "card", Name = "Karta płatnicza", Description = "Visa, Mastercard, Amex", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "revolut_pay", Name = "Revolut Pay", Description = "Płatność Revolut Pay", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var order = await caller.CreateOrderAsync(request); - return new PaymentResponse - { - PaymentUniqueId = order?.Id, - RedirectUrl = order?.CheckoutUrl, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public async Task GetStatus(string paymentId) - { - var order = await caller.GetOrderAsync(paymentId); - var status = order?.State switch - { - "completed" => PaymentStatusEnum.Finished, - "processing" => PaymentStatusEnum.Processing, - "authorised" => PaymentStatusEnum.Processing, - "failed" => PaymentStatusEnum.Rejected, - "cancelled" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Created, - }; - return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; - } - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - var timestamp = payload.QueryParameters.TryGetValue("Revolut-Request-Timestamp", out var t) ? t.ToString() : string.Empty; - var signature = payload.QueryParameters.TryGetValue("Revolut-Signature", out var s) ? s.ToString() : string.Empty; - - if (!caller.VerifyWebhookSignature(body, timestamp, signature)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); - - var status = PaymentStatusEnum.Processing; - try - { - var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("event", out var ev)) - status = ev.GetString() switch - { - "ORDER_COMPLETED" => PaymentStatusEnum.Finished, - "ORDER_AUTHORISED" => PaymentStatusEnum.Processing, - "ORDER_CANCELLED" => PaymentStatusEnum.Rejected, - "PAYMENT_DECLINED" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Processing, - }; - } - catch { /* ignore */ } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -public static class RevolutProviderExtensions -{ - public static void RegisterRevolutProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("Revolut"); - services.AddTransient(); - } -} - -public class RevolutConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public RevolutConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(RevolutServiceOptions options) - { - var s = configuration.GetSection(RevolutServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.ApiKey = s.ApiKey; - options.ApiUrl = s.ApiUrl; - options.ReturnUrl = s.ReturnUrl; - options.WebhookSecret = s.WebhookSecret; - } -} diff --git a/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj b/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Revolut/TailoredApps.Shared.Payments.Provider.Revolut.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj b/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj deleted file mode 100644 index c0e5a9c..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Tpay/TailoredApps.Shared.Payments.Provider.Tpay.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) - net8.0;net9.0;net10.0 - True - True - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs b/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs deleted file mode 100644 index fc9d7f6..0000000 --- a/src/TailoredApps.Shared.Payments.Provider.Tpay/TpayProvider.cs +++ /dev/null @@ -1,263 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace TailoredApps.Shared.Payments.Provider.Tpay; - -public class TpayServiceOptions -{ - public static string ConfigurationKey => "Payments:Providers:Tpay"; - public string ClientId { get; set; } = string.Empty; - public string ClientSecret { get; set; } = string.Empty; - public string MerchantId { get; set; } = string.Empty; - public string ApiUrl { get; set; } = "https://api.tpay.com"; - public string ReturnUrl { get; set; } = string.Empty; - public string NotifyUrl { get; set; } = string.Empty; - public string SecurityCode { get; set; } = string.Empty; -} - -file class TpayTokenResponse -{ - [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; -} - -file class TpayTransactionRequest -{ - [JsonPropertyName("amount")] public decimal Amount { get; set; } - [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; - [JsonPropertyName("hiddenDescription")] public string? HiddenDescription { get; set; } - [JsonPropertyName("lang")] public string Lang { get; set; } = "pl"; - [JsonPropertyName("pay")] public TpayPay Pay { get; set; } = new(); - [JsonPropertyName("payer")] public TpayPayer Payer { get; set; } = new(); - [JsonPropertyName("callbacks")] public TpayCallbacks Callbacks { get; set; } = new(); -} - -file class TpayPay -{ - [JsonPropertyName("groupId")] public int? GroupId { get; set; } - [JsonPropertyName("channel")] public string? Channel { get; set; } -} - -file class TpayPayer -{ - [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; - [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; -} - -file class TpayCallbacks -{ - [JsonPropertyName("payerUrls")] public TpayPayerUrls PayerUrls { get; set; } = new(); - [JsonPropertyName("notification")] public TpayNotification Notification { get; set; } = new(); -} - -file class TpayPayerUrls -{ - [JsonPropertyName("success")] public string Success { get; set; } = string.Empty; - [JsonPropertyName("error")] public string Error { get; set; } = string.Empty; -} - -file class TpayNotification -{ - [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; - [JsonPropertyName("email")] public string Email { get; set; } = string.Empty; -} - -file class TpayTransactionResponse -{ - [JsonPropertyName("transactionId")] public string? TransactionId { get; set; } - [JsonPropertyName("transactionPaymentUrl")] public string? PaymentUrl { get; set; } - [JsonPropertyName("title")] public string? Title { get; set; } -} - -file class TpayStatusResponse -{ - [JsonPropertyName("status")] public string? Status { get; set; } -} - -public interface ITpayServiceCaller -{ - Task GetAccessTokenAsync(); - Task CreateTransactionAsync(string token, PaymentRequest request); - Task GetTransactionStatusAsync(string token, string transactionId); - bool VerifyNotification(string body, string signature); -} - -public class TpayServiceCaller : ITpayServiceCaller -{ - private readonly TpayServiceOptions options; - private readonly IHttpClientFactory httpClientFactory; - - public TpayServiceCaller(IOptions options, IHttpClientFactory httpClientFactory) - { - this.options = options.Value; - this.httpClientFactory = httpClientFactory; - } - - public async Task GetAccessTokenAsync() - { - using var client = httpClientFactory.CreateClient("Tpay"); - var content = new FormUrlEncodedContent([ - new("grant_type", "client_credentials"), - new("client_id", options.ClientId), - new("client_secret", options.ClientSecret), - ]); - var response = await client.PostAsync($"{options.ApiUrl}/oauth/auth", content); - response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json)?.AccessToken ?? string.Empty; - } - - public async Task CreateTransactionAsync(string token, PaymentRequest request) - { - using var client = httpClientFactory.CreateClient("Tpay"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - - var body = new TpayTransactionRequest - { - Amount = request.Amount, - Description = request.Title ?? request.Description ?? "Order", - Payer = new TpayPayer { Email = request.Email ?? string.Empty, Name = $"{request.FirstName} {request.Surname}".Trim() }, - Callbacks = new TpayCallbacks - { - PayerUrls = new TpayPayerUrls { Success = options.ReturnUrl, Error = options.ReturnUrl }, - Notification = new TpayNotification { Url = options.NotifyUrl, Email = request.Email ?? string.Empty }, - }, - }; - - if (!string.IsNullOrWhiteSpace(request.PaymentChannel)) - body.Pay = new TpayPay { Channel = request.PaymentChannel }; - - var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); - var response = await client.PostAsync($"{options.ApiUrl}/transactions", content); - var json = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); - } - - public async Task GetTransactionStatusAsync(string token, string transactionId) - { - using var client = httpClientFactory.CreateClient("Tpay"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await client.GetAsync($"{options.ApiUrl}/transactions/{transactionId}"); - if (!response.IsSuccessStatusCode) return PaymentStatusEnum.Rejected; - var json = await response.Content.ReadAsStringAsync(); - var status = JsonSerializer.Deserialize(json)?.Status; - return status switch - { - "correct" => PaymentStatusEnum.Finished, - "pending" => PaymentStatusEnum.Processing, - "error" => PaymentStatusEnum.Rejected, - "chargeback" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Created, - }; - } - - public bool VerifyNotification(string body, string signature) - { - var input = body + options.SecurityCode; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - var computed = Convert.ToHexString(hash).ToLowerInvariant(); - return string.Equals(computed, signature, StringComparison.OrdinalIgnoreCase); - } -} - -public class TpayProvider : IPaymentProvider -{ - private readonly ITpayServiceCaller caller; - - public TpayProvider(ITpayServiceCaller caller) => this.caller = caller; - - public string Key => "Tpay"; - public string Name => "Tpay"; - public string Description => "Operator płatności Tpay — przelewy, BLIK, karty."; - public string Url => "https://tpay.com"; - - public Task> GetPaymentChannels(string currency) - { - ICollection channels = - [ - new PaymentChannel { Id = "150", Name = "BLIK", Description = "BLIK", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "103", Name = "Karta płatnicza", Description = "Visa, Mastercard", PaymentModel = PaymentModel.OneTime }, - new PaymentChannel { Id = "21", Name = "Przelew online", Description = "mTransfer, iPKO", PaymentModel = PaymentModel.OneTime }, - ]; - return Task.FromResult(channels); - } - - public async Task RequestPayment(PaymentRequest request) - { - var token = await caller.GetAccessTokenAsync(); - var tx = await caller.CreateTransactionAsync(token, request); - return new PaymentResponse - { - PaymentUniqueId = tx?.TransactionId, - RedirectUrl = tx?.PaymentUrl, - PaymentStatus = PaymentStatusEnum.Created, - }; - } - - public async Task GetStatus(string paymentId) - { - var token = await caller.GetAccessTokenAsync(); - var status = await caller.GetTransactionStatusAsync(token, paymentId); - return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status }; - } - - public Task TransactionStatusChange(TransactionStatusChangePayload payload) - { - var body = payload.Payload?.ToString() ?? string.Empty; - var sig = payload.QueryParameters.TryGetValue("X-Signature", out var s) ? s.ToString() : string.Empty; - - if (!caller.VerifyNotification(body, sig)) - return Task.FromResult(new PaymentResponse { PaymentStatus = PaymentStatusEnum.Rejected, ResponseObject = "Invalid signature" }); - - var status = PaymentStatusEnum.Processing; - try - { - var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("tr_status", out var st)) - status = st.GetString() switch - { - "TRUE" => PaymentStatusEnum.Finished, - "FALSE" => PaymentStatusEnum.Rejected, - _ => PaymentStatusEnum.Processing, - }; - } - catch { /* ignore */ } - - return Task.FromResult(new PaymentResponse { PaymentStatus = status, ResponseObject = "OK" }); - } -} - -public static class TpayProviderExtensions -{ - public static void RegisterTpayProvider(this IServiceCollection services) - { - services.AddOptions(); - services.ConfigureOptions(); - services.AddHttpClient("Tpay"); - services.AddTransient(); - } -} - -public class TpayConfigureOptions : IConfigureOptions -{ - private readonly IConfiguration configuration; - public TpayConfigureOptions(IConfiguration configuration) => this.configuration = configuration; - public void Configure(TpayServiceOptions options) - { - var s = configuration.GetSection(TpayServiceOptions.ConfigurationKey).Get(); - if (s is null) return; - options.ClientId = s.ClientId; - options.ClientSecret = s.ClientSecret; - options.MerchantId = s.MerchantId; - options.ApiUrl = s.ApiUrl; - options.ReturnUrl = s.ReturnUrl; - options.NotifyUrl = s.NotifyUrl; - options.SecurityCode = s.SecurityCode; - } -} diff --git a/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs b/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs deleted file mode 100644 index e6e74e9..0000000 --- a/tests/TailoredApps.Shared.Payments.Tests/MultiProviderPaymentTest.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Primitives; -using TailoredApps.Shared.Payments; -using TailoredApps.Shared.Payments.Provider.Adyen; -using TailoredApps.Shared.Payments.Provider.HotPay; -using TailoredApps.Shared.Payments.Provider.PayNow; -using TailoredApps.Shared.Payments.Provider.PayU; -using TailoredApps.Shared.Payments.Provider.Przelewy24; -using TailoredApps.Shared.Payments.Provider.Revolut; -using TailoredApps.Shared.Payments.Provider.Tpay; -using Xunit; - -namespace TailoredApps.Shared.Payments.Tests; - -/// -/// Smoke testy dla 7 nowych providerów płatności. -/// Testy jednostkowe — nie wymagają połączenia sieciowego. -/// -public class MultiProviderPaymentTest -{ - private static IHost BuildHost() => - Host.CreateDefaultBuilder() - .ConfigureAppConfiguration(cfg => cfg.AddJsonFile("appsettings.json", optional: true)) - .ConfigureServices((_, services) => - { - // Wszystkie 7 providerów - services.RegisterAdyenProvider(); - services.RegisterPayUProvider(); - services.RegisterPrzelewy24Provider(); - services.RegisterTpayProvider(); - services.RegisterHotPayProvider(); - services.RegisterPayNowProvider(); - services.RegisterRevolutProvider(); - - services.AddPayments() - .RegisterPaymentProvider() - .RegisterPaymentProvider() - .RegisterPaymentProvider() - .RegisterPaymentProvider() - .RegisterPaymentProvider() - .RegisterPaymentProvider() - .RegisterPaymentProvider(); - }) - .Build(); - - // ─── Provider list ──────────────────────────────────────────────────────── - - [Fact] - public async Task AllProviders_AreRegistered() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var providers = await service.GetProviders(); - var ids = providers.Select(p => p.Id).ToList(); - - Assert.Contains("Adyen", ids); - Assert.Contains("PayU", ids); - Assert.Contains("Przelewy24", ids); - Assert.Contains("Tpay", ids); - Assert.Contains("HotPay", ids); - Assert.Contains("PayNow", ids); - Assert.Contains("Revolut", ids); - } - - // ─── GetChannels ────────────────────────────────────────────────────────── - - [Theory] - [InlineData("Adyen", "PLN")] - [InlineData("PayU", "PLN")] - [InlineData("Przelewy24", "PLN")] - [InlineData("Tpay", "PLN")] - [InlineData("HotPay", "PLN")] - [InlineData("PayNow", "PLN")] - [InlineData("Revolut", "PLN")] - public async Task GetChannels_PLN_ReturnsNonEmptyList(string providerKey, string currency) - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var channels = await service.GetChannels(providerKey, currency); - Assert.NotEmpty(channels); - } - - [Fact] - public async Task Adyen_GetChannels_EUR_ReturnsIdeal() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var channels = await service.GetChannels("Adyen", "EUR"); - Assert.Contains(channels, c => c.Id == "ideal"); - } - - [Fact] - public async Task Revolut_GetChannels_ContainsRevolutPay() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var channels = await service.GetChannels("Revolut", "PLN"); - Assert.Contains(channels, c => c.Id == "revolut_pay"); - } - - [Fact] - public async Task PayNow_GetChannels_ContainsBlikAndCard() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var channels = await service.GetChannels("PayNow", "PLN"); - Assert.Contains(channels, c => c.Id == "BLIK"); - Assert.Contains(channels, c => c.Id == "CARD"); - } - - // ─── Invalid webhook → Rejected ────────────────────────────────────────── - - [Theory] - [InlineData("PayU")] - [InlineData("Przelewy24")] - [InlineData("Tpay")] - [InlineData("PayNow")] - [InlineData("Revolut")] - public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected(string providerKey) - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - - var result = await service.TransactionStatusChange(providerKey, new TransactionStatusChangePayload - { - ProviderId = providerKey, - Payload = """{"status":"CONFIRMED","orderId":"test_123"}""", - QueryParameters = new Dictionary - { - { "OpenPayU-Signature", new StringValues("invalidsignature") }, - { "Signature", new StringValues("invalidsignature") }, - { "X-Signature", new StringValues("invalidsignature") }, - { "Revolut-Signature", new StringValues("v1=invalidsignature") }, - { "Revolut-Request-Timestamp",new StringValues("1234567890") }, - }, - }); - - Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); - } - - [Fact] - public async Task HotPay_TransactionStatusChange_InvalidHash_ReturnsRejected() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - - var result = await service.TransactionStatusChange("HotPay", new TransactionStatusChangePayload - { - ProviderId = "HotPay", - Payload = string.Empty, - QueryParameters = new Dictionary - { - { "HASH", new StringValues("invalidhash") }, - { "KWOTA", new StringValues("9.99") }, - { "ID_PLATNOSCI", new StringValues("test_123") }, - { "STATUS", new StringValues("SUCCESS") }, - }, - }); - - Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); - } - - // ─── Integration tests (Skip) ───────────────────────────────────────────── - - [Fact(Skip = "Integration — requires API credentials in appsettings.json")] - public async Task PayU_RegisterPayment_CreatesOrder() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var result = await service.RegisterPayment(new PaymentRequest - { - PaymentProvider = "PayU", - PaymentChannel = "c", - PaymentModel = PaymentModel.OneTime, - Title = "Test order", - Currency = "PLN", - Amount = 1.00m, - Email = "test@example.com", - }); - Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); - Assert.NotEmpty(result.RedirectUrl!); - } - - [Fact(Skip = "Integration — requires API credentials in appsettings.json")] - public async Task Revolut_RegisterPayment_CreatesOrder() - { - var host = BuildHost(); - var service = host.Services.GetRequiredService(); - var result = await service.RegisterPayment(new PaymentRequest - { - PaymentProvider = "Revolut", - PaymentChannel = "card", - PaymentModel = PaymentModel.OneTime, - Title = "Test order", - Currency = "PLN", - Amount = 1.00m, - Email = "test@example.com", - }); - Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); - } -} diff --git a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj index eac5f76..79ceb45 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj +++ b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj @@ -22,13 +22,6 @@ - - - - - - - diff --git a/tests/TailoredApps.Shared.Payments.Tests/appsettings.json b/tests/TailoredApps.Shared.Payments.Tests/appsettings.json index 46599bc..b90b291 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/appsettings.json +++ b/tests/TailoredApps.Shared.Payments.Tests/appsettings.json @@ -13,60 +13,6 @@ "WebhookSecret": "whsec_REPLACE_ME", "SuccessUrl": "https://integrationtest.tailoredapps.pl/payment/success?session={CHECKOUT_SESSION_ID}", "CancelUrl": "https://integrationtest.tailoredapps.pl/payment/cancel" - }, - "Adyen": { - "ApiKey": "REPLACE_ME", - "MerchantAccount": "REPLACE_ME", - "ClientKey": "test_REPLACE_ME", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "NotificationHmacKey": "REPLACE_ME", - "IsTest": true - }, - "PayU": { - "ClientId": "REPLACE_ME", - "ClientSecret": "REPLACE_ME", - "PosId": "REPLACE_ME", - "SignatureKey": "REPLACE_ME", - "ServiceUrl": "https://secure.snd.payu.com", - "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/payu", - "ContinueUrl": "https://integrationtest.tailoredapps.pl/payment/return" - }, - "Przelewy24": { - "MerchantId": 0, - "PosId": 0, - "ApiKey": "REPLACE_ME", - "CrcKey": "REPLACE_ME", - "ServiceUrl": "https://sandbox.przelewy24.pl", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/p24" - }, - "Tpay": { - "ClientId": "REPLACE_ME", - "ClientSecret": "REPLACE_ME", - "MerchantId": "REPLACE_ME", - "ApiUrl": "https://openapi.sandbox.tpay.com", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/tpay", - "SecurityCode": "REPLACE_ME" - }, - "HotPay": { - "SecretHash": "REPLACE_ME", - "ServiceUrl": "https://platnosci.hotpay.pl", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "NotifyUrl": "https://integrationtest.tailoredapps.pl/payment/notify/hotpay" - }, - "PayNow": { - "ApiKey": "REPLACE_ME", - "SignatureKey": "REPLACE_ME", - "ApiUrl": "https://api.sandbox.paynow.pl", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "ContinueUrl": "https://integrationtest.tailoredapps.pl/payment/return" - }, - "Revolut": { - "ApiKey": "REPLACE_ME", - "ApiUrl": "https://sandbox-merchant.revolut.com/api", - "ReturnUrl": "https://integrationtest.tailoredapps.pl/payment/return", - "WebhookSecret": "REPLACE_ME" } } }