Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/TailoredApps.Shared.Payments.Provider.Adyen/AdyenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public bool VerifyNotificationHmac(string payload, string hmacSignature)
// ─── Provider ─────────────────────────────────────────────────────────────────

/// <summary>Implementacja <see cref="IPaymentProvider"/> dla Adyen Checkout.</summary>
public class AdyenProvider : IPaymentProvider
public class AdyenProvider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly IAdyenServiceCaller caller;

Expand Down Expand Up @@ -255,6 +255,41 @@ public async Task<PaymentResponse> GetStatus(string paymentId)
return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status };
}

// ─── IWebhookPaymentProvider ─────────────────────────────────────────────

/// <inheritdoc/>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var body = request.Body ?? string.Empty;
var hmac = request.Headers.TryGetValue("HmacSignature", out var h) ? h.ToString() : string.Empty;

var payload = new TransactionStatusChangePayload
{
Payload = body,
QueryParameters = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
{ "HmacSignature", hmac },
},
};

var response = await TransactionStatusChange(payload);

if (response.PaymentStatus == PaymentStatusEnum.Rejected)
{
var msg = response.ResponseObject?.ToString() ?? string.Empty;
if (msg.Contains("signature", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hash", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("sign", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hmac", StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail(msg);
}

if (response.PaymentStatus == PaymentStatusEnum.Processing && string.IsNullOrEmpty(response.PaymentUniqueId))
return PaymentWebhookResult.Ignore("Non-actionable event");

return PaymentWebhookResult.Ok(response);
}

/// <inheritdoc/>
public Task<PaymentResponse> TransactionStatusChange(TransactionStatusChangePayload payload)
{
Expand Down Expand Up @@ -310,6 +345,9 @@ public static void RegisterAdyenProvider(this IServiceCollection services)
services.ConfigureOptions<AdyenConfigureOptions>();
services.AddHttpClient("Adyen");
services.AddTransient<IAdyenServiceCaller, AdyenServiceCaller>();
services.AddTransient<AdyenProvider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<AdyenProvider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<AdyenProvider>());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using TailoredApps.Shared.Payments;
using TailoredApps.Shared.Payments.Provider.CashBill.Models;
using static TailoredApps.Shared.Payments.Provider.CashBill.CashbillServiceCaller;

namespace TailoredApps.Shared.Payments.Provider.CashBill
{
public class CashBillProvider : IPaymentProvider
/// <summary>
/// Payment provider implementation for CashBill.
/// Supports both polling-based status checks and back-channel webhook notifications
/// via <see cref="IWebhookPaymentProvider"/>.
/// </summary>
public class CashBillProvider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly ICashbillServiceCaller cashbillService;
public CashBillProvider(ICashbillServiceCaller cashbillService)
Expand Down Expand Up @@ -112,16 +120,67 @@ public async Task<PaymentResponse> TransactionStatusChange(TransactionStatusChan
// }
// return null;
}

// ─── IWebhookPaymentProvider ─────────────────────────────────────────

/// <summary>
/// Handles an incoming CashBill back-channel HTTP notification.
/// </summary>
/// <remarks>
/// CashBill sends a GET/POST with query-string parameters:
/// <c>cmd</c> (event type), <c>args</c> (transaction ID), <c>sign</c> (MD5 signature).<br/>
/// The method:
/// <list type="number">
/// <item>Validates that <c>args</c> (transaction ID) is present.</item>
/// <item>Verifies the MD5 signature via <c>GetSignForNotificationService</c>.</item>
/// <item>Fetches the current payment status from CashBill API.</item>
/// <item>Returns a normalised <see cref="PaymentWebhookResult"/>.</item>
/// </list>
/// </remarks>
/// <param name="request">Unified HTTP webhook request containing query parameters.</param>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var cmd = request.Query.TryGetValue("cmd", out var c) ? c.ToString() : string.Empty;
var transactionId = request.Query.TryGetValue("args", out var a) ? a.ToString() : string.Empty;
var sign = request.Query.TryGetValue("sign", out var s) ? s.ToString() : string.Empty;

if (string.IsNullOrEmpty(transactionId))
return PaymentWebhookResult.Fail("Missing transactionId (args) in query string.");

// Verify MD5 signature: MD5(cmd + args + shopSecretPhrase)
var notification = new TransactionStatusChanged { Command = cmd, TransactionId = transactionId, Sign = sign };
var expectedSign = await cashbillService.GetSignForNotificationService(notification);
if (!string.Equals(expectedSign, sign, StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail($"Invalid signature. expected={expectedSign} got={sign}");

// Signature valid — poll the API for the actual payment status
var statusResponse = await GetStatus(transactionId);
return PaymentWebhookResult.Ok(statusResponse);
}
}

/// <summary>DI extension methods for the CashBill payment provider.</summary>
public static class CashBillProviderExtensions
{
/// <summary>
/// Registers the CashBill provider and its dependencies.
/// The provider is exposed as both <see cref="IPaymentProvider"/>
/// (used by the <c>PaymentService</c> aggregator)
/// and <see cref="IWebhookPaymentProvider"/>
/// (used for back-channel webhook dispatch).
/// </summary>
/// <param name="services">The DI service collection.</param>
public static void RegisterCashbillProvider(this IServiceCollection services)
{
services.AddOptions<CashbillServiceOptions>();
services.ConfigureOptions<CashbillConfigureOptions>();
services.AddTransient<ICashbillServiceCaller, CashbillServiceCaller>();
services.AddTransient<ICashbillHttpClient, CashbillHttpClient>();

// Register as both IPaymentProvider and IWebhookPaymentProvider
services.AddTransient<CashBillProvider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<CashBillProvider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<CashBillProvider>());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public bool VerifyNotification(string hash, string kwota, string idPlatnosci, st
}

/// <summary>Implementacja <see cref="IPaymentProvider"/> dla HotPay.</summary>
public class HotPayProvider : IPaymentProvider
public class HotPayProvider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly IHotPayServiceCaller caller;

Expand Down Expand Up @@ -146,6 +146,45 @@ public async Task<PaymentResponse> RequestPayment(PaymentRequest request)
public Task<PaymentResponse> GetStatus(string paymentId)
=> Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing });

// ─── IWebhookPaymentProvider ─────────────────────────────────────────────

/// <inheritdoc/>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var hash = request.Query.TryGetValue("HASH", out var h) ? h.ToString() : string.Empty;
var kwota = request.Query.TryGetValue("KWOTA", out var k) ? k.ToString() : string.Empty;
var idPlatnosci = request.Query.TryGetValue("ID_PLATNOSCI", out var i) ? i.ToString() : string.Empty;
var status = request.Query.TryGetValue("STATUS", out var s) ? s.ToString() : string.Empty;

var payload = new TransactionStatusChangePayload
{
QueryParameters = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
{ "HASH", hash },
{ "KWOTA", kwota },
{ "ID_PLATNOSCI", idPlatnosci },
{ "STATUS", status },
},
};

var response = await TransactionStatusChange(payload);

if (response.PaymentStatus == PaymentStatusEnum.Rejected)
{
var msg = response.ResponseObject?.ToString() ?? string.Empty;
if (msg.Contains("signature", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hash", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("sign", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hmac", StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail(msg);
}

if (response.PaymentStatus == PaymentStatusEnum.Processing && string.IsNullOrEmpty(response.PaymentUniqueId))
return PaymentWebhookResult.Ignore("Non-actionable event");

return PaymentWebhookResult.Ok(response);
}

/// <inheritdoc/>
public Task<PaymentResponse> TransactionStatusChange(TransactionStatusChangePayload payload)
{
Expand Down Expand Up @@ -173,6 +212,9 @@ public static void RegisterHotPayProvider(this IServiceCollection services)
services.ConfigureOptions<HotPayConfigureOptions>();
services.AddHttpClient("HotPay");
services.AddTransient<IHotPayServiceCaller, HotPayServiceCaller>();
services.AddTransient<HotPayProvider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<HotPayProvider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<HotPayProvider>());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ public bool VerifySignature(string body, string signature)
}

/// <summary>Implementacja <see cref="IPaymentProvider"/> dla PayNow (mBank).</summary>
public class PayNowProvider : IPaymentProvider
public class PayNowProvider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly IPayNowServiceCaller caller;

Expand Down Expand Up @@ -194,6 +194,41 @@ public async Task<PaymentResponse> GetStatus(string paymentId)
return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status };
}

// ─── IWebhookPaymentProvider ─────────────────────────────────────────────

/// <inheritdoc/>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var body = request.Body ?? string.Empty;
var signature = request.Headers.TryGetValue("Signature", out var s) ? s.ToString() : string.Empty;

var payload = new TransactionStatusChangePayload
{
Payload = body,
QueryParameters = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
{ "Signature", signature },
},
};

var response = await TransactionStatusChange(payload);

if (response.PaymentStatus == PaymentStatusEnum.Rejected)
{
var msg = response.ResponseObject?.ToString() ?? string.Empty;
if (msg.Contains("signature", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hash", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("sign", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hmac", StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail(msg);
}

if (response.PaymentStatus == PaymentStatusEnum.Processing && string.IsNullOrEmpty(response.PaymentUniqueId))
return PaymentWebhookResult.Ignore("Non-actionable event");

return PaymentWebhookResult.Ok(response);
}

/// <inheritdoc/>
public Task<PaymentResponse> TransactionStatusChange(TransactionStatusChangePayload payload)
{
Expand Down Expand Up @@ -233,6 +268,9 @@ public static void RegisterPayNowProvider(this IServiceCollection services)
services.ConfigureOptions<PayNowConfigureOptions>();
services.AddHttpClient("PayNow");
services.AddTransient<IPayNowServiceCaller, PayNowServiceCaller>();
services.AddTransient<PayNowProvider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<PayNowProvider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<PayNowProvider>());
}
}

Expand Down
40 changes: 39 additions & 1 deletion src/TailoredApps.Shared.Payments.Provider.PayU/PayUProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ public bool VerifySignature(string body, string incomingSignature)
// ─── Provider ─────────────────────────────────────────────────────────────────

/// <summary>Implementacja <see cref="IPaymentProvider"/> dla PayU.</summary>
public class PayUProvider : IPaymentProvider
public class PayUProvider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly IPayUServiceCaller caller;

Expand Down Expand Up @@ -300,6 +300,41 @@ public async Task<PaymentResponse> GetStatus(string paymentId)
return new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = status };
}

// ─── IWebhookPaymentProvider ─────────────────────────────────────────────

/// <inheritdoc/>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var body = request.Body ?? string.Empty;
var signature = request.Headers.TryGetValue("OpenPayU-Signature", out var s) ? s.ToString() : string.Empty;

var payload = new TransactionStatusChangePayload
{
Payload = body,
QueryParameters = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
{ "OpenPayU-Signature", signature },
},
};

var response = await TransactionStatusChange(payload);

if (response.PaymentStatus == PaymentStatusEnum.Rejected)
{
var msg = response.ResponseObject?.ToString() ?? string.Empty;
if (msg.Contains("signature", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hash", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("sign", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hmac", StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail(msg);
}

if (response.PaymentStatus == PaymentStatusEnum.Processing && string.IsNullOrEmpty(response.PaymentUniqueId))
return PaymentWebhookResult.Ignore("Non-actionable event");

return PaymentWebhookResult.Ok(response);
}

/// <inheritdoc/>
public Task<PaymentResponse> TransactionStatusChange(TransactionStatusChangePayload payload)
{
Expand Down Expand Up @@ -340,6 +375,9 @@ public static void RegisterPayUProvider(this IServiceCollection services)
services.ConfigureOptions<PayUConfigureOptions>();
services.AddHttpClient("PayU");
services.AddTransient<IPayUServiceCaller, PayUServiceCaller>();
services.AddTransient<PayUProvider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<PayUProvider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<PayUProvider>());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ public bool VerifyNotification(string body)
// ─── Provider ─────────────────────────────────────────────────────────────────

/// <summary>Implementacja <see cref="IPaymentProvider"/> dla Przelewy24.</summary>
public class Przelewy24Provider : IPaymentProvider
public class Przelewy24Provider : IPaymentProvider, IWebhookPaymentProvider
{
private readonly IPrzelewy24ServiceCaller caller;
private readonly Przelewy24ServiceOptions options;
Expand Down Expand Up @@ -255,6 +255,37 @@ public async Task<PaymentResponse> RequestPayment(PaymentRequest request)
public Task<PaymentResponse> GetStatus(string paymentId)
=> Task.FromResult(new PaymentResponse { PaymentUniqueId = paymentId, PaymentStatus = PaymentStatusEnum.Processing });

// ─── IWebhookPaymentProvider ─────────────────────────────────────────────

/// <inheritdoc/>
public async Task<PaymentWebhookResult> HandleWebhookAsync(PaymentWebhookRequest request)
{
var body = request.Body ?? string.Empty;

var payload = new TransactionStatusChangePayload
{
Payload = body,
QueryParameters = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>(),
};

var response = await TransactionStatusChange(payload);

if (response.PaymentStatus == PaymentStatusEnum.Rejected)
{
var msg = response.ResponseObject?.ToString() ?? string.Empty;
if (msg.Contains("signature", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hash", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("sign", StringComparison.OrdinalIgnoreCase) ||
msg.Contains("hmac", StringComparison.OrdinalIgnoreCase))
return PaymentWebhookResult.Fail(msg);
}

if (response.PaymentStatus == PaymentStatusEnum.Processing && string.IsNullOrEmpty(response.PaymentUniqueId))
return PaymentWebhookResult.Ignore("Non-actionable event");

return PaymentWebhookResult.Ok(response);
}

/// <inheritdoc/>
public async Task<PaymentResponse> TransactionStatusChange(TransactionStatusChangePayload payload)
{
Expand Down Expand Up @@ -300,6 +331,9 @@ public static void RegisterPrzelewy24Provider(this IServiceCollection services)
services.ConfigureOptions<Przelewy24ConfigureOptions>();
services.AddHttpClient("Przelewy24");
services.AddTransient<IPrzelewy24ServiceCaller, Przelewy24ServiceCaller>();
services.AddTransient<Przelewy24Provider>();
services.AddTransient<IPaymentProvider>(sp => sp.GetRequiredService<Przelewy24Provider>());
services.AddTransient<IWebhookPaymentProvider>(sp => sp.GetRequiredService<Przelewy24Provider>());
}
}

Expand Down
Loading
Loading