diff --git a/.env.example b/.env.example index 793313e..ef35eef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 7741582..6984262 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-}" diff --git a/src/Meezi.API/Controllers/PaymentController.cs b/src/Meezi.API/Controllers/PaymentController.cs new file mode 100644 index 0000000..9a9df8c --- /dev/null +++ b/src/Meezi.API/Controllers/PaymentController.cs @@ -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; + +/// FlatRender Pay (ZarinPal broker) checkout + webhook. +[ApiController] +public class PaymentController : CafeApiControllerBase +{ + private readonly IBillingService _billing; + private readonly IFlatPayService _flatPay; + private readonly ILogger _logger; + + public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger logger) + { + _billing = billing; + _flatPay = flatPay; + _logger = logger; + } + + /// Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to. + [Authorize] + [HttpPost("api/payment/request")] + public async Task 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(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(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(false, null, + new ApiError("PAYMENT_FAILED", "Could not start the payment."))); + + return Ok(new ApiResponse(true, new PaymentRequestResponse(url, paymentId))); + } + + /// 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. + [AllowAnonymous] + [HttpPost("api/payment/webhook")] + public async Task 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(); + } + + /// Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12). + 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, + }; + } +} diff --git a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs index 0f057a9..3bad869 100644 --- a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); services.AddSingleton(); + services.Configure(configuration.GetSection(FlatPayOptions.SectionName)); + services.AddHttpClient((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(); services.AddScoped(); diff --git a/src/Meezi.API/Models/Payments/PaymentDtos.cs b/src/Meezi.API/Models/Payments/PaymentDtos.cs new file mode 100644 index 0000000..2c0a883 --- /dev/null +++ b/src/Meezi.API/Models/Payments/PaymentDtos.cs @@ -0,0 +1,6 @@ +namespace Meezi.API.Models.Payments; + +/// Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12". +public record PaymentRequestDto(string ProductId); + +public record PaymentRequestResponse(string Url, string PaymentId); diff --git a/src/Meezi.API/Services/BillingService.cs b/src/Meezi.API/Services/BillingService.cs index 7ba93c3..1943297 100644 --- a/src/Meezi.API/Services/BillingService.cs +++ b/src/Meezi.API/Services/BillingService.cs @@ -40,6 +40,21 @@ public interface IBillingService string cafeId, string paymentId, CancellationToken cancellationToken = default); + + /// 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. + Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync( + string cafeId, + PlanTier tier, + int months, + CancellationToken cancellationToken = default); + + /// Grant a FlatPay order after the broker reports it Paid: activate the plan using + /// the same coverage/queueing logic as the other providers. Idempotent. + Task 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); + } + + /// 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). + 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 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; } /// End of the cafe's current paid coverage: the later of its active plan expiry diff --git a/src/Meezi.API/Services/Payments/FlatPayService.cs b/src/Meezi.API/Services/Payments/FlatPayService.cs new file mode 100644 index 0000000..7979890 --- /dev/null +++ b/src/Meezi.API/Services/Payments/FlatPayService.cs @@ -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"; +} + +/// +/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated +/// with X-Api-Key + X-Signature = 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. +/// +public interface IFlatPayService +{ + /// Create a payment at the broker and return its hosted payment URL (null on failure). + /// is echoed back and also embedded in metadata.payment_id. + Task RequestAsync( + string userId, string productId, long amountToman, string description, string clientRef, + CancellationToken ct = default); + + /// Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header. + bool VerifyWebhook(byte[] rawBytes, string? signature); + + /// Idempotency: true only the first time a given broker payment id is seen. + bool TryMarkProcessed(string id); +} + +public sealed class FlatPayService : IFlatPayService +{ + private readonly HttpClient _http; + private readonly FlatPayOptions _opts; + private readonly ILogger _logger; + + // Webhooks can be redelivered; remember the broker ids we've already granted. + private readonly ConcurrentDictionary _seen = new(); + + public FlatPayService(HttpClient http, IOptions opts, ILogger logger) + { + _http = http; + _opts = opts.Value; + _logger = logger; + } + + public async Task 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); +} diff --git a/src/Meezi.API/appsettings.json b/src/Meezi.API/appsettings.json index 5d705b2..2f26e89 100644 --- a/src/Meezi.API/appsettings.json +++ b/src/Meezi.API/appsettings.json @@ -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" }, diff --git a/src/Meezi.Core/Enums/PaymentProvider.cs b/src/Meezi.Core/Enums/PaymentProvider.cs index ca180ad..709095d 100644 --- a/src/Meezi.Core/Enums/PaymentProvider.cs +++ b/src/Meezi.Core/Enums/PaymentProvider.cs @@ -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