diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..5ff8348
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,7 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": { "version": "8.0.11", "commands": ["dotnet-ef"] }
+ }
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ffa2c06
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+**/bin
+**/obj
+.git
+.vs
+.tools
+.env
+artifacts
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1630640
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+* text=auto
+*.cs text eol=lf
+*.csproj text eol=lf
+*.json text eol=lf
+*.md text eol=lf
+*.yaml text eol=lf
+*.yml text eol=lf
+*.ps1 text eol=lf
+*.py text eol=lf
+Dockerfile text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6bfcfb3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,23 @@
+name: billing-ci
+on: [push, pull_request]
+permissions:
+ contents: read
+jobs:
+ integration:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - name: Start full stack and apply migrations
+ run: docker compose up --build -d
+ - name: Test against PostgreSQL and RabbitMQ
+ run: docker compose --profile test run --build --rm tests
+ - name: Smoke and outage recovery
+ shell: pwsh
+ run: ./scripts/verify.ps1
+ - name: Logs
+ if: failure()
+ run: docker compose logs --no-color
+ - name: Cleanup
+ if: always()
+ run: docker compose down -v
diff --git a/.gitignore b/.gitignore
index 104b544..a7b6dce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -482,3 +482,5 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
+
+.tools/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..ca063ef
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+ true
+ latest
+
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a3d971f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
+WORKDIR /src
+COPY Directory.Build.props global.json ./
+COPY ["Subscription & Billing/Subscription & Billing.csproj", "Subscription & Billing/"]
+RUN dotnet restore "Subscription & Billing/Subscription & Billing.csproj"
+
+FROM restore AS test-restore
+COPY tests/Billing.Tests/Billing.Tests.csproj tests/Billing.Tests/
+RUN dotnet restore tests/Billing.Tests/Billing.Tests.csproj
+
+FROM test-restore AS test
+COPY . .
+ENTRYPOINT ["dotnet", "test", "tests/Billing.Tests/Billing.Tests.csproj", "-c", "Release", "--no-restore", "--logger", "console;verbosity=normal"]
+
+FROM restore AS build
+COPY . .
+RUN dotnet publish "Subscription & Billing/Subscription & Billing.csproj" -c Release --no-restore -o /app /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+WORKDIR /app
+COPY --from=build /app .
+USER app
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "Billing.dll"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..575f913
--- /dev/null
+++ b/README.md
@@ -0,0 +1,85 @@
+# Subscription Billing
+
+ASP.NET Core backend: запись оплаты, создание подписки и асинхронная активация.
+PostgreSQL 16 · RabbitMQ 4 · EF Core 8 · MassTransit 8 · .NET 8.
+
+## Запуск
+
+Нужен Docker с Compose. Из корня репозитория:
+
+```sh
+docker compose up --build -d
+```
+
+Открой [Swagger](http://localhost:8080/swagger). Compose запускает БД, брокер,
+миграции, API и worker. Первая сборка скачивает образы. Данные сохраняются в volumes.
+Остановка: `docker compose down`. Локальные пароли заданы в Compose;
+их можно заменить через `.env` (`POSTGRES_PASSWORD`, `RABBITMQ_PASSWORD`).
+
+## Архитектура
+
+```mermaid
+flowchart LR
+ C[Client + Idempotency-Key] --> A[API: validation + concurrency limit]
+ A --> S[SubscriptionService]
+ S -->|one transaction| DB[(PostgreSQL: subscription + payment + response + outbox)]
+ DB --> D[MassTransit outbox delivery]
+ D -->|publisher confirm| R[RabbitMQ durable queue]
+ R --> W[Worker: retry + inbox]
+ W -->|conditional activation| DB
+ W -->|failed after retries| E[Error queue]
+```
+
+- **Деньги и событие атомарны:** outbox записывается в той же транзакции.
+ При недоступном брокере запрос сохраняется, доставка продолжится после восстановления.
+- **Параллельные повторы безопасны:** PostgreSQL advisory lock по ключу,
+ сохранённый ответ, `409` при смене тела. Нет блокировок в памяти одного сервера.
+- **Доставка at-least-once:** inbox и условный UPDATE защищают активацию от дублей.
+ Необработанные события после трёх повторов попадают в `subscription-activation_error`.
+- **Контроль нагрузки:** максимум 64 одновременных API-запроса, затем `429`;
+ prefetch 32, 8 обработчиков на worker; индекс и cursor pagination с лимитом 100.
+- **Эксплуатация:** отдельные API/worker, миграции перед запуском, контейнер без root,
+ JSON-логи, `traceId` в ProblemDetails, `/health/live` и `/health/ready`.
+ API остаётся готовым при сбое брокера, пока доступна БД; worker требует брокер.
+
+## Попробовать
+
+1. `POST /api/users`: `{"name":"Stepan","email":"stepan@example.com"}`.
+2. `POST /api/subscriptions/create`: `{"userId":1,"plan":"basic"}`,
+ заголовок `Idempotency-Key: 75be42df-1323-48c2-a317-55bb64445a1b`.
+3. Ответ `202` и `Location`; `GET /api/subscriptions/1` покажет `isActive: true`
+ после обработки события. Повтор шага 2 вернёт исходный ответ без второй оплаты.
+
+Цены задаёт сервер: basic = 990 RUB, pro = 1990 RUB. Список:
+`GET /api/subscriptions?userId=1&afterId=0&limit=20`. Полный контракт в Swagger.
+
+## Проверка
+
+```sh
+docker compose --profile test run --build --rm tests
+```
+
+xUnit + WebApplicationFactory проверяют настоящий PostgreSQL и RabbitMQ:
+20 параллельных повторов, откат после вставки платежа, конфликт ключа,
+валидацию, серверную цену, пагинацию, дубли событий и активацию.
+Тесты используют отдельную БД `billing_tests` и отдельный exchange/queue.
+
+`pwsh ./scripts/verify.ps1` проверяет полный стек, отключает брокер,
+перезапускает API и проверяет доставку сохранённого события после восстановления.
+GitHub Actions запускает обе проверки. [Архитектурные решения и масштабирование](docs/architecture.md).
+
+Мини-бенчмарк записи (Python 3, без пакетов):
+`python scripts/load.py --requests 200 --concurrency 20`.
+Он измеряет p50/p95 и число принятых запросов в секунду, затем проверяет активацию.
+[Результат проверок и условия замера](docs/verification.md).
+
+## Границы проекта
+
+Это демонстрация надёжности backend, оплата симулируется: платёжного провайдера,
+авторизации, возвратов и периодических списаний пока нет. Compose открыт только
+на localhost. Для публичного сервиса нужны auth, проверенные webhooks провайдера,
+TLS и управление секретами. Высокая нагрузка требует замеров, а не обещаний RPS.
+
+Контракт изменён: клиентский `amount` удалён, POST теперь возвращает `202`.
+Начальная миграция предназначена для новой БД; старую БД, созданную через
+`EnsureCreated`, нужно переносить отдельно с резервной копией.
diff --git a/Subscription & Billing.sln b/Subscription & Billing.sln
index 4805cc5..8232464 100644
--- a/Subscription & Billing.sln
+++ b/Subscription & Billing.sln
@@ -5,6 +5,10 @@ VisualStudioVersion = 17.11.35222.181
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Subscription & Billing", "Subscription & Billing\Subscription & Billing.csproj", "{355B9D45-5658-43C0-B86D-5135298F19E9}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4127394C-64CD-400E-9F55-570D4DE21232}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Tests", "tests\Billing.Tests\Billing.Tests.csproj", "{321D0816-D5EA-4EAC-B948-169278FD8EC1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +19,10 @@ Global
{355B9D45-5658-43C0-B86D-5135298F19E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{355B9D45-5658-43C0-B86D-5135298F19E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{355B9D45-5658-43C0-B86D-5135298F19E9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -22,4 +30,7 @@ Global
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A288BAF6-7662-485E-AB6E-A4FCF8F7AA78}
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {321D0816-D5EA-4EAC-B948-169278FD8EC1} = {4127394C-64CD-400E-9F55-570D4DE21232}
+ EndGlobalSection
EndGlobal
diff --git a/Subscription & Billing/.config/dotnet-tools.json b/Subscription & Billing/.config/dotnet-tools.json
deleted file mode 100644
index b0e38ab..0000000
--- a/Subscription & Billing/.config/dotnet-tools.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "version": 1,
- "isRoot": true,
- "tools": {}
-}
\ No newline at end of file
diff --git a/Subscription & Billing/AppDbContext.cs b/Subscription & Billing/AppDbContext.cs
index b9d03fc..91576f3 100644
--- a/Subscription & Billing/AppDbContext.cs
+++ b/Subscription & Billing/AppDbContext.cs
@@ -1,17 +1,31 @@
+using MassTransit;
using Microsoft.EntityFrameworkCore;
using Subscription_Backend.Models;
-using System.Collections.Generic;
-namespace Subscription_Backend
-{
- public class AppDbContext : DbContext
- {
- public AppDbContext(DbContextOptions options) : base(options) { }
+namespace Subscription_Backend;
- public DbSet Users => Set();
- public DbSet Subscriptions => Set();
- public DbSet Payments => Set();
- public DbSet IdempotencyRecords => Set();
+public sealed class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Users => Set();
+ public DbSet Subscriptions => Set();
+ public DbSet Payments => Set();
+ public DbSet IdempotencyRecords => Set();
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.Entity().Property(x => x.Name).HasMaxLength(120);
+ model.Entity().Property(x => x.Email).HasMaxLength(254);
+ model.Entity().HasIndex(x => x.Email).IsUnique();
+ model.Entity().Property(x => x.Plan).HasMaxLength(32);
+ model.Entity().HasOne().WithMany().HasForeignKey(x => x.UserId);
+ model.Entity().HasIndex(x => new { x.UserId, x.Id });
+ model.Entity().HasIndex(x => x.SubscriptionId).IsUnique();
+ model.Entity().Property(x => x.Amount).HasPrecision(18, 2);
+ model.Entity().ToTable(t => t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0"));
+ model.Entity().HasKey(x => x.Key);
+ model.Entity().Property(x => x.RequestHash).HasMaxLength(64);
+ model.AddInboxStateEntity();
+ model.AddOutboxMessageEntity();
+ model.AddOutboxStateEntity();
}
}
diff --git a/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs b/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs
index d0b3683..d5a80c1 100644
--- a/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs
+++ b/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs
@@ -1,40 +1,21 @@
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Subscription_Backend.Messages;
-using Subscription_Backend.Models;
namespace Subscription_Backend.Consumers;
-public class SubscriptionActivationConsumer : IConsumer
+public sealed class SubscriptionActivationConsumer(AppDbContext db,
+ ILogger logger) : IConsumer
{
- private readonly AppDbContext _db;
- private readonly ILogger _logger;
-
- public SubscriptionActivationConsumer(AppDbContext db, ILogger logger)
- {
- _db = db;
- _logger = logger;
- }
-
public async Task Consume(ConsumeContext context)
{
- var message = context.Message;
- _logger.LogInformation($"[Worker] Ïîëó÷åíî ñîáûòèå îïëàòû äëÿ ïîäïèñêè {message.SubscriptionId}");
-
- // Óáðàëè Random Exception.
- // Åñëè áàçà óïàäåò ñàìà ïî ñåáå — Retry ñðàáîòàåò àâòîìàòè÷åñêè.
-
- var subscription = await _db.Subscriptions.FindAsync(message.SubscriptionId);
-
- if (subscription == null)
- {
- _logger.LogError($"Ïîäïèñêà {message.SubscriptionId} íå íàéäåíà!");
- return;
- }
-
- subscription.IsActive = true;
- await _db.SaveChangesAsync();
-
- _logger.LogInformation($"[Worker] Ïîäïèñêà {message.SubscriptionId} óñïåøíî àêòèâèðîâàíà!");
+ var id = context.Message.SubscriptionId;
+ if (!await db.Payments.AnyAsync(x => x.SubscriptionId == id && x.Amount == context.Message.Amount,
+ context.CancellationToken))
+ throw new InvalidOperationException($"Payment for subscription {id} is missing or inconsistent.");
+ // Also safe for different message IDs carrying the same business event.
+ var changed = await db.Subscriptions.Where(x => x.Id == id && !x.IsActive)
+ .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true), context.CancellationToken);
+ logger.LogInformation("Activation processed for {SubscriptionId}; changed {Changed}", id, changed);
}
-}
\ No newline at end of file
+}
diff --git a/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs b/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs
index 27eb73b..60f2bd6 100644
--- a/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs
+++ b/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs
@@ -1,34 +1,46 @@
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using Subscription_Backend.Dtos;
-using Subscription_Backend.Models;
using Subscription_Backend.Services;
namespace Subscription_Backend.Controllers;
[ApiController]
[Route("api/subscriptions")]
-public class SubscriptionWorkflowController : ControllerBase
+public sealed class SubscriptionWorkflowController(SubscriptionService service, AppDbContext db) : ControllerBase
{
- private readonly SubscriptionService _service;
-
- public SubscriptionWorkflowController(SubscriptionService service)
+ [HttpPost("create")]
+ [ProducesResponseType(StatusCodes.Status202Accepted)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create(CreateSubscriptionRequest request,
+ [FromHeader(Name = "Idempotency-Key")] Guid key, CancellationToken ct)
{
- _service = service;
+ if (key == Guid.Empty)
+ return Problem(statusCode: 400, detail: "A non-empty UUID Idempotency-Key header is required.");
+ var result = await service.CreateSubscriptionAsync(request, key, ct);
+ return AcceptedAtAction(nameof(Get), new { id = result.Id }, result);
}
- [HttpPost("create")]
- public async Task> Create(
- CreateSubscriptionRequest request,
- [FromHeader(Name = "Idempotency-Key")] Guid idempotencyKey)
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Get(int id, CancellationToken ct)
{
- if (idempotencyKey == Guid.Empty)
- return BadRequest("Idempotency-Key header is required");
-
- var subscription = await _service.CreateSubscriptionAsync(
- request,
- idempotencyKey);
-
- return Ok(subscription);
+ var result = await db.Subscriptions.AsNoTracking().Where(x => x.Id == id)
+ .Select(x => new SubscriptionResponse(x.Id, x.UserId, x.Plan, x.IsActive)).SingleOrDefaultAsync(ct);
+ return result is null ? NotFound() : Ok(result);
}
+ [HttpGet]
+ public async Task>> List(
+ [FromQuery] int userId, [FromQuery] int afterId = 0, [FromQuery] int limit = 20, CancellationToken ct = default)
+ {
+ if (userId < 1 || afterId < 0 || limit is < 1 or > 100)
+ return Problem(statusCode: 400, detail: "userId > 0, afterId >= 0, limit between 1 and 100 required.");
+ return Ok(await db.Subscriptions.AsNoTracking().Where(x => x.UserId == userId && x.Id > afterId)
+ .OrderBy(x => x.Id).Take(limit)
+ .Select(x => new SubscriptionResponse(x.Id, x.UserId, x.Plan, x.IsActive)).ToListAsync(ct));
+ }
}
diff --git a/Subscription & Billing/Controllers/UsersController.cs b/Subscription & Billing/Controllers/UsersController.cs
new file mode 100644
index 0000000..12cb813
--- /dev/null
+++ b/Subscription & Billing/Controllers/UsersController.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using Subscription_Backend.Dtos;
+using Subscription_Backend.Models;
+
+namespace Subscription_Backend.Controllers;
+
+[ApiController]
+[Route("api/users")]
+public sealed class UsersController(AppDbContext db) : ControllerBase
+{
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create(CreateUserRequest request, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.Name))
+ return Problem(statusCode: 400, detail: "Name must not be blank.");
+ var user = new User { Name = request.Name.Trim(), Email = request.Email.Trim().ToLowerInvariant() };
+ db.Users.Add(user);
+ try { await db.SaveChangesAsync(ct); }
+ catch (DbUpdateException e) when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
+ { return Problem(statusCode: 409, detail: "Email is already registered."); }
+ return CreatedAtAction(nameof(Get), new { id = user.Id }, new UserResponse(user.Id, user.Name, user.Email));
+ }
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Get(int id, CancellationToken ct)
+ {
+ var user = await db.Users.AsNoTracking().Where(x => x.Id == id)
+ .Select(x => new UserResponse(x.Id, x.Name, x.Email)).SingleOrDefaultAsync(ct);
+ return user is null ? NotFound() : Ok(user);
+ }
+}
diff --git a/Subscription & Billing/Controllers/WeatherForecastController.cs b/Subscription & Billing/Controllers/WeatherForecastController.cs
deleted file mode 100644
index 65c060f..0000000
--- a/Subscription & Billing/Controllers/WeatherForecastController.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace Subscription___Billing.Controllers
-{
- [ApiController]
- [Route("[controller]")]
- public class WeatherForecastController : ControllerBase
- {
- private static readonly string[] Summaries = new[]
- {
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
- };
-
- private readonly ILogger _logger;
-
- public WeatherForecastController(ILogger logger)
- {
- _logger = logger;
- }
-
- [HttpGet(Name = "GetWeatherForecast")]
- public IEnumerable Get()
- {
- return Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = Summaries[Random.Shared.Next(Summaries.Length)]
- })
- .ToArray();
- }
- }
-}
diff --git a/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs b/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs
index 30561f4..f5f5b46 100644
--- a/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs
+++ b/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs
@@ -1,8 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
namespace Subscription_Backend.Dtos;
-public class CreateSubscriptionRequest
+public sealed record CreateSubscriptionRequest
{
- public int UserId { get; set; }
- public string Plan { get; set; } = null!;
- public decimal Amount { get; set; }
+ [Range(1, int.MaxValue)]
+ public int UserId { get; init; }
+ [Required, RegularExpression("^(basic|pro)$")]
+ public string Plan { get; init; } = "";
}
+
+public sealed record SubscriptionResponse(int Id, int UserId, string Plan, bool IsActive);
+public sealed record CreateUserRequest(
+ [Required, StringLength(120, MinimumLength = 1)] string Name,
+ [Required, EmailAddress, StringLength(254)] string Email);
+public sealed record UserResponse(int Id, string Name, string Email);
diff --git a/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs b/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs
new file mode 100644
index 0000000..48558b5
--- /dev/null
+++ b/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+using Subscription_Backend.Services;
+
+namespace Subscription_Backend.Infrastructure;
+
+public sealed class BillingExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
+ {
+ if (exception is not BillingException billing) return false;
+ context.Response.StatusCode = billing.StatusCode;
+ return await problems.TryWriteAsync(new ProblemDetailsContext
+ {
+ HttpContext = context,
+ ProblemDetails = new ProblemDetails { Status = billing.StatusCode, Title = "Billing request rejected", Detail = billing.Message }
+ });
+ }
+}
diff --git a/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs b/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs
new file mode 100644
index 0000000..f802800
--- /dev/null
+++ b/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Subscription_Backend.Infrastructure;
+
+public sealed class DatabaseHealthCheck(IServiceScopeFactory scopes) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
+ {
+ await using var scope = scopes.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.Database.CanConnectAsync(ct)
+ ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("Database unavailable.");
+ }
+}
diff --git a/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs b/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..645cb19
--- /dev/null
+++ b/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs
@@ -0,0 +1,12 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Subscription_Backend.Infrastructure;
+
+public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory
+{
+ public AppDbContext CreateDbContext(string[] args) => new(new DbContextOptionsBuilder()
+ .UseNpgsql(Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")
+ ?? "Host=localhost;Database=billing;Username=billing")
+ .Options);
+}
diff --git a/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs b/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs
new file mode 100644
index 0000000..f6aab28
--- /dev/null
+++ b/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs
@@ -0,0 +1,334 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Subscription_Backend;
+
+#nullable disable
+
+namespace Subscription_Backend.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260928205116_InitialBilling")]
+ partial class InitialBilling
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Delivered");
+
+ b.ToTable("InboxState");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.Property("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Headers")
+ .HasColumnType("text");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("SequenceNumber");
+
+ b.HasIndex("EnqueueTime");
+
+ b.HasIndex("ExpirationTime");
+
+ b.HasIndex("OutboxId", "SequenceNumber")
+ .IsUnique();
+
+ b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
+ .IsUnique();
+
+ b.ToTable("OutboxMessage");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
+ {
+ b.Property("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("OutboxId");
+
+ b.HasIndex("Created");
+
+ b.ToTable("OutboxState");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.IdempotencyRecord", b =>
+ {
+ b.Property("Key")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Response")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Key");
+
+ b.ToTable("IdempotencyRecords");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Payment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("PaidAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SubscriptionId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubscriptionId")
+ .IsUnique();
+
+ b.ToTable("Payments", t =>
+ {
+ t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0");
+ });
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Subscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Id");
+
+ b.ToTable("Subscriptions");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
+ .WithMany()
+ .HasForeignKey("OutboxId");
+
+ b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
+ .WithMany()
+ .HasForeignKey("InboxMessageId", "InboxConsumerId")
+ .HasPrincipalKey("MessageId", "ConsumerId");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Payment", b =>
+ {
+ b.HasOne("Subscription_Backend.Models.Subscription", "Subscription")
+ .WithMany()
+ .HasForeignKey("SubscriptionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Subscription");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Subscription", b =>
+ {
+ b.HasOne("Subscription_Backend.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs b/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs
new file mode 100644
index 0000000..6c072dc
--- /dev/null
+++ b/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs
@@ -0,0 +1,242 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Subscription_Backend.Migrations
+{
+ ///
+ public partial class InitialBilling : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "IdempotencyRecords",
+ columns: table => new
+ {
+ Key = table.Column(type: "uuid", nullable: false),
+ RequestHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ Response = table.Column(type: "text", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_IdempotencyRecords", x => x.Key);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "InboxState",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ MessageId = table.Column(type: "uuid", nullable: false),
+ ConsumerId = table.Column(type: "uuid", nullable: false),
+ LockId = table.Column(type: "uuid", nullable: false),
+ RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true),
+ Received = table.Column(type: "timestamp with time zone", nullable: false),
+ ReceiveCount = table.Column(type: "integer", nullable: false),
+ ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true),
+ Consumed = table.Column(type: "timestamp with time zone", nullable: true),
+ Delivered = table.Column(type: "timestamp with time zone", nullable: true),
+ LastSequenceNumber = table.Column(type: "bigint", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_InboxState", x => x.Id);
+ table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId });
+ });
+
+ migrationBuilder.CreateTable(
+ name: "OutboxState",
+ columns: table => new
+ {
+ OutboxId = table.Column(type: "uuid", nullable: false),
+ LockId = table.Column(type: "uuid", nullable: false),
+ RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true),
+ Created = table.Column(type: "timestamp with time zone", nullable: false),
+ Delivered = table.Column(type: "timestamp with time zone", nullable: true),
+ LastSequenceNumber = table.Column(type: "bigint", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_OutboxState", x => x.OutboxId);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false),
+ Email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "OutboxMessage",
+ columns: table => new
+ {
+ SequenceNumber = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true),
+ SentTime = table.Column(type: "timestamp with time zone", nullable: false),
+ Headers = table.Column(type: "text", nullable: true),
+ Properties = table.Column(type: "text", nullable: true),
+ InboxMessageId = table.Column(type: "uuid", nullable: true),
+ InboxConsumerId = table.Column(type: "uuid", nullable: true),
+ OutboxId = table.Column(type: "uuid", nullable: true),
+ MessageId = table.Column(type: "uuid", nullable: false),
+ ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ MessageType = table.Column(type: "text", nullable: false),
+ Body = table.Column(type: "text", nullable: false),
+ ConversationId = table.Column(type: "uuid", nullable: true),
+ CorrelationId = table.Column(type: "uuid", nullable: true),
+ InitiatorId = table.Column(type: "uuid", nullable: true),
+ RequestId = table.Column(type: "uuid", nullable: true),
+ SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber);
+ table.ForeignKey(
+ name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId",
+ columns: x => new { x.InboxMessageId, x.InboxConsumerId },
+ principalTable: "InboxState",
+ principalColumns: new[] { "MessageId", "ConsumerId" });
+ table.ForeignKey(
+ name: "FK_OutboxMessage_OutboxState_OutboxId",
+ column: x => x.OutboxId,
+ principalTable: "OutboxState",
+ principalColumn: "OutboxId");
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Subscriptions",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ UserId = table.Column(type: "integer", nullable: false),
+ Plan = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ IsActive = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Subscriptions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Subscriptions_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Payments",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ SubscriptionId = table.Column(type: "integer", nullable: false),
+ Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ PaidAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Payments", x => x.Id);
+ table.CheckConstraint("CK_Payment_Amount", "\"Amount\" > 0");
+ table.ForeignKey(
+ name: "FK_Payments_Subscriptions_SubscriptionId",
+ column: x => x.SubscriptionId,
+ principalTable: "Subscriptions",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_InboxState_Delivered",
+ table: "InboxState",
+ column: "Delivered");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OutboxMessage_EnqueueTime",
+ table: "OutboxMessage",
+ column: "EnqueueTime");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OutboxMessage_ExpirationTime",
+ table: "OutboxMessage",
+ column: "ExpirationTime");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber",
+ table: "OutboxMessage",
+ columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OutboxMessage_OutboxId_SequenceNumber",
+ table: "OutboxMessage",
+ columns: new[] { "OutboxId", "SequenceNumber" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OutboxState_Created",
+ table: "OutboxState",
+ column: "Created");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Payments_SubscriptionId",
+ table: "Payments",
+ column: "SubscriptionId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Subscriptions_UserId_Id",
+ table: "Subscriptions",
+ columns: new[] { "UserId", "Id" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_Email",
+ table: "Users",
+ column: "Email",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "IdempotencyRecords");
+
+ migrationBuilder.DropTable(
+ name: "OutboxMessage");
+
+ migrationBuilder.DropTable(
+ name: "Payments");
+
+ migrationBuilder.DropTable(
+ name: "InboxState");
+
+ migrationBuilder.DropTable(
+ name: "OutboxState");
+
+ migrationBuilder.DropTable(
+ name: "Subscriptions");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs b/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..5ee0b4c
--- /dev/null
+++ b/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,331 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Subscription_Backend;
+
+#nullable disable
+
+namespace Subscription_Backend.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Consumed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReceiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Received")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Delivered");
+
+ b.ToTable("InboxState");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.Property("SequenceNumber")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber"));
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("DestinationAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EnqueueTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FaultAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Headers")
+ .HasColumnType("text");
+
+ b.Property("InboxConsumerId")
+ .HasColumnType("uuid");
+
+ b.Property("InboxMessageId")
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("MessageType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OutboxId")
+ .HasColumnType("uuid");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResponseAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SentTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SourceAddress")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("SequenceNumber");
+
+ b.HasIndex("EnqueueTime");
+
+ b.HasIndex("ExpirationTime");
+
+ b.HasIndex("OutboxId", "SequenceNumber")
+ .IsUnique();
+
+ b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
+ .IsUnique();
+
+ b.ToTable("OutboxMessage");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
+ {
+ b.Property("OutboxId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Delivered")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSequenceNumber")
+ .HasColumnType("bigint");
+
+ b.Property("LockId")
+ .HasColumnType("uuid");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("bytea");
+
+ b.HasKey("OutboxId");
+
+ b.HasIndex("Created");
+
+ b.ToTable("OutboxState");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.IdempotencyRecord", b =>
+ {
+ b.Property("Key")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Response")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Key");
+
+ b.ToTable("IdempotencyRecords");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Payment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("PaidAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SubscriptionId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubscriptionId")
+ .IsUnique();
+
+ b.ToTable("Payments", t =>
+ {
+ t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0");
+ });
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Subscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Id");
+
+ b.ToTable("Subscriptions");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
+ {
+ b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
+ .WithMany()
+ .HasForeignKey("OutboxId");
+
+ b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
+ .WithMany()
+ .HasForeignKey("InboxMessageId", "InboxConsumerId")
+ .HasPrincipalKey("MessageId", "ConsumerId");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Payment", b =>
+ {
+ b.HasOne("Subscription_Backend.Models.Subscription", "Subscription")
+ .WithMany()
+ .HasForeignKey("SubscriptionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Subscription");
+ });
+
+ modelBuilder.Entity("Subscription_Backend.Models.Subscription", b =>
+ {
+ b.HasOne("Subscription_Backend.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Subscription & Billing/Models/IdempotencyRecord.cs b/Subscription & Billing/Models/IdempotencyRecord.cs
index b26f58a..9c5c841 100644
--- a/Subscription & Billing/Models/IdempotencyRecord.cs
+++ b/Subscription & Billing/Models/IdempotencyRecord.cs
@@ -1,14 +1,9 @@
-using System.ComponentModel.DataAnnotations;
-
namespace Subscription_Backend.Models;
-public class IdempotencyRecord
+public sealed class IdempotencyRecord
{
- [Key]
public Guid Key { get; set; }
-
- public string Endpoint { get; set; } = null!;
+ public string RequestHash { get; set; } = null!;
public string Response { get; set; } = null!;
- public int StatusCode { get; set; }
public DateTime CreatedAt { get; set; }
-}
\ No newline at end of file
+}
diff --git a/Subscription & Billing/Program.cs b/Subscription & Billing/Program.cs
index ad573d7..24e4614 100644
--- a/Subscription & Billing/Program.cs
+++ b/Subscription & Billing/Program.cs
@@ -1,62 +1,109 @@
-using MassTransit;
+using System.Threading.RateLimiting;
+using MassTransit;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore;
using Subscription_Backend;
using Subscription_Backend.Consumers;
+using Subscription_Backend.Infrastructure;
using Subscription_Backend.Services;
var builder = WebApplication.CreateBuilder(args);
+var role = builder.Configuration["ServiceRole"] ?? "All";
+if (role is not ("Api" or "Worker" or "All" or "Migrate"))
+ throw new InvalidOperationException("ServiceRole must be Api, Worker, All or Migrate.");
+var connection = builder.Configuration.GetConnectionString("DefaultConnection")
+ ?? throw new InvalidOperationException("ConnectionStrings__DefaultConnection is required.");
+builder.Services.AddDbContext(o => o.UseNpgsql(connection));
-builder.Services.AddControllers();
-
-builder.Services.AddDbContext(options =>
- options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"),
- npgsqlOptions => npgsqlOptions.EnableRetryOnFailure(
- maxRetryCount: 5,
- maxRetryDelay: TimeSpan.FromSeconds(10),
- errorCodesToAdd: null)
- ));
+if (role == "Migrate")
+{
+ var migrationApp = builder.Build();
+ await using var scope = migrationApp.Services.CreateAsyncScope();
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
+ await migrationApp.DisposeAsync();
+ return;
+}
+builder.Services.AddControllers().AddJsonOptions(o =>
+ o.JsonSerializerOptions.UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow);
builder.Services.AddScoped();
-
+builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
+ ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);
+builder.Services.AddExceptionHandler();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+builder.Logging.ClearProviders();
+builder.Logging.AddJsonConsole();
+builder.Services.AddRateLimiter(o =>
+{
+ o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ o.OnRejected = async (context, _) => await Results.Problem(
+ statusCode: 429, detail: "Too many concurrent requests. Retry with backoff and the same idempotency key.")
+ .ExecuteAsync(context.HttpContext);
+ o.AddConcurrencyLimiter("api", options =>
+ {
+ options.PermitLimit = builder.Configuration.GetValue("Api:ConcurrencyLimit", 64);
+ options.QueueLimit = 0;
+ options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+ });
+});
+builder.Services.AddHealthChecks().AddCheck("postgres", tags: ["ready"]);
+builder.Services.Configure(o =>
+{
+ // The bus outbox lets HTTP writes succeed while the broker is unavailable.
+ o.WaitUntilStarted = false;
+ o.StopTimeout = TimeSpan.FromSeconds(20);
+});
builder.Services.AddMassTransit(x =>
{
- x.AddConsumer();
-
+ if (role is "Worker" or "All") x.AddConsumer();
+ x.AddEntityFrameworkOutbox(o =>
+ {
+ o.UsePostgres();
+ o.QueryDelay = TimeSpan.FromSeconds(1);
+ o.UseBusOutbox();
+ });
x.UsingRabbitMq((context, cfg) =>
{
- cfg.Host("localhost", "/", h =>
- {
- h.Username("guest");
- h.Password("guest");
- });
-
- cfg.UseMessageRetry(r =>
+ cfg.Host(builder.Configuration["RabbitMq:Host"] ?? "localhost", "/", h =>
{
- r.Intervals(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10));
+ h.Username(builder.Configuration["RabbitMq:Username"] ?? "billing");
+ h.Password(builder.Configuration["RabbitMq:Password"] ?? throw new InvalidOperationException("RabbitMq__Password is required."));
});
-
- cfg.ConfigureEndpoints(context);
+ // Declare the durable destination even when API starts before a worker.
+ var queue = builder.Configuration["RabbitMq:ActivationQueue"] ?? "subscription-activation";
+ cfg.Message(m => m.SetEntityName(queue + "-paid"));
+ cfg.Publish(p => p.BindQueue(queue, queue));
+ if (role is "Worker" or "All")
+ cfg.ReceiveEndpoint(builder.Configuration["RabbitMq:ActivationQueue"] ?? "subscription-activation", e =>
+ {
+ e.PrefetchCount = 32;
+ e.ConcurrentMessageLimit = 8;
+ e.UseMessageRetry(r => r.Intervals(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5)));
+ e.UseEntityFrameworkOutbox(context);
+ e.ConfigureConsumer(context);
+ });
});
});
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
-
var app = builder.Build();
-
-using (var scope = app.Services.CreateScope())
+app.UseExceptionHandler();
+app.UseRateLimiter();
+app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
+app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
- var db = scope.ServiceProvider.GetRequiredService();
- db.Database.EnsureCreated();
-}
-
-if (app.Environment.IsDevelopment())
+ // API can durably accept writes through the bus outbox while RabbitMQ is down.
+ // Workers need the transport to make progress, so include it for those roles.
+ Predicate = check => check.Name == "postgres" ||
+ (role is "Worker" or "All" && check.Tags.Contains("masstransit"))
+});
+if (role is "Api" or "All")
{
app.UseSwagger();
app.UseSwaggerUI();
+ app.MapControllers().RequireRateLimiting("api");
}
+app.Run();
-app.UseHttpsRedirection();
-app.MapControllers();
-
-app.Run();
\ No newline at end of file
+public partial class Program;
diff --git a/Subscription & Billing/Services/PlanCatalog.cs b/Subscription & Billing/Services/PlanCatalog.cs
new file mode 100644
index 0000000..2b5b370
--- /dev/null
+++ b/Subscription & Billing/Services/PlanCatalog.cs
@@ -0,0 +1,17 @@
+namespace Subscription_Backend.Services;
+
+// Prices belong to the server, never to the caller.
+public static class PlanCatalog
+{
+ public static decimal Price(string plan) => plan switch
+ {
+ "basic" => 990m,
+ "pro" => 1990m,
+ _ => throw new BillingException(400, "Unknown plan. Use basic or pro.")
+ };
+}
+
+public sealed class BillingException(int statusCode, string message) : Exception(message)
+{
+ public int StatusCode { get; } = statusCode;
+}
diff --git a/Subscription & Billing/Services/SubscriptionService.cs b/Subscription & Billing/Services/SubscriptionService.cs
index cac39ce..79a2614 100644
--- a/Subscription & Billing/Services/SubscriptionService.cs
+++ b/Subscription & Billing/Services/SubscriptionService.cs
@@ -1,85 +1,55 @@
-using MassTransit;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using MassTransit;
using Microsoft.EntityFrameworkCore;
using Subscription_Backend.Dtos;
using Subscription_Backend.Messages;
using Subscription_Backend.Models;
-using System.Text.Json;
namespace Subscription_Backend.Services;
-public class SubscriptionService
+public sealed class SubscriptionService(AppDbContext db, IPublishEndpoint publisher,
+ ILogger logger)
{
- private readonly AppDbContext _db;
- private readonly IPublishEndpoint _publishEndpoint;
-
- public SubscriptionService(AppDbContext db, IPublishEndpoint publishEndpoint)
- {
- _db = db;
- _publishEndpoint = publishEndpoint;
- }
-
- public async Task CreateSubscriptionAsync(CreateSubscriptionRequest request, Guid idempotencyKey)
+ public async Task CreateSubscriptionAsync(
+ CreateSubscriptionRequest request, Guid key, CancellationToken ct)
{
- var existing = await _db.IdempotencyRecords
- .FirstOrDefaultAsync(x =>
- x.Key == idempotencyKey &&
- x.Endpoint == "POST /api/subscriptions/create");
-
- if (existing != null)
+ var amount = PlanCatalog.Price(request.Plan);
+ var hash = Convert.ToHexString(SHA256.HashData(
+ Encoding.UTF8.GetBytes($"v1:{request.UserId}:{request.Plan}")));
+ await using var tx = await db.Database.BeginTransactionAsync(ct);
+ // Works across replicas. Hash collisions only serialize unrelated requests.
+ var lockId = BitConverter.ToInt64(SHA256.HashData(key.ToByteArray()), 0);
+ await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock({lockId})", ct);
+ var existing = await db.IdempotencyRecords.AsNoTracking().SingleOrDefaultAsync(x => x.Key == key, ct);
+ if (existing is not null)
{
- return JsonSerializer.Deserialize(existing.Response)!;
+ if (existing.RequestHash != hash)
+ throw new BillingException(409, "Idempotency key was used with a different request.");
+ return JsonSerializer.Deserialize(existing.Response)!;
}
-
- var strategy = _db.Database.CreateExecutionStrategy();
-
- return await strategy.ExecuteAsync(async () =>
+ if (!await db.Users.AnyAsync(x => x.Id == request.UserId, ct))
+ throw new BillingException(404, "User not found.");
+
+ var subscription = new Subscription { UserId = request.UserId, Plan = request.Plan };
+ db.Subscriptions.Add(subscription);
+ db.Payments.Add(new Payment { Subscription = subscription, Amount = amount });
+ await db.SaveChangesAsync(ct);
+ var response = new SubscriptionResponse(subscription.Id, subscription.UserId, subscription.Plan, false);
+ db.IdempotencyRecords.Add(new IdempotencyRecord
{
- await using var tx = await _db.Database.BeginTransactionAsync();
-
- try
- {
- var user = await _db.Users.FindAsync(request.UserId);
- if (user == null)
- throw new Exception("User not found");
-
- var subscription = new Subscription
- {
- UserId = user.Id,
- Plan = request.Plan,
- IsActive = false
- };
-
- _db.Subscriptions.Add(subscription);
- await _db.SaveChangesAsync();
-
- _db.Payments.Add(new Payment
- {
- SubscriptionId = subscription.Id,
- Amount = request.Amount
- });
-
- await _db.SaveChangesAsync();
-
- _db.IdempotencyRecords.Add(new IdempotencyRecord
- {
- Key = idempotencyKey,
- Endpoint = "POST /api/subscriptions/create",
- Response = JsonSerializer.Serialize(subscription),
- StatusCode = 200,
- CreatedAt = DateTime.UtcNow
- });
-
- await _db.SaveChangesAsync();
-
- await tx.CommitAsync();
-
- return subscription;
- }
- catch
- {
- await tx.RollbackAsync();
- throw;
- }
+ Key = key, RequestHash = hash, Response = JsonSerializer.Serialize(response), CreatedAt = DateTime.UtcNow
});
+ // Scoped publisher is intercepted by the EF bus outbox.
+ // Payment, subscription, response and event commit or roll back together.
+ await publisher.Publish(new SubscriptionPaidEvent
+ {
+ SubscriptionId = subscription.Id, Amount = amount, PaidAt = DateTime.UtcNow
+ }, ct);
+ await db.SaveChangesAsync(ct);
+ await tx.CommitAsync(ct);
+ logger.LogInformation("Payment recorded for {SubscriptionId}, key {IdempotencyKey}", subscription.Id, key);
+ return response;
}
-}
\ No newline at end of file
+}
diff --git a/Subscription & Billing/Subscription & Billing.csproj b/Subscription & Billing/Subscription & Billing.csproj
index 67b51b0..dee8eec 100644
--- a/Subscription & Billing/Subscription & Billing.csproj
+++ b/Subscription & Billing/Subscription & Billing.csproj
@@ -1,20 +1,18 @@
-
net8.0
enable
enable
- Subscription___Billing
+ Subscription_Backend
+ Billing
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
diff --git a/Subscription & Billing/Subscription & Billing.http b/Subscription & Billing/Subscription & Billing.http
index 552ddc5..16e65df 100644
--- a/Subscription & Billing/Subscription & Billing.http
+++ b/Subscription & Billing/Subscription & Billing.http
@@ -1,6 +1,20 @@
-@Subscription___Billing_HostAddress = http://localhost:5265
+@host = http://localhost:8080
-GET {{Subscription___Billing_HostAddress}}/weatherforecast/
-Accept: application/json
+### Create a user; substitute the returned id below
+POST {{host}}/api/users
+Content-Type: application/json
-###
+{"name":"Stepan","email":"stepan@example.com"}
+
+### Create subscription; retry with the same key and body
+POST {{host}}/api/subscriptions/create
+Content-Type: application/json
+Idempotency-Key: 75be42df-1323-48c2-a317-55bb64445a1b
+
+{"userId":1,"plan":"basic"}
+
+### Current state
+GET {{host}}/api/subscriptions/1
+
+### Readiness
+GET {{host}}/health/ready
diff --git a/Subscription & Billing/Subscription_Billing.md b/Subscription & Billing/Subscription_Billing.md
deleted file mode 100644
index 3ed6b99..0000000
--- a/Subscription & Billing/Subscription_Billing.md
+++ /dev/null
@@ -1,561 +0,0 @@
-# Subscription & Billing Backend
-
-> Снимок проекта на момент **недели 3** (API + транзакции + готовность к асинхронности).
-
----
-
-## Properties/launchSettings.json
-```json
-{
- "$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:4721",
- "sslPort": 44338
- }
- },
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "swagger",
- "applicationUrl": "http://localhost:5265",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "launchUrl": "swagger",
- "applicationUrl": "https://localhost:7289;http://localhost:5265",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "swagger",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
-```
-
----
-
-## Controllers/SubscriptionBillingController.cs
-```csharp
-using Microsoft.AspNetCore.Mvc;
-using Subscription_Backend.Dtos;
-using Subscription_Backend.Models;
-using Subscription_Backend.Services;
-
-namespace Subscription_Backend.Controllers;
-
-[ApiController]
-[Route("api/subscriptions")]
-public class SubscriptionWorkflowController : ControllerBase
-{
- private readonly SubscriptionService _service;
-
- public SubscriptionWorkflowController(SubscriptionService service)
- {
- _service = service;
- }
-
- [HttpPost("create")]
- public async Task> Create(CreateSubscriptionRequest request)
- {
- var subscription = await _service.CreateSubscriptionAsync(request);
- return Ok(subscription);
- }
-}
-```
-
----
-
-## Controllers/WeatherForecastController.cs
-```csharp
-using Microsoft.AspNetCore.Mvc;
-
-namespace Subscription___Billing.Controllers
-{
- [ApiController]
- [Route("[controller]")]
- public class WeatherForecastController : ControllerBase
- {
- private static readonly string[] Summaries = new[]
- {
- "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
- };
-
- private readonly ILogger _logger;
-
- public WeatherForecastController(ILogger logger)
- {
- _logger = logger;
- }
-
- [HttpGet(Name = "GetWeatherForecast")]
- public IEnumerable Get()
- {
- return Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = Summaries[Random.Shared.Next(Summaries.Length)]
- })
- .ToArray();
- }
- }
-}
-```
-
----
-
-## Dtos/CreateSubscriptionRequest.cs
-```csharp
-namespace Subscription_Backend.Dtos;
-
-public class CreateSubscriptionRequest
-{
- public int UserId { get; set; }
- public string Plan { get; set; } = null!;
- public decimal Amount { get; set; }
-}
-```
-
----
-
-## Models/Payment.cs
-```csharp
-namespace Subscription_Backend.Models;
-
-public class Payment
-{
- public int Id { get; set; }
-
- public int SubscriptionId { get; set; }
- public Subscription Subscription { get; set; } = null!;
-
- public decimal Amount { get; set; }
- public DateTime PaidAt { get; set; } = DateTime.UtcNow;
-}
-```
-
----
-
-## Models/Subscription.cs
-```csharp
-namespace Subscription_Backend.Models
-{
- public class Subscription
- {
- public int Id { get; set; }
- public int UserId { get; set; }
- public string Plan { get; set; } = null!;
- public bool IsActive { get; set; }
- }
-}
-```
-
----
-
-## Models/User.cs
-```csharp
-namespace Subscription_Backend.Models
-{
- public class User
- {
- public int Id { get; set; }
- public string Name { get; set; } = null!;
- public string Email { get; set; } = null!;
- }
-}
-```
-
----
-
-## Services/SubscriptionService.cs
-```csharp
-using MassTransit;
-using Microsoft.EntityFrameworkCore;
-using Subscription_Backend.Dtos;
-using Subscription_Backend.Messages;
-using Subscription_Backend.Models;
-
-namespace Subscription_Backend.Services;
-
-public class SubscriptionService
-{
- private readonly AppDbContext _db;
- private readonly IPublishEndpoint _publishEndpoint;
-
- public SubscriptionService(AppDbContext db, IPublishEndpoint publishEndpoint)
- {
- _db = db;
- _publishEndpoint = publishEndpoint;
- }
-
- public async Task