Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/PaymentService.cs
T
soroush.asadi 376cdf6a1c
CI/CD / CI · Web (tsc) (push) Successful in 1m10s
CI/CD / Deploy · full stack (push) Failing after 11m4s
feat(payment): route FlatRender plan purchases through the broker
- identity: when FlatPay (broker) is configured, InitiateZarinPalAsync
  routes through pay.flatrender.ir instead of calling ZarinPal directly;
  new HandleBrokerCallbackAsync confirms the payment via the broker
  inquiry API (authoritative, not trusting the redirect) and activates
  the plan. New public endpoint GET /v1/payments/callback/broker
  (already public at the gateway via /callback/*). Env-gated — empty
  FlatPay__ApiKey keeps the legacy direct-ZarinPal path.
- broker: deliver webhooks inline on enqueue (best-effort) in addition
  to the retry loop, so clients credit near-instantly (db.GetWebhook +
  goroutine kick).
- compose + ENV_FILE: FlatPay__* for identity (FLATPAY_FLATRENDER_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:34:45 +03:30

628 lines
28 KiB
C#

using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class PaymentService(
IdentityDbContext db,
IHttpClientFactory httpClientFactory,
IConfiguration config) : IPaymentService
{
// ── Config ────────────────────────────────────────────────────────────────────
private string ZarinPalMerchantId => config["ZarinPal:MerchantId"] ?? "";
private string ZarinPalCallbackUrl => config["ZarinPal:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/zarinpal";
private bool ZarinPalSandbox => config["ZarinPal:Sandbox"] == "true";
private string SnapPayClientId => config["SnapPay:ClientId"] ?? "";
private string SnapPayClientSecret => config["SnapPay:ClientSecret"] ?? "";
private string SnapPayBaseUrl => config["SnapPay:BaseUrl"] ?? "https://api.snappay.ir";
private string SnapPayCallbackUrl => config["SnapPay:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/snappay";
// Tara payment gateway (tara.ir) — API key auth
// Docs: https://www.tara.ir/documents/payment-gateway
private string TaraApiKey => config["Tara:ApiKey"] ?? "";
private string TaraBaseUrl => config["Tara:BaseUrl"] ?? "https://api.tara.ir";
private string TaraCallbackUrl => config["Tara:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/tara";
private string StripeSecretKey => config["Stripe:SecretKey"] ?? "";
private string StripeWebhookSecret => config["Stripe:WebhookSecret"] ?? "";
private const long IrrToToman = 10; // 1 Toman = 10 Rials
// FlatRender Pay broker (pay.flatrender.ir) — routes ZarinPal through the single
// verified domain. When configured, plan purchases go through it instead of a
// direct ZarinPal call (so FlatRender shares the same broker as meezi/bargevasat).
private string FlatPayBaseUrl => config["FlatPay:BaseUrl"] ?? "https://pay.flatrender.ir";
private string FlatPayApiKey => config["FlatPay:ApiKey"] ?? "";
private string FlatPaySecret => config["FlatPay:Secret"] ?? "";
private string FlatPayReturnBase => config["FlatPay:ReturnBase"] ?? "https://api.flatrender.ir";
private bool BrokerEnabled => !string.IsNullOrEmpty(FlatPayApiKey) && !string.IsNullOrEmpty(FlatPaySecret);
// ── Queries ───────────────────────────────────────────────────────────────────
public async Task<PagedResponse<PaymentResponse>> GetUserPaymentsAsync(Guid userId, int page, int pageSize)
{
var total = await db.Payments.CountAsync(p => p.UserId == userId);
var payments = await db.Payments
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<PaymentResponse>(
payments.Select(MapPaymentResponse).ToList(),
new PaginationMeta(page, pageSize, total, (long)(page * pageSize) < total)
);
}
public async Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments
.FirstOrDefaultAsync(p => p.Id == paymentId && p.UserId == userId)
?? throw new KeyNotFoundException("Payment not found");
return MapPaymentResponse(payment);
}
// ── ZarinPal initiation ───────────────────────────────────────────────────────
public async Task<string> InitiateZarinPalAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
// Prefer the shared broker (single ZarinPal-verified domain) when configured.
if (BrokerEnabled)
return await InitiateViaBrokerAsync(payment);
if (string.IsNullOrEmpty(ZarinPalMerchantId))
throw new InvalidOperationException("ZarinPal:MerchantId is not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var baseUrl = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/v4/payment"
: "https://api.zarinpal.com/pg/v4/payment";
var http = httpClientFactory.CreateClient("zarinpal");
var reqBody = new
{
merchant_id = ZarinPalMerchantId,
amount = amountToman,
callback_url = ZarinPalCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
metadata = new { order_id = paymentId.ToString() },
};
var resp = await http.PostAsJsonAsync($"{baseUrl}/request.json", reqBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var code = root.GetProperty("data").GetProperty("code").GetInt32();
if (code != 100)
{
var errDetail = root.TryGetProperty("errors", out var errEl) ? errEl.ToString() : "no details";
throw new InvalidOperationException($"ZarinPal request failed (code={code}): {errDetail}");
}
var authority = root.GetProperty("data").GetProperty("authority").GetString()!;
payment.GatewayToken = authority;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var startPage = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/StartPay/"
: "https://www.zarinpal.com/pg/StartPay/";
return $"{startPage}{authority}";
}
// ── ZarinPal callback (browser returns after payment) ─────────────────────────
public async Task<string> HandleZarinPalCallbackAsync(string authority, string status)
{
if (status != "OK")
return "/payment/result?status=failed&gateway=zarinpal";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == authority && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this authority");
var amountToman = payment.AmountMinor / IrrToToman;
var baseUrl = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/v4/payment"
: "https://api.zarinpal.com/pg/v4/payment";
var http = httpClientFactory.CreateClient("zarinpal");
var verifyBody = new
{
merchant_id = ZarinPalMerchantId,
amount = amountToman,
authority,
};
var resp = await http.PostAsJsonAsync($"{baseUrl}/verify.json", verifyBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var code = root.GetProperty("data").GetProperty("code").GetInt32();
if (code is 100 or 101) // 101 = already verified (idempotent)
{
var refId = root.GetProperty("data").GetProperty("ref_id").GetInt64().ToString();
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = refId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={refId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = $"ZarinPal verify code: {code}";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=zarinpal";
}
// ── FlatRender Pay broker (pay.flatrender.ir) ─────────────────────────────────
private string BrokerSign(byte[] message)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(FlatPaySecret));
return Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();
}
/// <summary>Create the payment at the broker and return its StartPay URL.</summary>
private async Task<string> InitiateViaBrokerAsync(Payment payment)
{
var amountToman = payment.AmountMinor / IrrToToman;
var returnUrl = $"{FlatPayReturnBase.TrimEnd('/')}/v1/payments/callback/broker?payment_id={payment.Id}";
var body = new
{
amount = amountToman,
currency = "IRT",
description = payment.Title ?? "پرداخت فلت‌رندر",
client_ref = payment.Id.ToString(),
return_url = returnUrl,
metadata = new { payment_id = payment.Id.ToString() },
};
var json = JsonSerializer.Serialize(body);
var bytes = Encoding.UTF8.GetBytes(json);
var http = httpClientFactory.CreateClient("broker");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{FlatPayBaseUrl.TrimEnd('/')}/v1/pay/request");
req.Headers.TryAddWithoutValidation("X-Api-Key", FlatPayApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", BrokerSign(bytes));
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = doc!.RootElement;
if (!resp.IsSuccessStatusCode || !root.TryGetProperty("payment_url", out var urlEl))
{
var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "broker error";
throw new InvalidOperationException($"FlatPay request failed: {msg}");
}
// Persist the broker transaction id so the return callback can confirm it.
if (root.TryGetProperty("id", out var idEl))
payment.GatewayToken = idEl.GetString();
payment.GatewayOrderId = "flatpay";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return urlEl.GetString()!;
}
/// <summary>
/// Broker return — authoritatively confirm via the inquiry API (do NOT trust the
/// redirect alone), then activate the plan.
/// </summary>
public async Task<string> HandleBrokerCallbackAsync(Guid paymentId, string brokerTxnId)
{
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == paymentId)
?? throw new KeyNotFoundException("Payment not found");
if (payment.Status == PaymentStatus.Succeeded)
return $"/payment/result?status=success&ref={payment.GatewayTrackId}";
var txnId = !string.IsNullOrEmpty(brokerTxnId) ? brokerTxnId : payment.GatewayToken ?? "";
if (string.IsNullOrEmpty(txnId))
return "/payment/result?status=failed&gateway=broker";
var json = JsonSerializer.Serialize(new { id = txnId });
var bytes = Encoding.UTF8.GetBytes(json);
var http = httpClientFactory.CreateClient("broker");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{FlatPayBaseUrl.TrimEnd('/')}/v1/pay/inquiry");
req.Headers.TryAddWithoutValidation("X-Api-Key", FlatPayApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", BrokerSign(bytes));
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var status = "";
string? refId = null;
try
{
var resp = await http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = doc!.RootElement;
if (root.TryGetProperty("status", out var st)) status = st.GetString() ?? "";
if (root.TryGetProperty("ref_id", out var r) && r.ValueKind == JsonValueKind.String) refId = r.GetString();
}
catch { /* broker unreachable — leave pending, user can retry */ }
if (status == "Paid")
{
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = refId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={refId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = $"broker status: {status}";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=broker";
}
// ── SnapPay initiation ────────────────────────────────────────────────────────
// API ref: https://developer.snappay.ir/
// Flow: POST /api/v1/payment/token → get paymentToken → redirect to snappay.ir/payment/{token}
// Callback query params: paymentToken, merchantOrderId, shapSnapStatus (DONE|FAIL|CANCEL)
// Verify: POST /api/v1/payment/verify
public async Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
if (string.IsNullOrEmpty(SnapPayClientId) || string.IsNullOrEmpty(SnapPayClientSecret))
throw new InvalidOperationException("SnapPay:ClientId / SnapPay:ClientSecret are not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var http = httpClientFactory.CreateClient("snappay");
var reqBody = new
{
clientId = SnapPayClientId,
clientSecret = SnapPayClientSecret,
amount = amountToman,
currency = "TOMAN",
callbackUrl = SnapPayCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
merchantOrderId = paymentId.ToString(),
};
var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/token", reqBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (!root.TryGetProperty("status", out var statusEl) || !statusEl.GetBoolean())
{
var error = root.TryGetProperty("error", out var e) ? e.ToString() : "SnapPay error";
throw new InvalidOperationException($"SnapPay payment request failed: {error}");
}
var paymentToken = root.GetProperty("response").GetProperty("paymentToken").GetString()!;
payment.GatewayToken = paymentToken;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return $"https://snappay.ir/payment/{paymentToken}";
}
public async Task<string> HandleSnapPayCallbackAsync(string paymentToken, string shapStatus)
{
// shapSnapStatus values: DONE = success, FAIL / CANCEL = failure
if (!shapStatus.Equals("DONE", StringComparison.OrdinalIgnoreCase))
return "/payment/result?status=failed&gateway=snappay";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == paymentToken && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this token");
var http = httpClientFactory.CreateClient("snappay");
var verifyBody = new
{
clientId = SnapPayClientId,
clientSecret = SnapPayClientSecret,
paymentToken,
};
var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/verify", verifyBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (root.TryGetProperty("status", out var okEl) && okEl.GetBoolean())
{
var responseObj = root.GetProperty("response");
var transactionId = responseObj.TryGetProperty("transactionId", out var tid)
? tid.ToString() : paymentToken;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = transactionId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={transactionId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = root.TryGetProperty("error", out var err)
? err.ToString() : "SnapPay verification failed";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=snappay";
}
// ── Tara initiation ────────────────────────────────────────────────────────────
// API ref: https://www.tara.ir/documents/payment-gateway
// Auth: X-Api-Key request header
// Flow: POST /v1/payment/request → { token, redirectUrl }
// → redirect browser to redirectUrl (or fallback: {baseUrl}/payment/{token})
// Callback query params: token, status (OK|FAILED)
// Verify: POST /v1/payment/verify body: { token, orderId }
public async Task<string> InitiateTaraAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
if (string.IsNullOrEmpty(TaraApiKey))
throw new InvalidOperationException("Tara:ApiKey is not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var http = httpClientFactory.CreateClient("tara");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/request");
request.Headers.Add("X-Api-Key", TaraApiKey);
request.Content = JsonContent.Create(new
{
amount = amountToman,
orderId = paymentId.ToString(),
callbackUrl = TaraCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
});
var resp = await http.SendAsync(request);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (!root.TryGetProperty("token", out var tokenEl))
{
var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "Tara error";
throw new InvalidOperationException($"Tara payment request failed: {msg}");
}
var token = tokenEl.GetString()!;
payment.GatewayToken = token;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Use the redirectUrl Tara returns; fall back to constructed URL if absent
if (root.TryGetProperty("redirectUrl", out var urlEl) && !string.IsNullOrEmpty(urlEl.GetString()))
return urlEl.GetString()!;
return $"{TaraBaseUrl}/payment/{token}";
}
public async Task<string> HandleTaraCallbackAsync(string token, string status)
{
var ok = status.Equals("OK", StringComparison.OrdinalIgnoreCase)
|| status.Equals("SUCCESS", StringComparison.OrdinalIgnoreCase);
if (!ok)
return "/payment/result?status=failed&gateway=tara";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == token && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this token");
var http = httpClientFactory.CreateClient("tara");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/verify");
request.Headers.Add("X-Api-Key", TaraApiKey);
request.Content = JsonContent.Create(new
{
token,
orderId = payment.Id.ToString(),
});
var resp = await http.SendAsync(request);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var verified = root.TryGetProperty("status", out var statusEl)
&& (statusEl.GetString()?.ToUpperInvariant() is "OK" or "SUCCESS" or "VERIFIED");
if (verified)
{
var trackId = root.TryGetProperty("trackId", out var tid)
? tid.GetString() ?? token : token;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = trackId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={trackId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString() : "Tara verification failed";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=tara";
}
// ── Stripe webhook ────────────────────────────────────────────────────────────
public async Task HandleStripeWebhookAsync(string payload, string signature)
{
if (!VerifyStripeSignature(payload, signature, StripeWebhookSecret))
throw new UnauthorizedAccessException("Invalid Stripe webhook signature");
using var json = JsonDocument.Parse(payload);
var eventType = json.RootElement.GetProperty("type").GetString();
if (eventType != "checkout.session.completed") return;
var sessionObj = json.RootElement.GetProperty("data").GetProperty("object");
var metadata = sessionObj.GetProperty("metadata");
if (!metadata.TryGetProperty("payment_id", out var pidEl)) return;
if (!Guid.TryParse(pidEl.GetString(), out var paymentId)) return;
var payment = await db.Payments.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == paymentId);
if (payment is null || payment.Status != PaymentStatus.Pending) return;
var piId = sessionObj.TryGetProperty("payment_intent", out var pi) ? pi.GetString() : null;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayOrderId = piId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
}
// ── Refunds ───────────────────────────────────────────────────────────────────
public async Task<RefundResponse> IssueRefundAsync(
Guid paymentId, long? amountMinor, string reason, string refundTo)
{
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == paymentId)
?? throw new KeyNotFoundException("Payment not found");
if (payment.Status != PaymentStatus.Succeeded)
throw new InvalidOperationException("Only succeeded payments can be refunded");
var refundAmount = amountMinor ?? payment.AmountMinor;
payment.Status = PaymentStatus.Refunded;
payment.RefundedAt = DateTime.UtcNow;
payment.RefundAmountMinor = refundAmount;
payment.RefundReason = reason;
payment.UpdatedAt = DateTime.UtcNow;
// Credit back to user balance (default) or affiliate balance
if (string.IsNullOrEmpty(refundTo) || refundTo == "Balance")
payment.User.BalanceMinor += refundAmount;
else if (refundTo == "Affiliate")
payment.User.AffiliateBalanceMinor += refundAmount;
await db.SaveChangesAsync();
return new RefundResponse(paymentId, "Refunded");
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private async Task ActivatePlanAsync(Payment payment)
{
var plan = await db.Plans.FindAsync(payment.PlanId!.Value);
if (plan is null) return;
var durationMonths = plan.MonthsDuration ?? plan.BillingPeriod switch
{
BillingPeriod.Monthly => 1,
BillingPeriod.Quarterly => 3,
BillingPeriod.SemiAnnual => 6,
BillingPeriod.Annual => 12,
BillingPeriod.Lifetime => 1200,
BillingPeriod.OneTime => 1,
_ => 1,
};
var now = DateTime.UtcNow;
db.UserPlans.Add(new UserPlan
{
UserId = payment.UserId,
TenantId = payment.TenantId,
PlanId = plan.Id,
PlanCode = plan.Code,
PlanName = plan.Name,
PriceMinorPaid = payment.AmountMinor,
Currency = payment.Currency,
InitialSecondsCharge = plan.SecondsCharge,
RemainChargeSec = plan.SecondsCharge,
StartsAt = now,
ExpiresAt = now.AddMonths(durationMonths),
AutoRenew = false,
PaymentId = payment.Id,
});
}
private static bool VerifyStripeSignature(string payload, string header, string secret)
{
// Stripe header: t=<unix_ts>,v1=<hex_sig>[,...]
if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(header)) return false;
var ts = "";
var sig = "";
foreach (var part in header.Split(','))
{
if (part.StartsWith("t=")) ts = part[2..];
if (part.StartsWith("v1=")) sig = part[3..];
}
if (string.IsNullOrEmpty(ts) || string.IsNullOrEmpty(sig)) return false;
var message = $"{ts}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expectedBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
var expected = Convert.ToHexString(expectedBytes).ToLowerInvariant();
return expected == sig;
}
private static PaymentResponse MapPaymentResponse(Payment p) => new(
p.Id, p.Gateway.ToString(), p.Status.ToString(), p.Action.ToString(),
p.AmountMinor, p.Currency, p.Title, p.Description,
p.CardLast4, p.ConfirmedAt, p.FailedAt, p.FailureReason, p.CreatedAt
);
}