feat(payment): FlatRender Pay (ZarinPal broker) checkout + webhook
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m3s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 53s
CI/CD / Deploy · all services (push) Successful in 1m41s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m3s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 53s
CI/CD / Deploy · all services (push) Successful in 1m41s
Adds a signed broker integration for online plan purchases:
- FlatPayService: POST /v1/pay/request with X-Api-Key + X-Signature =
hex(HMAC-SHA256(secret, raw JSON bytes)); the exact serialized bytes are both
signed and sent. VerifyWebhook does a fixed-time compare of the digest, plus an
in-memory first-seen idempotency set.
- POST /api/payment/request (auth, ManageBilling): parses a "Tier:Months" product
(e.g. "Pro:12"), prices it via the plan catalog, creates a Pending SubscriptionPayment
(provider=FlatPay) as the order, and returns the broker payment URL. The order id is
the client_ref / metadata.payment_id.
- POST /api/payment/webhook (anonymous; HMAC is the auth — 401 on bad signature):
on status=Paid + first-seen id, grants the order via the shared plan-activation
path (extracted ActivatePaymentAsync, reused by all providers). Always 200 after a
valid signature so the broker won't retry an accepted job.
- Config FlatPay__{ApiKey,Secret,BaseUrl,ReturnUrl} (env-supplied; secrets stay out
of git), compose + .env.example wiring. PaymentProvider.FlatPay appended (int, no
migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
ZARINPAL_SANDBOX=false
|
||||
|
||||
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
|
||||
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
|
||||
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
|
||||
FLATPAY_API_KEY=
|
||||
FLATPAY_SECRET=
|
||||
FLATPAY_BASE_URL=https://pay.flatrender.ir
|
||||
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
|
||||
|
||||
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
||||
|
||||
@@ -94,6 +94,10 @@ services:
|
||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
|
||||
FlatPay__Secret: "${FLATPAY_SECRET:-}"
|
||||
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
|
||||
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
|
||||
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Payments;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Services.Payments;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
|
||||
[ApiController]
|
||||
public class PaymentController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IBillingService _billing;
|
||||
private readonly IFlatPayService _flatPay;
|
||||
private readonly ILogger<PaymentController> _logger;
|
||||
|
||||
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
|
||||
{
|
||||
_billing = billing;
|
||||
_flatPay = flatPay;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
|
||||
[Authorize]
|
||||
[HttpPost("api/payment/request")]
|
||||
public async Task<IActionResult> CreatePayment(
|
||||
[FromBody] PaymentRequestDto request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
|
||||
|
||||
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
|
||||
|
||||
var (paymentId, amountToman, code, message) =
|
||||
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
|
||||
if (paymentId is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
|
||||
var description = $"میزی — اشتراک {tier} ({months} ماه)";
|
||||
var url = await _flatPay.RequestAsync(
|
||||
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
|
||||
|
||||
if (string.IsNullOrEmpty(url))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
|
||||
|
||||
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
|
||||
}
|
||||
|
||||
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
|
||||
/// signature so the broker doesn't retry a job we've accepted.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/payment/webhook")]
|
||||
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await Request.Body.CopyToAsync(ms, ct);
|
||||
var raw = ms.ToArray();
|
||||
|
||||
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
|
||||
if (!_flatPay.VerifyWebhook(raw, signature))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var status = GetString(root, "status");
|
||||
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
|
||||
|
||||
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(brokerId)
|
||||
&& _flatPay.TryMarkProcessed(brokerId))
|
||||
{
|
||||
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
|
||||
? m
|
||||
: default;
|
||||
var paymentId = GetString(meta, "payment_id");
|
||||
|
||||
if (!string.IsNullOrEmpty(paymentId))
|
||||
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
|
||||
else
|
||||
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FlatPay webhook processing error");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
|
||||
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
|
||||
{
|
||||
tier = default;
|
||||
months = 0;
|
||||
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) return false;
|
||||
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
|
||||
&& tier != PlanTier.Free
|
||||
&& int.TryParse(parts[1], out months)
|
||||
&& months > 0;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement el, string name)
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
|
||||
return null;
|
||||
return v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString(),
|
||||
JsonValueKind.Number => v.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Meezi.API.Services;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.API.Services.Payments;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure;
|
||||
using Serilog;
|
||||
@@ -95,6 +96,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ReceiptBuilder>();
|
||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
|
||||
services.Configure<FlatPayOptions>(configuration.GetSection(FlatPayOptions.SectionName));
|
||||
services.AddHttpClient<IFlatPayService, FlatPayService>((sp, c) =>
|
||||
{
|
||||
var baseUrl = configuration["FlatPay:BaseUrl"];
|
||||
c.BaseAddress = new Uri(string.IsNullOrWhiteSpace(baseUrl) ? "https://pay.flatrender.ir" : baseUrl);
|
||||
c.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
services.AddHttpClient(nameof(PosDeviceService));
|
||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Meezi.API.Models.Payments;
|
||||
|
||||
/// <summary>Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12".</summary>
|
||||
public record PaymentRequestDto(string ProductId);
|
||||
|
||||
public record PaymentRequestResponse(string Url, string PaymentId);
|
||||
@@ -40,6 +40,21 @@ public interface IBillingService
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Price a plan+months bundle and create a Pending FlatPay SubscriptionPayment
|
||||
/// (the "order"); the returned id is passed to the broker as client_ref / metadata.payment_id.</summary>
|
||||
Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Grant a FlatPay order after the broker reports it Paid: activate the plan using
|
||||
/// the same coverage/queueing logic as the other providers. Idempotent.</summary>
|
||||
Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
|
||||
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
/// <summary>Apply a paid SubscriptionPayment: book it after the current coverage (queued) or
|
||||
/// activate it now, update the cafe plan, persist, and send the confirmation SMS. Shared by all
|
||||
/// providers (gateway callbacks and the FlatPay webhook).</summary>
|
||||
private async Task ActivatePaymentAsync(SubscriptionPayment payment, CancellationToken cancellationToken)
|
||||
{
|
||||
var cafe = payment.Cafe;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||
}
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
public async Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (months is < 1 or > 36)
|
||||
return (null, 0m, "INVALID_MONTHS", "Months must be between 1 and 36.");
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (null, 0m, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
if (!await _platformCatalog.IsBillableOnlineAsync(tier, cancellationToken))
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan requires contacting sales.");
|
||||
|
||||
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(tier, cancellationToken);
|
||||
if (monthly <= 0)
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan has no online price.");
|
||||
|
||||
var amountToman = monthly * months;
|
||||
var payment = new SubscriptionPayment
|
||||
{
|
||||
CafeId = cafeId,
|
||||
PlanTier = tier,
|
||||
Months = months,
|
||||
AmountToman = amountToman,
|
||||
AmountRials = PlanPricing.ToRials(amountToman),
|
||||
Provider = PaymentProvider.FlatPay,
|
||||
Status = SubscriptionPaymentStatus.Pending,
|
||||
};
|
||||
|
||||
_db.SubscriptionPayments.Add(payment);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (payment.Id, amountToman, null, null);
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(paymentId))
|
||||
return false;
|
||||
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.Include(p => p.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == paymentId && p.Provider == PaymentProvider.FlatPay,
|
||||
cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
{
|
||||
_logger.LogWarning("FlatPay grant: no pending order {PaymentId}", paymentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Already granted (webhook redelivery / double-process) → idempotent no-op.
|
||||
if (payment.Status is SubscriptionPaymentStatus.Completed or SubscriptionPaymentStatus.Scheduled)
|
||||
return true;
|
||||
|
||||
payment.RefId = refId;
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
_logger.LogInformation("FlatPay grant applied: payment {PaymentId} → {Tier} x{Months}m",
|
||||
payment.Id, payment.PlanTier, payment.Months);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Payments;
|
||||
|
||||
public sealed class FlatPayOptions
|
||||
{
|
||||
public const string SectionName = "FlatPay";
|
||||
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string Secret { get; set; } = "";
|
||||
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
|
||||
public string ReturnUrl { get; set; } = "https://meezi.ir/payment/return";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated
|
||||
/// with <c>X-Api-Key</c> + <c>X-Signature</c> = hex(HMAC-SHA256(secret, raw JSON bytes));
|
||||
/// webhooks are verified the same way. The signature is computed over the EXACT bytes
|
||||
/// that are sent/received, so we serialize once and reuse the buffer.
|
||||
/// </summary>
|
||||
public interface IFlatPayService
|
||||
{
|
||||
/// <summary>Create a payment at the broker and return its hosted payment URL (null on failure).
|
||||
/// <paramref name="clientRef"/> is echoed back and also embedded in metadata.payment_id.</summary>
|
||||
Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header.</summary>
|
||||
bool VerifyWebhook(byte[] rawBytes, string? signature);
|
||||
|
||||
/// <summary>Idempotency: true only the first time a given broker payment id is seen.</summary>
|
||||
bool TryMarkProcessed(string id);
|
||||
}
|
||||
|
||||
public sealed class FlatPayService : IFlatPayService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly FlatPayOptions _opts;
|
||||
private readonly ILogger<FlatPayService> _logger;
|
||||
|
||||
// Webhooks can be redelivered; remember the broker ids we've already granted.
|
||||
private readonly ConcurrentDictionary<string, byte> _seen = new();
|
||||
|
||||
public FlatPayService(HttpClient http, IOptions<FlatPayOptions> opts, ILogger<FlatPayService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var body = new PayRequestBody(
|
||||
amountToman,
|
||||
"IRT",
|
||||
description,
|
||||
clientRef,
|
||||
_opts.ReturnUrl,
|
||||
new PayMetadata(userId, productId, clientRef));
|
||||
|
||||
// Serialize once: these exact bytes are both signed and sent.
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(body);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/pay/request");
|
||||
req.Content = new ByteArrayContent(bytes);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
|
||||
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await _http.SendAsync(req, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("FlatPay /v1/pay/request failed {Status}: {Body}", (int)resp.StatusCode, respBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(respBody);
|
||||
var url = ExtractPaymentUrl(doc.RootElement);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
_logger.LogError("FlatPay request returned no payment_url: {Body}", respBody);
|
||||
return url;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FlatPay request error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhook(byte[] rawBytes, string? signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature)) return false;
|
||||
var expected = Sign(rawBytes);
|
||||
var provided = signature.Trim().ToLowerInvariant();
|
||||
|
||||
// Compare the ascii hex digests in fixed time.
|
||||
var a = Encoding.ASCII.GetBytes(expected);
|
||||
var b = Encoding.ASCII.GetBytes(provided);
|
||||
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
|
||||
}
|
||||
|
||||
public bool TryMarkProcessed(string id) =>
|
||||
!string.IsNullOrEmpty(id) && _seen.TryAdd(id, 0);
|
||||
|
||||
private string Sign(byte[] body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
|
||||
return Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractPaymentUrl(JsonElement root)
|
||||
{
|
||||
if (TryGetString(root, "payment_url") is { } direct) return direct;
|
||||
// Some broker responses nest the result under "data".
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
return TryGetString(data, "payment_url");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement el, string name) =>
|
||||
el.ValueKind == JsonValueKind.Object
|
||||
&& el.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private sealed record PayRequestBody(
|
||||
[property: JsonPropertyName("amount")] long Amount,
|
||||
[property: JsonPropertyName("currency")] string Currency,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("client_ref")] string ClientRef,
|
||||
[property: JsonPropertyName("return_url")] string ReturnUrl,
|
||||
[property: JsonPropertyName("metadata")] PayMetadata Metadata);
|
||||
|
||||
private sealed record PayMetadata(
|
||||
[property: JsonPropertyName("user_id")] string UserId,
|
||||
[property: JsonPropertyName("product_id")] string ProductId,
|
||||
[property: JsonPropertyName("payment_id")] string PaymentId);
|
||||
}
|
||||
@@ -44,6 +44,12 @@
|
||||
"MerchantId": "",
|
||||
"Sandbox": true
|
||||
},
|
||||
"FlatPay": {
|
||||
"BaseUrl": "https://pay.flatrender.ir",
|
||||
"ReturnUrl": "https://meezi.ir/payment/return",
|
||||
"ApiKey": "",
|
||||
"Secret": ""
|
||||
},
|
||||
"Billing": {
|
||||
"DashboardBaseUrl": "http://localhost:3101"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ public enum PaymentProvider
|
||||
{
|
||||
ZarinPal = 0,
|
||||
Tara = 1,
|
||||
SnappPay = 2
|
||||
SnappPay = 2,
|
||||
// Appended (stored as int) so existing rows keep their meaning — no migration needed.
|
||||
FlatPay = 3
|
||||
}
|
||||
|
||||
public static class PaymentProviderIds
|
||||
|
||||
Reference in New Issue
Block a user