Files
meezi/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs
T
soroush.asadi 9765491f6f
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
fix(prod): payment/tax gateways never fake success outside Development
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>
2026-06-12 10:16:01 +03:30

260 lines
13 KiB
C#

using Meezi.Admin.API.Models;
using Meezi.Core.Platform;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Core.Interfaces;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.Admin.API.Services;
public interface IPlatformIntegrationService
{
Task<PlatformIntegrationsDto> GetIntegrationsAsync(CancellationToken ct = default);
Task SaveIntegrationsAsync(UpdatePlatformIntegrationsRequest request, CancellationToken ct = default);
}
public class PlatformIntegrationService : IPlatformIntegrationService
{
public const string KeyActiveGateway = "payment.activeGateway";
public const string KeyKavenegarApi = "integrations.kavenegar.apiKey";
public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate";
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")
];
private readonly AppDbContext _db;
private readonly IPlatformCatalogService _catalog;
private readonly IPlatformRuntimeConfig _runtime;
public PlatformIntegrationService(
AppDbContext db,
IPlatformCatalogService catalog,
IPlatformRuntimeConfig runtime)
{
_db = db;
_catalog = catalog;
_runtime = runtime;
}
public async Task<PlatformIntegrationsDto> GetIntegrationsAsync(CancellationToken ct = default)
{
var settings = await _db.PlatformSettings.AsNoTracking().ToListAsync(ct);
var map = settings.ToDictionary(s => s.Key, s => s.Value, StringComparer.OrdinalIgnoreCase);
var active = map.GetValueOrDefault(KeyActiveGateway) ?? "zarinpal";
var gateways = Gateways.Select(g => MapGateway(g.Id, g.NameFa, g.Prefix, active, map)).ToList();
var kavenegar = new KavenegarConfigDto(
map.GetValueOrDefault(KeyKavenegarEnabled) is "true",
MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)),
map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "meeziotp",
map.GetValueOrDefault(KeyKavenegarSender) ?? string.Empty,
HasSecret(map, KeyKavenegarApi));
var ai = new AiIntegrationsConfigDto(
new OpenAiIntegrationConfigDto(
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiEnabled) is not "false",
MaskSecret(map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiApiKey)),
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiModel) ?? "gpt-4o-mini",
map.GetValueOrDefault(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled) is not "false",
HasSecret(map, PlatformIntegrationKeys.OpenAiApiKey)),
new MeshyIntegrationConfigDto(
map.GetValueOrDefault(PlatformIntegrationKeys.MeshyEnabled) is not "false",
MaskSecret(map.GetValueOrDefault(PlatformIntegrationKeys.MeshyApiKey)),
map.GetValueOrDefault(PlatformIntegrationKeys.MeshyMenu3dEnabled) is not "false",
HasSecret(map, PlatformIntegrationKeys.MeshyApiKey)));
return new PlatformIntegrationsDto(active, gateways, kavenegar, ai);
}
public async Task SaveIntegrationsAsync(UpdatePlatformIntegrationsRequest request, CancellationToken ct = default)
{
var active = request.ActivePaymentGateway.Trim().ToLowerInvariant();
if (!Gateways.Any(g => g.Id == active))
active = "zarinpal";
await UpsertAsync(KeyActiveGateway, active, "payment", "درگاه پیش‌فرض اشتراک", ct);
foreach (var gw in request.PaymentGateways)
{
var meta = Gateways.FirstOrDefault(g => g.Id == gw.Id);
if (string.IsNullOrEmpty(meta.Id)) continue;
await UpsertAsync($"{meta.Prefix}.enabled", gw.IsEnabled ? "true" : "false", "payment", $"فعال {meta.NameFa}", ct);
await UpsertAsync($"{meta.Prefix}.sandbox", gw.Sandbox ? "true" : "false", "payment", $"حالت تست {meta.NameFa}", ct);
if (gw.Id == "zarinpal")
{
if (!string.IsNullOrWhiteSpace(gw.MerchantId))
await UpsertAsync($"{meta.Prefix}.merchantId", gw.MerchantId.Trim(), "payment", "مرچنت زرین‌پال", ct);
}
if (gw.Credentials is not null)
await SaveCredentialsAsync(meta.Prefix, gw.Id, gw.Credentials, ct);
}
// SMS (Kavenegar) is no longer managed from the admin UI — marketing SMS is
// bring-your-own-provider per café, and the platform OTP credentials live in
// env/appsettings (or the previously-stored DB values, left untouched here).
if (request.Kavenegar is { } kavenegar)
{
await UpsertAsync(KeyKavenegarEnabled, kavenegar.IsEnabled ? "true" : "false", "integrations", "فعال کاوه‌نگار", ct);
await UpsertAsync(KeyKavenegarOtpTemplate, kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct);
if (!string.IsNullOrWhiteSpace(kavenegar.ApiKey) && !IsMaskedPlaceholder(kavenegar.ApiKey))
await UpsertAsync(KeyKavenegarApi, kavenegar.ApiKey.Trim(), "integrations", "API Key کاوه‌نگار", ct);
if (!string.IsNullOrWhiteSpace(kavenegar.SenderNumber))
await UpsertAsync(KeyKavenegarSender, kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوه‌نگار", ct);
}
if (request.Ai is { } ai)
{
await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct);
await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(ai.OpenAi.Model) ? "gpt-4o-mini" : ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct);
await UpsertAsync(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled, ai.OpenAi.CoffeeAdvisorEnabled ? "true" : "false", "integrations", "مشاور قهوه OpenAI", ct);
if (!string.IsNullOrWhiteSpace(ai.OpenAi.ApiKey) && !IsMaskedPlaceholder(ai.OpenAi.ApiKey))
await UpsertAsync(PlatformIntegrationKeys.OpenAiApiKey, ai.OpenAi.ApiKey.Trim(), "integrations", "API Key OpenAI", ct);
await UpsertAsync(PlatformIntegrationKeys.MeshyEnabled, ai.Meshy.IsEnabled ? "true" : "false", "integrations", "فعال Meshy", ct);
await UpsertAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, ai.Meshy.Menu3dEnabled ? "true" : "false", "integrations", "ساخت ۳D منو با Meshy", ct);
if (!string.IsNullOrWhiteSpace(ai.Meshy.ApiKey) && !IsMaskedPlaceholder(ai.Meshy.ApiKey))
await UpsertAsync(PlatformIntegrationKeys.MeshyApiKey, ai.Meshy.ApiKey.Trim(), "integrations", "API Key Meshy", ct);
}
await _db.SaveChangesAsync(ct);
_catalog.InvalidateCache();
_runtime.InvalidateCache();
}
private async Task SaveCredentialsAsync(
string prefix,
string gatewayId,
UpdatePaymentGatewayCredentialsRequest creds,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(creds.BaseUrl))
await UpsertAsync($"{prefix}.baseUrl", creds.BaseUrl.Trim(), "payment", "آدرس API", ct);
if (gatewayId == "tara")
{
if (!string.IsNullOrWhiteSpace(creds.Username))
await UpsertAsync($"{prefix}.username", creds.Username.Trim(), "payment", "نام کاربری تارا", ct);
if (!string.IsNullOrWhiteSpace(creds.Password) && !IsMaskedPlaceholder(creds.Password))
await UpsertAsync($"{prefix}.password", creds.Password.Trim(), "payment", "رمز تارا", ct);
if (!string.IsNullOrWhiteSpace(creds.BranchCode))
await UpsertAsync($"{prefix}.branchCode", creds.BranchCode.Trim(), "payment", "کد شعبه تارا", ct);
if (!string.IsNullOrWhiteSpace(creds.TerminalCode))
await UpsertAsync($"{prefix}.terminalCode", creds.TerminalCode.Trim(), "payment", "ترمینال تارا", ct);
}
else if (gatewayId == "snapppay")
{
if (!string.IsNullOrWhiteSpace(creds.ClientId))
await UpsertAsync($"{prefix}.clientId", creds.ClientId.Trim(), "payment", "Client ID اسنپ‌پی", ct);
if (!string.IsNullOrWhiteSpace(creds.ClientSecret) && !IsMaskedPlaceholder(creds.ClientSecret))
await UpsertAsync($"{prefix}.clientSecret", creds.ClientSecret.Trim(), "payment", "Client Secret اسنپ‌پی", ct);
if (!string.IsNullOrWhiteSpace(creds.Username))
await UpsertAsync($"{prefix}.username", creds.Username.Trim(), "payment", "نام کاربری اسنپ‌پی", ct);
if (!string.IsNullOrWhiteSpace(creds.Password) && !IsMaskedPlaceholder(creds.Password))
await UpsertAsync($"{prefix}.password", creds.Password.Trim(), "payment", "رمز اسنپ‌پی", ct);
}
}
private async Task UpsertAsync(string key, string value, string category, string descFa, CancellationToken ct)
{
var row = await _db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == key, ct);
if (row is null)
{
_db.PlatformSettings.Add(new PlatformSetting
{
Key = key,
Value = value,
Category = category,
DescriptionFa = descFa
});
}
else
{
row.Value = value;
row.UpdatedAt = DateTime.UtcNow;
}
}
private static PaymentGatewayConfigDto MapGateway(
string id,
string nameFa,
string prefix,
string activeGateway,
Dictionary<string, string> map)
{
var enabled = map.GetValueOrDefault($"{prefix}.enabled") is "true";
var sandbox = map.GetValueOrDefault($"{prefix}.sandbox") is not "false";
string? merchantId = null;
string? apiKey = null;
var hasSecret = false;
GatewayCredentialsDto? credentials = null;
if (id == "zarinpal")
{
merchantId = map.GetValueOrDefault($"{prefix}.merchantId");
hasSecret = HasSecret(map, $"{prefix}.merchantId");
}
else if (id == "tara")
{
credentials = new GatewayCredentialsDto(
map.GetValueOrDefault($"{prefix}.username"),
MaskSecret(map.GetValueOrDefault($"{prefix}.password")),
map.GetValueOrDefault($"{prefix}.branchCode"),
map.GetValueOrDefault($"{prefix}.terminalCode"),
null,
null,
map.GetValueOrDefault($"{prefix}.baseUrl"),
HasSecret(map, $"{prefix}.password"),
false);
hasSecret = credentials.HasStoredPassword;
}
else if (id == "snapppay")
{
credentials = new GatewayCredentialsDto(
map.GetValueOrDefault($"{prefix}.username"),
MaskSecret(map.GetValueOrDefault($"{prefix}.password")),
null,
null,
map.GetValueOrDefault($"{prefix}.clientId"),
MaskSecret(map.GetValueOrDefault($"{prefix}.clientSecret")),
map.GetValueOrDefault($"{prefix}.baseUrl"),
HasSecret(map, $"{prefix}.password"),
HasSecret(map, $"{prefix}.clientSecret"));
hasSecret = credentials.HasStoredPassword || credentials.HasStoredClientSecret;
}
return new PaymentGatewayConfigDto(
id,
nameFa,
enabled,
activeGateway == id,
merchantId,
apiKey,
sandbox,
hasSecret,
credentials);
}
private static bool HasSecret(Dictionary<string, string> map, string key) =>
!string.IsNullOrWhiteSpace(map.GetValueOrDefault(key));
private static string? MaskSecret(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : "••••••••";
private static bool IsMaskedPlaceholder(string? value) =>
string.IsNullOrWhiteSpace(value) || value.Contains("••••", StringComparison.Ordinal);
}