From 7f36e65c34fc9866ee2bc2b8aad0ece6f20b7d2c Mon Sep 17 00:00:00 2001 From: Bruno Loures Date: Sun, 16 Aug 2026 00:42:30 -0300 Subject: [PATCH] release: prepare InterestExtensions 3.0.0 --- .config/dotnet-tools.json | 12 + .github/dependabot.yml | 30 +++ .github/workflows/ci.yml | 108 +++++++++ .github/workflows/main.yml | 51 ---- .github/workflows/release.yml | 144 +++++++++++ CHANGELOG.md | 41 ++++ CONTRIBUTING.md | 46 ++++ .../InterestExtension.PackageSmoke.csproj | 15 ++ InterestExtension.PackageSmoke/Program.cs | 46 ++++ .../CompoundInterestTests.cs | 72 ++++++ .../InterestExtension.Tests.csproj | 43 ++-- .../InterestExtensionsTests.cs | 54 ----- .../SimpleInterestTests.cs | 29 +++ InterestExtension.Tests/ValidationTests.cs | 106 +++++++++ InterestExtension.Tests/packages.lock.json | 225 ++++++++++++++++++ InterestExtension/Enums/InterestPeriod.cs | 18 +- InterestExtension/InterestExtension.cs | 198 +++++++++++---- InterestExtension/InterestExtension.csproj | 65 +++-- InterestExtension/packages.lock.json | 22 ++ LICENSE | 2 +- MIGRATION.md | 43 ++++ NuGet.Config | 7 + README.md | 101 +++++--- coverlet.runsettings | 15 ++ global.json | 7 + 25 files changed, 1266 insertions(+), 234 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj create mode 100644 InterestExtension.PackageSmoke/Program.cs create mode 100644 InterestExtension.Tests/CompoundInterestTests.cs delete mode 100644 InterestExtension.Tests/InterestExtensionsTests.cs create mode 100644 InterestExtension.Tests/SimpleInterestTests.cs create mode 100644 InterestExtension.Tests/ValidationTests.cs create mode 100644 InterestExtension.Tests/packages.lock.json create mode 100644 InterestExtension/packages.lock.json create mode 100644 MIGRATION.md create mode 100644 NuGet.Config create mode 100644 coverlet.runsettings create mode 100644 global.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..5804950 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.11", + "commands": [ + "reportgenerator" + ] + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8a7022b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 + +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: monthly + timezone: America/Sao_Paulo + groups: + test-dependencies: + patterns: + - Microsoft.NET.Test.Sdk + - MSTest.* + - coverlet.* + open-pull-requests-limit: 5 + commit-message: + prefix: build(deps) + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + timezone: America/Sao_Paulo + groups: + actions: + patterns: + - "*" + open-pull-requests-limit: 5 + commit-message: + prefix: build(actions) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e1611e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + build-test-pack: + name: Build, test and pack + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + cache: true + cache-dependency-path: | + InterestExtension/packages.lock.json + InterestExtension.Tests/packages.lock.json + + - name: Restore local tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore InterestExtension.sln --locked-mode + + - name: Build + run: dotnet build InterestExtension.sln --configuration Release --no-restore -p:ContinuousIntegrationBuild=true + + - name: Test with coverage + run: dotnet test InterestExtension.sln --configuration Release --no-build --settings coverlet.runsettings --logger "trx;LogFilePrefix=test-results" --results-directory TestResults --collect:"XPlat Code Coverage" + + - name: Generate coverage report + run: dotnet tool run reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"artifacts/coverage" -reporttypes:"Html;Cobertura;MarkdownSummaryGithub" + + - name: Add coverage to job summary + run: cat artifacts/coverage/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY" + + - name: Enforce coverage thresholds + shell: pwsh + run: | + [xml] $report = Get-Content artifacts/coverage/Cobertura.xml -Raw + $culture = [System.Globalization.CultureInfo]::InvariantCulture + $lineRate = [double]::Parse($report.coverage.'line-rate', $culture) + $branchRate = [double]::Parse($report.coverage.'branch-rate', $culture) + + if ($lineRate -lt 0.95 -or $branchRate -lt 0.90) { + throw "Coverage is below the required 95% lines / 90% branches." + } + + "Coverage gate passed: {0:P2} lines / {1:P2} branches." -f $lineRate, $branchRate | + Add-Content $env:GITHUB_STEP_SUMMARY + + - name: Pack and validate + run: dotnet pack InterestExtension/InterestExtension.csproj --configuration Release --no-build --no-restore -p:ContinuousIntegrationBuild=true --output artifacts/packages + + - name: Restore package smoke test + run: dotnet restore InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --packages artifacts/smoke-packages --source artifacts/packages + + - name: Test installed package on .NET 8 + run: dotnet run --project InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --framework net8.0 --no-restore -- artifacts/packages + + - name: Test installed package on .NET 10 + run: dotnet run --project InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --framework net10.0 --no-restore -- artifacts/packages + + - name: Upload package, test and coverage artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ci-artifacts + path: | + artifacts/packages/* + artifacts/coverage/* + TestResults/**/*.trx + if-no-files-found: error + retention-days: 14 + + - name: Upload diagnostics after a failure + if: ${{ !cancelled() && failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: diagnostics-${{ github.run_attempt }} + path: | + TestResults/**/*.trx + TestResults/**/coverage.cobertura.xml + artifacts/coverage/* + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 7323f4e..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: .NET Build and Test - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Build - run: dotnet build -c Release - - - name: Run tests with coverage - run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" - - - name: Install ReportGenerator - run: dotnet tool install -g dotnet-reportgenerator-globaltool - - - name: Generate coverage report - run: | - reportgenerator \ - -reports:**/coverage.cobertura.xml \ - -targetdir:coveragereport \ - -reporttypes:Html - - - name: Upload coverage report artifact - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coveragereport - - - name: Create package - if: github.event_name == 'push' - run: dotnet pack -c Release - - - name: Publish package to NuGet - if: github.event_name == 'push' - run: dotnet nuget push "InterestExtension/bin/Release/*.nupkg" --skip-duplicate -k ${{ secrets.NUGET_TOKEN }} -s https://api.nuget.org/v3/index.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..afe0ad3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,144 @@ +name: Publish NuGet + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: nuget-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + validate: + name: Validate release artifact + if: ${{ !github.event.release.prerelease && !github.event.release.draft }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + cache: true + cache-dependency-path: | + InterestExtension/packages.lock.json + InterestExtension.Tests/packages.lock.json + + - name: Verify tag matches package version + id: version + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Expected a stable vMAJOR.MINOR.PATCH tag, got $RELEASE_TAG" + exit 1 + } + + tag_version="${RELEASE_TAG#v}" + project_version="$(dotnet msbuild InterestExtension/InterestExtension.csproj -nologo -getProperty:PackageVersion | tr -d '\r')" + + [[ "$tag_version" == "$project_version" ]] || { + echo "Tag version $tag_version does not match package version $project_version" + exit 1 + } + + echo "version=$tag_version" >> "$GITHUB_OUTPUT" + + - name: Verify tagged commit is on main + shell: bash + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor HEAD origin/main || { + echo "The release tag must point to a commit contained in main." + exit 1 + } + + - name: Restore dependencies + run: dotnet restore InterestExtension.sln --locked-mode + + - name: Build + run: dotnet build InterestExtension.sln --configuration Release --no-restore -p:ContinuousIntegrationBuild=true + + - name: Test + run: dotnet test InterestExtension.sln --configuration Release --no-build + + - name: Pack and validate + run: dotnet pack InterestExtension/InterestExtension.csproj --configuration Release --no-build --no-restore -p:ContinuousIntegrationBuild=true --output artifacts/packages + + - name: Restore package smoke test + run: dotnet restore InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --packages artifacts/smoke-packages --source artifacts/packages + + - name: Test installed package on .NET 8 + run: dotnet run --project InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --framework net8.0 --no-restore -- artifacts/packages + + - name: Test installed package on .NET 10 + run: dotnet run --project InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj --framework net10.0 --no-restore -- artifacts/packages + + - name: Upload validated release artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: InterestExtensions-${{ steps.version.outputs.version }} + path: artifacts/packages/* + if-no-files-found: error + + publish: + name: Publish to NuGet.org + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: nuget.org + permissions: + contents: read + id-token: write + + steps: + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Download validated release artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: InterestExtensions-${{ needs.validate.outputs.version }} + path: artifacts/packages + + - name: Verify downloaded package files + shell: bash + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + test -f "artifacts/packages/InterestExtensions.$VERSION.nupkg" + test -f "artifacts/packages/InterestExtensions.$VERSION.snupkg" + + - name: Authenticate to NuGet.org + id: nuget + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ vars.NUGET_USER }} + + - name: Publish package + env: + PACKAGE_PATH: artifacts/packages/InterestExtensions.${{ needs.validate.outputs.version }}.nupkg + run: dotnet nuget push "$PACKAGE_PATH" --api-key "${{ steps.nuget.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0beaddd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# 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-16 + +### 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. + +## [2.0.1] - 2025-05-25 + +- Fixed the build workflow and added Coverlet-based test coverage collection. + +[3.0.0]: https://github.com/louresb/InterestExtensions/compare/v2.0.1...v3.0.0 +[2.0.1]: https://github.com/louresb/InterestExtensions/tree/v2.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9f63a9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing + +## 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. + +```powershell +dotnet tool restore +dotnet restore InterestExtension.sln --locked-mode +dotnet build InterestExtension.sln --configuration Release --no-restore +dotnet test InterestExtension.sln --configuration Release --no-build --settings coverlet.runsettings --collect:"XPlat Code Coverage" +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. + +To release a stable version: + +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. + +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. + +Duplicate package versions fail by design; the release workflow never uses `--skip-duplicate`. diff --git a/InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj b/InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj new file mode 100644 index 0000000..5ac5f3d --- /dev/null +++ b/InterestExtension.PackageSmoke/InterestExtension.PackageSmoke.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0;net10.0 + enable + enable + true + + + + + + + diff --git a/InterestExtension.PackageSmoke/Program.cs b/InterestExtension.PackageSmoke/Program.cs new file mode 100644 index 0000000..dc2f310 --- /dev/null +++ b/InterestExtension.PackageSmoke/Program.cs @@ -0,0 +1,46 @@ +using System.IO.Compression; +using InterestExtension; +using InterestExtension.Enums; + +const decimal expected = 102.83908863189753692594868626m; +var actual = 100m.CalculateCompoundInterest(0.004m, 7, InterestPeriod.Monthly); + +if (actual != expected) +{ + throw new InvalidOperationException($"Expected {expected}, but the installed package returned {actual}."); +} + +if (args.Length != 1) +{ + throw new ArgumentException("Pass the package output directory as the only argument."); +} + +var packageDirectory = Path.GetFullPath(args[0]); +var packagePath = Directory.GetFiles(packageDirectory, "InterestExtensions.*.nupkg").Single(); +var symbolPackagePath = Path.ChangeExtension(packagePath, ".snupkg"); + +if (!File.Exists(symbolPackagePath)) +{ + throw new InvalidDataException($"Symbol package not found: {symbolPackagePath}"); +} + +using var package = ZipFile.OpenRead(packagePath); +var entries = package.Entries.Select(entry => entry.FullName).ToHashSet(StringComparer.Ordinal); +var expectedEntries = new[] +{ + "README.md", + "icon.png", + "lib/netstandard2.0/InterestExtension.dll", + "lib/netstandard2.0/InterestExtension.xml", + "lib/net8.0/InterestExtension.dll", + "lib/net8.0/InterestExtension.xml" +}; + +var missingEntries = expectedEntries.Where(entry => !entries.Contains(entry)).ToArray(); + +if (missingEntries.Length > 0) +{ + throw new InvalidDataException($"Package is missing: {string.Join(", ", missingEntries)}"); +} + +Console.WriteLine($"Package smoke test passed on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}."); diff --git a/InterestExtension.Tests/CompoundInterestTests.cs b/InterestExtension.Tests/CompoundInterestTests.cs new file mode 100644 index 0000000..11ce9c5 --- /dev/null +++ b/InterestExtension.Tests/CompoundInterestTests.cs @@ -0,0 +1,72 @@ +using InterestExtension.Enums; + +namespace InterestExtension.Tests; + +[TestClass] +public sealed class CompoundInterestTests +{ + [TestMethod] + public void CalculateCompoundInterestReturnsExactAnnualResult() + => Assert.AreEqual(102.8338248981532688384m, 100m.CalculateCompoundInterest(0.004m, 7)); + + [TestMethod] + public void CalculateCompoundInterestReturnsExactMonthlyResult() + => Assert.AreEqual( + 102.83908863189753692594868626m, + 100m.CalculateCompoundInterest(0.004m, 7, InterestPeriod.Monthly)); + + [TestMethod] + public void CalculateCompoundInterestReturnsExactDailyResult() + => Assert.AreEqual( + 102.83955266413341871521872628m, + 100m.CalculateCompoundInterest(0.004m, 7, InterestPeriod.Daily)); + + [TestMethod] + public void AnnualOverloadsAreEquivalent() + { + var totalWithDefault = 100m.CalculateCompoundInterest(0.05m, 5); + var totalWithPeriod = 100m.CalculateCompoundInterest(0.05m, 5, InterestPeriod.Yearly); + var interestWithDefault = 100m.CalculateCompoundInterestAmount(0.05m, 5); + var interestWithPeriod = 100m.CalculateCompoundInterestAmount(0.05m, 5, InterestPeriod.Yearly); + + Assert.AreEqual(totalWithPeriod, totalWithDefault); + Assert.AreEqual(interestWithPeriod, interestWithDefault); + } + + [TestMethod] + [DataRow(InterestPeriod.Yearly)] + [DataRow(InterestPeriod.Monthly)] + [DataRow(InterestPeriod.Daily)] + public void InterestAmountEqualsTotalMinusPrincipal(InterestPeriod periodType) + { + const decimal principal = 250m; + var total = principal.CalculateCompoundInterest(0.0375m, 9, periodType); + var interest = principal.CalculateCompoundInterestAmount(0.0375m, 9, periodType); + + Assert.AreEqual(total - principal, interest); + } + + [TestMethod] + public void ZeroRateOrPeriodLeavesPrincipalUnchanged() + { + Assert.AreEqual(100m, 100m.CalculateCompoundInterest(0m, 7)); + Assert.AreEqual(100m, 100m.CalculateCompoundInterest(0.05m, 0, InterestPeriod.Daily)); + Assert.AreEqual(0m, 100m.CalculateCompoundInterestAmount(0m, 7)); + Assert.AreEqual(0m, 100m.CalculateCompoundInterestAmount(0.05m, 0, InterestPeriod.Monthly)); + } + + [TestMethod] + public void ZeroPrincipalReturnsZero() + { + Assert.AreEqual(0m, 0m.CalculateCompoundInterest(0.05m, 10, InterestPeriod.Monthly)); + Assert.AreEqual(0m, 0m.CalculateCompoundInterestAmount(0.05m, 10, InterestPeriod.Daily)); + } + + [TestMethod] + public void ResultsAreNotRoundedByTheLibrary() + { + var result = 100m.CalculateCompoundInterest(0.004m, 7, InterestPeriod.Monthly); + + Assert.AreNotEqual(decimal.Round(result, 2), result); + } +} diff --git a/InterestExtension.Tests/InterestExtension.Tests.csproj b/InterestExtension.Tests/InterestExtension.Tests.csproj index f173a2d..a31fd3a 100644 --- a/InterestExtension.Tests/InterestExtension.Tests.csproj +++ b/InterestExtension.Tests/InterestExtension.Tests.csproj @@ -1,26 +1,29 @@ - - net8.0 - enable - enable - - false - true - + + net8.0;net10.0 + enable + enable + true + true + false - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + false + true + - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/InterestExtension.Tests/InterestExtensionsTests.cs b/InterestExtension.Tests/InterestExtensionsTests.cs deleted file mode 100644 index d9f7ea0..0000000 --- a/InterestExtension.Tests/InterestExtensionsTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using InterestExtension.Enums; - -namespace InterestExtension.Tests; - -[TestClass] -public class InterestExtensionsTests -{ - [TestMethod] - public void ShouldCalculateSimpleInterestCorrectly() => - Assert.AreEqual(102.80m, 100m.CalculateSimpleInterest(0.004m, 7)); - - [TestMethod] - public void ShouldCalculateCompoundInterestCorrectly() => - Assert.AreEqual(102.83m, Math.Round(100m.CalculateCompoundInterest(0.004m, 7), 2)); - - [TestMethod] - public void ShouldCalculateCompoundInterestWithMonthlyCompounding() - { - var result = Math.Round(100m.CalculateCompoundInterest(0.004m, 7, InterestPeriod.Monthly), 2); - Assert.AreEqual(102.84m, result); - } - - [TestMethod] - public void ShouldCalculateSimpleInterestAmountCorrectly() => - Assert.AreEqual(2.80m, 100m.CalculateSimpleInterestAmount(0.004m, 7)); - - [TestMethod] - public void ShouldCalculateCompoundInterestAmountWithDailyCompounding() - { - var result = Math.Round(100m.CalculateCompoundInterestAmount(0.004m, 7, InterestPeriod.Daily), 2); - Assert.AreEqual(2.84m, result); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void ShouldThrowOnNegativePrincipalSimple() - { - _ = (-1m).CalculateSimpleInterest(0.05m, 1); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void ShouldThrowOnNegativePeriodCompound() - { - _ = 100m.CalculateCompoundInterest(0.05m, -1); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void ShouldThrowOnInvalidPeriodType() - { - _ = 100m.CalculateCompoundInterest(0.05m, 1, (InterestPeriod)999); - } -} \ No newline at end of file diff --git a/InterestExtension.Tests/SimpleInterestTests.cs b/InterestExtension.Tests/SimpleInterestTests.cs new file mode 100644 index 0000000..c999c16 --- /dev/null +++ b/InterestExtension.Tests/SimpleInterestTests.cs @@ -0,0 +1,29 @@ +namespace InterestExtension.Tests; + +[TestClass] +public sealed class SimpleInterestTests +{ + [TestMethod] + public void CalculateSimpleInterestReturnsTotalAmount() + => Assert.AreEqual(102.80m, 100m.CalculateSimpleInterest(0.004m, 7)); + + [TestMethod] + public void CalculateSimpleInterestAmountReturnsOnlyEarnedInterest() + => Assert.AreEqual(2.80m, 100m.CalculateSimpleInterestAmount(0.004m, 7)); + + [TestMethod] + public void ZeroRateOrPeriodLeavesPrincipalUnchanged() + { + Assert.AreEqual(100m, 100m.CalculateSimpleInterest(0m, 7)); + Assert.AreEqual(100m, 100m.CalculateSimpleInterest(0.05m, 0)); + Assert.AreEqual(0m, 100m.CalculateSimpleInterestAmount(0m, 7)); + Assert.AreEqual(0m, 100m.CalculateSimpleInterestAmount(0.05m, 0)); + } + + [TestMethod] + public void ZeroPrincipalReturnsZero() + { + Assert.AreEqual(0m, 0m.CalculateSimpleInterest(0.05m, 10)); + Assert.AreEqual(0m, 0m.CalculateSimpleInterestAmount(0.05m, 10)); + } +} diff --git a/InterestExtension.Tests/ValidationTests.cs b/InterestExtension.Tests/ValidationTests.cs new file mode 100644 index 0000000..d4d771f --- /dev/null +++ b/InterestExtension.Tests/ValidationTests.cs @@ -0,0 +1,106 @@ +using InterestExtension.Enums; + +namespace InterestExtension.Tests; + +[TestClass] +public sealed class ValidationTests +{ + [TestMethod] + public void EveryCalculationRejectsNegativePrincipal() + => AssertAllThrowForParameter( + "principal", + () => (-1m).CalculateSimpleInterest(0.05m, 1), + () => (-1m).CalculateSimpleInterestAmount(0.05m, 1), + () => (-1m).CalculateCompoundInterest(0.05m, 1), + () => (-1m).CalculateCompoundInterest(0.05m, 1, InterestPeriod.Monthly), + () => (-1m).CalculateCompoundInterestAmount(0.05m, 1), + () => (-1m).CalculateCompoundInterestAmount(0.05m, 1, InterestPeriod.Daily)); + + [TestMethod] + public void EveryCalculationRejectsNegativeInterestRate() + => AssertAllThrowForParameter( + "interestRate", + () => 100m.CalculateSimpleInterest(-0.05m, 1), + () => 100m.CalculateSimpleInterestAmount(-0.05m, 1), + () => 100m.CalculateCompoundInterest(-0.05m, 1), + () => 100m.CalculateCompoundInterest(-0.05m, 1, InterestPeriod.Monthly), + () => 100m.CalculateCompoundInterestAmount(-0.05m, 1), + () => 100m.CalculateCompoundInterestAmount(-0.05m, 1, InterestPeriod.Daily)); + + [TestMethod] + public void EveryCalculationRejectsNegativePeriod() + => AssertAllThrowForParameter( + "period", + () => 100m.CalculateSimpleInterest(0.05m, -1), + () => 100m.CalculateSimpleInterestAmount(0.05m, -1), + () => 100m.CalculateCompoundInterest(0.05m, -1), + () => 100m.CalculateCompoundInterest(0.05m, -1, InterestPeriod.Monthly), + () => 100m.CalculateCompoundInterestAmount(0.05m, -1), + () => 100m.CalculateCompoundInterestAmount(0.05m, -1, InterestPeriod.Daily)); + + [TestMethod] + [DataRow(-1)] + [DataRow(999)] + public void CompoundCalculationsRejectUnknownPeriodType(int value) + { + var periodType = (InterestPeriod)value; + + AssertAllThrowForParameter( + "periodType", + () => 100m.CalculateCompoundInterest(0.05m, 1, periodType), + () => 100m.CalculateCompoundInterestAmount(0.05m, 1, periodType)); + } + + [TestMethod] + public void ZeroValueIdentitiesStillRejectUnknownPeriodType() + { + var periodType = (InterestPeriod)999; + + AssertAllThrowForParameter( + "periodType", + () => 0m.CalculateCompoundInterest(decimal.MaxValue, 0, periodType), + () => 0m.CalculateCompoundInterestAmount(decimal.MaxValue, 0, periodType)); + } + + [TestMethod] + public void DailyCompoundingRejectsPeriodCountOverflow() + => Assert.ThrowsExactly( + () => 100m.CalculateCompoundInterest(0.05m, int.MaxValue, InterestPeriod.Daily)); + + [TestMethod] + public void CalculationRejectsDecimalOverflow() + => Assert.ThrowsExactly( + () => decimal.MaxValue.CalculateCompoundInterest(1m, 1)); + + [TestMethod] + public void InterestOnlyCalculationsDoNotAddPrincipalBeforeReturning() + { + Assert.AreEqual(decimal.MaxValue * 0.5m, decimal.MaxValue.CalculateSimpleInterestAmount(0.5m, 1)); + Assert.AreEqual(decimal.MaxValue, decimal.MaxValue.CalculateCompoundInterestAmount(1m, 1)); + } + + [TestMethod] + public void ZeroValueIdentitiesShortCircuitBeforeOverflow() + { + Assert.AreEqual(decimal.MaxValue, decimal.MaxValue.CalculateSimpleInterest(decimal.MaxValue, 0)); + Assert.AreEqual(0m, decimal.MaxValue.CalculateSimpleInterestAmount(decimal.MaxValue, 0)); + + Assert.AreEqual(1m, 1m.CalculateCompoundInterest(decimal.MaxValue, 0, InterestPeriod.Daily)); + Assert.AreEqual(0m, 1m.CalculateCompoundInterestAmount(decimal.MaxValue, 0, InterestPeriod.Daily)); + + Assert.AreEqual(decimal.MaxValue, decimal.MaxValue.CalculateCompoundInterest(0m, int.MaxValue, InterestPeriod.Daily)); + Assert.AreEqual(0m, decimal.MaxValue.CalculateCompoundInterestAmount(0m, int.MaxValue, InterestPeriod.Daily)); + + Assert.AreEqual(0m, 0m.CalculateCompoundInterest(decimal.MaxValue, int.MaxValue, InterestPeriod.Daily)); + Assert.AreEqual(0m, 0m.CalculateCompoundInterestAmount(decimal.MaxValue, int.MaxValue, InterestPeriod.Daily)); + } + + private static void AssertAllThrowForParameter(string parameterName, params Action[] calculations) + { + foreach (var calculation in calculations) + { + var exception = Assert.ThrowsExactly(calculation); + Assert.AreEqual(parameterName, exception.ParamName); + } + } +} diff --git a/InterestExtension.Tests/packages.lock.json b/InterestExtension.Tests/packages.lock.json new file mode 100644 index 0000000..02f63ab --- /dev/null +++ b/InterestExtension.Tests/packages.lock.json @@ -0,0 +1,225 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.9.0, )", + "resolved": "18.9.0", + "contentHash": "xIzVXa/VpKXqDWzyC/5Hw8JbFY5u9k3Jc53CpcjBiOXvkQGio484LD9FNwOiGUSMDcYL3Rcw9sNgGi5WrswlPQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.9.0", + "Microsoft.TestPlatform.TestHost": "18.9.0" + } + }, + "MSTest.TestAdapter": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "YxXA1YGqwcZ1UjZXHgeu65AWDR25PQ2lN5N5CYOhxftqOQh/FcgQk0Vz0nfHqLhqOGFI6g9uIfOWqAdobSNKSg==", + "dependencies": { + "MSTest.TestFramework": "4.3.3", + "Microsoft.Testing.Extensions.VSTestBridge": "2.3.3", + "Microsoft.Testing.Platform.MSBuild": "2.3.3" + } + }, + "MSTest.TestFramework": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "Y4OSRh7VKV3nW+/vIqvfJ3sdLWfVJ9G11KpzynFAq3Bh/fpMaXt73v8GfL4mTz6pJ/ix3hcep4iy6qQNXt408w==", + "dependencies": { + "MSTest.Analyzers": "4.3.3" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "MtegCIKGuG0r/LCdIjoR1HgzCGIUBhR3aNdbtQpts5vMXQuqBDZ2Jsd2uFKoHFM2XrQV6ZrDf3F5oLi3SLTp8w==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==", + "dependencies": { + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "JLovZjH6K10YiHO/BcidsaSHo/M2DfOe9FDjxjFdV6aoHO6VYTTYlLBNcyLJSo20q5gDTmNvmxYVH39A942aJA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Microsoft.Testing.Extensions.Telemetry": "2.3.3", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3", + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "Fz/qXC52VXXopzHj7v2reCKcCqSxkTDCkEDfkNAH0vni/7qK/KLMiEYtRmnUanxO2F2g2zZZ60qCpxF0AF7URA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "Oq+Pma/J86aZL72t71bcESvDszDsTjUfl6PIsuDdPoKTHZfNMhKamCfLD5fKx51W/u0KozXtJo2/3fnt0sgPxg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.9.0" + } + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "cTcdSPIZ8WvchbC5DFmfHxrvVNZCtops+2W94le+Q+mx110Y9L9mqNVVc9flF35lFeL5GsY+rxGBylsjDk4+zg==" + }, + "InterestExtensions": { + "type": "Project" + } + }, + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.9.0, )", + "resolved": "18.9.0", + "contentHash": "xIzVXa/VpKXqDWzyC/5Hw8JbFY5u9k3Jc53CpcjBiOXvkQGio484LD9FNwOiGUSMDcYL3Rcw9sNgGi5WrswlPQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.9.0", + "Microsoft.TestPlatform.TestHost": "18.9.0" + } + }, + "MSTest.TestAdapter": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "YxXA1YGqwcZ1UjZXHgeu65AWDR25PQ2lN5N5CYOhxftqOQh/FcgQk0Vz0nfHqLhqOGFI6g9uIfOWqAdobSNKSg==", + "dependencies": { + "MSTest.TestFramework": "4.3.3", + "Microsoft.Testing.Extensions.VSTestBridge": "2.3.3", + "Microsoft.Testing.Platform.MSBuild": "2.3.3" + } + }, + "MSTest.TestFramework": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "Y4OSRh7VKV3nW+/vIqvfJ3sdLWfVJ9G11KpzynFAq3Bh/fpMaXt73v8GfL4mTz6pJ/ix3hcep4iy6qQNXt408w==", + "dependencies": { + "MSTest.Analyzers": "4.3.3" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "MtegCIKGuG0r/LCdIjoR1HgzCGIUBhR3aNdbtQpts5vMXQuqBDZ2Jsd2uFKoHFM2XrQV6ZrDf3F5oLi3SLTp8w==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==", + "dependencies": { + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "JLovZjH6K10YiHO/BcidsaSHo/M2DfOe9FDjxjFdV6aoHO6VYTTYlLBNcyLJSo20q5gDTmNvmxYVH39A942aJA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Microsoft.Testing.Extensions.Telemetry": "2.3.3", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3", + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.3.3", + "contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.3.3" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "Fz/qXC52VXXopzHj7v2reCKcCqSxkTDCkEDfkNAH0vni/7qK/KLMiEYtRmnUanxO2F2g2zZZ60qCpxF0AF7URA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.9.0", + "contentHash": "Oq+Pma/J86aZL72t71bcESvDszDsTjUfl6PIsuDdPoKTHZfNMhKamCfLD5fKx51W/u0KozXtJo2/3fnt0sgPxg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.9.0" + } + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "cTcdSPIZ8WvchbC5DFmfHxrvVNZCtops+2W94le+Q+mx110Y9L9mqNVVc9flF35lFeL5GsY+rxGBylsjDk4+zg==" + }, + "InterestExtensions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/InterestExtension/Enums/InterestPeriod.cs b/InterestExtension/Enums/InterestPeriod.cs index 6d4c575..789511f 100644 --- a/InterestExtension/Enums/InterestPeriod.cs +++ b/InterestExtension/Enums/InterestPeriod.cs @@ -1,8 +1,22 @@ -namespace InterestExtension.Enums; +namespace InterestExtension.Enums; +/// +/// Specifies how often compound interest is applied within a year. +/// public enum InterestPeriod { + /// + /// Compounds once per year. + /// Yearly, + + /// + /// Compounds twelve times per year. + /// Monthly, + + /// + /// Compounds 365 times per year. + /// Daily -} \ No newline at end of file +} diff --git a/InterestExtension/InterestExtension.cs b/InterestExtension/InterestExtension.cs index d176e4c..551aee3 100644 --- a/InterestExtension/InterestExtension.cs +++ b/InterestExtension/InterestExtension.cs @@ -7,18 +7,29 @@ namespace InterestExtension; /// public static class InterestCalculator { + private const int MonthsPerYear = 12; + private const int DaysPerYear = 365; + /// /// Calculates the total amount after applying simple interest. /// /// The initial principal amount. - /// The interest rate per period (e.g., 0.05 for 5%). - /// The number of periods. + /// The annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. /// The total amount after applying simple interest. + /// + /// Thrown when , , or is negative. + /// + /// Thrown when the result exceeds the range of . + /// The result is not rounded. Apply the rounding policy required by your financial domain. public static decimal CalculateSimpleInterest(this decimal principal, decimal interestRate, int period) { - if (principal < 0) throw new ArgumentOutOfRangeException(nameof(principal), "Principal cannot be negative."); - if (interestRate < 0) throw new ArgumentOutOfRangeException(nameof(interestRate), "Interest rate cannot be negative."); - if (period < 0) throw new ArgumentOutOfRangeException(nameof(period), "Period cannot be negative."); + ValidateInputs(principal, interestRate, period); + + if (principal == 0 || interestRate == 0 || period == 0) + { + return principal; + } return principal + (principal * interestRate * period); } @@ -27,79 +38,178 @@ public static decimal CalculateSimpleInterest(this decimal principal, decimal in /// Calculates the total amount after applying compound interest compounded annually. /// /// The initial principal amount. - /// The interest rate per period (e.g., 0.05 for 5%). - /// The number of periods. + /// The annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. /// The total amount after applying compound interest. + /// + /// Thrown when , , or is negative. + /// + /// Thrown when the result exceeds the range of . + /// The result is not rounded. Apply the rounding policy required by your financial domain. public static decimal CalculateCompoundInterest(this decimal principal, decimal interestRate, int period) - { - if (principal < 0) throw new ArgumentOutOfRangeException(nameof(principal)); - if (interestRate < 0) throw new ArgumentOutOfRangeException(nameof(interestRate)); - if (period < 0) throw new ArgumentOutOfRangeException(nameof(period)); - - return principal * (decimal)Math.Pow(1 + (double)interestRate, period); - } + => principal.CalculateCompoundInterest(interestRate, period, InterestPeriod.Yearly); /// - /// Calculates the total amount after applying compound interest with a specified compounding period. + /// Calculates the total amount after applying compound interest with a specified compounding frequency. /// /// The initial principal amount. - /// The interest rate per period (e.g., 0.05 for 5%). - /// The number of periods. - /// The compounding period type (Yearly, Monthly, Daily). + /// The nominal annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. + /// How often interest is compounded within each year. /// The total amount after applying compound interest. - /// Thrown when an invalid period type is provided. + /// + /// Thrown when , , or is negative, + /// or when is not a defined value. + /// + /// Thrown when the number of compounding periods or the result exceeds the supported range. + /// + /// Daily compounding uses a fixed 365-day year. The result is not rounded; apply the rounding policy required by your financial domain. + /// public static decimal CalculateCompoundInterest( this decimal principal, decimal interestRate, int period, InterestPeriod periodType) { - if (principal < 0) throw new ArgumentOutOfRangeException(nameof(principal)); - if (interestRate < 0) throw new ArgumentOutOfRangeException(nameof(interestRate)); - if (period < 0) throw new ArgumentOutOfRangeException(nameof(period)); + ValidateInputs(principal, interestRate, period); - var n = periodType switch + var periodsPerYear = GetPeriodsPerYear(periodType); + + if (principal == 0 || interestRate == 0 || period == 0) { - InterestPeriod.Yearly => 1, - InterestPeriod.Monthly => 12, - InterestPeriod.Daily => 365, - _ => throw new ArgumentOutOfRangeException(nameof(periodType), "Invalid interest period.") - }; + return principal; + } + + return principal * CalculateCompoundFactor(interestRate, period, periodsPerYear); + } - return principal * (decimal)Math.Pow(1 + ((double)interestRate / n), n * period); + /// + /// Calculates only the simple interest earned, excluding the principal. + /// + /// The initial principal amount. + /// The annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. + /// The interest earned. + /// + /// Thrown when , , or is negative. + /// + /// Thrown when the result exceeds the range of . + /// The result is not rounded. Apply the rounding policy required by your financial domain. + public static decimal CalculateSimpleInterestAmount(this decimal principal, decimal interestRate, int period) + { + ValidateInputs(principal, interestRate, period); + + if (principal == 0 || interestRate == 0 || period == 0) + { + return 0m; + } + + return principal * interestRate * period; } /// - /// Calculates only the simple interest earned (not the final amount). + /// Calculates only the compound interest earned with annual compounding, excluding the principal. /// /// The initial principal amount. - /// The interest rate per period. - /// The number of periods. + /// The annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. /// The interest earned. - public static decimal CalculateSimpleInterestAmount(this decimal principal, decimal interestRate, int period) => - principal * interestRate * period; + /// + /// Thrown when , , or is negative. + /// + /// Thrown when the result exceeds the range of . + /// The result is not rounded. Apply the rounding policy required by your financial domain. + public static decimal CalculateCompoundInterestAmount(this decimal principal, decimal interestRate, int period) + => principal.CalculateCompoundInterestAmount(interestRate, period, InterestPeriod.Yearly); /// - /// Calculates only the compound interest earned (not the final amount). + /// Calculates only the compound interest earned, excluding the principal. /// /// The initial principal amount. - /// The interest rate per period. - /// The number of periods. - /// The compounding period type. + /// The nominal annual interest rate expressed as a fraction (for example, 0.05 for 5%). + /// The number of whole years. + /// How often interest is compounded within each year. /// The interest earned. + /// + /// Thrown when , , or is negative, + /// or when is not a defined value. + /// + /// Thrown when the number of compounding periods or the result exceeds the supported range. + /// + /// Daily compounding uses a fixed 365-day year. The result is not rounded; apply the rounding policy required by your financial domain. + /// public static decimal CalculateCompoundInterestAmount( this decimal principal, decimal interestRate, int period, InterestPeriod periodType) { - int n = periodType switch + ValidateInputs(principal, interestRate, period); + + var periodsPerYear = GetPeriodsPerYear(periodType); + + if (principal == 0 || interestRate == 0 || period == 0) + { + return 0m; + } + + return principal * (CalculateCompoundFactor(interestRate, period, periodsPerYear) - 1m); + } + + private static void ValidateInputs(decimal principal, decimal interestRate, int period) + { + if (principal < 0) + { + throw new ArgumentOutOfRangeException(nameof(principal), principal, "Principal cannot be negative."); + } + + if (interestRate < 0) + { + 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) + => periodType switch { InterestPeriod.Yearly => 1, - InterestPeriod.Monthly => 12, - InterestPeriod.Daily => 365, - _ => 1 + InterestPeriod.Monthly => MonthsPerYear, + InterestPeriod.Daily => DaysPerYear, + _ => throw new ArgumentOutOfRangeException(nameof(periodType), periodType, "Invalid interest period.") }; - return principal * (decimal)Math.Pow(1 + ((double)interestRate / n), n * period) - principal; + + private static decimal CalculateCompoundFactor(decimal interestRate, int period, int periodsPerYear) + { + var totalPeriods = checked(periodsPerYear * period); + var periodicRate = interestRate / periodsPerYear; + + return Pow(1m + periodicRate, totalPeriods); + } + + private static decimal Pow(decimal value, int exponent) + { + var result = 1m; + + while (exponent > 0) + { + if ((exponent & 1) == 1) + { + result *= value; + } + + exponent >>= 1; + + if (exponent > 0) + { + value *= value; + } + } + + return result; } -} \ No newline at end of file +} diff --git a/InterestExtension/InterestExtension.csproj b/InterestExtension/InterestExtension.csproj index 0db5a3e..87f961d 100644 --- a/InterestExtension/InterestExtension.csproj +++ b/InterestExtension/InterestExtension.csproj @@ -1,30 +1,43 @@ - + - - net8.0 - enable - enable + + netstandard2.0;net8.0 + 10.0 + enable + enable + true + true + true + true - InterestExtensions - 2.0.1 - Bruno Loures - Bruno Loures - Simple or compound interest calculation. - Simple or compound interest calculation. - money;interest;simple;compound - https://github.com/louresb/InterestExtensions - https://github.com/louresb/InterestExtensions - MIT - MIT - icon.png - InterestExtensions - Simple or compound interest calculation. - README.md - + InterestExtensions + 3.0.0 + Bruno Loures + Bruno Loures + Copyright © Bruno Loures 2023-2026 + Extension methods for simple and compound interest calculations with yearly, monthly, and daily compounding. + finance;financial;money;interest;simple-interest;compound-interest + https://github.com/louresb/InterestExtensions + https://github.com/louresb/InterestExtensions/blob/main/CHANGELOG.md + https://github.com/louresb/InterestExtensions + git + true + true + MIT + false + icon.png + README.md + InterestExtensions - - - - + true + snupkg + true + 2.0.1 + - \ No newline at end of file + + + + + + diff --git a/InterestExtension/packages.lock.json b/InterestExtension/packages.lock.json new file mode 100644 index 0000000..84b47b0 --- /dev/null +++ b/InterestExtension/packages.lock.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.0": { + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + } + }, + "net8.0": {} + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index f3805ac..3f67648 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Bruno Loures +Copyright (c) 2023-2026 Bruno Loures Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..069a176 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,43 @@ +# 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/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/README.md b/README.md index 666ea11..878a6c8 100644 --- a/README.md +++ b/README.md @@ -2,70 +2,99 @@ [![NuGet Version](https://img.shields.io/nuget/v/InterestExtensions.svg?color=blue&label=NuGet%20Version)](https://www.nuget.org/packages/InterestExtensions) [![NuGet Downloads](https://img.shields.io/nuget/dt/InterestExtensions.svg?color=orange&label=NuGet%20Downloads)](https://www.nuget.org/packages/InterestExtensions) -[![Build Status](https://github.com/louresb/InterestExtensions/actions/workflows/main.yml/badge.svg)](https://github.com/louresb/InterestExtensions/actions) -[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/louresb/InterestExtensions/blob/main/LICENSE) -[![Development Status](https://img.shields.io/badge/status-active-brightgreen.svg)](https://github.com/louresb/InterestExtensions) +[![CI](https://github.com/louresb/InterestExtensions/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/louresb/InterestExtensions/actions/workflows/ci.yml?query=branch%3Amain) +[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/louresb/InterestExtensions/blob/v3.0.0/LICENSE) -This extension facilitates the calculation of both simple interest and compound interest. - ---- +Small, dependency-free extension methods for simple and compound interest calculations. ## Installation -To install the package via NuGet, you can use the following command: - ```powershell dotnet add package InterestExtensions ``` -You can also visit the [NuGet package page](https://www.nuget.org/packages/InterestExtensions) for more information and to download the package. +## Supported platforms ---- +| Package asset | Intended consumers | +| --- | --- | +| `netstandard2.0` | .NET implementations that support .NET Standard 2.0 | +| `net8.0` | .NET 8 and later, including .NET 10 | -## Features +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. -- Simple and compound interest calculations -- Overloads for different compounding periods (yearly, monthly, daily) -- XML documentation for IntelliSense and API docs -- Thoroughly unit-tested -- .NET 8 and above supported +## 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. +- `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`. -## Usage Examples +The formulas are: + +```text +simple total = principal × (1 + annual rate × years) +compound total = principal × (1 + annual rate / frequency)^(frequency × years) +``` + +## Usage ```csharp using InterestExtension; using InterestExtension.Enums; -decimal principal = 100m; -decimal interestRate = 0.004m; -int period = 7; +decimal principal = 1_000m; +decimal annualRate = 0.05m; // 5% per year +int years = 3; -// Calculate simple interest (final amount) -decimal simple = principal.CalculateSimpleInterest(interestRate, period); +decimal simpleTotal = principal.CalculateSimpleInterest(annualRate, years); +decimal simpleInterest = principal.CalculateSimpleInterestAmount(annualRate, years); -// Calculate simple interest amount (just the interest earned) -decimal simpleAmount = principal.CalculateSimpleInterestAmount(interestRate, period); +decimal annualTotal = principal.CalculateCompoundInterest(annualRate, years); +decimal annualInterest = principal.CalculateCompoundInterestAmount(annualRate, years); -// Calculate compound interest (final amount, annual compounding) -decimal compound = principal.CalculateCompoundInterest(interestRate, period); +decimal monthlyTotal = principal.CalculateCompoundInterest( + annualRate, + years, + InterestPeriod.Monthly); -// Calculate compound interest (monthly compounding) -decimal compoundMonthly = principal.CalculateCompoundInterest(interestRate, period, InterestPeriod.Monthly); +decimal dailyInterest = principal.CalculateCompoundInterestAmount( + annualRate, + years, + InterestPeriod.Daily); -// Calculate only the interest earned -decimal earned = principal.CalculateCompoundInterestAmount(interestRate, period, InterestPeriod.Daily); +// InterestExtensions does not choose a financial rounding policy for you. +decimal displayAmount = decimal.Round(monthlyTotal, 2, MidpointRounding.ToEven); ``` ---- +## API overview -## Contributing +| Method | Result | +| --- | --- | +| `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`. -Contributions are welcome! If you encounter any issues or have suggestions for improvements, feel free to open an issue or submit a pull request. +## 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. + +## Scope and limitations + +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. + +## 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. ## License -[MIT License](https://github.com/louresb/InterestExtensions/blob/main/LICENSE) © [Bruno Loures](https://github.com/louresb) +Licensed under the [MIT License](https://github.com/louresb/InterestExtensions/blob/v3.0.0/LICENSE). Copyright © Bruno Loures. diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..8339d0f --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,15 @@ + + + + + + + cobertura + [InterestExtension]* + CompilerGeneratedAttribute,GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute + true + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..17d728e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestFeature", + "allowPrerelease": false + } +}