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 @@
[](https://www.nuget.org/packages/InterestExtensions)
[](https://www.nuget.org/packages/InterestExtensions)
-[](https://github.com/louresb/InterestExtensions/actions)
-[](https://github.com/louresb/InterestExtensions/blob/main/LICENSE)
-[](https://github.com/louresb/InterestExtensions)
+[](https://github.com/louresb/InterestExtensions/actions/workflows/ci.yml?query=branch%3Amain)
+[](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
+ }
+}