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

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:
soroush.asadi
2026-06-26 04:20:02 +03:30
parent b0896dc777
commit 4cc1c3a423
9 changed files with 408 additions and 2 deletions
+8
View File
@@ -83,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
ZARINPAL_MERCHANT_ID= ZARINPAL_MERCHANT_ID=
ZARINPAL_SANDBOX=false 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 ──────────────────────────────────────────────────────────── # ── SMS: Kavenegar ────────────────────────────────────────────────────────────
# Empty = OTP is logged to API console (fine for dev, not for production) # Empty = OTP is logged to API console (fine for dev, not for production)
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
+4
View File
@@ -94,6 +94,10 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}" Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" 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__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}" Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}" 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.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing; using Meezi.API.Services.Printing;
using Meezi.API.Services.Payments;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure; using Meezi.Infrastructure;
using Serilog; using Serilog;
@@ -95,6 +96,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<ReceiptBuilder>(); services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>(); services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>(); 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.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>(); services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>(); 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);
+93 -1
View File
@@ -40,6 +40,21 @@ public interface IBillingService
string cafeId, string cafeId,
string paymentId, string paymentId,
CancellationToken cancellationToken = default); 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 public class BillingService : IBillingService
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
payment.RefId = verify.RefId; 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 cafe = payment.Cafe;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, 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 /// <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);
}
+6
View File
@@ -44,6 +44,12 @@
"MerchantId": "", "MerchantId": "",
"Sandbox": true "Sandbox": true
}, },
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ReturnUrl": "https://meezi.ir/payment/return",
"ApiKey": "",
"Secret": ""
},
"Billing": { "Billing": {
"DashboardBaseUrl": "http://localhost:3101" "DashboardBaseUrl": "http://localhost:3101"
}, },
+3 -1
View File
@@ -4,7 +4,9 @@ public enum PaymentProvider
{ {
ZarinPal = 0, ZarinPal = 0,
Tara = 1, 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 public static class PaymentProviderIds