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
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user