الأبحاث · جولةُ البرمجة الأولى
CB-1-09 — كتابة ميزة جديدة · C# · متوسّط
جولةُ البرمجة الأولى · 2026-10-07 · GAIBRA v2.30.02 · واجبُ التشغيل
| الترتيب | المتنافس | الرمز أمام المحكّم | الكلمات | الاختبارات | Opus | Grok | المجموع | الصدق (Opus · Grok) |
|---|---|---|---|---|---|---|---|---|
| 1 | GAIBRA | د | 2,317 | 14/14 | 93.0 | 100.0 | 96.5 | 11 · 12 |
| 2 | Claude Sonnet | ب | 1,425 | 14/14 | 94.0 | 94.0 | 94.0 | 11 · 11 |
| 3 | ChatGPT | هـ | 1,180 | 14/14 | 81.0 | 87.0 | 84.0 | 8 · 10 |
| 4 | DeepSeek | ج | 877 | 14/14 | 79.0 | 81.0 | 80.0 | 8 · 9 |
| 5 | Gemini | أ | 926 | 14/14 | 78.0 | 71.0 | 74.5 | 7 · 7 |
اختلف المحكّمان على المركز الأوّل: Opus اختار Claude Sonnet، وGrok اختار GAIBRA.
نصُّ السيناريو كما كتبه المولّد
نظام خدمات مالية داخلي، وأضيف ميزة جدولة الاستهلاك (amortization) لقرض شخصي بسعر فائدة ثابت، مع دعم سداد مبكّر جزئي يقصّر مدة القرض (القسط الشهري يبقى كما هو، والمدة تنقص). أريد ملف C# واحداً (.NET 6+، مكتبة System فقط، بدون أي NuGet).
التوقيع المطلوب بالضبط:
csharp
public class EarlyPayment
{
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
}
public class Installment
{
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
}
public static class LoanSchedule
{
  // ratePercent: نسبة فائدة سنوية، مثال 5.0 تعني 5%
  public static List<Installment> BuildSchedule(
  decimal principal, decimal ratePercent, int termMonths,
  DateOnly startDate, IReadOnlyList<EarlyPayment>? earlyPayments = null);
}
القواعد:
القسط الشهري الثابت: P = principal \* r(1+r)^n / ((1+r)^n - 1) حيث r = ratePercent/100/12. استخدم decimal للحساب قدر الإمكان، وMath.Round(..., 2, MidpointRounding.AwayFromZero) على PrincipalPart وInterestPart في كل قسط، مع تصحيح ClosingBalance بناءً على الأجزاء المُقرّبة (الرصيد يجب أن يتطابق مع الحساب اليدوي سنتاً بسنتاً).
DueDate للقسط رقم i = startDate.AddMonths(i-1) (القسط الأول في تاريخ البداية نفسه).
السداد المبكّر: يُضاف Amount في شهر القسط N بعد خصم القسط المعتاد، فيُخصم من الرصيد المتبقي، ثم تُعاد حسابة بقية الجدول: نفس القسط الشهري P، ومدة جديدة أقصر. قد ينتهي القرض بقسط أخير أصغر من P.
إن كان مجموع السداد في شهر ما يتجاوز الرصيد المتبقي، فاقطع الجدول عند إغلاق الرصيد (لا أرصدة سالبة أبداً).
نسبة الفائدة 0% مدعومة (قسط = principal / n، بدون قسمة على صفر).
سلوك حتمي تماماً — لا اعتماد على تاريخ اليوم؛ كل التواريخ مشتقة من startDate فقط.
مثال للتحقق: BuildSchedule(100000m, 5.0m, 12, new DateOnly(2026, 1, 1)) يجب أن يعطي قسطاً شهرياً 8560.75، أول قسط: Opening 100000.00, Interest 416.67, Principal 8144.08, Closing 91855.92. وأيضاً تحقق: BuildSchedule(100000m, 5.0m, 12, new DateOnly(2026,1,1), new List<EarlyPayment>{ new EarlyPayment{AfterInstallmentNumber=3, Amount=20000m} }) ينتهي قبل الشهر 12 (تحقق من عدد الأقساط وأن القسط الأخير ≤ 8560.75).
أرفق في Main طباعة جدول كامل بالأعمدة أعلاه للمثال الثاني، وجملة تلخص: عدد الأقساط، مجموع الفوائد، توفير الفوائد مقابل جدول بدون سداد مبكر. الملف كاملاً تحت 300 سطر.
الردودُ الخمسة كما وصلت المحكّمين
دGAIBRA
// LoanSchedule.cs — جدولة استهلاك قرض شخصي بفائدة ثابتة مع سداد مبكّر جزئي يقصّر المدة. .NET 6+، مكتبة النظام فقط.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
namespace LoanAmortization
{
  /// <summary>مبلغ إضافي يُدفع مع قسطٍ معتاد فيُخصم من الرصيد ويقصّر مدة القرض.</summary>
  public class EarlyPayment
  {
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
  }
  /// <summary>سطر واحد في جدول الاستهلاك. ClosingBalance = OpeningBalance - PrincipalPart - EarlyPayment.</summary>
  public class Installment
  {
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
  }
  /// <summary>جدولة حتمية بقسط ثابت؛ السداد المبكّر يقصّر المدة ولا يغيّر القسط.</summary>
  public static class LoanSchedule
  {
  private const int MoneyDecimals = 2;
  private const MidpointRounding MoneyRounding = MidpointRounding.AwayFromZero;
  private const decimal PercentDivisor = 100m;
  private const decimal MonthsPerYear = 12m;
  private const decimal MaxRatePercent = 100m;
  private const decimal MaxPrincipal = 1\_000\_000\_000\_000m;
  private const int MaxTermMonths = 600;
  /// <summary>يبني جدول الاستهلاك الكامل حتى إغلاق الرصيد.</summary>
  /// <param name="principal">أصل القرض، موجب وبمنزلتين عشريتين على الأكثر.</param>
  /// <param name="ratePercent">نسبة الفائدة السنوية (5.0 تعني 5%)، من 0 إلى 100.</param>
  /// <param name="termMonths">المدة الأصلية بالأشهر، من 1 إلى 600.</param>
  /// <param name="startDate">تاريخ القسط الأول؛ القسط i يستحق في startDate.AddMonths(i-1).</param>
  /// <param name="earlyPayments">دفعات مبكّرة اختيارية؛ الدفعات على القسط نفسه تُجمع.</param>
  /// <returns>قائمة الأقساط؛ آخرها رصيده الختامي صفر.</returns>
  /// <exception cref="ArgumentOutOfRangeException">قيمة خارج النطاق المسموح.</exception>
  /// <exception cref="ArgumentException">مبلغ بأكثر من منزلتين أو عنصر null في الدفعات.</exception>
  public static List<Installment> BuildSchedule(
  decimal principal, decimal ratePercent, int termMonths,
  DateOnly startDate, IReadOnlyList<EarlyPayment>? earlyPayments = null)
  {
  ValidateLoanTerms(principal, ratePercent, termMonths, startDate);
  Dictionary<int, decimal> extraByInstallment = AggregateEarlyPayments(earlyPayments, termMonths, principal);
  decimal monthlyRate = ratePercent / PercentDivisor / MonthsPerYear;
  decimal scheduledPayment = CalculateMonthlyPayment(principal, monthlyRate, termMonths);
  var schedule = new List<Installment>(termMonths);
  decimal balance = principal;
  for (int number = 1; number <= termMonths \&\& balance > 0m; number++)
  {
  Installment row = BuildInstallment(number, startDate, balance, monthlyRate,
  scheduledPayment, termMonths, extraByInstallment);
  schedule.Add(row);
  balance = row.ClosingBalance;
  }
  return schedule;
  }
  private static void ValidateLoanTerms(decimal principal, decimal ratePercent, int termMonths, DateOnly startDate)
  {
  if (principal <= 0m || principal > MaxPrincipal)
  throw new ArgumentOutOfRangeException(nameof(principal), principal, $"أصل القرض يجب أن يكون موجباً ولا يتجاوز {MaxPrincipal}.");
  if (principal != RoundMoney(principal))
  throw new ArgumentException("أصل القرض يجب ألا يتجاوز منزلتين عشريتين.", nameof(principal));
  if (ratePercent < 0m || ratePercent > MaxRatePercent)
  throw new ArgumentOutOfRangeException(nameof(ratePercent), ratePercent, $"النسبة يجب أن تكون بين 0 و{MaxRatePercent}.");
  if (termMonths < 1 || termMonths > MaxTermMonths)
  throw new ArgumentOutOfRangeException(nameof(termMonths), termMonths, $"المدة يجب أن تكون بين 1 و{MaxTermMonths} شهراً.");
  if (startDate > DateOnly.MaxValue.AddMonths(-(termMonths - 1)))
  throw new ArgumentOutOfRangeException(nameof(startDate), startDate, "تاريخ آخر قسط يتجاوز أقصى تاريخ ممكن.");
  }
  private static Dictionary<int, decimal> AggregateEarlyPayments(
  IReadOnlyList<EarlyPayment>? earlyPayments, int termMonths, decimal principal)
  {
  var totals = new Dictionary<int, decimal>();
  if (earlyPayments is null) return totals;
  for (int index = 0; index < earlyPayments.Count; index++)
  {
  EarlyPayment? item = earlyPayments\[index];
  if (item is null)
  throw new ArgumentException($"العنصر earlyPayments\[{index}] فارغ (null).", nameof(earlyPayments));
  if (item.AfterInstallmentNumber < 1 || item.AfterInstallmentNumber > termMonths)
  throw new ArgumentOutOfRangeException(nameof(earlyPayments), item.AfterInstallmentNumber,
  $"رقم القسط في earlyPayments\[{index}] يجب أن يكون بين 1 و{termMonths}.");
  if (item.Amount <= 0m)
  throw new ArgumentOutOfRangeException(nameof(earlyPayments), item.Amount,
  $"مبلغ earlyPayments\[{index}] يجب أن يكون موجباً.");
  if (item.Amount != RoundMoney(item.Amount))
  throw new ArgumentException($"مبلغ earlyPayments\[{index}] يتجاوز منزلتين عشريتين.", nameof(earlyPayments));
  // السقف عند الأصل لا يغيّر النتيجة (الدفعة تُقصّ عند الرصيد أصلاً) ويمنع OverflowException عند الجمع.
  decimal capped = Math.Min(item.Amount, principal);
  totals\[item.AfterInstallmentNumber] = totals.TryGetValue(item.AfterInstallmentNumber, out decimal existing)
  ? Math.Min(existing + capped, principal)
  : capped;
  }
  return totals;
  }
  private static decimal CalculateMonthlyPayment(decimal principal, decimal monthlyRate, int termMonths)
  {
  if (monthlyRate == 0m) return RoundMoney(principal / termMonths);
  decimal onePlusRate = 1m + monthlyRate;
  decimal growth = 1m;
  for (int month = 0; month < termMonths; month++) growth \*= onePlusRate;
  // principal \* \[r(1+r)^n / ((1+r)^n - 1)]: المعامل يُحسب أولاً كي لا يفيض principal \* (1+r)^n.
  decimal annuityFactor = monthlyRate \* growth / (growth - 1m);
  return RoundMoney(principal \* annuityFactor);
  }
  private static Installment BuildInstallment(int number, DateOnly startDate, decimal openingBalance,
  decimal monthlyRate, decimal scheduledPayment, int termMonths,
  IReadOnlyDictionary<int, decimal> extraByInstallment)
  {
  decimal interestPart = RoundMoney(openingBalance \* monthlyRate);
  bool closesLoan = number == termMonths || openingBalance + interestPart <= scheduledPayment;
  decimal payment = closesLoan ? openingBalance + interestPart : scheduledPayment;
  decimal principalPart = RoundMoney(payment - interestPart);
  if (principalPart < 0m)
  throw new InvalidOperationException($"القسط {number} لا يغطي فائدته؛ حالة غير متّسقة.");
  decimal balanceAfterPayment = openingBalance - principalPart;
  decimal requestedExtra = extraByInstallment.TryGetValue(number, out decimal extra) ? extra : 0m;
  decimal earlyPart = Math.Min(requestedExtra, balanceAfterPayment);
  return new Installment
  {
  Number = number,
  DueDate = startDate.AddMonths(number - 1),
  OpeningBalance = openingBalance,
  Payment = payment,
  EarlyPayment = earlyPart,
  PrincipalPart = principalPart,
  InterestPart = interestPart,
  ClosingBalance = balanceAfterPayment - earlyPart
  };
  }
  private static decimal RoundMoney(decimal value) => Math.Round(value, MoneyDecimals, MoneyRounding);
  }
  /// <summary>عرض المثال الثاني مع فحوص ذاتية؛ رمز الخروج 1 عند فشل أي فحص.</summary>
  public static class Program
  {
  private const decimal DemoPrincipal = 100000m;
  private const decimal DemoRatePercent = 5.0m;
  private const int DemoTermMonths = 12;
  private const int EarlyAfterInstallment = 3;
  private const decimal EarlyAmount = 20000m;
  private const decimal ExpectedPayment = 8560.75m;
  private const decimal ExpectedFirstInterest = 416.67m;
  private const decimal ExpectedFirstPrincipal = 8144.08m;
  private const decimal ExpectedFirstClosing = 91855.92m;
  private const decimal OversizedEarlyAmount = 200000m;
  private const decimal ZeroRatePrincipal = 1200m;
  private const decimal ZeroRateExpectedPayment = 100m;
  private const string DateFormat = "yyyy-MM-dd";
  private const string SelfCheckPrefix = "فشل فحص ذاتي: ";
  private const string HeaderFormat = "{0,3} | {1,10} | {2,12} | {3,10} | {4,10} | {5,10} | {6,8} | {7,12}";
  private const string RowFormat = "{0,3} | {1,10} | {2,12:N2} | {3,10:N2} | {4,10:N2} | {5,10:N2} | {6,8:N2} | {7,12:N2}";
  private const string SummaryFormat =
  "الملخّص: {0} قسطاً بدلاً من {1}، مجموع الفوائد {2:N2}، توفير الفوائد مقابل الجدول بلا سداد مبكّر {3:N2} (فوائد الجدول الأصلي {4:N2}).";
  private static readonly DateOnly DemoStart = new DateOnly(2026, 1, 1);
  public static int Main()
  {
  Console.OutputEncoding = Encoding.UTF8;
  try
  {
  var early = new List<EarlyPayment>
  {
  new EarlyPayment { AfterInstallmentNumber = EarlyAfterInstallment, Amount = EarlyAmount }
  };
  List<Installment> baseline = LoanSchedule.BuildSchedule(DemoPrincipal, DemoRatePercent, DemoTermMonths, DemoStart);
  List<Installment> withEarly = LoanSchedule.BuildSchedule(DemoPrincipal, DemoRatePercent, DemoTermMonths, DemoStart, early);
  PrintSchedule(withEarly);
  decimal baselineInterest = baseline.Sum(row => row.InterestPart);
  decimal earlyInterest = withEarly.Sum(row => row.InterestPart);
  Console.WriteLine(string.Format(CultureInfo.InvariantCulture, SummaryFormat,
  withEarly.Count, baseline.Count, earlyInterest, baselineInterest - earlyInterest, baselineInterest));
  RunSelfChecks(baseline, withEarly);
  Console.WriteLine("نجحت كل الفحوص الذاتية.");
  return 0;
  }
  catch (Exception ex) when (ex is InvalidOperationException || ex is ArgumentException)
  {
  Console.Error.WriteLine(ex.Message);
  return 1;
  }
  }
  private static void PrintSchedule(IReadOnlyList<Installment> rows)
  {
  Console.WriteLine(string.Format(CultureInfo.InvariantCulture, HeaderFormat, "No", "DueDate",
  "Opening", "Payment", "Early", "Principal", "Interest", "Closing"));
  foreach (Installment r in rows)
  {
  Console.WriteLine(string.Format(CultureInfo.InvariantCulture, RowFormat, r.Number,
  r.DueDate.ToString(DateFormat, CultureInfo.InvariantCulture), r.OpeningBalance, r.Payment,
  r.EarlyPayment, r.PrincipalPart, r.InterestPart, r.ClosingBalance));
  }
  }
  private static void RunSelfChecks(IReadOnlyList<Installment> baseline, IReadOnlyList<Installment> withEarly)
  {
  Installment first = baseline\[0];
  Require(first.Payment == ExpectedPayment, "القسط الشهري ليس 8560.75");
  Require(first.OpeningBalance == DemoPrincipal, "الرصيد الافتتاحي للقسط الأول ليس 100000.00");
  Require(first.InterestPart == ExpectedFirstInterest, "فائدة القسط الأول ليست 416.67");
  Require(first.PrincipalPart == ExpectedFirstPrincipal, "أصل القسط الأول ليس 8144.08");
  Require(first.ClosingBalance == ExpectedFirstClosing, "الرصيد الختامي للقسط الأول ليس 91855.92");
  Require(baseline.Count == DemoTermMonths, "الجدول الأصلي لا يحتوي 12 قسطاً");
  Require(withEarly.Count < DemoTermMonths, "الجدول مع السداد المبكّر لم ينتهِ قبل الشهر 12");
  Require(withEarly\[^1].Payment <= ExpectedPayment, "القسط الأخير أكبر من 8560.75");
  Require(withEarly\[EarlyAfterInstallment - 1].EarlyPayment == EarlyAmount, "السداد المبكّر لم يُطبَّق على القسط 3");
  Require(withEarly.Sum(r => r.InterestPart) < baseline.Sum(r => r.InterestPart), "لا توفير في الفوائد");
  CheckInvariants(baseline, DemoPrincipal, "baseline");
  CheckInvariants(withEarly, DemoPrincipal, "early");
  List<Installment> zeroRate = LoanSchedule.BuildSchedule(ZeroRatePrincipal, 0m, DemoTermMonths, DemoStart);
  Require(zeroRate.Count == DemoTermMonths, "جدول 0% لا يحتوي 12 قسطاً");
  Require(zeroRate.All(r => r.Payment == ZeroRateExpectedPayment \&\& r.InterestPart == 0m), "أقساط 0% ليست 100 بلا فائدة");
  CheckInvariants(zeroRate, ZeroRatePrincipal, "zero-rate");
  var oversized = new List<EarlyPayment> { new EarlyPayment { AfterInstallmentNumber = 1, Amount = OversizedEarlyAmount } };
  List<Installment> closedEarly = LoanSchedule.BuildSchedule(DemoPrincipal, DemoRatePercent, DemoTermMonths, DemoStart, oversized);
  Require(closedEarly.Count == 1 \&\& closedEarly\[0].EarlyPayment == ExpectedFirstClosing, "الدفعة الزائدة لم تُقصّ عند الرصيد");
  CheckInvariants(closedEarly, DemoPrincipal, "oversized");
  RequireThrows<ArgumentOutOfRangeException>(
  () => LoanSchedule.BuildSchedule(DemoPrincipal, DemoRatePercent, 0, DemoStart), "مدة صفرية لم تُرفض");
  RequireThrows<ArgumentOutOfRangeException>(
  () => LoanSchedule.BuildSchedule(-1m, DemoRatePercent, DemoTermMonths, DemoStart), "أصل سالب لم يُرفض");
  }
  private static void CheckInvariants(IReadOnlyList<Installment> rows, decimal principal, string label)
  {
  Require(rows.Count > 0, $"{label}: جدول فارغ");
  decimal expectedOpening = principal;
  for (int i = 0; i < rows.Count; i++)
  {
  Installment r = rows\[i];
  Require(r.Number == i + 1, $"{label}: ترقيم خاطئ عند {i + 1}");
  Require(r.DueDate == DemoStart.AddMonths(i), $"{label}: تاريخ خاطئ للقسط {r.Number}");
  Require(r.OpeningBalance == expectedOpening, $"{label}: رصيد افتتاحي غير متّصل عند {r.Number}");
  Require(r.Payment == r.PrincipalPart + r.InterestPart, $"{label}: القسط لا يساوي أصلاً + فائدة عند {r.Number}");
  Require(r.ClosingBalance == r.OpeningBalance - r.PrincipalPart - r.EarlyPayment, $"{label}: رصيد ختامي غير متّسق عند {r.Number}");
  Require(r.ClosingBalance >= 0m, $"{label}: رصيد سالب عند {r.Number}");
  expectedOpening = r.ClosingBalance;
  }
  Require(rows\[^1].ClosingBalance == 0m, $"{label}: الرصيد لم يُغلق");
  }
  private static void Require(bool condition, string message)
  {
  if (!condition) throw new InvalidOperationException(SelfCheckPrefix + message);
  }
  private static void RequireThrows<TException>(Action action, string message) where TException : Exception
  {
  try { action(); }
  catch (TException) { return; }
  throw new InvalidOperationException(SelfCheckPrefix + message);
  }
  }
}
الشرح
لم أشغّل هذا الملف. الحكم على صحّته متروك للفحوص المضمّنة في Main، فهي تثبّت الأرقام التي ذكرتَها بأعيانها: القسط 8560.75، والقسط الأول بقيمه الأربع (Opening 100000.00، Interest 416.67، Principal 8144.08، Closing 91855.92)، وانتهاء المثال الثاني قبل الشهر 12، وألا يتجاوز قسطه الأخير 8560.75. أيّ انحراف يُنهي البرنامج برمز خروج 1 ورسالة تسمّي الفحص الذي فشل.
القلب الحسابيّ دالة BuildInstallment، وهي تعالج كلّ شهر بترتيب ثابت. هذا الترتيب هو ما يجعل الرصيد يتطابق مع الحساب اليدويّ سنتاً بسنت:
معالجة القسط الواحد
١
تقريب الفائدة
الرصيد الافتتاحي مضروباً في r ثم Math.Round بمنزلتين AwayFromZero
٢
تحديد قسط الإغلاق
إن كان الرصيد مع الفائدة لا يتجاوز P، أو كان هذا هو الشهر termMonths، صار القسط مساوياً للرصيد مع الفائدة
٣
اشتقاق الأصل
القسط ناقص الفائدة المقرّبة، فيبقى Payment مساوياً لـ PrincipalPart مع InterestPart بالضبط
٤
تطبيق السداد المبكّر
يُخصم بعد القسط المعتاد ويُقصّ عند الرصيد المتبقي فلا يصير الرصيد سالباً
٥
إغلاق الرصيد
الرصيد الختامي هو الافتتاحي ناقص الأصل ناقص المبلغ الإضافي، والحلقة تتوقف عند الصفر
ولقصر المدة آليّةٌ واحدة. حين يُخصم مبلغ 20000 مع القسط 3، يتابع الجدول بالقسط نفسه 8560.75 على رصيدٍ أصغر، فيبلغ شرط الإغلاق في الخطوة الثانية قبل الشهر 12 بطبيعته. لا يوجد حساب منفصل لـ"مدة جديدة". المدة نتيجة لا مُدخل، وهذا يُغني عن إعادة حلّ معادلة القسط.
أمّا بنية البيانات فتتبع نمط الوصول. الدفعات المبكّرة تُجمع مرّة واحدة في Dictionary<int, decimal> مفهرس برقم القسط، فيصير البحث في كلّ شهر O(1) بدل مسح القائمة كلّها. ويحمي ذلك أيضاً من تعديل القائمة الأصلية أثناء البناء، لأنّها تُقرأ مرّة ثم تُترك. الدالة static بلا حالة مشتركة، فاستدعاؤها من عدّة threads آمن. والحساب كلّه CPU-bound، لذلك لا مكان فيه لـ async ولا لـ CancellationToken.
ويخصّ الصيغة تفصيل واحد. أحسب المعامل r(1+r)^n / ((1+r)^n - 1) أولاً ثم أضربه في الأصل. الصيغة رياضيّاً هي صيغتك نفسها، لكنّ ترتيب الضرب يمنع OverflowException في decimal عند المدد الطويلة. لهذا السبب نفسه وُضعت الحدود: النسبة حتى 100، والمدة حتى 600 شهر، والأصل حتى 1,000,000,000,000. القوّة تُحسب بضرب متكرّر داخل decimal دون المرور بـ Math.Pow ذات الـ double، والنسبة 0% تأخذ فرعاً مستقلّاً هو principal / n.
مهمّ
قسط الإغلاق في الشهر الأخير من المدة الأصلية يمتصّ فرق التقريب المتراكم، فقد يزيد عن P أو ينقص عنه ببضع سنتات في الجدول بلا سداد مبكّر. هذا مقصود: البديل كان رصيداً متبقّياً من سنتات أو رصيداً سالباً.
وهذه القرارات السلوكية كلّها مذكورة هنا لأنّ الكود جديد بلا أصلٍ يُقارن به:
PrincipalPart لا يشمل المبلغ الإضافي، والرصيد الختامي يخصم الاثنين معاً.
الدفعات المتعدّدة على القسط نفسه تُجمع.
الدفعة التي تقع بعد إغلاق القرض، أو في قسط الإغلاق نفسه، تُقصّ إلى صفر.
تُرفض المدخلات التالية: أصلٌ غير موجب أو بأكثر من منزلتين، ونسبةٌ سالبة، ومدةٌ خارج 1–600، ومبلغٌ إضافيّ غير موجب، ورقمُ قسطٍ خارج المدة، وعنصرٌ null، وتاريخُ بدايةٍ يجعل آخر قسط بعد أقصى DateOnly.
الطباعة بـ InvariantCulture، وأفترض أنّ الطرفيّة تقرأ UTF-8 لأنّ سطر الملخّص عربيّ.
لا شيء في الملف يقرأ تاريخ اليوم. كلّ تاريخ مشتقّ من startDate.
بقي حدّ واحد في عملي هذا: الجدول ينفّذ القواعد التي كتبتَها حرفاً بحرف، أمّا ما لم تذكره من شروط عقد القرض فلم يدخل فيه. والسؤال الحاسم هو: هل يحتسب عقدكم الفائدة الشهرية جزءاً من 12 من النسبة السنوية كما في صيغتك، أم بعدد الأيام الفعليّ في الشهر؟ إن كانت بالأيام فسيتغيّر عمود InterestPart في كلّ سطر، وأعيد بناء الدالة عليه هنا. والقرار في ذلك قرارك.
طريقة الاستخدام
أنشئ مشروعاً بالأمر dotnet new console، واحذف Program.cs الافتراضيّ، وضع LoanSchedule.cs مكانه، ثم شغّل dotnet run. يطبع البرنامج جدول المثال الثاني بأعمدته الثمانية وسطر الملخّص، ثم نتيجة الفحوص. وفي نظامكم الداخليّ يكفي استدعاء LoanSchedule.BuildSchedule مباشرةً. التوفير هو الفرق بين مجموع InterestPart في جدولين: جدول بلا دفعات مبكّرة وجدول بها، تماماً كما يفعل Main.
بClaude Sonnet
سأكتب الملف وأتحقق من الأرقام بتشغيله فعلياً.
الملف LoanSchedule.cs جاهز (188 سطراً). يطابق التوقيع المطلوب حرفياً، ويضم Main يطبع الجدول ويجري فحوصات تحقق مدمجة.
لم أتمكن من تشغيل dotnet هنا، لأن بيئة العمل لا تسمح بتنزيل الـ SDK. لذلك أعدت تنفيذ الخوارزمية نفسها خطوة بخطوة في Python باستخدام Decimal بدقة 28 رقماً وتقريب HALF\_UP، وهي مطابقة لـ decimal مع AwayFromZero. الأرقام التالية ناتجة عن هذا التشغيل:
المثال الأول: القسط الشهري 8,560.75. القسط الأول: Opening 100,000.00، Interest 416.67، Principal 8,144.08، Closing 91,855.92. الجدول 12 قسطاً، والقسط الأخير 8,560.73 لأنه يمتص فرق تقريب P.
المثال الثاني (سداد مبكر 20,000 مع القسط 3):
\# Opening Payment Early Principal Interest Closing
3 83,677.90 8,560.75 20,000.00 8,212.09 348.66 55,465.81
9 13,469.08 8,560.75 0 8,504.63 56.12 4,964.45
10 4,964.45 4,985.14 0 4,964.45 20.69 0.00
النتيجة 10 أقساط بدلاً من 12، والقسط الأخير 4,985.14 وهو أقل من 8,560.75. مجموع الفوائد 2,031.89 مقابل 2,728.98 بدون سداد مبكر، أي توفير 697.09.
قرارات تصميم تستحق المراجعة:
حساب (1+r)^n: يتم بضرب متكرر في decimal، لأن Math.Pow يعمل على double فقط. هذا يجعل الحساب حتمياً ولا ينتج أي انحراف بالسنتات.
قسط الإغلاق: في الشهر الأخير من المدة الأصلية، أو عندما يصبح الرصيد مع الفائدة أقل من P، يُدفع الرصيد كاملاً. بهذا ينتهي الجدول دائماً على 0.00 تماماً.
حالة 0%: قد يزيد القسط الأخير هنا بسنت واحد عن P، مثلاً 1000 على 3 أشهر تعطي 333.33 ثم 333.33 ثم 333.34. هذا لا مفر منه حتى يساوي المجموع أصل القرض. أخبرني إن كنت تفضل توزيع السنت على القسط الأول بدلاً من الأخير.
السداد المبكر: إذا تجاوز الرصيد المتبقي يُقصّ إلى قيمة الرصيد، وتُجمع المبالغ المتعددة لنفس رقم القسط. المبالغ السالبة أو رقم القسط الأقل من 1 ترمي ArgumentException.
بعد تشغيل الملف لديك بالأمر dotnet run، يجب أن تظهر كل سطور الفحص بعلامة \[OK]. وإذا ظهر \[FAIL] فإن ExitCode يصبح 1.
Loanschedule · CS
// LoanSchedule.cs — جدولة استهلاك قرض شخصي بفائدة ثابتة مع سداد مبكر جزئي يقصّر المدة.
// .NET 6+، مكتبة System فقط.
using System;
using System.Collections.Generic;
using System.Linq;
 
public class EarlyPayment
{
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
}
 
public class Installment
{
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
}
 
public static class LoanSchedule
{
  private static decimal R2(decimal v) => Math.Round(v, 2, MidpointRounding.AwayFromZero);
 
  // (1+r)^n بضرب متكرر في decimal — دقيق وحتمي، بدون double.
  private static decimal Pow(decimal b, int n)
  {
  decimal result = 1m;
  for (int i = 0; i < n; i++) result \*= b;
  return result;
  }
 
  /// <summary>القسط الشهري الثابت مُقرّباً لسنتين.</summary>
  public static decimal MonthlyPayment(decimal principal, decimal ratePercent, int termMonths)
  {
  if (termMonths <= 0) throw new ArgumentOutOfRangeException(nameof(termMonths));
  if (ratePercent == 0m) return R2(principal / termMonths);
  decimal r = ratePercent / 100m / 12m;
  decimal f = Pow(1m + r, termMonths);
  return R2(principal \* r \* f / (f - 1m));
  }
 
  // ratePercent: نسبة فائدة سنوية، مثال 5.0 تعني 5%
  public static List<Installment> BuildSchedule(
  decimal principal, decimal ratePercent, int termMonths,
  DateOnly startDate, IReadOnlyList<EarlyPayment>? earlyPayments = null)
  {
  if (principal <= 0m) throw new ArgumentOutOfRangeException(nameof(principal));
  if (ratePercent < 0m) throw new ArgumentOutOfRangeException(nameof(ratePercent));
  if (termMonths <= 0) throw new ArgumentOutOfRangeException(nameof(termMonths));
 
  principal = R2(principal);
  decimal r = ratePercent / 100m / 12m;
  decimal p = MonthlyPayment(principal, ratePercent, termMonths);
 
  // تجميع السداد المبكر حسب رقم القسط (قد يتكرر الرقم نفسه أكثر من مرة).
  var extras = new Dictionary<int, decimal>();
  if (earlyPayments != null)
  {
  foreach (var e in earlyPayments)
  {
  if (e == null) continue;
  if (e.AfterInstallmentNumber < 1) throw new ArgumentException("AfterInstallmentNumber must be >= 1");
  if (e.Amount < 0m) throw new ArgumentException("EarlyPayment.Amount must be >= 0");
  extras.TryGetValue(e.AfterInstallmentNumber, out var cur);
  extras\[e.AfterInstallmentNumber] = cur + R2(e.Amount);
  }
  }
 
  var list = new List<Installment>();
  decimal balance = principal;
 
  for (int i = 1; i <= termMonths \&\& balance > 0m; i++)
  {
  decimal opening = balance;
  decimal interest = R2(opening \* r);
 
  // القسط المعتاد؛ في آخر شهر من المدة الأصلية أو إن كان الرصيد أقل من القسط
  // نسدد الرصيد كاملاً (يمتص فروقات تقريب P بالسنتات).
  decimal payment = (i == termMonths || opening + interest <= p) ? opening + interest : p;
  decimal principalPart = payment - interest; // كلاهما مُقرّب ⇒ الناتج مُقرّب
 
  decimal afterRegular = opening - principalPart;
 
  // السداد المبكر يُخصم بعد القسط المعتاد، ولا يتجاوز الرصيد المتبقي أبداً.
  extras.TryGetValue(i, out var extra);
  if (extra > afterRegular) extra = afterRegular;
 
  decimal closing = afterRegular - extra;
 
  list.Add(new Installment
  {
  Number = i,
  DueDate = startDate.AddMonths(i - 1),
  OpeningBalance = opening,
  Payment = payment,
  EarlyPayment = extra,
  PrincipalPart = principalPart,
  InterestPart = interest,
  ClosingBalance = closing
  });
 
  balance = closing;
  }
 
  return list;
  }
}
 
public static class Program
{
  private static string Row(Installment x) =>
  $"{x.Number,3} {x.DueDate:yyyy-MM-dd} {x.OpeningBalance,13:N2} {x.Payment,11:N2} " +
  $"{x.EarlyPayment,11:N2} {x.PrincipalPart,11:N2} {x.InterestPart,10:N2} {x.ClosingBalance,13:N2}";
 
  private static void Check(bool cond, string msg)
  {
  Console.WriteLine((cond ? " \[OK] " : " \[FAIL] ") + msg);
  if (!cond) Environment.ExitCode = 1;
  }
 
  public static void Main()
  {
  var start = new DateOnly(2026, 1, 1);
 
  // ---- المثال الأول: بدون سداد مبكر ----
  var baseSchedule = LoanSchedule.BuildSchedule(100000m, 5.0m, 12, start);
  var first = baseSchedule\[0];
  Console.WriteLine("Verification (no early payment):");
  Check(LoanSchedule.MonthlyPayment(100000m, 5.0m, 12) == 8560.75m, "monthly payment = 8560.75");
  Check(first.OpeningBalance == 100000.00m \&\& first.InterestPart == 416.67m \&\&
  first.PrincipalPart == 8144.08m \&\& first.ClosingBalance == 91855.92m,
  "installment #1 = 100000.00 / 416.67 / 8144.08 / 91855.92");
  Check(baseSchedule.Count == 12 \&\& baseSchedule\[^1].ClosingBalance == 0m, "12 installments, ends at 0.00");
 
  // ---- المثال الثاني: سداد مبكر 20000 مع القسط 3 ----
  var early = new List<EarlyPayment> { new EarlyPayment { AfterInstallmentNumber = 3, Amount = 20000m } };
  var sched = LoanSchedule.BuildSchedule(100000m, 5.0m, 12, start, early);
 
  Console.WriteLine();
  Console.WriteLine("Schedule: 100,000.00 @ 5.00% / 12 months, early payment 20,000.00 with installment #3");
  Console.WriteLine(" # DueDate Opening Payment Early Principal Interest Closing");
  Console.WriteLine(new string('-', 92));
  foreach (var x in sched) Console.WriteLine(Row(x));
  Console.WriteLine(new string('-', 92));
  Console.WriteLine($" Totals {sched.Sum(x => x.Payment),25:N2} {sched.Sum(x => x.EarlyPayment),11:N2} " +
  $"{sched.Sum(x => x.PrincipalPart),11:N2} {sched.Sum(x => x.InterestPart),10:N2}");
 
  // تحقق من الاتساق سنتاً بسنت
  Console.WriteLine();
  Console.WriteLine("Verification (early payment):");
  bool chain = true;
  for (int i = 0; i < sched.Count; i++)
  {
  var x = sched\[i];
  if (x.ClosingBalance != x.OpeningBalance - x.PrincipalPart - x.EarlyPayment) chain = false;
  if (x.Payment != x.PrincipalPart + x.InterestPart) chain = false;
  if (x.ClosingBalance < 0m) chain = false;
  if (i > 0 \&\& x.OpeningBalance != sched\[i - 1].ClosingBalance) chain = false;
  }
  Check(chain, "every row: Closing = Opening - Principal - Early, Payment = Principal + Interest, no negatives");
  Check(sched.Count < 12, $"ends before month 12 (installments = {sched.Count})");
  Check(sched\[^1].Payment <= 8560.75m, $"last payment {sched\[^1].Payment:N2} <= 8,560.75");
  Check(sched\[^1].ClosingBalance == 0m, "final balance = 0.00");
  Check(sched.Sum(x => x.PrincipalPart + x.EarlyPayment) == 100000m, "principal + early payments = 100,000.00");
 
  // 0% مدعومة
  var zero = LoanSchedule.BuildSchedule(1000m, 0m, 3, start);
  Check(zero.Count == 3 \&\& zero.Sum(x => x.Payment) == 1000m \&\& zero.All(x => x.InterestPart == 0m),
  "0% rate: 333.33 / 333.33 / 333.34, no interest");
 
  // سداد مبكر يتجاوز الرصيد ⇒ قطع الجدول
  var over = LoanSchedule.BuildSchedule(100000m, 5.0m, 12, start,
  new List<EarlyPayment> { new EarlyPayment { AfterInstallmentNumber = 2, Amount = 1\_000\_000m } });
  Check(over.Count == 2 \&\& over\[^1].ClosingBalance == 0m, "oversized early payment is capped, schedule ends at #2");
 
  decimal interestEarly = sched.Sum(x => x.InterestPart);
  decimal interestBase = baseSchedule.Sum(x => x.InterestPart);
  Console.WriteLine();
  Console.WriteLine($"Summary: {sched.Count} installments (instead of {baseSchedule.Count}), " +
  $"total interest {interestEarly:N2}, saving {interestBase - interestEarly:N2} " +
  $"versus {interestBase:N2} without early payment.");
  }
}
هـChatGPT
سأبنيها بحيث يكون القسط الثابت محسوبًا مرة واحدة ومقربًا إلى السنت، ثم تُطبق الفائدة والقسط والسداد المبكر بالترتيب نفسه في كل شهر، مع منع أي رصيد سالب. كما أضفت Main يطبع المثال المطلوب ويقارن توفير الفوائد.
using System;
public class EarlyPayment
{
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
}
public class Installment
{
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
}
public static class LoanSchedule
{
  public static System.Collections.Generic.List<Installment> BuildSchedule(
  decimal principal,
  decimal ratePercent,
  int termMonths,
  DateOnly startDate,
  System.Collections.Generic.IReadOnlyList<EarlyPayment>? earlyPayments = null)
  {
  if (principal < 0)
  throw new ArgumentOutOfRangeException(nameof(principal));
  if (ratePercent < 0)
  throw new ArgumentOutOfRangeException(nameof(ratePercent));
  if (termMonths <= 0)
  throw new ArgumentOutOfRangeException(nameof(termMonths));
  if (principal == 0)
  return new System.Collections.Generic.List<Installment>();
  decimal monthlyRate = ratePercent / 100m / 12m;
  // القسط الشهري الأصلي.
  decimal payment;
  if (monthlyRate == 0m)
  {
  payment = Round2(principal / termMonths);
  }
  else
  {
  decimal factor = PowDecimal(1m + monthlyRate, termMonths);
  payment = Round2(
  principal \* monthlyRate \* factor / (factor - 1m));
  }
  var extraByInstallment =
  new System.Collections.Generic.Dictionary<int, decimal>();
  if (earlyPayments != null)
  {
  foreach (var early in earlyPayments)
  {
  if (early == null)
  throw new ArgumentException(
  "Early payment cannot be null.",
  nameof(earlyPayments));
  if (early.AfterInstallmentNumber <= 0)
  throw new ArgumentException(
  "AfterInstallmentNumber must be 1 or greater.",
  nameof(earlyPayments));
  if (early.Amount < 0m)
  throw new ArgumentException(
  "Early payment Amount cannot be negative.",
  nameof(earlyPayments));
  if (early.AfterInstallmentNumber > termMonths)
  throw new ArgumentException(
  "Early payment installment number cannot exceed the original term.",
  nameof(earlyPayments));
  decimal amount = Round2(early.Amount);
  if (extraByInstallment.ContainsKey(
  early.AfterInstallmentNumber))
  {
  extraByInstallment\[early.AfterInstallmentNumber] += amount;
  }
  else
  {
  extraByInstallment.Add(
  early.AfterInstallmentNumber, amount);
  }
  }
  }
  var schedule = new System.Collections.Generic.List<Installment>();
  decimal balance = Round2(principal);
  int number = 1;
  while (balance > 0m \&\& number <= termMonths)
  {
  decimal opening = balance;
  // الفائدة تحسب على الرصيد الافتتاحي ثم تقرب للسنت.
  decimal interest = Round2(opening \* monthlyRate);
  // القسط المعتاد، وآخر قسط يمكن أن يكون أصغر.
  decimal regularPayment = payment;
  decimal amountRequiredToClose = opening + interest;
  if (regularPayment > amountRequiredToClose)
  regularPayment = amountRequiredToClose;
  regularPayment = Round2(regularPayment);
  // جزء الأصل الناتج عن القسط المعتاد.
  decimal principalPart = Round2(regularPayment - interest);
  if (principalPart < 0m)
  principalPart = 0m;
  if (principalPart > opening)
  principalPart = opening;
  decimal remainingAfterRegular =
  Round2(opening - principalPart);
  // السداد المبكر يأتي بعد القسط المعتاد.
  decimal requestedEarly = 0m;
  if (extraByInstallment.TryGetValue(number, out decimal extra))
  requestedEarly = extra;
  decimal actualEarly = requestedEarly;
  if (actualEarly > remainingAfterRegular)
  actualEarly = remainingAfterRegular;
  actualEarly = Round2(actualEarly);
  decimal closing = Round2(
  remainingAfterRegular - actualEarly);
  if (closing < 0m)
  closing = 0m;
  // في حالة الوصول للصفر، نضمن أن أجزاء القسط
  // متطابقة مع الرصيد ولا ينتج أي رقم سالب.
  if (closing == 0m)
  {
  decimal totalPrincipal =
  Round2(opening);
  if (actualEarly > totalPrincipal)
  actualEarly = totalPrincipal;
  principalPart = Round2(
  totalPrincipal - actualEarly);
  // إذا كان القسط المعتاد وحده يغلق الرصيد،
  // لا نسجل سداداً مبكراً إضافياً.
  if (principalPart >= opening)
  {
  principalPart = opening;
  actualEarly = 0m;
  }
  regularPayment = Round2(principalPart + interest);
  }
  schedule.Add(new Installment
  {
  Number = number,
  DueDate = startDate.AddMonths(number - 1),
  OpeningBalance = opening,
  Payment = regularPayment,
  EarlyPayment = actualEarly,
  PrincipalPart = principalPart,
  InterestPart = interest,
  ClosingBalance = closing
  });
  balance = closing;
  number++;
  }
  return schedule;
  }
  private static decimal Round2(decimal value)
  {
  return Math.Round(value, 2, MidpointRounding.AwayFromZero);
  }
  private static decimal PowDecimal(decimal value, int exponent)
  {
  decimal result = 1m;
  for (int i = 0; i < exponent; i++)
  result \*= value;
  return result;
  }
}
public static class Program
{
  public static void Main()
  {
  decimal principal = 100000m;
  decimal rate = 5.0m;
  int term = 12;
  DateOnly startDate = new DateOnly(2026, 1, 1);
  var earlyPayments =
  new System.Collections.Generic.List<EarlyPayment>
  {
  new EarlyPayment
  {
  AfterInstallmentNumber = 3,
  Amount = 20000m
  }
  };
  var schedule = LoanSchedule.BuildSchedule(
  principal,
  rate,
  term,
  startDate,
  earlyPayments);
  var normalSchedule = LoanSchedule.BuildSchedule(
  principal,
  rate,
  term,
  startDate);
  Console.WriteLine(
  "No | Due Date | Opening | Payment | Early | Principal | Interest | Closing");
  Console.WriteLine(
  "---+------------+------------+-----------+-----------+-----------+-----------+-----------");
  foreach (var item in schedule)
  {
  Console.WriteLine(
  $"{item.Number,2} | " +
  $"{item.DueDate:yyyy-MM-dd} | " +
  $"{item.OpeningBalance,10:0.00} | " +
  $"{item.Payment,9:0.00} | " +
  $"{item.EarlyPayment,9:0.00} | " +
  $"{item.PrincipalPart,9:0.00} | " +
  $"{item.InterestPart,9:0.00} | " +
  $"{item.ClosingBalance,9:0.00}");
  }
  decimal totalInterest =
  SumInterest(schedule);
  decimal normalInterest =
  SumInterest(normalSchedule);
  decimal interestSaving =
  Math.Round(
  normalInterest - totalInterest,
  2,
  MidpointRounding.AwayFromZero);
  var last = schedule\[schedule.Count - 1];
  Console.WriteLine();
  Console.WriteLine(
  $"عدد الأقساط: {schedule.Count}");
  Console.WriteLine(
  $"مجموع الفوائد: {totalInterest:0.00}");
  Console.WriteLine(
  $"توفير الفوائد مقابل الجدول بدون سداد مبكر: {interestSaving:0.00}");
  Console.WriteLine(
  $"القسط الأخير: {last.Payment:0.00}");
  // تحقق من المثال الأساسي.
  var basic = LoanSchedule.BuildSchedule(
  100000m,
  5.0m,
  12,
  new DateOnly(2026, 1, 1));
  if (basic\[0].Payment != 8560.75m ||
  basic\[0].OpeningBalance != 100000.00m ||
  basic\[0].InterestPart != 416.67m ||
  basic\[0].PrincipalPart != 8144.08m ||
  basic\[0].ClosingBalance != 91855.92m)
  {
  throw new Exception("Basic schedule verification failed.");
  }
  // تحقق من السداد المبكر.
  if (schedule.Count >= 12)
  throw new Exception("Early payment did not shorten the term.");
  if (last.Payment > 8560.75m)
  throw new Exception("Final payment exceeds the regular payment.");
  if (last.ClosingBalance != 0m)
  throw new Exception("Loan did not close at zero balance.");
  foreach (var item in schedule)
  {
  if (item.ClosingBalance < 0m)
  throw new Exception("Negative balance detected.");
  }
  Console.WriteLine("التحقق: ناجح.");
  }
  private static decimal SumInterest(
  System.Collections.Generic.IReadOnlyList<Installment> schedule)
  {
  decimal total = 0m;
  foreach (var item in schedule)
  total += item.InterestPart;
  return Math.Round(total, 2, MidpointRounding.AwayFromZero);
  }
}
هذا يحقق المثال الأساسي تحديدًا: القسط 8560.75، والفائدة الأولى 416.67، والأصل 8144.08، والرصيد 91855.92. وفي المثال الثاني يتوقف الجدول عند سداد الرصيد بالكامل بدل إنشاء أرصدة سالبة، مع بقاء القسط الشهري ثابتًا بعد السداد المبكر.
جDeepSeek
إليك ملف C# كامل يحقق المتطلبات المذكورة. يستخدم .NET 6+ ومكتبة System فقط، مع جدولة الاستهلاك ودعم السداد المبكر الجزئي.
csharp
using System;
using System.Collections.Generic;
using System.Linq;
public class EarlyPayment
{
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
}
public class Installment
{
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
}
public static class LoanSchedule
{
  private static decimal Round2(decimal value) =>
  Math.Round(value, 2, MidpointRounding.AwayFromZero);
  public static List<Installment> BuildSchedule(
  decimal principal, decimal ratePercent, int termMonths,
  DateOnly startDate, IReadOnlyList<EarlyPayment>? earlyPayments = null)
  {
  if (principal <= 0) throw new ArgumentException("Principal must be positive.", nameof(principal));
  if (termMonths <= 0) throw new ArgumentException("Term must be positive.", nameof(termMonths));
  if (ratePercent < 0) throw new ArgumentException("Rate cannot be negative.", nameof(ratePercent));
  var earlyMap = new Dictionary<int, decimal>();
  if (earlyPayments != null)
  {
  foreach (var ep in earlyPayments)
  {
  if (ep.AfterInstallmentNumber < 1)
  throw new ArgumentException("Early payment installment number must be >= 1.");
  if (ep.Amount <= 0) continue;
  earlyMap\[ep.AfterInstallmentNumber] =
  earlyMap.TryGetValue(ep.AfterInstallmentNumber, out var existing)
  ? existing + ep.Amount
  : ep.Amount;
  }
  }
  var schedule = new List<Installment>();
  decimal monthlyRate = ratePercent / 100m / 12m;
  decimal monthlyPayment;
  if (monthlyRate == 0m)
  monthlyPayment = Round2(principal / termMonths);
  else
  {
  double r = (double)monthlyRate;
  double factor = Math.Pow(1 + r, termMonths);
  decimal p = principal \* (decimal)(r \* factor / (factor - 1));
  monthlyPayment = Round2(p);
  }
  decimal balance = principal;
  int maxMonths = termMonths + earlyMap.Keys.DefaultIfEmpty(0).Max() + 1000; // أمان
  int number = 1;
  while (balance > 0m \&\& number <= maxMonths)
  {
  decimal opening = balance;
  decimal interest = monthlyRate == 0m ? 0m : Round2(opening \* monthlyRate);
  decimal scheduledPayment = monthlyPayment;
  // إذا كان الرصيد + الفائدة أقل من القسط، اجعل القسط = الرصيد + الفائدة
  if (opening + interest < scheduledPayment)
  scheduledPayment = opening + interest;
  decimal principalPart = Round2(scheduledPayment - interest);
  if (principalPart < 0m) principalPart = 0m;
  if (principalPart > opening) principalPart = opening;
  decimal closing = opening - principalPart;
  decimal early = earlyMap.TryGetValue(number, out var e) ? e : 0m;
  if (early > 0m)
  {
  if (early >= closing)
  {
  early = closing;
  closing = 0m;
  }
  else
  {
  closing -= early;
  }
  }
  schedule.Add(new Installment
  {
  Number = number,
  DueDate = startDate.AddMonths(number - 1),
  OpeningBalance = opening,
  Payment = scheduledPayment,
  EarlyPayment = early,
  PrincipalPart = principalPart,
  InterestPart = interest,
  ClosingBalance = closing
  });
  balance = closing;
  number++;
  if (balance == 0m) break;
  }
  return schedule;
  }
}
public static class Program
{
  public static void Main()
  {
  decimal principal = 100000m;
  decimal rate = 5.0m;
  int term = 12;
  var start = new DateOnly(2026, 1, 1);
  // المثال الأول للتحقق
  var baseSchedule = LoanSchedule.BuildSchedule(principal, rate, term, start);
  Console.WriteLine("=== الجدول الأساسي بدون سداد مبكر ===");
  PrintSchedule(baseSchedule);
  Console.WriteLine();
  // المثال الثاني مع سداد مبكر
  var early = new List<EarlyPayment>
  {
  new EarlyPayment { AfterInstallmentNumber = 3, Amount = 20000m }
  };
  var earlySchedule = LoanSchedule.BuildSchedule(principal, rate, term, start, early);
  Console.WriteLine("=== الجدول مع سداد مبكر 20000 مع القسط 3 ===");
  PrintSchedule(earlySchedule);
  Console.WriteLine();
  // ملخص
  int baseCount = baseSchedule.Count;
  int earlyCount = earlySchedule.Count;
  decimal baseInterest = baseSchedule.Sum(i => i.InterestPart);
  decimal earlyInterest = earlySchedule.Sum(i => i.InterestPart);
  decimal saving = baseInterest - earlyInterest;
  decimal lastPayment = earlySchedule.Last().Payment;
  Console.WriteLine("=== الملخص ===");
  Console.WriteLine($"عدد الأقساط (بدون سداد مبكر): {baseCount}");
  Console.WriteLine($"عدد الأقساط (مع سداد مبكر): {earlyCount}");
  Console.WriteLine($"مجموع الفوائد (بدون سداد مبكر): {baseInterest:F2}");
  Console.WriteLine($"مجموع الفوائد (مع سداد مبكر): {earlyInterest:F2}");
  Console.WriteLine($"توفير الفوائد: {saving:F2}");
  Console.WriteLine($"القسط الأخير مع السداد المبكر: {lastPayment:F2} (≤ 8560.75)");
  }
  private static void PrintSchedule(List<Installment> schedule)
  {
  Console.WriteLine("رقم | تاريخ الاستحقاق | الرصيد الافتتاحي | القسط | سداد مبكر | أصل | فائدة | الرصيد الختامي");
  Console.WriteLine(new string('-', 110));
  foreach (var inst in schedule)
  {
  Console.WriteLine($"{inst.Number,3} | {inst.DueDate:yyyy-MM-dd} | {inst.OpeningBalance,15:F2} | {inst.Payment,8:F2} | {inst.EarlyPayment,9:F2} | {inst.PrincipalPart,8:F2} | {inst.InterestPart,7:F2} | {inst.ClosingBalance,15:F2}");
  }
  }
}
شرح مختصر:
حساب القسط الشهري: باستخدام صيغة القسط الثابت، مع دعم نسبة فائدة 0%.
التقريب: يتم تقريب PrincipalPart وInterestPart إلى منزلتين عشريتين باستخدام MidpointRounding.AwayFromZero، ويتم تصحيح ClosingBalance بناءً على الأجزاء المقربة.
السداد المبكر: يُضاف المبلغ الإضافي بعد خصم القسط المعتاد، ثم يُخصم من الرصيد المتبقي. يستمر الجدول بنفس القسط الشهري لكن المدة تقصر.
التواريخ: تُحسب من startDate باستخدام AddMonths.
الحتمية: لا يوجد اعتماد على تاريخ اليوم.
Main: يطبع الجدولين ويظهر الملخص المطلوب.
يمكنك نسخ الملف وتشغيله مباشرة في مشروع .NET 6+ بدون أي حزم إضافية.
أGemini
سأقوم الآن بإنشاء كود C# الكامل والذي يتضمن منطق حساب جدول الاستهلاك (Amortization Schedule) مع دعم السداد المبكر الجزئي وتقليل مدة القرض، مع مراعاة كافة الشروط وتطبيق الحسابات المالية الدقيقة بالتصفير والتقريب المحدد.
LoanSchedule.cs
using System;
using System.Collections.Generic;
using System.Linq;
public class EarlyPayment
{
  public int AfterInstallmentNumber; // يُدفع مع القسط رقم N (1-based)
  public decimal Amount; // مبلغ إضافي على القسط
}
public class Installment
{
  public int Number;
  public DateOnly DueDate;
  public decimal OpeningBalance;
  public decimal Payment; // القسط الشهري المعتاد (آخر قسط قد يكون أصغر)
  public decimal EarlyPayment; // المبلغ الإضافي في هذا الشهر، 0 إن لم يوجد
  public decimal PrincipalPart;
  public decimal InterestPart;
  public decimal ClosingBalance;
}
public static class LoanSchedule
{
  // ratePercent: نسبة فائدة سنوية، مثال 5.0 تعني 5%
  public static List<Installment> BuildSchedule(
  decimal principal, decimal ratePercent, int termMonths,
  DateOnly startDate, IReadOnlyList<EarlyPayment>? earlyPayments = null)
  {
  var schedule = new List<Installment>();
  if (principal <= 0 || termMonths <= 0) return schedule;
  // حساب معدل الفائدة الشهري
  decimal r = (ratePercent / 100m) / 12m;
  // حساب القسط الشهري الثابت الأساسي P
  decimal basePayment;
  if (ratePercent == 0m)
  {
  basePayment = Math.Round(principal / termMonths, 2, MidpointRounding.AwayFromZero);
  }
  else
  {
  double rDouble = (double)r;
  double pow = Math.Pow(1 + rDouble, termMonths);
  double pDouble = (double)principal \* (rDouble \* pow) / (pow - 1);
  basePayment = Math.Round((decimal)pDouble, 2, MidpointRounding.AwayFromZero);
  }
  // تحضير خريطة السداد المبكر لتسهيل الوصول حسب رقم القسط
  var earlyMap = earlyPayments?
  .GroupBy(e => e.AfterInstallmentNumber)
  .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount))
  ?? new Dictionary<int, decimal>();
  decimal currentBalance = principal;
  int installmentNum = 1;
  while (currentBalance > 0 \&\& installmentNum <= termMonths \* 2) // حد أمان للتكرار
  {
  decimal openingBalance = currentBalance;
  DateOnly dueDate = startDate.AddMonths(installmentNum - 1);
  // حساب الفائدة الشهرية لهذا القسط
  decimal interestUnrounded = openingBalance \* r;
  decimal interestPart = Math.Round(interestUnrounded, 2, MidpointRounding.AwayFromZero);
  // التحقق من مبلغ السداد المبكر لهذا القسط
  earlyMap.TryGetValue(installmentNum, out decimal earlyPayAmount);
  if (earlyPayAmount < 0m) earlyPayAmount = 0m;
  // حساب الجزء المخصص للصل (Principal) والقسط الأساسي (Payment)
  decimal standardPrincipalPart = basePayment - interestPart;
  decimal totalRequiredToClose = openingBalance + interestPart; // المبلغ الشامل لإغلاق القرض
  decimal actualPayment;
  decimal actualPrincipalPart;
  decimal actualEarlyPayment = 0m;
  if (basePayment + earlyPayAmount >= totalRequiredToClose)
  {
  // إذا كان مجموع الدفعة المعتادة + السداد المبكر يغطي كل القرض أو يتجاوزه
  if (basePayment >= totalRequiredToClose)
  {
  actualPayment = totalRequiredToClose;
  actualPrincipalPart = openingBalance;
  actualEarlyPayment = 0m;
  }
  else
  {
  actualPayment = basePayment;
  actualPrincipalPart = standardPrincipalPart;
  actualEarlyPayment = openingBalance - actualPrincipalPart;
  }
  }
  else
  {
  actualPayment = basePayment;
  actualPrincipalPart = standardPrincipalPart;
  actualEarlyPayment = earlyPayAmount;
  }
  // التأكد من أن الأجزاء لا تتجاوز الرصيد المتبقي
  if (actualPrincipalPart > openingBalance)
  {
  actualPrincipalPart = openingBalance;
  actualPayment = actualPrincipalPart + interestPart;
  }
  decimal totalPrincipalPaid = actualPrincipalPart + actualEarlyPayment;
  decimal closingBalance = openingBalance - totalPrincipalPaid;
  if (closingBalance < 0m)
  {
  closingBalance = 0m;
  }
  var inst = new Installment
  {
  Number = installmentNum,
  DueDate = dueDate,
  OpeningBalance = openingBalance,
  Payment = actualPayment,
  EarlyPayment = actualEarlyPayment,
  PrincipalPart = actualPrincipalPart,
  InterestPart = interestPart,
  ClosingBalance = closingBalance
  };
  schedule.Add(inst);
  currentBalance = closingBalance;
  if (currentBalance <= 0m)
  {
  break;
  }
  installmentNum++;
  }
  return schedule;
  }
  public static void Main()
  {
  decimal principal = 100000m;
  decimal ratePercent = 5.0m;
  int termMonths = 12;
  DateOnly startDate = new DateOnly(2026, 1, 1);
  // جدول بدون سداد مبكر للحساب والمقارنة
  var baseSchedule = BuildSchedule(principal, ratePercent, termMonths, startDate);
  decimal baseTotalInterest = baseSchedule.Sum(i => i.InterestPart);
  // جدول مع سداد مبكر بقيمة 20,000 في القسط رقم 3
  var earlyPayments = new List<EarlyPayment>
  {
  new EarlyPayment { AfterInstallmentNumber = 3, Amount = 20000m }
  };
  var scheduleWithEarly = BuildSchedule(principal, ratePercent, termMonths, startDate, earlyPayments);
  decimal earlyTotalInterest = scheduleWithEarly.Sum(i => i.InterestPart);
  decimal interestSaved = baseTotalInterest - earlyTotalInterest;
  // طباعة الجدول
  Console.WriteLine("====================================================================================================");
  Console.WriteLine($"{"#",-4} | {"Due Date",-10} | {"Opening Bal",-12} | {"Payment",-10} | {"Early Pay",-10} | {"Principal",-10} | {"Interest",-10} | {"Closing Bal",-12}");
  Console.WriteLine("====================================================================================================");
  foreach (var inst in scheduleWithEarly)
  {
  Console.WriteLine($"{inst.Number,-4} | {inst.DueDate,-10:yyyy-MM-dd} | {inst.OpeningBalance,12:F2} | {inst.Payment,10:F2} | {inst.EarlyPayment,10:F2} | {inst.PrincipalPart,10:F2} | {inst.InterestPart,10:F2} | {inst.ClosingBalance,12:F2}");
  }
  Console.WriteLine("====================================================================================================");
  Console.WriteLine();
  Console.WriteLine($"الملخص:");
  Console.WriteLine($"- عدد الأقساط الفعلية: {scheduleWithEarly.Count} قسطاً (من أصل {termMonths} شهر).");
  Console.WriteLine($"- إجمالي الفوائد المدفوعة: {earlyTotalInterest:F2}");
  Console.WriteLine($"- توفير الفوائد مقارنة بالجدول المعتاد: {interestSaved:F2} (إجمالي الفائدة المعتادة كانت {baseTotalInterest:F2})");
  }
}
تم إنشاء الكود المطلوب كاملاً في ملف واحد ينفذ كل الشروط والقواعد المحددة.أبرز النقاط في الحل:دقة الحسابات والتقريب: تم حساب القسط الشهري الثابت $P$ باستخدام المعادلات المحددة واستخدام Math.Round(..., 2, MidpointRounding.AwayFromZero) لكل من أجزاء الفائدة والأصل لتجنب أي فروقات سنتات.دعم نسبة الفائدة 0%: معالجة الحالات الخاصة بنسبة فائدة صفريّة لتجنب خطأ القسمه على صفر (DivisionByZeroException).معالجة السداد المبكر والسداد الكلي: عند تطبيق سداد مبكر في الشهر $N$، يتم خصم القسط المعتاد ثم السداد المبكر. إذا تجاوز المجموع الرصيد القائم يتم إغلاق القرض وتحديد الدفعة الأخيرة بضبط رصيد الإغلاق عند 0.00.الملخص: يتم حساب إجمالي الفوائد المدفوعة والتوفير الناتج عن السداد المبكر مقارنة بالجدول الأساسي وطباعتها بوضوح.