diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7512b27
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,16 @@
+# Example environment variables for Docker deployment
+# Copy this file to .env and adjust values as needed
+
+# Predictor Settings
+PREDICTOR__MAXCALCULATIONPERIODMONTHS=36
+PREDICTOR__DEFAULTINITIALBUDGET=48750
+PREDICTOR__ENABLEEXAMPLEDATA=true
+PREDICTOR__MAXALLOWEDCALCULATIONPERIOD=120
+
+# ASP.NET Core Settings
+ASPNETCORE_ENVIRONMENT=Production
+ASPNETCORE_URLS=http://+:8080
+
+# Logging
+LOGGING__LOGLEVEL__DEFAULT=Information
+LOGGING__LOGLEVEL__MICROSOFT_ASPNETCORE=Warning
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
new file mode 100644
index 0000000..2a0e45f
--- /dev/null
+++ b/docs/CONFIGURATION.md
@@ -0,0 +1,84 @@
+# Configuration Guide
+
+## Application Settings
+
+The Predictor application is fully configurable through the `PredictorSettings` section in `appsettings.json`. All settings can be overridden using environment variables or other ASP.NET Core configuration sources.
+
+### Available Settings
+
+| Setting | Default | Description |
+| ----------------------------- | ------- | -------------------------------------------------------------- |
+| `MaxCalculationPeriodMonths` | 36 | Maximum number of months to calculate predictions for |
+| `DefaultInitialBudget` | 48750 | Default initial budget used in example data |
+| `EnableExampleData` | true | Enable/disable the `/example-data` endpoint |
+| `MaxAllowedCalculationPeriod` | 120 | Maximum allowed calculation period to prevent abuse (10 years) |
+
+### Configuration Examples
+
+#### appsettings.json
+
+```json
+{
+ "PredictorSettings": {
+ "MaxCalculationPeriodMonths": 24,
+ "DefaultInitialBudget": 25000,
+ "EnableExampleData": true,
+ "MaxAllowedCalculationPeriod": 60
+ }
+}
+```
+
+#### Environment Variables
+
+```bash
+# Docker/Container deployment
+PREDICTOR__MAXCALCULATIONPERIODMONTHS=24
+PREDICTOR__DEFAULTINITIALBUDGET=25000
+PREDICTOR__ENABLEEXAMPLEDATA=true
+PREDICTOR__MAXALLOWEDCALCULATIONPERIOD=60
+
+# Or use the ASPNETCORE prefix
+ASPNETCORE_PREDICTORSETTINGS__MAXCALCULATIONPERIODMONTHS=24
+```
+
+#### Docker Compose
+
+```yaml
+version: "3.8"
+services:
+ predictor:
+ image: predictor:latest
+ environment:
+ - PREDICTOR__MAXCALCULATIONPERIODMONTHS=24
+ - PREDICTOR__DEFAULTINITIALBUDGET=25000
+ - PREDICTOR__ENABLEEXAMPLEDATA=true
+ - PREDICTOR__MAXALLOWEDCALCULATIONPERIOD=60
+ ports:
+ - "8080:8080"
+```
+
+### Environment-Specific Configuration
+
+The application uses standard ASP.NET Core configuration layering:
+
+1. **appsettings.json** - Base configuration
+2. **appsettings.{Environment}.json** - Environment-specific overrides
+3. **Environment variables** - Runtime overrides
+4. **Command line arguments** - Highest priority
+
+#### Development vs Production
+
+- **Development**: Uses `appsettings.Development.json` with shorter calculation periods for faster testing
+- **Production**: Uses `appsettings.json` with full calculation periods
+
+### Configuration Validation
+
+The application validates configuration at startup and uses sensible defaults if values are missing. The calculation period is automatically capped at `MaxAllowedCalculationPeriod` to prevent resource abuse.
+
+### Why This Matters
+
+✅ **Deployment Flexibility**: Change limits without code changes
+✅ **Environment-Specific**: Different settings for dev/staging/production
+✅ **Docker Ready**: Full support for container deployment
+✅ **Twelve-Factor**: Follows twelve-factor app configuration principles
+✅ **Security**: Sensitive settings can be injected via environment variables
diff --git a/src/.gitignore b/src/.gitignore
index df0dc0a..1f7e0c3 100644
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -6,6 +6,13 @@
# Project private files
DownloadResults/
+# Environment files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
# User-specific files
*.rsuser
*.suo
diff --git a/src/Predictor.Web/ExampleData.cs b/src/Predictor.Web/ExampleData.cs
index a3fae95..2e01bfe 100644
--- a/src/Predictor.Web/ExampleData.cs
+++ b/src/Predictor.Web/ExampleData.cs
@@ -4,17 +4,22 @@ namespace Predictor.Web;
public static class ExampleData
{
- public static CalculateInput CalculateInputExample { get; } = new(
- InitialBudget: 48_750,
- StartCalculationMonth: MonthDate.Now,
- Incomes: [
- // Infinite recurring income (no EndDate)
- new("Primary Salary", 5_400, MonthDate.Now, new RecurringConfig(1)),
- new("Spouse Salary", 4_100, MonthDate.Now, new RecurringConfig(1)),
- new("Rental Property A", 1_500, MonthDate.Now, new RecurringConfig(1)),
- new("Rental Property B", 1_100, MonthDate.Now.AddMonths(3), new RecurringConfig(1)),
- new("Investment Dividends", 320, MonthDate.Now.AddMonths(1), new RecurringConfig(3)),
- new("Side Business", 850, MonthDate.Now.AddMonths(2), new RecurringConfig(1)),
+ [Obsolete("Use GetCalculateInputExample(PredictorSettings settings) instead")]
+ public static CalculateInput CalculateInputExample => GetCalculateInputExample(new PredictorSettings());
+
+ public static CalculateInput GetCalculateInputExample(PredictorSettings settings)
+ {
+ return new CalculateInput(
+ InitialBudget: settings.DefaultInitialBudget,
+ StartCalculationMonth: MonthDate.Now,
+ Incomes: [
+ // Infinite recurring income (no EndDate)
+ new("Primary Salary", 5_400, MonthDate.Now, new RecurringConfig(1)),
+ new("Spouse Salary", 4_100, MonthDate.Now, new RecurringConfig(1)),
+ new("Rental Property A", 1_500, MonthDate.Now, new RecurringConfig(1)),
+ new("Rental Property B", 1_100, MonthDate.Now.AddMonths(3), new RecurringConfig(1)),
+ new("Investment Dividends", 320, MonthDate.Now.AddMonths(1), new RecurringConfig(3)),
+ new("Side Business", 850, MonthDate.Now.AddMonths(2), new RecurringConfig(1)),
// Finite recurring income (with EndDate)
new("Contract Work", 2_200, MonthDate.Now, new RecurringConfig(1, MonthDate.Now.AddMonths(18))),
@@ -123,4 +128,5 @@ public static class ExampleData
new("Luxury Purchase", 18_000, MonthDate.Now.AddMonths(42)),
new("Investment Property Down Payment", 25_000, MonthDate.Now.AddMonths(45)),
new("Retirement Catch-up", 30_000, MonthDate.Now.AddMonths(48))]);
+ }
}
diff --git a/src/Predictor.Web/Models/PredictorSettings.cs b/src/Predictor.Web/Models/PredictorSettings.cs
new file mode 100644
index 0000000..71bccc0
--- /dev/null
+++ b/src/Predictor.Web/Models/PredictorSettings.cs
@@ -0,0 +1,26 @@
+namespace Predictor.Web.Models;
+
+public class PredictorSettings
+{
+ public const string SectionName = "PredictorSettings";
+
+ ///
+ /// Maximum number of months to calculate predictions for
+ ///
+ public int MaxCalculationPeriodMonths { get; set; } = 36;
+
+ ///
+ /// Default initial budget for examples
+ ///
+ public decimal DefaultInitialBudget { get; set; } = 48_750m;
+
+ ///
+ /// Enable/disable example data endpoint
+ ///
+ public bool EnableExampleData { get; set; } = true;
+
+ ///
+ /// Maximum allowed calculation period to prevent abuse
+ ///
+ public int MaxAllowedCalculationPeriod { get; set; } = 120; // 10 years
+}
diff --git a/src/Predictor.Web/Program.cs b/src/Predictor.Web/Program.cs
index e0d6be4..36bb1a9 100644
--- a/src/Predictor.Web/Program.cs
+++ b/src/Predictor.Web/Program.cs
@@ -3,6 +3,10 @@
var builder = WebApplication.CreateBuilder(args);
+// Configure PredictorSettings
+builder.Services.Configure(
+ builder.Configuration.GetSection(PredictorSettings.SectionName));
+
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -19,20 +23,31 @@
app.UseAuthorization();
app.MapControllers();
-app.MapGet("/example-data", () => ExampleData.CalculateInputExample);
+// Get settings
+var settings = app.Services.GetRequiredService()
+ .GetSection(PredictorSettings.SectionName)
+ .Get() ?? new PredictorSettings();
+
+// Conditionally register example data endpoint
+if (settings.EnableExampleData)
+{
+ app.MapGet("/example-data", () => ExampleData.GetCalculateInputExample(settings));
+}
app.MapPost("/calc", (CalculateInput input) =>
{
var months = new List();
var budget = input.InitialBudget;
- foreach (var currentMonth in MonthDate.Range(input.StartCalculationMonth, 12 * 3 - 1))
+ var calculationPeriod = Math.Min(settings.MaxCalculationPeriodMonths, settings.MaxAllowedCalculationPeriod);
+
+ foreach (var currentMonth in MonthDate.Range(input.StartCalculationMonth, calculationPeriod - 1))
{
var month = Calculator.CalculateMonth(input, currentMonth, budget);
budget = month.BudgetAfter;
months.Add(month);
}
- return new CalculationOutput([.. months]);
+ return Results.Ok(new CalculationOutput([.. months]));
});
app.Run();
\ No newline at end of file
diff --git a/src/Predictor.Web/appsettings.Development.json b/src/Predictor.Web/appsettings.Development.json
index 0c208ae..b451ebd 100644
--- a/src/Predictor.Web/appsettings.Development.json
+++ b/src/Predictor.Web/appsettings.Development.json
@@ -4,5 +4,11 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "PredictorSettings": {
+ "MaxCalculationPeriodMonths": 24,
+ "DefaultInitialBudget": 25000,
+ "EnableExampleData": true,
+ "MaxAllowedCalculationPeriod": 60
}
}
diff --git a/src/Predictor.Web/appsettings.json b/src/Predictor.Web/appsettings.json
index 10f68b8..59b2773 100644
--- a/src/Predictor.Web/appsettings.json
+++ b/src/Predictor.Web/appsettings.json
@@ -5,5 +5,11 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "PredictorSettings": {
+ "MaxCalculationPeriodMonths": 36,
+ "DefaultInitialBudget": 48750,
+ "EnableExampleData": true,
+ "MaxAllowedCalculationPeriod": 120
+ }
}