diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afe0ad3..ee62823 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,15 @@ name: Publish NuGet on: - release: - types: [published] + push: + tags: + - "v*.*.*" permissions: contents: read concurrency: - group: nuget-${{ github.event.release.tag_name }} + group: nuget-${{ github.ref_name }} cancel-in-progress: false env: @@ -18,7 +19,6 @@ env: jobs: validate: name: Validate release artifact - if: ${{ !github.event.release.prerelease && !github.event.release.draft }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -30,7 +30,7 @@ jobs: - name: Checkout release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.sha }} fetch-depth: 0 - name: Setup .NET @@ -48,7 +48,7 @@ jobs: id: version shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.ref_name }} run: | [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Expected a stable vMAJOR.MINOR.PATCH tag, got $RELEASE_TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0beaddd..52498df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,37 +1,13 @@ # Changelog -All notable changes to this project are documented in this file. The project follows [Semantic Versioning](https://semver.org/). +## [3.0.0] - 2026-08-18 -## [3.0.0] - 2026-08-16 +- Added .NET Standard 2.0 support. +- Added annual interest-only calculations and custom compounding periods. +- Compound calculations now use decimal arithmetic throughout. +- Negative inputs and unsupported `InterestPeriod` values now throw `ArgumentOutOfRangeException`. -### Added - -- `netstandard2.0` package asset while retaining the optimized `net8.0` asset. -- .NET 8 and .NET 10 test execution. -- Annual overload for `CalculateCompoundInterestAmount`. -- XML API documentation, portable symbols, Source Link metadata, and `.snupkg` generation. -- Package validation against 2.0.1. -- Reproducible CI artifacts, coverage reporting, locked dependencies, and monthly Dependabot updates. -- Safe NuGet release workflow based on a published GitHub Release and Trusted Publishing. - -### Changed - -- Compound calculations now use decimal exponentiation instead of converting through `double`. -- Documentation now defines `interestRate` as a nominal annual fractional rate and `period` as whole years, matching the existing formulas. -- Daily compounding is explicitly documented as 365 compounding periods per year. -- Package version is now published only through the dedicated release workflow; pushes to `main` never publish. - -### Fixed - -- All public methods now reject negative principal, interest rate, and period values consistently. -- `CalculateCompoundInterestAmount` now rejects undefined `InterestPeriod` values instead of silently treating them as yearly. -- Package contents now include the XML documentation promised by the README. - -### Breaking changes - -- Invalid calls that previously returned a value can now throw `ArgumentOutOfRangeException`. -- Valid compound calculations can differ in their least significant decimal digits because the `double` conversion was removed. -- Consumers that depend on exact 2.x behavior must remain on 2.0.1 until they have reviewed these changes. +**Breaking:** invalid-input behavior changed, and compound results can differ from 2.x in their least significant decimal digits. ## [2.0.1] - 2025-05-25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f63a9a..b42f2df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Local development -Install the .NET 10 SDK. The solution builds the library for `netstandard2.0` and `net8.0`, and runs its tests on .NET 8 and .NET 10. +Install the .NET 10 SDK, then run: ```powershell dotnet tool restore @@ -12,35 +12,23 @@ dotnet test InterestExtension.sln --configuration Release --no-build --settings dotnet pack InterestExtension/InterestExtension.csproj --configuration Release --no-build --no-restore ``` -CI additionally restores `InterestExtension.PackageSmoke` from the newly created local package into an isolated package cache and runs it on .NET 8 and .NET 10. This verifies the actual `.nupkg`, its XML documentation, README, icon, and symbol package rather than only testing a project reference. - -When intentionally updating a NuGet dependency, regenerate the lock files with: - -```powershell -dotnet restore InterestExtension.sln --force-evaluate -``` - -The repository-level `NuGet.Config` clears machine-specific feeds and restores dependencies only from NuGet.org. - ## Pull requests - Add or update tests for behavior changes. -- Preserve the public API unless the change is explicitly planned for a major release. -- Update `README.md`, `MIGRATION.md`, and `CHANGELOG.md` when the public contract changes. -- Keep calculations unrounded; rounding rules belong to the consuming financial domain. - -## Release policy - -A push to `main` runs CI but never publishes a package. +- Preserve the public API unless the change is planned for a major release. +- Update the README and changelog when the public contract changes. +- Keep calculations unrounded; rounding rules belong to the consuming domain. -To release a stable version: +## Releasing -1. Set the version in `InterestExtension.csproj` and update the changelog. -2. Merge a green pull request. -3. Create a `vMAJOR.MINOR.PATCH` tag that exactly matches the project version. -4. Publish a non-prerelease GitHub Release for that tag. -5. The protected `nuget.org` environment authenticates through NuGet Trusted Publishing and publishes the package. +After merging a green pull request, tag the commit on `main` with the version declared in `InterestExtension.csproj` and push the tag: -The NuGet.org Trusted Publishing policy must match owner `louresb`, repository `InterestExtensions`, workflow file `release.yml`, and GitHub environment `nuget.org`. The repository variable `NUGET_USER` must contain the NuGet.org profile name, not an email address. +```powershell +git switch main +git pull --ff-only +$version = dotnet msbuild InterestExtension/InterestExtension.csproj -nologo -getProperty:PackageVersion +git tag "v$version" +git push origin "v$version" +``` -Duplicate package versions fail by design; the release workflow never uses `--skip-duplicate`. +The tag starts the workflow that validates and publishes the package through NuGet Trusted Publishing. Duplicate or mismatched versions fail without publishing. diff --git a/InterestExtension.PackageSmoke/Program.cs b/InterestExtension.PackageSmoke/Program.cs index dc2f310..c2f094a 100644 --- a/InterestExtension.PackageSmoke/Program.cs +++ b/InterestExtension.PackageSmoke/Program.cs @@ -10,6 +10,15 @@ throw new InvalidOperationException($"Expected {expected}, but the installed package returned {actual}."); } +const decimal expectedCustomPeriods = 1196.1474756866648607810499868m; +var actualCustomPeriods = 1000m.CalculateCompoundInterestForPeriods(0.12m, 18, 12); + +if (actualCustomPeriods != expectedCustomPeriods) +{ + throw new InvalidOperationException( + $"Expected {expectedCustomPeriods}, but the installed package returned {actualCustomPeriods}."); +} + if (args.Length != 1) { throw new ArgumentException("Pass the package output directory as the only argument."); diff --git a/InterestExtension.Tests/CustomCompoundingPeriodTests.cs b/InterestExtension.Tests/CustomCompoundingPeriodTests.cs new file mode 100644 index 0000000..6f3b6eb --- /dev/null +++ b/InterestExtension.Tests/CustomCompoundingPeriodTests.cs @@ -0,0 +1,67 @@ +namespace InterestExtension.Tests; + +[TestClass] +public sealed class CustomCompoundingPeriodTests +{ + [TestMethod] + public void CalculateCompoundInterestForPeriodsReturnsExactResult() + => Assert.AreEqual( + 1196.1474756866648607810499868m, + 1000m.CalculateCompoundInterestForPeriods( + 0.12m, + compoundingPeriodCount: 18, + compoundingPeriodsPerYear: 12)); + + [TestMethod] + public void CustomMonthlyPeriodsMatchWholeYearOverload() + { + var customTotal = 750m.CalculateCompoundInterestForPeriods(0.08m, 36, 12); + var customInterest = 750m.CalculateCompoundInterestAmountForPeriods(0.08m, 36, 12); + var monthlyTotal = 750m.CalculateCompoundInterest(0.08m, 3, Enums.InterestPeriod.Monthly); + var monthlyInterest = 750m.CalculateCompoundInterestAmount(0.08m, 3, Enums.InterestPeriod.Monthly); + + Assert.AreEqual(monthlyTotal, customTotal); + Assert.AreEqual(monthlyInterest, customInterest); + } + + [TestMethod] + public void CustomFrequencySupportsNonPresetPeriods() + { + var quarterly = 1000m.CalculateCompoundInterestForPeriods(0.08m, 8, 4); + var semiannual = 1000m.CalculateCompoundInterestForPeriods(0.08m, 4, 2); + + Assert.AreEqual(1171.6593810022656m, quarterly); + Assert.AreEqual(1169.85856m, semiannual); + } + + [TestMethod] + public void InterestAmountEqualsTotalMinusPrincipal() + { + const decimal principal = 1000m; + var total = principal.CalculateCompoundInterestForPeriods(0.12m, 18, 12); + var interest = principal.CalculateCompoundInterestAmountForPeriods(0.12m, 18, 12); + + Assert.AreEqual(total - principal, interest); + } + + [TestMethod] + public void ZeroValueIdentitiesReturnWithoutRoundingOrOverflow() + { + Assert.AreEqual(decimal.MaxValue, decimal.MaxValue.CalculateCompoundInterestForPeriods(0m, int.MaxValue, 1)); + Assert.AreEqual(0m, decimal.MaxValue.CalculateCompoundInterestAmountForPeriods(0m, int.MaxValue, 1)); + + Assert.AreEqual(1m, 1m.CalculateCompoundInterestForPeriods(decimal.MaxValue, 0, 1)); + Assert.AreEqual(0m, 1m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 0, 1)); + + Assert.AreEqual(0m, 0m.CalculateCompoundInterestForPeriods(decimal.MaxValue, int.MaxValue, 1)); + Assert.AreEqual(0m, 0m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, int.MaxValue, 1)); + } + + [TestMethod] + public void ResultsAreNotRoundedByTheLibrary() + { + var result = 1000m.CalculateCompoundInterestForPeriods(0.12m, 18, 12); + + Assert.AreNotEqual(decimal.Round(result, 2), result); + } +} diff --git a/InterestExtension.Tests/ValidationTests.cs b/InterestExtension.Tests/ValidationTests.cs index d4d771f..95d8ab9 100644 --- a/InterestExtension.Tests/ValidationTests.cs +++ b/InterestExtension.Tests/ValidationTests.cs @@ -13,8 +13,10 @@ public void EveryCalculationRejectsNegativePrincipal() () => (-1m).CalculateSimpleInterestAmount(0.05m, 1), () => (-1m).CalculateCompoundInterest(0.05m, 1), () => (-1m).CalculateCompoundInterest(0.05m, 1, InterestPeriod.Monthly), + () => (-1m).CalculateCompoundInterestForPeriods(0.05m, 1, 12), () => (-1m).CalculateCompoundInterestAmount(0.05m, 1), - () => (-1m).CalculateCompoundInterestAmount(0.05m, 1, InterestPeriod.Daily)); + () => (-1m).CalculateCompoundInterestAmount(0.05m, 1, InterestPeriod.Daily), + () => (-1m).CalculateCompoundInterestAmountForPeriods(0.05m, 1, 12)); [TestMethod] public void EveryCalculationRejectsNegativeInterestRate() @@ -24,8 +26,10 @@ public void EveryCalculationRejectsNegativeInterestRate() () => 100m.CalculateSimpleInterestAmount(-0.05m, 1), () => 100m.CalculateCompoundInterest(-0.05m, 1), () => 100m.CalculateCompoundInterest(-0.05m, 1, InterestPeriod.Monthly), + () => 100m.CalculateCompoundInterestForPeriods(-0.05m, 1, 12), () => 100m.CalculateCompoundInterestAmount(-0.05m, 1), - () => 100m.CalculateCompoundInterestAmount(-0.05m, 1, InterestPeriod.Daily)); + () => 100m.CalculateCompoundInterestAmount(-0.05m, 1, InterestPeriod.Daily), + () => 100m.CalculateCompoundInterestAmountForPeriods(-0.05m, 1, 12)); [TestMethod] public void EveryCalculationRejectsNegativePeriod() @@ -51,6 +55,29 @@ public void CompoundCalculationsRejectUnknownPeriodType(int value) () => 100m.CalculateCompoundInterestAmount(0.05m, 1, periodType)); } + [TestMethod] + public void CustomCompoundCalculationsRejectNegativePeriodCount() + => AssertAllThrowForParameter( + "compoundingPeriodCount", + () => 100m.CalculateCompoundInterestForPeriods(0.05m, -1, 12), + () => 100m.CalculateCompoundInterestAmountForPeriods(0.05m, -1, 12)); + + [TestMethod] + [DataRow(0)] + [DataRow(-1)] + public void CustomCompoundCalculationsRejectInvalidFrequency(int compoundingPeriodsPerYear) + => AssertAllThrowForParameter( + "compoundingPeriodsPerYear", + () => 100m.CalculateCompoundInterestForPeriods(0.05m, 1, compoundingPeriodsPerYear), + () => 100m.CalculateCompoundInterestAmountForPeriods(0.05m, 1, compoundingPeriodsPerYear)); + + [TestMethod] + public void ZeroValueIdentitiesStillRejectInvalidCustomFrequency() + => AssertAllThrowForParameter( + "compoundingPeriodsPerYear", + () => 0m.CalculateCompoundInterestForPeriods(decimal.MaxValue, 0, 0), + () => 0m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 0, 0)); + [TestMethod] public void ZeroValueIdentitiesStillRejectUnknownPeriodType() { @@ -69,8 +96,14 @@ public void DailyCompoundingRejectsPeriodCountOverflow() [TestMethod] public void CalculationRejectsDecimalOverflow() - => Assert.ThrowsExactly( + { + Assert.ThrowsExactly( () => decimal.MaxValue.CalculateCompoundInterest(1m, 1)); + Assert.ThrowsExactly( + () => decimal.MaxValue.CalculateCompoundInterestForPeriods(1m, 1, 1)); + Assert.ThrowsExactly( + () => decimal.MaxValue.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 1, 1)); + } [TestMethod] public void InterestOnlyCalculationsDoNotAddPrincipalBeforeReturning() diff --git a/InterestExtension/InterestExtension.cs b/InterestExtension/InterestExtension.cs index 551aee3..aaa383a 100644 --- a/InterestExtension/InterestExtension.cs +++ b/InterestExtension/InterestExtension.cs @@ -83,6 +83,48 @@ public static decimal CalculateCompoundInterest( return principal * CalculateCompoundFactor(interestRate, period, periodsPerYear); } + /// + /// Calculates the total amount after a specific number of compounding periods. + /// + /// The initial principal amount. + /// The nominal annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The total number of times interest is applied. + /// The number of compounding periods in one year. + /// The total amount after applying compound interest. + /// + /// Thrown when , , or + /// is negative, or when + /// is less than one. + /// + /// Thrown when the result exceeds the range of . + /// + /// The periodic rate is divided by + /// . For example, a count of 18 and a frequency of 12 represent + /// 18 monthly compounding periods. The result is not rounded. + /// + public static decimal CalculateCompoundInterestForPeriods( + this decimal principal, + decimal interestRate, + int compoundingPeriodCount, + int compoundingPeriodsPerYear) + { + ValidateCompoundingPeriodInputs( + principal, + interestRate, + compoundingPeriodCount, + compoundingPeriodsPerYear); + + if (principal == 0 || interestRate == 0 || compoundingPeriodCount == 0) + { + return principal; + } + + return principal * CalculateCompoundFactorForPeriods( + interestRate, + compoundingPeriodCount, + compoundingPeriodsPerYear); + } + /// /// Calculates only the simple interest earned, excluding the principal. /// @@ -156,7 +198,84 @@ public static decimal CalculateCompoundInterestAmount( return principal * (CalculateCompoundFactor(interestRate, period, periodsPerYear) - 1m); } + /// + /// Calculates only the compound interest earned after a specific number of compounding periods. + /// + /// The initial principal amount. + /// The nominal annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The total number of times interest is applied. + /// The number of compounding periods in one year. + /// The interest earned. + /// + /// Thrown when , , or + /// is negative, or when + /// is less than one. + /// + /// Thrown when the result exceeds the range of . + /// + /// The periodic rate is divided by + /// . For example, a count of 18 and a frequency of 12 represent + /// 18 monthly compounding periods. The result is not rounded. + /// + public static decimal CalculateCompoundInterestAmountForPeriods( + this decimal principal, + decimal interestRate, + int compoundingPeriodCount, + int compoundingPeriodsPerYear) + { + ValidateCompoundingPeriodInputs( + principal, + interestRate, + compoundingPeriodCount, + compoundingPeriodsPerYear); + + if (principal == 0 || interestRate == 0 || compoundingPeriodCount == 0) + { + return 0m; + } + + return principal * (CalculateCompoundFactorForPeriods( + interestRate, + compoundingPeriodCount, + compoundingPeriodsPerYear) - 1m); + } + private static void ValidateInputs(decimal principal, decimal interestRate, int period) + { + ValidatePrincipalAndInterestRate(principal, interestRate); + + if (period < 0) + { + throw new ArgumentOutOfRangeException(nameof(period), period, "Period cannot be negative."); + } + } + + private static void ValidateCompoundingPeriodInputs( + decimal principal, + decimal interestRate, + int compoundingPeriodCount, + int compoundingPeriodsPerYear) + { + ValidatePrincipalAndInterestRate(principal, interestRate); + + if (compoundingPeriodCount < 0) + { + throw new ArgumentOutOfRangeException( + nameof(compoundingPeriodCount), + compoundingPeriodCount, + "Compounding period count cannot be negative."); + } + + if (compoundingPeriodsPerYear < 1) + { + throw new ArgumentOutOfRangeException( + nameof(compoundingPeriodsPerYear), + compoundingPeriodsPerYear, + "Compounding periods per year must be greater than zero."); + } + } + + private static void ValidatePrincipalAndInterestRate(decimal principal, decimal interestRate) { if (principal < 0) { @@ -167,11 +286,6 @@ private static void ValidateInputs(decimal principal, decimal interestRate, int { throw new ArgumentOutOfRangeException(nameof(interestRate), interestRate, "Interest rate cannot be negative."); } - - if (period < 0) - { - throw new ArgumentOutOfRangeException(nameof(period), period, "Period cannot be negative."); - } } private static int GetPeriodsPerYear(InterestPeriod periodType) @@ -186,9 +300,18 @@ private static int GetPeriodsPerYear(InterestPeriod periodType) private static decimal CalculateCompoundFactor(decimal interestRate, int period, int periodsPerYear) { var totalPeriods = checked(periodsPerYear * period); - var periodicRate = interestRate / periodsPerYear; - return Pow(1m + periodicRate, totalPeriods); + return CalculateCompoundFactorForPeriods(interestRate, totalPeriods, periodsPerYear); + } + + private static decimal CalculateCompoundFactorForPeriods( + decimal interestRate, + int compoundingPeriodCount, + int compoundingPeriodsPerYear) + { + var periodicRate = interestRate / compoundingPeriodsPerYear; + + return Pow(1m + periodicRate, compoundingPeriodCount); } private static decimal Pow(decimal value, int exponent) diff --git a/InterestExtension/InterestExtension.csproj b/InterestExtension/InterestExtension.csproj index 87f961d..4cad104 100644 --- a/InterestExtension/InterestExtension.csproj +++ b/InterestExtension/InterestExtension.csproj @@ -15,7 +15,7 @@ Bruno Loures Bruno Loures Copyright © Bruno Loures 2023-2026 - Extension methods for simple and compound interest calculations with yearly, monthly, and daily compounding. + Extension methods for simple and compound interest calculations with preset or custom compounding periods. finance;financial;money;interest;simple-interest;compound-interest https://github.com/louresb/InterestExtensions https://github.com/louresb/InterestExtensions/blob/main/CHANGELOG.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 069a176..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,43 +0,0 @@ -# Migrating from 2.x to 3.0 - -Version 3 preserves the package ID, assembly, `InterestExtension` namespace, `InterestCalculator` class, `InterestPeriod` enum, and all existing method signatures. The major version communicates intentional changes to validation and numerical behavior. - -## Review invalid-input handling - -Every public calculation now throws `ArgumentOutOfRangeException` when `principal`, `interestRate`, or `period` is negative. - -Both compound overloads that accept `InterestPeriod` now throw `ArgumentOutOfRangeException` for undefined enum values. In 2.0.1, `CalculateCompoundInterestAmount` silently treated an undefined value as `Yearly`. - -If an application intentionally sent invalid values, validate or normalize them before calling version 3. - -## Review numerical assertions - -Compound calculations no longer convert `decimal` values to `double` for `Math.Pow`. Version 3 uses decimal exponentiation, so the least significant decimal digits can differ from 2.x. - -Avoid asserting a rounded display value against the raw result. Apply an explicit domain rule instead: - -```csharp -decimal raw = principal.CalculateCompoundInterest(rate, years, InterestPeriod.Monthly); -decimal amount = decimal.Round(raw, 2, MidpointRounding.ToEven); -``` - -Choose `ToEven`, `AwayFromZero`, or another rule based on the financial product and jurisdiction; the library deliberately does not choose one. - -## Confirm rate and period semantics - -- `interestRate` is a nominal annual fractional rate (`0.05m` means 5% per year). -- `period` is a number of whole years. -- Monthly compounding uses 12 periods per year. -- Daily compounding uses 365 periods per year and does not inspect calendar dates. - -These definitions clarify the formulas already used by 2.x; they do not introduce a new formula. - -## Optional annual interest-only overload - -Version 3 adds this convenience overload: - -```csharp -decimal interest = principal.CalculateCompoundInterestAmount(rate, years); -``` - -It is equivalent to passing `InterestPeriod.Yearly`. diff --git a/README.md b/README.md index a9055da..1999bf0 100644 --- a/README.md +++ b/README.md @@ -12,31 +12,28 @@ Small, dependency-free extension methods for simple and compound interest calcul dotnet add package InterestExtensions ``` -## Supported platforms +## Compatibility -| Package asset | Intended consumers | -| --- | --- | -| `netstandard2.0` | .NET implementations that support .NET Standard 2.0 | -| `net8.0` | .NET 8 and later, including .NET 10 | - -The package is built as `netstandard2.0;net8.0` and tested on both .NET 8 and .NET 10. A separate `net10.0` assembly is unnecessary because .NET 10 consumes the compatible `net8.0` asset. +Targets .NET Standard 2.0 and .NET 8. Tested on .NET 8 and .NET 10. ## Calculation contract - `principal` is a non-negative decimal amount. - `interestRate` is a non-negative nominal annual rate expressed as a fraction: use `0.05m` for 5% per year. - `period` is a non-negative number of whole years. +- `compoundingPeriodCount` is the total number of times interest is applied. +- `compoundingPeriodsPerYear` is a positive frequency used to derive the periodic rate. - `Yearly`, `Monthly`, and `Daily` compound 1, 12, and 365 times per year respectively. - Daily compounding uses a fixed 365-day year; it is not a date-based day-count convention. - Results are returned without implicit rounding. Choose the scale and midpoint rule required by your currency and domain. -- Invalid negative inputs or an unknown `InterestPeriod` throw `ArgumentOutOfRangeException`. -- Calculations that exceed the range of `decimal` throw `OverflowException`. +- Invalid inputs throw `ArgumentOutOfRangeException`; calculations outside the range of `decimal` throw `OverflowException`. The formulas are: ```text simple total = principal × (1 + annual rate × years) -compound total = principal × (1 + annual rate / frequency)^(frequency × years) +periodic rate = annual rate / periods per year +compound total = principal × (1 + periodic rate)^period count ``` ## Usage @@ -60,10 +57,16 @@ decimal monthlyTotal = principal.CalculateCompoundInterest( years, InterestPeriod.Monthly); -decimal dailyInterest = principal.CalculateCompoundInterestAmount( +// 18 monthly compounding periods, equivalent to 18 months. +decimal eighteenMonthTotal = principal.CalculateCompoundInterestForPeriods( annualRate, - years, - InterestPeriod.Daily); + compoundingPeriodCount: 18, + compoundingPeriodsPerYear: 12); + +decimal eighteenMonthInterest = principal.CalculateCompoundInterestAmountForPeriods( + annualRate, + compoundingPeriodCount: 18, + compoundingPeriodsPerYear: 12); // InterestExtensions does not choose a financial rounding policy for you. decimal displayAmount = decimal.Round(monthlyTotal, 2, MidpointRounding.ToEven); @@ -75,21 +78,15 @@ decimal displayAmount = decimal.Round(monthlyTotal, 2, MidpointRounding.ToEven); | --- | --- | | `CalculateSimpleInterest` | Principal plus simple interest | | `CalculateSimpleInterestAmount` | Simple interest only | -| `CalculateCompoundInterest` | Principal plus compound interest | -| `CalculateCompoundInterestAmount` | Compound interest only | - -The compound methods have an annual overload and an overload that accepts `InterestPeriod`. - -## Version 3 - -Version 3 keeps the existing namespace, class, enum, and method signatures while making the calculation contract consistent. It validates all methods uniformly, rejects unknown enum values, adds the annual `CalculateCompoundInterestAmount` overload, and uses deterministic decimal exponentiation instead of converting through `double`. - -Existing 2.x consumers should read the [migration guide](https://github.com/louresb/InterestExtensions/blob/v3.0.0/MIGRATION.md) before opting into 3.0.0. See the [changelog](https://github.com/louresb/InterestExtensions/blob/v3.0.0/CHANGELOG.md) for the complete release notes. +| `CalculateCompoundInterest` | Principal plus compound interest for whole years | +| `CalculateCompoundInterestAmount` | Compound interest only for whole years | +| `CalculateCompoundInterestForPeriods` | Principal plus compound interest for a custom period count and frequency | +| `CalculateCompoundInterestAmountForPeriods` | Compound interest only for a custom period count and frequency | -## Scope and limitations +## Scope -InterestExtensions is a small mathematical utility, not a regulatory or accounting engine. It does not model dates, leap years, 30/360 or Actual/Actual conventions, fees, taxes, variable rates, currencies, or product-specific rounding rules. +InterestExtensions provides deterministic simple and compound interest calculations using `decimal`. Date-based calculations and product-specific financial rules are intentionally out of scope. ## Contributing -Contributions are welcome. See the [contribution guide](https://github.com/louresb/InterestExtensions/blob/v3.0.0/CONTRIBUTING.md) for the local workflow and release policy. +Contributions are welcome. See the [contribution guide](https://github.com/louresb/InterestExtensions/blob/main/CONTRIBUTING.md).