376cdf6a1c
- 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>
628 lines
28 KiB
C#
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
|
|
);
|
|
}
|