Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
name: Publish NuGet

on:
release:
types: [published]
push:
tags:
- "v*.*.*"

permissions:
contents: read

concurrency:
group: nuget-${{ github.event.release.tag_name }}
group: nuget-${{ github.ref_name }}
cancel-in-progress: false

env:
Expand All @@ -18,7 +19,6 @@ env:
jobs:
validate:
name: Validate release artifact
if: ${{ !github.event.release.prerelease && !github.event.release.draft }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
Expand All @@ -30,7 +30,7 @@ jobs:
- name: Checkout release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.release.tag_name }}
ref: ${{ github.sha }}
fetch-depth: 0

- name: Setup .NET
Expand All @@ -48,7 +48,7 @@ jobs:
id: version
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Expected a stable vMAJOR.MINOR.PATCH tag, got $RELEASE_TAG"
Expand Down
36 changes: 6 additions & 30 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,13 @@
# Changelog

All notable changes to this project are documented in this file. The project follows [Semantic Versioning](https://semver.org/).
## [3.0.0] - 2026-08-18

## [3.0.0] - 2026-08-16
- Added .NET Standard 2.0 support.
- Added annual interest-only calculations and custom compounding periods.
- Compound calculations now use decimal arithmetic throughout.
- Negative inputs and unsupported `InterestPeriod` values now throw `ArgumentOutOfRangeException`.

### Added

- `netstandard2.0` package asset while retaining the optimized `net8.0` asset.
- .NET 8 and .NET 10 test execution.
- Annual overload for `CalculateCompoundInterestAmount`.
- XML API documentation, portable symbols, Source Link metadata, and `.snupkg` generation.
- Package validation against 2.0.1.
- Reproducible CI artifacts, coverage reporting, locked dependencies, and monthly Dependabot updates.
- Safe NuGet release workflow based on a published GitHub Release and Trusted Publishing.

### Changed

- Compound calculations now use decimal exponentiation instead of converting through `double`.
- Documentation now defines `interestRate` as a nominal annual fractional rate and `period` as whole years, matching the existing formulas.
- Daily compounding is explicitly documented as 365 compounding periods per year.
- Package version is now published only through the dedicated release workflow; pushes to `main` never publish.

### Fixed

- All public methods now reject negative principal, interest rate, and period values consistently.
- `CalculateCompoundInterestAmount` now rejects undefined `InterestPeriod` values instead of silently treating them as yearly.
- Package contents now include the XML documentation promised by the README.

### Breaking changes

- Invalid calls that previously returned a value can now throw `ArgumentOutOfRangeException`.
- Valid compound calculations can differ in their least significant decimal digits because the `double` conversion was removed.
- Consumers that depend on exact 2.x behavior must remain on 2.0.1 until they have reviewed these changes.
**Breaking:** invalid-input behavior changed, and compound results can differ from 2.x in their least significant decimal digits.

## [2.0.1] - 2025-05-25

Expand Down
40 changes: 14 additions & 26 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Local development

Install the .NET 10 SDK. The solution builds the library for `netstandard2.0` and `net8.0`, and runs its tests on .NET 8 and .NET 10.
Install the .NET 10 SDK, then run:

```powershell
dotnet tool restore
Expand All @@ -12,35 +12,23 @@ dotnet test InterestExtension.sln --configuration Release --no-build --settings
dotnet pack InterestExtension/InterestExtension.csproj --configuration Release --no-build --no-restore
```

CI additionally restores `InterestExtension.PackageSmoke` from the newly created local package into an isolated package cache and runs it on .NET 8 and .NET 10. This verifies the actual `.nupkg`, its XML documentation, README, icon, and symbol package rather than only testing a project reference.

When intentionally updating a NuGet dependency, regenerate the lock files with:

```powershell
dotnet restore InterestExtension.sln --force-evaluate
```

The repository-level `NuGet.Config` clears machine-specific feeds and restores dependencies only from NuGet.org.

## Pull requests

- Add or update tests for behavior changes.
- Preserve the public API unless the change is explicitly planned for a major release.
- Update `README.md`, `MIGRATION.md`, and `CHANGELOG.md` when the public contract changes.
- Keep calculations unrounded; rounding rules belong to the consuming financial domain.

## Release policy

A push to `main` runs CI but never publishes a package.
- Preserve the public API unless the change is planned for a major release.
- Update the README and changelog when the public contract changes.
- Keep calculations unrounded; rounding rules belong to the consuming domain.

To release a stable version:
## Releasing

1. Set the version in `InterestExtension.csproj` and update the changelog.
2. Merge a green pull request.
3. Create a `vMAJOR.MINOR.PATCH` tag that exactly matches the project version.
4. Publish a non-prerelease GitHub Release for that tag.
5. The protected `nuget.org` environment authenticates through NuGet Trusted Publishing and publishes the package.
After merging a green pull request, tag the commit on `main` with the version declared in `InterestExtension.csproj` and push the tag:

The NuGet.org Trusted Publishing policy must match owner `louresb`, repository `InterestExtensions`, workflow file `release.yml`, and GitHub environment `nuget.org`. The repository variable `NUGET_USER` must contain the NuGet.org profile name, not an email address.
```powershell
git switch main
git pull --ff-only
$version = dotnet msbuild InterestExtension/InterestExtension.csproj -nologo -getProperty:PackageVersion
git tag "v$version"
git push origin "v$version"
```

Duplicate package versions fail by design; the release workflow never uses `--skip-duplicate`.
The tag starts the workflow that validates and publishes the package through NuGet Trusted Publishing. Duplicate or mismatched versions fail without publishing.
9 changes: 9 additions & 0 deletions InterestExtension.PackageSmoke/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
throw new InvalidOperationException($"Expected {expected}, but the installed package returned {actual}.");
}

const decimal expectedCustomPeriods = 1196.1474756866648607810499868m;
var actualCustomPeriods = 1000m.CalculateCompoundInterestForPeriods(0.12m, 18, 12);

if (actualCustomPeriods != expectedCustomPeriods)
{
throw new InvalidOperationException(
$"Expected {expectedCustomPeriods}, but the installed package returned {actualCustomPeriods}.");
}

if (args.Length != 1)
{
throw new ArgumentException("Pass the package output directory as the only argument.");
Expand Down
67 changes: 67 additions & 0 deletions InterestExtension.Tests/CustomCompoundingPeriodTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace InterestExtension.Tests;

[TestClass]
public sealed class CustomCompoundingPeriodTests
{
[TestMethod]
public void CalculateCompoundInterestForPeriodsReturnsExactResult()
=> Assert.AreEqual(
1196.1474756866648607810499868m,
1000m.CalculateCompoundInterestForPeriods(
0.12m,
compoundingPeriodCount: 18,
compoundingPeriodsPerYear: 12));

[TestMethod]
public void CustomMonthlyPeriodsMatchWholeYearOverload()
{
var customTotal = 750m.CalculateCompoundInterestForPeriods(0.08m, 36, 12);
var customInterest = 750m.CalculateCompoundInterestAmountForPeriods(0.08m, 36, 12);
var monthlyTotal = 750m.CalculateCompoundInterest(0.08m, 3, Enums.InterestPeriod.Monthly);
var monthlyInterest = 750m.CalculateCompoundInterestAmount(0.08m, 3, Enums.InterestPeriod.Monthly);

Assert.AreEqual(monthlyTotal, customTotal);
Assert.AreEqual(monthlyInterest, customInterest);
}

[TestMethod]
public void CustomFrequencySupportsNonPresetPeriods()
{
var quarterly = 1000m.CalculateCompoundInterestForPeriods(0.08m, 8, 4);
var semiannual = 1000m.CalculateCompoundInterestForPeriods(0.08m, 4, 2);

Assert.AreEqual(1171.6593810022656m, quarterly);
Assert.AreEqual(1169.85856m, semiannual);
}

[TestMethod]
public void InterestAmountEqualsTotalMinusPrincipal()
{
const decimal principal = 1000m;
var total = principal.CalculateCompoundInterestForPeriods(0.12m, 18, 12);
var interest = principal.CalculateCompoundInterestAmountForPeriods(0.12m, 18, 12);

Assert.AreEqual(total - principal, interest);
}

[TestMethod]
public void ZeroValueIdentitiesReturnWithoutRoundingOrOverflow()
{
Assert.AreEqual(decimal.MaxValue, decimal.MaxValue.CalculateCompoundInterestForPeriods(0m, int.MaxValue, 1));
Assert.AreEqual(0m, decimal.MaxValue.CalculateCompoundInterestAmountForPeriods(0m, int.MaxValue, 1));

Assert.AreEqual(1m, 1m.CalculateCompoundInterestForPeriods(decimal.MaxValue, 0, 1));
Assert.AreEqual(0m, 1m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 0, 1));

Assert.AreEqual(0m, 0m.CalculateCompoundInterestForPeriods(decimal.MaxValue, int.MaxValue, 1));
Assert.AreEqual(0m, 0m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, int.MaxValue, 1));
}

[TestMethod]
public void ResultsAreNotRoundedByTheLibrary()
{
var result = 1000m.CalculateCompoundInterestForPeriods(0.12m, 18, 12);

Assert.AreNotEqual(decimal.Round(result, 2), result);
}
}
39 changes: 36 additions & 3 deletions InterestExtension.Tests/ValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ public void EveryCalculationRejectsNegativePrincipal()
() => (-1m).CalculateSimpleInterestAmount(0.05m, 1),
() => (-1m).CalculateCompoundInterest(0.05m, 1),
() => (-1m).CalculateCompoundInterest(0.05m, 1, InterestPeriod.Monthly),
() => (-1m).CalculateCompoundInterestForPeriods(0.05m, 1, 12),
() => (-1m).CalculateCompoundInterestAmount(0.05m, 1),
() => (-1m).CalculateCompoundInterestAmount(0.05m, 1, InterestPeriod.Daily));
() => (-1m).CalculateCompoundInterestAmount(0.05m, 1, InterestPeriod.Daily),
() => (-1m).CalculateCompoundInterestAmountForPeriods(0.05m, 1, 12));

[TestMethod]
public void EveryCalculationRejectsNegativeInterestRate()
Expand All @@ -24,8 +26,10 @@ public void EveryCalculationRejectsNegativeInterestRate()
() => 100m.CalculateSimpleInterestAmount(-0.05m, 1),
() => 100m.CalculateCompoundInterest(-0.05m, 1),
() => 100m.CalculateCompoundInterest(-0.05m, 1, InterestPeriod.Monthly),
() => 100m.CalculateCompoundInterestForPeriods(-0.05m, 1, 12),
() => 100m.CalculateCompoundInterestAmount(-0.05m, 1),
() => 100m.CalculateCompoundInterestAmount(-0.05m, 1, InterestPeriod.Daily));
() => 100m.CalculateCompoundInterestAmount(-0.05m, 1, InterestPeriod.Daily),
() => 100m.CalculateCompoundInterestAmountForPeriods(-0.05m, 1, 12));

[TestMethod]
public void EveryCalculationRejectsNegativePeriod()
Expand All @@ -51,6 +55,29 @@ public void CompoundCalculationsRejectUnknownPeriodType(int value)
() => 100m.CalculateCompoundInterestAmount(0.05m, 1, periodType));
}

[TestMethod]
public void CustomCompoundCalculationsRejectNegativePeriodCount()
=> AssertAllThrowForParameter(
"compoundingPeriodCount",
() => 100m.CalculateCompoundInterestForPeriods(0.05m, -1, 12),
() => 100m.CalculateCompoundInterestAmountForPeriods(0.05m, -1, 12));

[TestMethod]
[DataRow(0)]
[DataRow(-1)]
public void CustomCompoundCalculationsRejectInvalidFrequency(int compoundingPeriodsPerYear)
=> AssertAllThrowForParameter(
"compoundingPeriodsPerYear",
() => 100m.CalculateCompoundInterestForPeriods(0.05m, 1, compoundingPeriodsPerYear),
() => 100m.CalculateCompoundInterestAmountForPeriods(0.05m, 1, compoundingPeriodsPerYear));

[TestMethod]
public void ZeroValueIdentitiesStillRejectInvalidCustomFrequency()
=> AssertAllThrowForParameter(
"compoundingPeriodsPerYear",
() => 0m.CalculateCompoundInterestForPeriods(decimal.MaxValue, 0, 0),
() => 0m.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 0, 0));

[TestMethod]
public void ZeroValueIdentitiesStillRejectUnknownPeriodType()
{
Expand All @@ -69,8 +96,14 @@ public void DailyCompoundingRejectsPeriodCountOverflow()

[TestMethod]
public void CalculationRejectsDecimalOverflow()
=> Assert.ThrowsExactly<OverflowException>(
{
Assert.ThrowsExactly<OverflowException>(
() => decimal.MaxValue.CalculateCompoundInterest(1m, 1));
Assert.ThrowsExactly<OverflowException>(
() => decimal.MaxValue.CalculateCompoundInterestForPeriods(1m, 1, 1));
Assert.ThrowsExactly<OverflowException>(
() => decimal.MaxValue.CalculateCompoundInterestAmountForPeriods(decimal.MaxValue, 1, 1));
}

[TestMethod]
public void InterestOnlyCalculationsDoNotAddPrincipalBeforeReturning()
Expand Down
Loading