forked from gongxuegit/tiku-backend.net
feat: add payment notifications and entitlement fulfillment
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
@@ -78,6 +81,28 @@ public sealed class CommerceController(
|
|||||||
cancellationToken));
|
cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("payments/notify/wechat-pay")]
|
||||||
|
[EndpointSummary("微信支付回调")]
|
||||||
|
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PaymentNotificationProcessResult>> WechatPayNotify(
|
||||||
|
[FromQuery] Guid tenantId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("payments/notify/alipay")]
|
||||||
|
[EndpointSummary("支付宝支付回调")]
|
||||||
|
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PaymentNotificationProcessResult>> AlipayNotify(
|
||||||
|
[FromQuery] Guid tenantId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
private CommerceActor ResolveActor()
|
private CommerceActor ResolveActor()
|
||||||
{
|
{
|
||||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
@@ -87,4 +112,64 @@ public sealed class CommerceController(
|
|||||||
|
|
||||||
return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<PaymentNotificationProcessResult> ProcessNotificationAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string provider,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (tenantId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new CommerceException("Tenant id is required for payment notification.", "tenant_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawBody = await ReadRawBodyAsync(Request, cancellationToken);
|
||||||
|
using var body = ParseNotificationBody(Request, rawBody, cancellationToken);
|
||||||
|
var headers = Request.Headers.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value.ToString(),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return await commerceService.ProcessPaymentNotificationAsync(
|
||||||
|
tenantId,
|
||||||
|
provider,
|
||||||
|
headers,
|
||||||
|
rawBody,
|
||||||
|
body.RootElement.Clone(),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRawBodyAsync(HttpRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(
|
||||||
|
request.Body,
|
||||||
|
Encoding.UTF8,
|
||||||
|
detectEncodingFromByteOrderMarks: false,
|
||||||
|
leaveOpen: false);
|
||||||
|
return await reader.ReadToEndAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument ParseNotificationBody(
|
||||||
|
HttpRequest request,
|
||||||
|
string rawBody,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.HasFormContentType)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
var form = QueryHelpers.ParseQuery(rawBody);
|
||||||
|
var values = form.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value.ToString(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
return JsonDocument.Parse(JsonSerializer.Serialize(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(rawBody))
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse("{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonDocument.Parse(rawBody);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ public sealed record CurrentEntitlementItem(
|
|||||||
string Status,
|
string Status,
|
||||||
int? DaysLeft);
|
int? DaysLeft);
|
||||||
|
|
||||||
|
public sealed record PaymentNotificationProcessResult(
|
||||||
|
string Provider,
|
||||||
|
string EventId,
|
||||||
|
string OrderNo,
|
||||||
|
string Status,
|
||||||
|
bool Idempotent);
|
||||||
|
|
||||||
public interface ICommerceService
|
public interface ICommerceService
|
||||||
{
|
{
|
||||||
Task<CommerceOrderItem> CreateOrderAsync(
|
Task<CommerceOrderItem> CreateOrderAsync(
|
||||||
@@ -89,6 +96,14 @@ public interface ICommerceService
|
|||||||
Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
|
Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
|
||||||
CommerceActor actor,
|
CommerceActor actor,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string provider,
|
||||||
|
IReadOnlyDictionary<string, string> headers,
|
||||||
|
string rawBody,
|
||||||
|
JsonElement body,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CommerceException(string message, string code) : Exception(message)
|
public sealed class CommerceException(string message, string code) : Exception(message)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Aop.Api;
|
using Aop.Api;
|
||||||
|
using Aop.Api.Util;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure.Commerce;
|
namespace Tiku.Infrastructure.Commerce;
|
||||||
@@ -25,9 +26,34 @@ internal sealed class AlipayProvider : IPaymentProvider
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_ = BuildClient(account);
|
_ = BuildClient(account);
|
||||||
throw new PaymentProviderException(
|
var values = ToDictionary(request.Body);
|
||||||
"Alipay SDK is configured; notification parsing is enabled in the notification batch.",
|
var signatureValid = AlipaySignature.RSACheckV1(
|
||||||
"alipay_notification_not_enabled");
|
values,
|
||||||
|
Required(account.ConfigPublic, "alipayPublicKey"),
|
||||||
|
GetString(account.ConfigPublic, "charset") ?? "UTF-8",
|
||||||
|
GetString(account.ConfigPublic, "signType") ?? "RSA2",
|
||||||
|
false);
|
||||||
|
var eventId = GetValue(values, "notify_id") ?? GetValue(values, "trade_no") ?? Guid.NewGuid().ToString("N");
|
||||||
|
var orderNo = GetValue(values, "out_trade_no")
|
||||||
|
?? throw new PaymentProviderException("Alipay notification order number is missing.", "alipay_notify_order_missing");
|
||||||
|
var tradeStatus = GetValue(values, "trade_status");
|
||||||
|
var amountCents = YuanToCents(GetValue(values, "total_amount") ?? GetValue(values, "receipt_amount"));
|
||||||
|
DateTimeOffset? paidAt = DateTimeOffset.TryParse(GetValue(values, "gmt_payment"), out var parsedPaidAt)
|
||||||
|
? parsedPaidAt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Task.FromResult(new PaymentNotificationResult(
|
||||||
|
Provider,
|
||||||
|
"payment_notify",
|
||||||
|
eventId,
|
||||||
|
orderNo,
|
||||||
|
GetValue(values, "trade_no"),
|
||||||
|
amountCents,
|
||||||
|
string.Equals(tradeStatus, "TRADE_SUCCESS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(tradeStatus, "TRADE_FINISHED", StringComparison.OrdinalIgnoreCase),
|
||||||
|
signatureValid,
|
||||||
|
paidAt,
|
||||||
|
request.Body));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DefaultAopClient BuildClient(PaymentProviderAccount account)
|
private static DefaultAopClient BuildClient(PaymentProviderAccount account)
|
||||||
@@ -63,4 +89,53 @@ internal sealed class AlipayProvider : IPaymentProvider
|
|||||||
"Alipay provider configuration is incomplete.",
|
"Alipay provider configuration is incomplete.",
|
||||||
"alipay_config_incomplete");
|
"alipay_config_incomplete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(JsonElement element)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
values[property.Name] = property.Value.ValueKind == JsonValueKind.String
|
||||||
|
? property.Value.GetString() ?? string.Empty
|
||||||
|
: property.Value.GetRawText();
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(key, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetValue(IReadOnlyDictionary<string, string> values, string key) =>
|
||||||
|
values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static int YuanToCents(string? value)
|
||||||
|
{
|
||||||
|
return decimal.TryParse(value, out var amount)
|
||||||
|
? (int)Math.Round(amount * 100, MidpointRounding.AwayFromZero)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,17 @@ public sealed class CommerceService(
|
|||||||
|
|
||||||
if (IsPaid(result.Status))
|
if (IsPaid(result.Status))
|
||||||
{
|
{
|
||||||
await MarkPaidAsync(actor, order, payment, result.ProviderTradeNo, result.RawPayload, cancellationToken);
|
await MarkPaidAsync(
|
||||||
|
actor,
|
||||||
|
order,
|
||||||
|
payment,
|
||||||
|
result.ProviderTradeNo,
|
||||||
|
result.RawPayload,
|
||||||
|
"payment_paid",
|
||||||
|
result.ProviderTradeNo,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -238,15 +248,146 @@ public sealed class CommerceService(
|
|||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string provider,
|
||||||
|
IReadOnlyDictionary<string, string> headers,
|
||||||
|
string rawBody,
|
||||||
|
JsonElement body,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedProvider = NormalizeProvider(provider);
|
||||||
|
var notification = await paymentGateway.ParsePaymentNotificationAsync(
|
||||||
|
normalizedProvider,
|
||||||
|
new PaymentNotificationRequest(
|
||||||
|
tenantId,
|
||||||
|
normalizedProvider,
|
||||||
|
headers,
|
||||||
|
rawBody,
|
||||||
|
body),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!notification.SignatureValid)
|
||||||
|
{
|
||||||
|
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
|
||||||
|
item =>
|
||||||
|
item.Provider == normalizedProvider &&
|
||||||
|
item.EventId == notification.EventId &&
|
||||||
|
item.ProcessedAt != null,
|
||||||
|
cancellationToken);
|
||||||
|
if (alreadyProcessed)
|
||||||
|
{
|
||||||
|
return new PaymentNotificationProcessResult(
|
||||||
|
normalizedProvider,
|
||||||
|
notification.EventId,
|
||||||
|
notification.OrderNo,
|
||||||
|
"processed",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = await dbContext.Orders
|
||||||
|
.SingleOrDefaultAsync(item =>
|
||||||
|
item.TenantId == tenantId &&
|
||||||
|
item.OrderNo == notification.OrderNo,
|
||||||
|
cancellationToken)
|
||||||
|
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||||
|
|
||||||
|
if (order.UserId is null)
|
||||||
|
{
|
||||||
|
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.AmountCents != notification.AmountCents)
|
||||||
|
{
|
||||||
|
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Provider = normalizedProvider,
|
||||||
|
EventType = notification.EventType,
|
||||||
|
EventId = notification.EventId,
|
||||||
|
SignatureValid = true,
|
||||||
|
Payload = notification.RawPayload,
|
||||||
|
Error = "payment_amount_mismatch"
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var payment = await dbContext.Payments
|
||||||
|
.Where(item =>
|
||||||
|
item.TenantId == tenantId &&
|
||||||
|
item.OrderId == order.Id &&
|
||||||
|
item.Provider == normalizedProvider)
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (payment is null)
|
||||||
|
{
|
||||||
|
payment = new Payment
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
OrderId = order.Id,
|
||||||
|
Provider = normalizedProvider,
|
||||||
|
Method = order.PayMethod,
|
||||||
|
Status = PaymentStatus.Pending,
|
||||||
|
AmountCents = order.AmountCents
|
||||||
|
};
|
||||||
|
dbContext.Payments.Add(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.Paid && order.Status == OrderStatus.Pending)
|
||||||
|
{
|
||||||
|
await MarkPaidAsync(
|
||||||
|
new CommerceActor(tenantId, order.UserId.Value),
|
||||||
|
order,
|
||||||
|
payment,
|
||||||
|
notification.ProviderTradeNo,
|
||||||
|
notification.RawPayload,
|
||||||
|
notification.EventType,
|
||||||
|
notification.EventId,
|
||||||
|
notification.SignatureValid,
|
||||||
|
notification.PaidAt,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
PaymentId = payment.Id,
|
||||||
|
Provider = normalizedProvider,
|
||||||
|
EventType = notification.EventType,
|
||||||
|
EventId = notification.EventId,
|
||||||
|
SignatureValid = notification.SignatureValid,
|
||||||
|
Payload = notification.RawPayload,
|
||||||
|
ProcessedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return new PaymentNotificationProcessResult(
|
||||||
|
normalizedProvider,
|
||||||
|
notification.EventId,
|
||||||
|
notification.OrderNo,
|
||||||
|
"processed",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MarkPaidAsync(
|
private async Task MarkPaidAsync(
|
||||||
CommerceActor actor,
|
CommerceActor actor,
|
||||||
Order order,
|
Order order,
|
||||||
Payment payment,
|
Payment payment,
|
||||||
string? providerTradeNo,
|
string? providerTradeNo,
|
||||||
JsonElement rawPayload,
|
JsonElement rawPayload,
|
||||||
|
string eventType,
|
||||||
|
string? eventId,
|
||||||
|
bool signatureValid,
|
||||||
|
DateTimeOffset? paidAtOverride,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var paidAt = DateTimeOffset.UtcNow;
|
var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
|
||||||
payment.Status = PaymentStatus.Paid;
|
payment.Status = PaymentStatus.Paid;
|
||||||
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
|
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
|
||||||
payment.PaidAt = paidAt;
|
payment.PaidAt = paidAt;
|
||||||
@@ -293,9 +434,9 @@ public sealed class CommerceService(
|
|||||||
TenantId = actor.TenantId,
|
TenantId = actor.TenantId,
|
||||||
PaymentId = payment.Id,
|
PaymentId = payment.Id,
|
||||||
Provider = payment.Provider,
|
Provider = payment.Provider,
|
||||||
EventType = "payment_paid",
|
EventType = eventType,
|
||||||
EventId = payment.ProviderTradeNo,
|
EventId = eventId,
|
||||||
SignatureValid = true,
|
SignatureValid = signatureValid,
|
||||||
Payload = rawPayload,
|
Payload = rawPayload,
|
||||||
ProcessedAt = paidAt
|
ProcessedAt = paidAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models;
|
||||||
|
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events;
|
||||||
using SKIT.FlurlHttpClient.Wechat.TenpayV3;
|
using SKIT.FlurlHttpClient.Wechat.TenpayV3;
|
||||||
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
|
using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
|
||||||
using Tiku.Application.Commerce;
|
using Tiku.Application.Commerce;
|
||||||
@@ -25,10 +27,35 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
|||||||
PaymentNotificationRequest request,
|
PaymentNotificationRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_ = BuildClient(account);
|
var client = BuildClient(account);
|
||||||
throw new PaymentProviderException(
|
var signatureValid = TryGetHeader(request.Headers, "Wechatpay-Timestamp", out var timestamp) &&
|
||||||
"WeChat Pay SDK is configured; notification parsing is enabled in the notification batch.",
|
TryGetHeader(request.Headers, "Wechatpay-Nonce", out var nonce) &&
|
||||||
"wechat_pay_notification_not_enabled");
|
TryGetHeader(request.Headers, "Wechatpay-Signature", out var signature) &&
|
||||||
|
TryGetHeader(request.Headers, "Wechatpay-Serial", out var serial) &&
|
||||||
|
client.VerifyEventSignature(timestamp, nonce, request.RawBody, signature, serial);
|
||||||
|
var payload = TryDecryptTransactionResource(client, request.Body, out var decrypted)
|
||||||
|
? decrypted
|
||||||
|
: request.Body;
|
||||||
|
var eventId = GetString(request.Body, "id") ?? GetString(payload, "transaction_id") ?? Guid.NewGuid().ToString("N");
|
||||||
|
var orderNo = GetString(payload, "out_trade_no", "outTradeNo")
|
||||||
|
?? throw new PaymentProviderException("WeChat Pay notification order number is missing.", "wechat_pay_notify_order_missing");
|
||||||
|
var tradeNo = GetString(payload, "transaction_id", "transactionId");
|
||||||
|
var tradeState = GetString(payload, "trade_state", "tradeState") ?? GetString(request.Body, "event_type");
|
||||||
|
var amount = GetInt(payload, "amount", "total") ?? GetInt(payload, "amountCents") ?? 0;
|
||||||
|
var paidAt = GetDateTimeOffset(payload, "success_time", "successTime");
|
||||||
|
|
||||||
|
return Task.FromResult(new PaymentNotificationResult(
|
||||||
|
Provider,
|
||||||
|
GetString(request.Body, "event_type") ?? "TRANSACTION.SUCCESS",
|
||||||
|
eventId,
|
||||||
|
orderNo,
|
||||||
|
tradeNo,
|
||||||
|
amount,
|
||||||
|
string.Equals(tradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(tradeState, "TRANSACTION.SUCCESS", StringComparison.OrdinalIgnoreCase),
|
||||||
|
signatureValid,
|
||||||
|
paidAt,
|
||||||
|
payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
|
private static WechatTenpayClient BuildClient(PaymentProviderAccount account)
|
||||||
@@ -65,4 +92,101 @@ internal sealed class WechatPayProvider : IPaymentProvider
|
|||||||
"WeChat Pay provider configuration is incomplete.",
|
"WeChat Pay provider configuration is incomplete.",
|
||||||
"wechat_pay_config_incomplete");
|
"wechat_pay_config_incomplete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryDecryptTransactionResource(
|
||||||
|
WechatTenpayClient client,
|
||||||
|
JsonElement body,
|
||||||
|
out JsonElement payload)
|
||||||
|
{
|
||||||
|
payload = default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (body.ValueKind != JsonValueKind.Object ||
|
||||||
|
!body.TryGetProperty("resource", out var resource))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookEvent = JsonSerializer.Deserialize<WechatTenpayEvent>(body.GetRawText());
|
||||||
|
if (webhookEvent?.Resource is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decrypted = client.DecryptEventResource<TransactionResource>(webhookEvent);
|
||||||
|
payload = JsonSerializer.SerializeToElement(decrypted);
|
||||||
|
_ = resource;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetHeader(
|
||||||
|
IReadOnlyDictionary<string, string> headers,
|
||||||
|
string name,
|
||||||
|
out string value)
|
||||||
|
{
|
||||||
|
foreach (var pair in headers)
|
||||||
|
{
|
||||||
|
if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = pair.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(key, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement element, string parentKey, string childKey)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(parentKey, out var parent) &&
|
||||||
|
parent.ValueKind == JsonValueKind.Object &&
|
||||||
|
parent.TryGetProperty(childKey, out var child) &&
|
||||||
|
child.TryGetInt32(out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement element, string key)
|
||||||
|
{
|
||||||
|
return element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(key, out var property) &&
|
||||||
|
property.TryGetInt32(out var value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? GetDateTimeOffset(JsonElement element, params string[] keys)
|
||||||
|
{
|
||||||
|
var value = GetString(element, keys);
|
||||||
|
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,81 @@ public sealed class CommerceEndpointTests
|
|||||||
Assert.Equal(firstPayment!.Id, secondPayment!.Id);
|
Assert.Equal(firstPayment!.Id, secondPayment!.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Payment_notification_marks_order_paid_once()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||||
|
var seed = await SeedLoginUserAsync(factory);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
||||||
|
var request = new CreateCommercePaymentDto
|
||||||
|
{
|
||||||
|
OrderNo = order.OrderNo,
|
||||||
|
Provider = "manual",
|
||||||
|
Method = "manual"
|
||||||
|
};
|
||||||
|
await client.PostAsJsonAsync("/api/commerce/payments", request);
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Authorization = null;
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
eventId = "notify-001",
|
||||||
|
orderNo = order.OrderNo,
|
||||||
|
providerTradeNo = "trade-notify-001",
|
||||||
|
amountCents = order.AmountCents,
|
||||||
|
paid = true,
|
||||||
|
signatureValid = true
|
||||||
|
};
|
||||||
|
var firstNotify = await client.PostAsJsonAsync(
|
||||||
|
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||||
|
payload);
|
||||||
|
var secondNotify = await client.PostAsJsonAsync(
|
||||||
|
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||||
|
payload);
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
||||||
|
.Content
|
||||||
|
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
var events = dbContext.PaymentEvents.Count(item => item.EventId == "notify-001");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, firstNotify.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, secondNotify.StatusCode);
|
||||||
|
Assert.True(entitlement!.IsActive);
|
||||||
|
Assert.Equal(1, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_payment_notification_signature_does_not_pay_order()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||||
|
var seed = await SeedLoginUserAsync(factory);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Authorization = null;
|
||||||
|
var notifyResponse = await client.PostAsJsonAsync(
|
||||||
|
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
eventId = "notify-invalid",
|
||||||
|
orderNo = order.OrderNo,
|
||||||
|
providerTradeNo = "trade-invalid",
|
||||||
|
amountCents = order.AmountCents,
|
||||||
|
paid = true,
|
||||||
|
signatureValid = false
|
||||||
|
});
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}");
|
||||||
|
var currentOrder = await orderResponse.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, notifyResponse.StatusCode);
|
||||||
|
Assert.Equal("Pending", currentOrder!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<CommerceOrderItem> CreateOrderAsync(HttpClient client, Guid planId)
|
private static async Task<CommerceOrderItem> CreateOrderAsync(HttpClient client, Guid planId)
|
||||||
{
|
{
|
||||||
var response = await client.PostAsJsonAsync(
|
var response = await client.PostAsJsonAsync(
|
||||||
@@ -312,7 +387,49 @@ public sealed class CommerceEndpointTests
|
|||||||
PaymentNotificationRequest request,
|
PaymentNotificationRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException();
|
var body = request.Body;
|
||||||
|
var eventId = GetString(body, "eventId") ?? Guid.NewGuid().ToString("N");
|
||||||
|
var orderNo = GetString(body, "orderNo") ?? throw new InvalidOperationException("orderNo missing");
|
||||||
|
var tradeNo = GetString(body, "providerTradeNo");
|
||||||
|
var amount = GetInt(body, "amountCents");
|
||||||
|
var paid = GetBoolean(body, "paid");
|
||||||
|
var signatureValid = GetBoolean(body, "signatureValid");
|
||||||
|
return Task.FromResult(new PaymentNotificationResult(
|
||||||
|
provider,
|
||||||
|
"payment_notify",
|
||||||
|
eventId,
|
||||||
|
orderNo,
|
||||||
|
tradeNo,
|
||||||
|
amount,
|
||||||
|
paid,
|
||||||
|
signatureValid,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
body));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement element, string key)
|
||||||
|
{
|
||||||
|
return element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(key, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.String
|
||||||
|
? property.GetString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetInt(JsonElement element, string key)
|
||||||
|
{
|
||||||
|
return element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(key, out var property) &&
|
||||||
|
property.TryGetInt32(out var value)
|
||||||
|
? value
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetBoolean(JsonElement element, string key)
|
||||||
|
{
|
||||||
|
return element.ValueKind == JsonValueKind.Object &&
|
||||||
|
element.TryGetProperty(key, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user