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
4 changes: 3 additions & 1 deletion src/Contracts/CheckoutEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ namespace Contracts;

public sealed record OrderPlaced(Guid OrderId, string Sku, int Quantity, decimal Amount) : IntegrationEvent;

public sealed record StockReserved(Guid OrderId, string Sku, int Quantity) : IntegrationEvent;
// Carries Amount downstream so Payments can charge without re-fetching the order
// (events carry what consumers need).
public sealed record StockReserved(Guid OrderId, string Sku, int Quantity, decimal Amount) : IntegrationEvent;

public sealed record StockReservationFailed(Guid OrderId, string Sku, string Reason) : IntegrationEvent;

Expand Down
2 changes: 1 addition & 1 deletion src/Services/Inventory/Consumers/OrderPlacedConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public async Task ConsumeAsync(OrderPlaced message, CancellationToken ct)

if (stock is not null && stock.TryReserve(message.Quantity))
{
outbox.Add(new StockReserved(message.OrderId, message.Sku, message.Quantity));
outbox.Add(new StockReserved(message.OrderId, message.Sku, message.Quantity, message.Amount));
}
else
{
Expand Down
33 changes: 33 additions & 0 deletions src/Services/Payments/Consumers/StockReservedConsumer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using BuildingBlocks.Messaging;
using Contracts;
using Services.Payments.Domain;
using Services.Payments.Infrastructure;

namespace Services.Payments.Consumers;

/// <summary>
/// Reacts to <see cref="StockReserved"/>: charges via the gateway and emits the saga's next
/// event. The charge record and the emitted event ride the inbox transaction, so charge +
/// emit + dedup commit atomically (ADR-001/002).
/// </summary>
public sealed class StockReservedConsumer(PaymentsDbContext db, IOutbox outbox, IPaymentGateway gateway)
: IIntegrationEventConsumer<StockReserved>
{
public Task ConsumeAsync(StockReserved message, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(message);

if (gateway.Authorize(message.Amount))
{
db.Payments.Add(Payment.Charged(message.OrderId, message.Amount));
outbox.Add(new PaymentCharged(message.OrderId, message.Amount));
}
else
{
db.Payments.Add(Payment.Failed(message.OrderId, message.Amount));
outbox.Add(new PaymentFailed(message.OrderId, "amount-exceeds-limit"));
}

return Task.CompletedTask;
}
}
21 changes: 21 additions & 0 deletions src/Services/Payments/Domain/IPaymentGateway.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Extensions.Options;

namespace Services.Payments.Domain;

public interface IPaymentGateway
{
bool Authorize(decimal amount);
}

public sealed class PaymentOptions
{
/// <summary>Charges above this amount are declined. Deterministic, so the saga's
/// happy path and its compensation path are both reproducible in tests.</summary>
public decimal DeclineAboveAmount { get; set; } = 1000m;
}

/// <summary>Stand-in for a real PSP adapter (Stripe/Iugu/etc.). Declines by a fixed rule.</summary>
public sealed class FakePaymentGateway(IOptions<PaymentOptions> options) : IPaymentGateway
{
public bool Authorize(decimal amount) => amount <= options.Value.DeclineAboveAmount;
}
33 changes: 33 additions & 0 deletions src/Services/Payments/Domain/Payment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Services.Payments.Domain;

public enum PaymentStatus
{
Charged,
Failed,
}

/// <summary>Record of a charge attempt for an order (one per order).</summary>
public sealed class Payment
{
public Guid OrderId { get; private set; }

public decimal Amount { get; private set; }

public PaymentStatus Status { get; private set; }

private Payment()
{
// EF Core materialization.
}

private Payment(Guid orderId, decimal amount, PaymentStatus status)
{
OrderId = orderId;
Amount = amount;
Status = status;
}

public static Payment Charged(Guid orderId, decimal amount) => new(orderId, amount, PaymentStatus.Charged);

public static Payment Failed(Guid orderId, decimal amount) => new(orderId, amount, PaymentStatus.Failed);
}
18 changes: 18 additions & 0 deletions src/Services/Payments/Infrastructure/PaymentConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Services.Payments.Domain;

namespace Services.Payments.Infrastructure;

internal sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.ToTable("payments");

builder.HasKey(x => x.OrderId);
builder.Property(x => x.OrderId).HasColumnName("order_id");
builder.Property(x => x.Amount).HasColumnName("amount").HasPrecision(18, 2);
builder.Property(x => x.Status).HasColumnName("status").HasConversion<string>().HasMaxLength(20);
}
}
23 changes: 23 additions & 0 deletions src/Services/Payments/Infrastructure/PaymentsDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using BuildingBlocks.Persistence;
using Microsoft.EntityFrameworkCore;
using Services.Payments.Domain;

namespace Services.Payments.Infrastructure;

/// <summary>
/// The Payments service context. Derives from <see cref="MessagingDbContext"/> so the charge
/// record, the emitted event (outbox) and the inbox mark commit atomically (ADR-001/002).
/// Everything lives in the <c>payments</c> schema (ADR-005).
/// </summary>
public sealed class PaymentsDbContext(DbContextOptions<PaymentsDbContext> options) : MessagingDbContext(options)
{
public DbSet<Payment> Payments => Set<Payment>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("payments");
modelBuilder.ApplyConfiguration(new PaymentConfiguration());

base.OnModelCreating(modelBuilder);
}
}
17 changes: 17 additions & 0 deletions src/Services/Payments/Infrastructure/PaymentsDbContextFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace Services.Payments.Infrastructure;

/// <summary>Design-time factory for <c>dotnet ef migrations</c>. Not used at runtime.</summary>
public sealed class PaymentsDbContextFactory : IDesignTimeDbContextFactory<PaymentsDbContext>
{
public PaymentsDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<PaymentsDbContext>()
.UseNpgsql("Host=localhost;Database=dcl;Username=dcl;Password=dcl;SearchPath=payments")
.Options;

return new PaymentsDbContext(options);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 85 additions & 0 deletions src/Services/Payments/Migrations/20260608170924_InitialCreate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Services.Payments.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "payments");

migrationBuilder.CreateTable(
name: "inbox",
schema: "payments",
columns: table => new
{
message_id = table.Column<Guid>(type: "uuid", nullable: false),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_inbox", x => x.message_id);
});

migrationBuilder.CreateTable(
name: "outbox",
schema: "payments",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
payload = table.Column<string>(type: "jsonb", nullable: false),
occurred_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
attempts = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_outbox", x => x.id);
});

migrationBuilder.CreateTable(
name: "payments",
schema: "payments",
columns: table => new
{
order_id = table.Column<Guid>(type: "uuid", nullable: false),
amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_payments", x => x.order_id);
});

migrationBuilder.CreateIndex(
name: "ix_outbox_pending",
schema: "payments",
table: "outbox",
column: "occurred_at",
filter: "processed_at IS NULL");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "inbox",
schema: "payments");

migrationBuilder.DropTable(
name: "outbox",
schema: "payments");

migrationBuilder.DropTable(
name: "payments",
schema: "payments");
}
}
}
Loading
Loading