مغایرت‌گیری خودکار شبانه: پیدا کردن مغایرت قبل از کاربر

پاسخ کوتاه: روزی یک بار، روز قبلِ بسته‌شده را بردارید؛ واریزهایی که لجر شما ثبت کرده و واریزهایی که درگاه پرداخت می‌گوید تسویه کرده را فهرست کنید و با شناسه درگاه تطبیقشان دهید. هر چیزی که دقیقاً جور درنمی‌آید (یک طرف ندارد، مبلغ فرق دارد، تکراری است) یافته‌ای است که یک نفر باید ببیند. تطبیق را به‌شکل یک تابع خالص بنویسید که بشود unit test کرد، از یک BackgroundService اجرایش کنید و نتیجه غیرخالی را پرسروصدا کنید.

یک مثال واقعی: مغایرت کوچکی که روزها وقت می‌گیرد

جمع‌ها به اندازه یک مبلغ کوچک با هم نمی‌خوانند. هیچ‌کس نمی‌داند کدام‌یک از هزاران تراکنش آن روز مسئول است؛ پس از پنل درگاه و دیتابیس اکسل می‌گیرند و ردیف به ردیف مقایسه می‌کنند. چند روز طول می‌کشد و مغایرت بعدی همین روند را از نو شروع می‌کند.

علت معمولاً پیش‌پاافتاده است: کال‌بکی که هیچ‌وقت نرسید، پس درگاه پول را گرفت ولی کیف‌پول شارژ نشد. یا تلاش دوباره‌ای که دو بار شارژ کرد. یا استردادی که فقط یک طرف ثبت کرد. وقتی یک برنامه دو فهرست را کنار هم بگذارد، دیدن هر کدام آسان است؛ با چشم تقریباً ناممکن.

چهار نوع مغایرت

نوع یعنی علت معمول
در لجر نیست درگاه پول را گرفته؛ کیف‌پول شارژ نشده کال‌بک گم‌شده یا ناموفق
در درگاه نیست کیف‌پول برای پولی شارژ شده که درگاه هرگز تسویه نکرده شارژ قبل از verify؛ پرداخت برگشتی
مبلغ فرق دارد شناسه یکسان، مبلغ متفاوت کارمزد، برداشت ناقص، باگ در مدیریت مبلغ
تکراری یک شناسه در یک طرف دو بار ثبت شده نبود idempotency روی کال‌بک یا تلاش دوباره

کد قابل اجرا

هسته یک تابع خالص است: دو فهرست ورودی، یک فهرست مغایرت خروجی. بدون دیتابیس و HTTP؛ پس unit test کردنش ساده است:

/// <summary>A deposit as our ledger recorded it.</summary>
public sealed record LedgerDeposit(string Reference, long AmountRials, DateOnly Date);

/// <summary>A deposit as the payment gateway's settlement report says it happened.</summary>
public sealed record GatewaySettlement(string Reference, long AmountRials, DateOnly Date);

public enum MismatchKind
{
    /// <summary>The gateway took the money, but the user's wallet was never credited.</summary>
    MissingInLedger,

    /// <summary>We credited a wallet for money the gateway never settled.</summary>
    MissingAtGateway,

    AmountDiffers,

    /// <summary>The same reference appears more than once on one side.</summary>
    Duplicate,
}

public sealed record Mismatch(string Reference, MismatchKind Kind, long? LedgerRials, long? GatewayRials);

public static class Reconciler
{
    /// <summary>Matches the two sides by gateway reference. An empty result means the day reconciles.</summary>
    public static IReadOnlyList<Mismatch> Reconcile(IEnumerable<LedgerDeposit> ledger, IEnumerable<GatewaySettlement> gateway)
    {
        var mismatches = new List<Mismatch>();

        var ledgerByRef = Index(ledger, d => d.Reference, d => d.AmountRials, mismatches, isLedger: true);
        var gatewayByRef = Index(gateway, s => s.Reference, s => s.AmountRials, mismatches, isLedger: false);

        foreach (var (reference, ledgerAmount) in ledgerByRef)
        {
            if (!gatewayByRef.TryGetValue(reference, out var gatewayAmount))
            {
                mismatches.Add(new Mismatch(reference, MismatchKind.MissingAtGateway, ledgerAmount, null));
            }
            else if (gatewayAmount != ledgerAmount)
            {
                mismatches.Add(new Mismatch(reference, MismatchKind.AmountDiffers, ledgerAmount, gatewayAmount));
            }
        }

        mismatches.AddRange(gatewayByRef
            .Where(g => !ledgerByRef.ContainsKey(g.Key))
            .Select(g => new Mismatch(g.Key, MismatchKind.MissingInLedger, null, g.Value)));

        return mismatches;
    }

    private static Dictionary<string, long> Index<T>(
        IEnumerable<T> rows, Func<T, string> reference, Func<T, long> amount, List<Mismatch> mismatches, bool isLedger)
    {
        var index = new Dictionary<string, long>(StringComparer.Ordinal);
        foreach (var row in rows)
        {
            if (!index.TryAdd(reference(row), amount(row)))
            {
                mismatches.Add(new Mismatch(reference(row), MismatchKind.Duplicate,
                    isLedger ? amount(row) : null, isLedger ? null : amount(row)));
            }
        }

        return index;
    }
}

زمان‌بندی یک BackgroundService است که تا ساعت ۲ بامداد به وقت تهران صبر می‌کند، روز قبل را مغایرت‌گیری می‌کند و نتیجه را لاگ می‌کند. منبع دو فهرست پشت یک interface است؛ پس برای job فرقی نمی‌کند گزارش درگاه API باشد، CSV یا جدول:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// <summary>The two sides for one day. Implement it with your ledger query and your gateway's settlement report.</summary>
public interface IReconciliationSources
{
    Task<IReadOnlyList<LedgerDeposit>> LedgerAsync(DateOnly day, CancellationToken ct);

    Task<IReadOnlyList<GatewaySettlement>> GatewayAsync(DateOnly day, CancellationToken ct);
}

/// <summary>Reconciles yesterday every night at 02:00 Tehran time and shouts when it doesn't add up.</summary>
public sealed class NightlyReconciliation(IServiceScopeFactory scopes, TimeProvider clock, ILogger<NightlyReconciliation> logger) : BackgroundService
{
    private static readonly TimeZoneInfo Tehran = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tehran");
    private static readonly TimeOnly RunAt = new(2, 0);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(UntilNextRun(), clock, stoppingToken);

            var yesterday = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(clock.GetUtcNow(), Tehran).DateTime).AddDays(-1);
            try
            {
                using var scope = scopes.CreateScope();
                var sources = scope.ServiceProvider.GetRequiredService<IReconciliationSources>();
                var mismatches = Reconciler.Reconcile(
                    await sources.LedgerAsync(yesterday, stoppingToken),
                    await sources.GatewayAsync(yesterday, stoppingToken));

                if (mismatches.Count == 0)
                {
                    logger.LogInformation("Reconciliation {Day}: OK.", yesterday);
                }
                else
                {
                    // Error level: this should page someone (alert rule on the log, Telegram, email).
                    logger.LogError("Reconciliation {Day}: {Count} mismatch(es): {@Mismatches}", yesterday, mismatches.Count, mismatches);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Reconciliation {Day} could not run.", yesterday);
            }
        }
    }

    private TimeSpan UntilNextRun()
    {
        var now = TimeZoneInfo.ConvertTime(clock.GetUtcNow(), Tehran);
        var next = now.Date + RunAt.ToTimeSpan();
        if (next <= now.DateTime)
        {
            next = next.AddDays(1);
        }

        return next - now.DateTime;
    }
}

با builder.Services.AddHostedService<NightlyReconciliation>() ثبتش کنید، همراه با پیاده‌سازی IReconciliationSources و TimeProvider.System. گرفتن TimeProvider به‌جای DateTime.UtcNow یعنی یک تست می‌تواند زمان را جلو ببرد و زمان‌بندی را بررسی کند.

مفید کردنش، نه فقط درست کردنش

  • روز بسته‌شده را مغایرت‌گیری کنید. بعد از نیمه‌شب برای روز قبل اجرا کنید تا کال‌بک‌های دیرکرده هم رسیده باشند. اگر درگاه دیرتر تسویه می‌کند، دو روز قبل را بررسی کنید.
  • برای یافته‌ها هشدار بگذارید. لاگ سطح errorی که کسی نمی‌خواند، همان اکسلی است که کسی باز نمی‌کند. آن را به کانالی بفرستید که یک آدم می‌بیند: تلگرام، ایمیل، سیستم پایش.
  • نتیجه‌ها را ذخیره کنید. جدولی از یافته‌ها با وضعیت (باز، توضیح‌داده‌شده، اصلاح‌شده) نشان می‌دهد آیا یک علت مدام برمی‌گردد.
  • رو به جلو اصلاح کنید. لجر را با ردیف‌های جدید (برگشت یا شارژ جاافتاده) اصلاح کنید، هرگز با ویرایش ردیف‌های گذشته. تاریخچه مغایرت‌گیری صادق می‌ماند.
  • علت‌ها را به کد تبدیل کنید. «در لجر نیست»ی که تکرار می‌شود یعنی مسیر کال‌بک تلاش دوباره یا یک بررسی تکمیلی verify لازم دارد، نه اکسل بهتر.

مزایا و معایب

مزایا

  • مغایرت‌ها صبح روز بعد پیدا می‌شوند، نه وقتی کاربر شکایت می‌کند یا گزارش ماهانه جور درنمی‌آید.
  • منطق تطبیق یک تابع خالص با unit test است و قواعدش مکتوب‌اند.
  • هر یافته به یک شناسه مشخص اشاره می‌کند؛ بررسی از ردیف درست شروع می‌شود.

معایب

  • به داده تسویه درگاه وابسته‌اید: قالب، زمان و در دسترس بودنش.
  • اختلاف زمانی بدون مغایرت‌گیری روی بازه بسته‌شده، هشدار کاذب می‌سازد.
  • مشکل را پیدا می‌کند، جلویش را نمی‌گیرد. idempotency و verify در جریان پرداخت همچنان آن کار را می‌کنند.

جایش در طراحی

مغایرت‌گیری تور ایمنی زیر بقیه قواعد پولی است: لجر به‌عنوان تنها منبع حقیقت، کال‌بک‌های idempotent، کنترل همزمانی روی موجودی. هر کدام جلوی یک دسته خطا را می‌گیرند؛ مغایرت‌گیری هر چیزی را که از آن‌ها عبور کند می‌گیرد و می‌گوید کدام قاعده کار لازم دارد.


مغایرت‌گیری لازم دارید، یا یک بازبینی دوم روی جریان پرداخت؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

چرا شبانه و نه لحظه‌ای؟

درگاه‌ها معمولاً داده تسویه را روزانه منتشر می‌کنند و بسیاری از اختلاف‌ها موقتی‌اند (کال‌بکی که دیر می‌رسد). اجرای شبانه روی یک روز بسته‌شده پاسخ پایدار می‌دهد. بررسی‌های لحظه‌ای هم جایشان در خود جریان پرداخت است: idempotency و verify.

وقتی مغایرت پیدا شد چه باید بشود؟

یک آدم باید در چند دقیقه باخبر شود: با سطح error لاگ و برایش هشدار بگذارید (تلگرام، ایمیل، سیستم پایش). با یک ردیف جدید در لجر اصلاحش کنید، هرگز با ویرایش تاریخچه، و علت را ثبت کنید تا همان نوع مغایرت با تغییر کد حل شود.

مغایرت‌گیر می‌تواند مغایرت‌ها را خودکار اصلاح کند؟

بعضی را، با احتیاط: واریزی که درگاه تسویه کرده ولی لجر جا انداخته، بعد از تأیید با درگاه می‌تواند خودکار شارژ شود. هر چیز مبهم، مثل اختلاف مبلغ یا تکراری، باید به یک آدم برسد.