diff --git a/src/CMSMicroservice.Domain/Common/SystemConstants.cs b/src/CMSMicroservice.Domain/Common/SystemConstants.cs
index 662d79b..634a85e 100644
--- a/src/CMSMicroservice.Domain/Common/SystemConstants.cs
+++ b/src/CMSMicroservice.Domain/Common/SystemConstants.cs
@@ -58,6 +58,14 @@ public static class SystemConstants
// Per-package magic wallet settings are now in Package entity:
// Package.MagicWalletMultiplier, Package.MagicWalletMaxDeposit, Package.MagicWalletMaxCredit
+ ///
+ /// آستانه ورود/خروج کیفپول جادویی (ریال).
+ /// وقتی موجودی کیفپول به این مقدار یا کمتر برسد، تریگر Entry/Exit اجرا میشود.
+ /// مقدار ۱٬۰۰۰٬۰۰۰ ریال = ۱۰۰ هزار تومان
+ /// (Q24: چون قیمت محصولات متفاوت است، موجودی دقیقاً صفر نمیشود)
+ ///
+ public const long MagicWalletEntryThreshold = 1_000_000;
+
#endregion
#region Shop Settings
@@ -98,6 +106,9 @@ public static class SystemConstants
["System.MaintenanceMode"] = SystemMaintenanceMode,
["System.EnableAuditLog"] = SystemEnableAuditLog,
+ // Magic Wallet (global threshold)
+ ["MagicWallet.EntryThreshold"] = MagicWalletEntryThreshold,
+
// Shop
["Shop.VAT"] = ShopVAT,
["Shop.VATEnabled"] = ShopVATEnabled
@@ -125,6 +136,9 @@ public static class SystemConstants
("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"),
("System.EnableAuditLog", SystemEnableAuditLog, "فعالسازی لاگ تغییرات"),
+ // Magic Wallet (global threshold)
+ ("MagicWallet.EntryThreshold", MagicWalletEntryThreshold, "آستانه ورود/خروج کیفپول جادویی (ریال) — Q24"),
+
// Shop
("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"),
("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟")
diff --git a/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs b/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs
new file mode 100644
index 0000000..3410d15
--- /dev/null
+++ b/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs
@@ -0,0 +1,288 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using CMSMicroservice.Application.Common.Interfaces;
+
+namespace CMSMicroservice.Infrastructure.BackgroundServices;
+
+///
+/// سرویس دیپلوی خودکار Stored Procedureها در استارتاپ (Q26).
+/// فایلهای .sql از EmbeddedResource خوانده شده، SHA256 محاسبه و با جدول
+/// [CMS].[__StoredProcedureVersions] مقایسه میشود.
+/// اگر checksum تغییر کرده باشد، SP دوباره اجرا (CREATE OR ALTER) میشود.
+/// این سرویس فقط یکبار در استارتاپ اجرا شده و سپس متوقف میشود.
+///
+public class StoredProcedureDeploymentService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+
+ ///
+ /// پیشوند نامفضای EmbeddedResource — فقط فایلهای .sql واقعی (بدون README)
+ ///
+ private const string ResourcePrefix = "CMSMicroservice.Infrastructure.Persistence.StoredProcedures.";
+
+ public StoredProcedureDeploymentService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // صبر برای آماده شدن دیتابیس و اتمام Migrationها
+ await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
+
+ _logger.LogInformation("StoredProcedureDeploymentService started — checking stored procedures...");
+
+ try
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ var connection = context.Database.GetDbConnection();
+ await connection.OpenAsync(stoppingToken);
+
+ // ── ساخت جدول ورژن اگر وجود ندارد ──
+ await EnsureVersionTableAsync(connection, stoppingToken);
+
+ // ── خواندن فایلهای SP از Embedded Resources ──
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceNames = assembly.GetManifestResourceNames()
+ .Where(r => r.StartsWith(ResourcePrefix, StringComparison.OrdinalIgnoreCase)
+ && r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
+ && !r.Contains("README", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(r => r)
+ .ToList();
+
+ _logger.LogInformation("Found {Count} SQL resources to check", resourceNames.Count);
+
+ var deployed = 0;
+ var skipped = 0;
+
+ foreach (var resourceName in resourceNames)
+ {
+ if (stoppingToken.IsCancellationRequested) break;
+
+ var fileName = resourceName[ResourcePrefix.Length..]; // e.g. "sp_CalculateWeeklyBalances.sql"
+ var sqlContent = await ReadResourceAsync(assembly, resourceName);
+ var checksum = ComputeSha256(sqlContent);
+
+ var existingChecksum = await GetStoredChecksumAsync(connection, fileName, stoppingToken);
+
+ if (string.Equals(existingChecksum, checksum, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogDebug("SP '{FileName}' is up-to-date (checksum match), skipping", fileName);
+ skipped++;
+ continue;
+ }
+
+ // ── اجرای SP (CREATE OR ALTER) ──
+ _logger.LogInformation("Deploying SP '{FileName}' (checksum changed: {Old} → {New})",
+ fileName,
+ existingChecksum ?? "NEW",
+ checksum[..12]);
+
+ try
+ {
+ await ExecuteSqlAsync(connection, sqlContent, stoppingToken);
+ await UpsertChecksumAsync(connection, fileName, checksum, stoppingToken);
+ deployed++;
+
+ _logger.LogInformation("Successfully deployed SP '{FileName}'", fileName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to deploy SP '{FileName}' — skipping", fileName);
+ }
+ }
+
+ _logger.LogInformation(
+ "StoredProcedureDeploymentService completed — deployed: {Deployed}, skipped: {Skipped}, total: {Total}",
+ deployed, skipped, resourceNames.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "StoredProcedureDeploymentService encountered a fatal error");
+ }
+ }
+
+ // ══════════════════════════════════════════════════════════════
+ // Helper Methods
+ // ══════════════════════════════════════════════════════════════
+
+ ///
+ /// ساخت جدول [CMS].[__StoredProcedureVersions] اگر وجود ندارد.
+ /// از Raw SQL استفاده میکنیم تا نیازی به Entity و Migration نباشد.
+ ///
+ private static async Task EnsureVersionTableAsync(
+ System.Data.Common.DbConnection connection,
+ CancellationToken ct)
+ {
+ const string sql = """
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = 'CMS' AND TABLE_NAME = '__StoredProcedureVersions'
+ )
+ BEGIN
+ CREATE TABLE [CMS].[__StoredProcedureVersions] (
+ [FileName] NVARCHAR(256) NOT NULL PRIMARY KEY,
+ [Checksum] NVARCHAR(64) NOT NULL,
+ [DeployedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
+ [DeployCount] INT NOT NULL DEFAULT 1
+ );
+ END
+ """;
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ ///
+ /// خواندن checksum ذخیرهشده برای یک فایل
+ ///
+ private static async Task GetStoredChecksumAsync(
+ System.Data.Common.DbConnection connection,
+ string fileName,
+ CancellationToken ct)
+ {
+ const string sql = "SELECT [Checksum] FROM [CMS].[__StoredProcedureVersions] WHERE [FileName] = @FileName";
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+
+ var param = cmd.CreateParameter();
+ param.ParameterName = "@FileName";
+ param.Value = fileName;
+ cmd.Parameters.Add(param);
+
+ var result = await cmd.ExecuteScalarAsync(ct);
+ return result as string;
+ }
+
+ ///
+ /// ذخیره/بهروزرسانی checksum بعد از دیپلوی موفق
+ ///
+ private static async Task UpsertChecksumAsync(
+ System.Data.Common.DbConnection connection,
+ string fileName,
+ string checksum,
+ CancellationToken ct)
+ {
+ const string sql = """
+ MERGE [CMS].[__StoredProcedureVersions] AS target
+ USING (SELECT @FileName AS [FileName]) AS source
+ ON target.[FileName] = source.[FileName]
+ WHEN MATCHED THEN
+ UPDATE SET [Checksum] = @Checksum,
+ [DeployedAt] = GETUTCDATE(),
+ [DeployCount] = target.[DeployCount] + 1
+ WHEN NOT MATCHED THEN
+ INSERT ([FileName], [Checksum], [DeployedAt], [DeployCount])
+ VALUES (@FileName, @Checksum, GETUTCDATE(), 1);
+ """;
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+
+ var pFileName = cmd.CreateParameter();
+ pFileName.ParameterName = "@FileName";
+ pFileName.Value = fileName;
+ cmd.Parameters.Add(pFileName);
+
+ var pChecksum = cmd.CreateParameter();
+ pChecksum.ParameterName = "@Checksum";
+ pChecksum.Value = checksum;
+ cmd.Parameters.Add(pChecksum);
+
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ ///
+ /// اجرای محتوای SQL روی کانکشن فعلی
+ ///
+ private static async Task ExecuteSqlAsync(
+ System.Data.Common.DbConnection connection,
+ string sqlContent,
+ CancellationToken ct)
+ {
+ // SP ها ممکن است GO separator داشته باشند — هر بلاک را جدا اجرا میکنیم
+ var batches = SplitBatches(sqlContent);
+
+ foreach (var batch in batches)
+ {
+ var trimmed = batch.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed)) continue;
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = trimmed;
+ cmd.CommandTimeout = 120; // SP های بزرگ ممکن است زمان ببرند
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+ }
+
+ ///
+ /// تقسیم SQL به بلاکها بر اساس GO separator
+ ///
+ private static List SplitBatches(string sql)
+ {
+ // GO باید تنها در خط خودش باشد (case-insensitive)
+ var lines = sql.Split('\n');
+ var batch = new StringBuilder();
+ var result = new List();
+
+ foreach (var line in lines)
+ {
+ if (line.Trim().Equals("GO", StringComparison.OrdinalIgnoreCase))
+ {
+ if (batch.Length > 0)
+ {
+ result.Add(batch.ToString());
+ batch.Clear();
+ }
+ }
+ else
+ {
+ batch.AppendLine(line);
+ }
+ }
+
+ if (batch.Length > 0)
+ result.Add(batch.ToString());
+
+ return result;
+ }
+
+ ///
+ /// خواندن محتوای یک Embedded Resource
+ ///
+ private static async Task ReadResourceAsync(Assembly assembly, string resourceName)
+ {
+ await using var stream = assembly.GetManifestResourceStream(resourceName)
+ ?? throw new InvalidOperationException($"Embedded resource not found: {resourceName}");
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return await reader.ReadToEndAsync();
+ }
+
+ ///
+ /// محاسبه SHA256 از محتوای SQL
+ ///
+ private static string ComputeSha256(string content)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+}
diff --git a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj
index 6522bf9..96ee9ad 100644
--- a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj
+++ b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj
@@ -30,4 +30,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
index 016fb9d..edd7a68 100644
--- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
+++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs
@@ -130,6 +130,9 @@ public static class ConfigureServices
if (configuration.GetValue("SeedWorkers:MagicWalletCycleSeed:Enabled"))
services.AddHostedService();
+ // Q26: Auto-deploy stored procedures on startup (checksum-based)
+ services.AddHostedService();
+
if (configuration.GetValue("UseInMemoryDatabase"))
{
services.AddDbContext(options =>
diff --git a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs
index 113e5b6..2c87922 100644
--- a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs
+++ b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs
@@ -338,7 +338,8 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
_context.UserWalletChangeLogs.Add(walletLog);
// ═══ Magic Wallet: Entry / Exit Trigger ═══
- if (wallet.Balance == 0)
+ // Q24: آستانه <= 1,000,000 ریال به جای == 0 (چون قیمت محصولات متفاوت است)
+ if (wallet.Balance <= SystemConstants.MagicWalletEntryThreshold)
{
var user = await _context.Users
.Include(u => u.ClubMembership)
@@ -358,7 +359,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
if (wallet.MagicTotalDeposited >= magicMaxDeposit)
{
// ── EXIT Magic Mode ──
- // هر دو شرط: Balance=0 و سقف 100M پر شده
+ // هر دو شرط: Balance<=Threshold و سقف MagicMaxDeposit پر شده
wallet.WalletMode = WalletMode.Normal;
wallet.MagicCompletedAt = DateTime.UtcNow;
@@ -398,7 +399,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
currentCycle.MagicStartedAt = DateTime.UtcNow;
}
}
- // ⚠️ اگه Magic باشه و Balance=0 ولی سقف پر نشده → هنوز Magic!
+ // ⚠️ اگه Magic باشه و Balance<=Threshold ولی سقف پر نشده → هنوز Magic!
// کاربر میتونه دوباره شارژ کنه
}