Fully implement Kavenegar SMS support
Core changes: - ISmsService: add SendBulkAsync (batches of 200) + GetAccountInfoAsync - KavenegarSmsService: POST requests, sender number config, bulk send via comma-separated receptors, account balance, full error code mapping (HTTP 400-432), enabled-flag check before any send - SmsMarketingService: replaced per-recipient loop with SendBulkAsync - SmsController: new GET /sms/balance endpoint returns Kavenegar credit - SmsDtos: SmsBalanceDto - IntegrationDtos + PlatformIntegrationService: SenderNumber field - appsettings.json + docker-compose: Kavenegar__SenderNumber = 90005671 Dashboard: - sms-screen: char counter, SMS parts indicator (Persian 70/67 chars, Latin 160/153), account balance card, sender line display, result banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,16 +11,32 @@ namespace Meezi.API.Controllers;
|
||||
public class SmsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ISmsMarketingService _smsMarketingService;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||
|
||||
public SmsController(
|
||||
ISmsMarketingService smsMarketingService,
|
||||
ISmsService smsService,
|
||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||
{
|
||||
_smsMarketingService = smsMarketingService;
|
||||
_smsService = smsService;
|
||||
_campaignValidator = campaignValidator;
|
||||
}
|
||||
|
||||
[HttpGet("balance")]
|
||||
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
|
||||
var dto = info is not null
|
||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
||||
: new SmsBalanceDto(0, "master", false);
|
||||
|
||||
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
|
||||
}
|
||||
|
||||
[HttpGet("usage")]
|
||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -10,3 +10,6 @@ public record SendSmsCampaignRequest(
|
||||
public record SmsCampaignResult(int SentCount, int FailedCount);
|
||||
|
||||
public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
|
||||
|
||||
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
|
||||
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
|
||||
|
||||
@@ -24,18 +24,15 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<SmsMarketingService> _logger;
|
||||
|
||||
public SmsMarketingService(
|
||||
AppDbContext db,
|
||||
ISmsService smsService,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<SmsMarketingService> logger)
|
||||
IConnectionMultiplexer redis)
|
||||
{
|
||||
_db = db;
|
||||
_smsService = smsService;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SmsUsageDto> GetUsageAsync(
|
||||
@@ -68,27 +65,12 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||
|
||||
var sent = 0;
|
||||
var failed = 0;
|
||||
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
|
||||
|
||||
foreach (var phone in phones)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(phone, request.Message, cancellationToken);
|
||||
sent++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send SMS to recipient");
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (result.SentCount > 0)
|
||||
await IncrementUsageAsync(cafeId, month, result.SentCount);
|
||||
|
||||
if (sent > 0)
|
||||
await IncrementUsageAsync(cafeId, month, sent);
|
||||
|
||||
return (true, new SmsCampaignResult(sent, failed), null, null);
|
||||
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
|
||||
}
|
||||
|
||||
private async Task<List<string>> ResolvePhonesAsync(
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
},
|
||||
"Kavenegar": {
|
||||
"ApiKey": "",
|
||||
"SenderNumber": "90005671",
|
||||
"OtpTemplate": "verify"
|
||||
},
|
||||
"ZarinPal": {
|
||||
|
||||
@@ -26,6 +26,7 @@ public record KavenegarConfigDto(
|
||||
bool IsEnabled,
|
||||
string? ApiKey,
|
||||
string OtpTemplate,
|
||||
string SenderNumber,
|
||||
bool HasStoredApiKey);
|
||||
|
||||
public record OpenAiIntegrationConfigDto(
|
||||
@@ -92,7 +93,8 @@ public record UpdatePaymentGatewayRequest(
|
||||
public record UpdateKavenegarRequest(
|
||||
bool IsEnabled,
|
||||
string? ApiKey,
|
||||
string OtpTemplate);
|
||||
string OtpTemplate,
|
||||
string SenderNumber);
|
||||
|
||||
public record AdminNotificationRowDto(
|
||||
string Id,
|
||||
|
||||
@@ -17,9 +17,10 @@ public interface IPlatformIntegrationService
|
||||
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 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";
|
||||
|
||||
private static readonly (string Id, string NameFa, string Prefix)[] Gateways =
|
||||
[
|
||||
@@ -56,6 +57,7 @@ public class PlatformIntegrationService : IPlatformIntegrationService
|
||||
map.GetValueOrDefault(KeyKavenegarEnabled) is "true",
|
||||
MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)),
|
||||
map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "verify",
|
||||
map.GetValueOrDefault(KeyKavenegarSender) ?? string.Empty,
|
||||
HasSecret(map, KeyKavenegarApi));
|
||||
|
||||
var ai = new AiIntegrationsConfigDto(
|
||||
@@ -109,6 +111,8 @@ public class PlatformIntegrationService : IPlatformIntegrationService
|
||||
await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct);
|
||||
if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey))
|
||||
await UpsertAsync(KeyKavenegarApi, request.Kavenegar.ApiKey.Trim(), "integrations", "API Key کاوهنگار", ct);
|
||||
if (!string.IsNullOrWhiteSpace(request.Kavenegar.SenderNumber))
|
||||
await UpsertAsync(KeyKavenegarSender, request.Kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوهنگار", ct);
|
||||
|
||||
await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, request.Ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct);
|
||||
await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(request.Ai.OpenAi.Model) ? "gpt-4o-mini" : request.Ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct);
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
namespace Meezi.Core.Interfaces;
|
||||
|
||||
public record KavenegarAccountInfo(long RemainCredit, string AccountType);
|
||||
|
||||
public record BulkSendResult(int SentCount, int FailedCount);
|
||||
|
||||
public interface ISmsService
|
||||
{
|
||||
/// <summary>Send a one-time password via Kavenegar Verify/Lookup template.</summary>
|
||||
Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Send a plain-text message to a single recipient.</summary>
|
||||
Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Send the same message to many recipients in batches of up to 200.
|
||||
/// Never throws — failures per batch are counted and returned.
|
||||
/// </summary>
|
||||
Task<BulkSendResult> SendBulkAsync(IReadOnlyList<string> phones, string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Returns credit balance from the Kavenegar account, or null if not configured.</summary>
|
||||
Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Meezi.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// Kavenegar SMS gateway implementation.
|
||||
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
|
||||
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
|
||||
/// </summary>
|
||||
public class KavenegarSmsService : ISmsService
|
||||
{
|
||||
// ── DB config keys ────────────────────────────────────────────────────────
|
||||
private const string DbKeyApiKey = "integrations.kavenegar.apiKey";
|
||||
private const string DbKeyEnabled = "integrations.kavenegar.enabled";
|
||||
private const string DbKeySender = "integrations.kavenegar.senderNumber";
|
||||
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate";
|
||||
|
||||
private const string BaseUrl = "https://api.kavenegar.com/v1";
|
||||
private const int MaxBatchSize = 200;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPlatformRuntimeConfig _platform;
|
||||
@@ -25,64 +40,194 @@ public class KavenegarSmsService : ISmsService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await GetApiKeyAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Kavenegar API key not configured — SMS to {Phone}: {Message}",
|
||||
phone,
|
||||
message);
|
||||
return;
|
||||
}
|
||||
|
||||
var url =
|
||||
$"https://api.kavenegar.com/v1/{apiKey}/sms/send.json" +
|
||||
$"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(message)}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Kavenegar SMS send failed with status {StatusCode}", response.StatusCode);
|
||||
throw new InvalidOperationException("SMS delivery failed.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<KavenegarResponse>(cancellationToken: cancellationToken);
|
||||
if (body?.Return?.Status is not 200)
|
||||
throw new InvalidOperationException("SMS delivery failed.");
|
||||
}
|
||||
// ── Public interface ──────────────────────────────────────────────────────
|
||||
|
||||
public async Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await GetApiKeyAsync(cancellationToken);
|
||||
var template = await GetOtpTemplateAsync(cancellationToken);
|
||||
|
||||
var (apiKey, _, template) = await GetConfigAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogInformation("Kavenegar API key not configured — OTP for {Phone} (dev only, not sent via SMS)", phone);
|
||||
_logger.LogInformation("Kavenegar not configured — OTP for {Phone} not sent", phone);
|
||||
return;
|
||||
}
|
||||
|
||||
var url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" +
|
||||
$"?receptor={Uri.EscapeDataString(phone)}&token={otp}&template={Uri.EscapeDataString(template)}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json";
|
||||
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
_logger.LogWarning("Kavenegar OTP send failed with status {StatusCode}", response.StatusCode);
|
||||
throw new InvalidOperationException("SMS delivery failed.");
|
||||
["receptor"] = phone,
|
||||
["token"] = otp,
|
||||
["template"] = template,
|
||||
});
|
||||
|
||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
||||
await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (apiKey, sender, _) = await GetConfigAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogInformation("Kavenegar not configured — SMS to {Phone}: {Message}", phone, message);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<KavenegarResponse>(cancellationToken: cancellationToken);
|
||||
if (body?.Return?.Status is not 200)
|
||||
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
|
||||
var content = BuildSendForm(phone, message, sender);
|
||||
|
||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
||||
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<BulkSendResult> SendBulkAsync(
|
||||
IReadOnlyList<string> phones,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (phones.Count == 0) return new BulkSendResult(0, 0);
|
||||
|
||||
var (apiKey, sender, _) = await GetConfigAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("Kavenegar returned status {Status}", body?.Return?.Status);
|
||||
throw new InvalidOperationException("SMS delivery failed.");
|
||||
_logger.LogInformation("Kavenegar not configured — bulk SMS skipped ({Count} recipients)", phones.Count);
|
||||
return new BulkSendResult(0, phones.Count);
|
||||
}
|
||||
|
||||
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
|
||||
int sent = 0, failed = 0;
|
||||
|
||||
foreach (var batch in phones.Chunk(MaxBatchSize))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Kavenegar /sms/send.json accepts comma-separated receptors
|
||||
var content = BuildSendForm(string.Join(",", batch), message, sender);
|
||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
||||
await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken);
|
||||
sent += batch.Length;
|
||||
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Kavenegar bulk batch failed ({Count} recipients)", batch.Length);
|
||||
failed += batch.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return new BulkSendResult(sent, failed);
|
||||
}
|
||||
|
||||
public async Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (apiKey, _, _) = await GetConfigAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/{apiKey}/account/info.json";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<KavenegarAccountInfoResponse>(cancellationToken: cancellationToken);
|
||||
if (body?.Return?.Status is not 200 || body.Entries is null)
|
||||
return null;
|
||||
|
||||
return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Kavenegar account info");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class KavenegarResponse
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender)
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["receptor"] = receptor,
|
||||
["message"] = message,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(sender))
|
||||
dict["sender"] = sender;
|
||||
return new FormUrlEncodedContent(dict);
|
||||
}
|
||||
|
||||
private async Task EnsureKavenegarSuccessAsync(
|
||||
HttpResponseMessage response,
|
||||
string operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorCode = (int)response.StatusCode;
|
||||
var detail = KavenegarHttpError(errorCode);
|
||||
_logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail);
|
||||
throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<KavenegarReturnEnvelope>(cancellationToken: cancellationToken);
|
||||
if (body?.Return?.Status is not 200)
|
||||
{
|
||||
var status = body?.Return?.Status ?? -1;
|
||||
_logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message);
|
||||
throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string KavenegarHttpError(int code) => code switch
|
||||
{
|
||||
400 => "Missing or invalid parameters",
|
||||
401 => "Account is inactive",
|
||||
403 => "Invalid API key",
|
||||
404 => "Method not found",
|
||||
405 => "Wrong HTTP method",
|
||||
411 => "Invalid recipient number",
|
||||
412 => "Invalid sender number",
|
||||
413 => "Message empty or too long",
|
||||
414 => "Too many recipients",
|
||||
417 => "Invalid scheduled date",
|
||||
418 => "Insufficient credit",
|
||||
422 => "Invalid characters in message",
|
||||
424 => "OTP template not found",
|
||||
426 => "IP is not whitelisted",
|
||||
428 => "Voice call requires numeric token",
|
||||
432 => "Code parameter missing in OTP template",
|
||||
_ => "Unknown error"
|
||||
};
|
||||
|
||||
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
|
||||
{
|
||||
var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
|
||||
// If explicitly disabled in DB, short-circuit
|
||||
if (enabled is "false")
|
||||
return (null, string.Empty, "verify");
|
||||
|
||||
var apiKey = await _platform.GetAsync(DbKeyApiKey, ct);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
apiKey = _configuration["Kavenegar:ApiKey"];
|
||||
|
||||
var sender = await _platform.GetAsync(DbKeySender, ct);
|
||||
if (string.IsNullOrWhiteSpace(sender))
|
||||
sender = _configuration["Kavenegar:SenderNumber"] ?? string.Empty;
|
||||
|
||||
var template = await _platform.GetAsync(DbKeyOtpTemplate, ct);
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
template = _configuration["Kavenegar:OtpTemplate"] ?? "verify";
|
||||
|
||||
return (apiKey, sender, template);
|
||||
}
|
||||
|
||||
// ── Response models ───────────────────────────────────────────────────────
|
||||
|
||||
private sealed class KavenegarReturnEnvelope
|
||||
{
|
||||
[JsonPropertyName("return")]
|
||||
public KavenegarReturn? Return { get; set; }
|
||||
@@ -92,21 +237,29 @@ public class KavenegarSmsService : ISmsService
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
private async Task<string?> GetApiKeyAsync(CancellationToken cancellationToken)
|
||||
private sealed class KavenegarAccountInfoResponse
|
||||
{
|
||||
var fromDb = await _platform.GetAsync("integrations.kavenegar.apiKey", cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
||||
return fromDb;
|
||||
return _configuration["Kavenegar:ApiKey"];
|
||||
[JsonPropertyName("return")]
|
||||
public KavenegarReturn? Return { get; set; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public KavenegarAccountEntries? Entries { get; set; }
|
||||
}
|
||||
|
||||
private async Task<string> GetOtpTemplateAsync(CancellationToken cancellationToken)
|
||||
private sealed class KavenegarAccountEntries
|
||||
{
|
||||
var fromDb = await _platform.GetAsync("integrations.kavenegar.otpTemplate", cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
||||
return fromDb;
|
||||
return _configuration["Kavenegar:OtpTemplate"] ?? "verify";
|
||||
[JsonPropertyName("remaincredit")]
|
||||
public long RemainCredit { get; set; }
|
||||
|
||||
[JsonPropertyName("expiredate")]
|
||||
public long ExpireDate { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user