fix(prod): payment/tax gateways never fake success outside Development
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 1m31s

Production-readiness audit fixes — every mock fallback is now gated on
IsDevelopment; in production these paths fail loudly instead:

- ZarinPal/Tara/SnappPay init: missing credentials returned a MOCK
  payment URL whose callback verified as paid — a café could activate a
  paid plan without paying. Now: "Payment gateway is not configured."
- Tara/SnappPay verify: a forged MOCK-* trace/token on the callback was
  accepted as a verified payment in any environment. Now rejected
  outside Development.
- Taraz (سامانه مودیان): returned a fake MOCK-TARAZ tracking code as if
  invoices reached the tax authority. Now returns an honest error (the
  real integration is not built yet).
- Admin integrations: NextPay/Vandar removed — they were listed but have
  no gateway implementation (selecting them silently used ZarinPal).
- docker-compose: ASPNETCORE_ENVIRONMENT default flipped Development →
  Production so a missing env var can never run prod in dev mode.

86 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 10:16:01 +03:30
parent 00649d0248
commit 9765491f6f
7 changed files with 94 additions and 24 deletions
@@ -22,13 +22,14 @@ public class PlatformIntegrationService : IPlatformIntegrationService
public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled";
public const string KeyKavenegarSender = "integrations.kavenegar.senderNumber";
// Only gateways the BillingPaymentOrchestrator actually implements. NextPay
// and Vandar were listed here before they had any implementation — enabling
// them silently fell back to ZarinPal, which is a trap for the operator.
private static readonly (string Id, string NameFa, string Prefix)[] Gateways =
[
("zarinpal", "زرین‌پال", "payment.zarinpal"),
("tara", "تارا", "payment.tara"),
("snapppay", "اسنپ‌پی", "payment.snapppay"),
("nextpay", "نکست‌پی", "payment.nextpay"),
("vandar", "وندار", "payment.vandar")
("snapppay", "اسنپ‌پی", "payment.snapppay")
];
private readonly AppDbContext _db;
@@ -97,11 +98,6 @@ public class PlatformIntegrationService : IPlatformIntegrationService
if (!string.IsNullOrWhiteSpace(gw.MerchantId))
await UpsertAsync($"{meta.Prefix}.merchantId", gw.MerchantId.Trim(), "payment", "مرچنت زرین‌پال", ct);
}
else if (gw.Id is "nextpay" or "vandar")
{
if (!string.IsNullOrWhiteSpace(gw.ApiKey) && !IsMaskedPlaceholder(gw.ApiKey))
await UpsertAsync($"{meta.Prefix}.apiKey", gw.ApiKey.Trim(), "payment", $"توکن {meta.NameFa}", ct);
}
if (gw.Credentials is not null)
await SaveCredentialsAsync(meta.Prefix, gw.Id, gw.Credentials, ct);
@@ -211,11 +207,6 @@ public class PlatformIntegrationService : IPlatformIntegrationService
merchantId = map.GetValueOrDefault($"{prefix}.merchantId");
hasSecret = HasSecret(map, $"{prefix}.merchantId");
}
else if (id is "nextpay" or "vandar")
{
apiKey = MaskSecret(map.GetValueOrDefault($"{prefix}.apiKey"));
hasSecret = HasSecret(map, $"{prefix}.apiKey");
}
else if (id == "tara")
{
credentials = new GatewayCredentialsDto(
@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Meezi.Core.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices;
@@ -19,15 +20,18 @@ public class SnappPayGateway : ISnappPayGateway
private readonly HttpClient _httpClient;
private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment;
private readonly ILogger<SnappPayGateway> _logger;
public SnappPayGateway(
HttpClient httpClient,
IPlatformRuntimeConfig platform,
IHostEnvironment environment,
ILogger<SnappPayGateway> logger)
{
_httpClient = httpClient;
_platform = platform;
_environment = environment;
_logger = logger;
}
@@ -46,6 +50,13 @@ public class SnappPayGateway : ISnappPayGateway
var creds = await LoadCredentialsAsync(cancellationToken);
if (!creds.IsConfigured)
{
// Mock checkout exists ONLY for local development — in production a
// fake-success would activate paid plans without real payment.
if (!_environment.IsDevelopment())
{
_logger.LogError("SnappPay credentials missing — refusing payment init in {Env}", _environment.EnvironmentName);
return new SnappPayInitResult(false, null, null, "Payment gateway is not configured.");
}
var mockToken = "MOCK-SNAPP-" + transactionId;
var mockUrl = $"{returnUrl}?paymentToken={mockToken}&transactionId={transactionId}&state=OK";
_logger.LogInformation("SnappPay mock payment {TransactionId} amount {Amount} Rials", transactionId, amountRials);
@@ -114,6 +125,13 @@ public class SnappPayGateway : ISnappPayGateway
{
if (paymentToken.StartsWith("MOCK-SNAPP-", StringComparison.Ordinal))
{
// Mock tokens are only honoured in local development — in production a
// forged MOCK- token on the callback would equal a free verified payment.
if (!_environment.IsDevelopment())
{
_logger.LogError("Rejected MOCK SnappPay token {Token} in {Env}", paymentToken, _environment.EnvironmentName);
return new SnappPayVerifyResult(false, null, "Invalid payment token.");
}
_logger.LogInformation("SnappPay mock verify {Token}", paymentToken);
return new SnappPayVerifyResult(true, paymentToken, null);
}
@@ -1,6 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using Meezi.Core.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices;
@@ -16,6 +17,7 @@ public class TaraPaymentGateway : ITaraPaymentGateway
private readonly HttpClient _httpClient;
private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment;
private readonly ILogger<TaraPaymentGateway> _logger;
private string? _cachedToken;
private DateTime _tokenExpiresUtc = DateTime.MinValue;
@@ -23,10 +25,12 @@ public class TaraPaymentGateway : ITaraPaymentGateway
public TaraPaymentGateway(
HttpClient httpClient,
IPlatformRuntimeConfig platform,
IHostEnvironment environment,
ILogger<TaraPaymentGateway> logger)
{
_httpClient = httpClient;
_platform = platform;
_environment = environment;
_logger = logger;
}
@@ -45,6 +49,13 @@ public class TaraPaymentGateway : ITaraPaymentGateway
var creds = await LoadCredentialsAsync(cancellationToken);
if (!creds.IsConfigured)
{
// Mock checkout exists ONLY for local development — in production a
// fake-success would activate paid plans without real payment.
if (!_environment.IsDevelopment())
{
_logger.LogError("Tara credentials missing — refusing payment init in {Env}", _environment.EnvironmentName);
return new TaraInitResult(false, null, null, "Payment gateway is not configured.");
}
var mockTrace = "MOCK-TARA-" + invoiceNumber;
var mockUrl = $"{callbackUrl}?traceNumber={mockTrace}&status=OK";
_logger.LogInformation("Tara mock payment trace {Trace} amount {Amount} Rials", mockTrace, amountRials);
@@ -114,6 +125,13 @@ public class TaraPaymentGateway : ITaraPaymentGateway
{
if (traceNumber.StartsWith("MOCK-TARA-", StringComparison.Ordinal))
{
// Mock traces are only honoured in local development — in production a
// forged MOCK- trace on the callback would equal a free verified payment.
if (!_environment.IsDevelopment())
{
_logger.LogError("Rejected MOCK Tara trace {Trace} in {Env}", traceNumber, _environment.EnvironmentName);
return new TaraVerifyResult(false, null, "Invalid payment trace.");
}
_logger.LogInformation("Tara mock verify {Trace}", traceNumber);
return new TaraVerifyResult(true, traceNumber, null);
}
@@ -1,17 +1,29 @@
using Meezi.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices;
/// <summary>
/// Taraz (سامانه مودیان) invoice submission. The real API integration is not
/// built yet, so this service NEVER fakes a tracking code outside development —
/// a merchant must not believe invoices reached the tax authority when they
/// did not.
/// </summary>
public class TarazTaxService : ITarazTaxService
{
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
private readonly ILogger<TarazTaxService> _logger;
public TarazTaxService(IConfiguration configuration, ILogger<TarazTaxService> logger)
public TarazTaxService(
IConfiguration configuration,
IHostEnvironment environment,
ILogger<TarazTaxService> logger)
{
_configuration = configuration;
_environment = environment;
_logger = logger;
}
@@ -20,20 +32,33 @@ public class TarazTaxService : ITarazTaxService
DateTime dateUtc,
CancellationToken cancellationToken = default)
{
var username = _configuration["Taraz:Username"];
if (string.IsNullOrWhiteSpace(username))
if (_environment.IsDevelopment())
{
_logger.LogInformation(
"Taraz not configured — skip submit for cafe {CafeId} date {Date}",
_logger.LogWarning(
"[DEV TARAZ] Pretend-submitted invoices for cafe {CafeId} date {Date}",
cafeId,
dateUtc.Date);
return Task.FromResult(new TarazSubmitResult(
true,
"MOCK-TARAZ",
"Taraz API not configured; submission logged only."));
"DEV-TARAZ",
"Development stub — nothing was sent to the tax authority."));
}
_logger.LogInformation("Taraz submit queued for cafe {CafeId} on {Date}", cafeId, dateUtc.Date);
return Task.FromResult(new TarazSubmitResult(true, null, "Submission queued."));
var username = _configuration["Taraz:Username"];
if (string.IsNullOrWhiteSpace(username))
{
return Task.FromResult(new TarazSubmitResult(
false,
null,
"سرویس مودیان هنوز پیکربندی نشده است. لطفاً با پشتیبانی تماس بگیرید."));
}
// Credentials exist but the actual Taraz API call is not implemented yet.
// Be honest with the merchant instead of returning a fake tracking code.
_logger.LogWarning("Taraz submit requested for cafe {CafeId} but the integration is not implemented", cafeId);
return Task.FromResult(new TarazSubmitResult(
false,
null,
"اتصال مستقیم به سامانه مودیان هنوز در دست توسعه است."));
}
}
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Meezi.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices;
@@ -17,17 +18,20 @@ public class ZarinPalGateway : IZarinPalGateway
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment;
private readonly ILogger<ZarinPalGateway> _logger;
public ZarinPalGateway(
HttpClient httpClient,
IConfiguration configuration,
IPlatformRuntimeConfig platform,
IHostEnvironment environment,
ILogger<ZarinPalGateway> logger)
{
_httpClient = httpClient;
_configuration = configuration;
_platform = platform;
_environment = environment;
_logger = logger;
}
@@ -40,6 +44,15 @@ public class ZarinPalGateway : IZarinPalGateway
var merchantId = await GetMerchantIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(merchantId))
{
// Mock checkout exists ONLY for local development. In production a
// missing merchant id must fail loudly — a fake-success here would
// activate paid plans without any real payment.
if (!_environment.IsDevelopment())
{
_logger.LogError("ZarinPal merchant id missing — refusing payment init in {Env}", _environment.EnvironmentName);
return new ZarinPalRequestResult(false, null, null, "Payment gateway is not configured.");
}
var mockAuthority = Guid.NewGuid().ToString("N")[..16];
var mockUrl = $"{callbackUrl}?Authority={mockAuthority}&Status=OK";
_logger.LogInformation("ZarinPal mock payment {Authority} amount {Amount} Rials", mockAuthority, amountRials);
@@ -81,6 +94,11 @@ public class ZarinPalGateway : IZarinPalGateway
var merchantId = await GetMerchantIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(merchantId))
{
if (!_environment.IsDevelopment())
{
_logger.LogError("ZarinPal merchant id missing — refusing payment verify in {Env}", _environment.EnvironmentName);
return new ZarinPalVerifyResult(false, null, "Payment gateway is not configured.");
}
_logger.LogInformation("ZarinPal mock verify authority {Authority}", authority);
return new ZarinPalVerifyResult(true, "MOCK-" + authority[..8], null);
}