+ `;
+
+ $('[data-action="take"]', stageNode).disabled = busy || current.status !== "Live";
+
+ const holdNode = $("[data-hold]", stageNode);
+ holdNode.hidden = !mine;
+ if (mine) {
+ const left = Date.parse(mine.expiresAt) - now;
+ $("[data-hold-clock]", stageNode).textContent = clock(left);
+ $("[data-hold-bar]", stageNode).style.width = `${Math.max(0, Math.min(100, (left / (current.holdSeconds * 1000)) * 100))}%`;
+ }
+
+ const clockNode = $("[data-clock]", stageNode);
+ clockNode.textContent = current.status === "Scheduled" ? `Abre en ${clock(Date.parse(current.opensAt) - now)}` : "";
+ };
+
+ const refresh = async () => {
+ try {
+ current = await api.drops.get(id);
+ skew = Date.parse(current.serverTime) - Date.now();
+ paint();
+ } catch {
+ /* Un fallo puntual de red se corrige en el siguiente ciclo. */
+ }
+ };
+
+ const say = (error, fallback) => {
+ const code = error instanceof ApiError ? error.code : null;
+ if (MESSAGES[code]) toast({ title: MESSAGES[code], variant: "error" });
+ else toastError(error, fallback);
+ };
+
+ paint();
+ this._poll = setInterval(refresh, POLL_MS);
+ this._tick = setInterval(paint, 250);
+
+ delegate(stageNode, "click", '[data-action="take"]', async (event, button) => {
+ try {
+ busy = true;
+ await withBusy(button, () => api.drops.take(id, 1));
+ } catch (error) {
+ say(error, "No se pudo reservar");
+ } finally {
+ busy = false;
+ await refresh();
+ }
+ });
+
+ delegate(stageNode, "click", '[data-action="confirm"]', async (event, button) => {
+ const mine = current.myHolds.find((h) => h.state === "Active");
+ if (!mine) return;
+ try {
+ const order = await withBusy(button, () => api.drops.confirm(id, mine.id));
+ toast({ title: "Compra confirmada", text: `Pedido ${order.reference} · ${fmtMoney(order.totalAmount)}`, variant: "success" });
+ } catch (error) {
+ say(error, "No se pudo confirmar");
+ } finally {
+ await refresh();
+ }
+ });
+
+ delegate(stageNode, "click", '[data-action="crowd"]', async (event, button) => {
+ // Cada comprador es un visitante distinto; las colisiones de escritura se reintentan
+ // como haría cualquier cliente, y lo que sobra recibe "agotado".
+ const burst = setInterval(refresh, 150);
+ try {
+ await withBusy(button, () =>
+ Promise.all(
+ Array.from({ length: CROWD }, async () => {
+ const visitor = crypto.randomUUID();
+ for (let attempt = 0; attempt < 40; attempt++) {
+ try {
+ await api.drops.takeAs(id, visitor, 1);
+ return;
+ } catch (error) {
+ if (!(error instanceof ApiError) || error.code !== "concurrency_conflict") return;
+ }
+ }
+ })
+ )
+ );
+ } finally {
+ clearInterval(burst);
+ await refresh();
+ }
+ });
+
+ delegate(stageNode, "click", '[data-action="reset"]', showLauncher);
+ };
+
+ delegate(root, "input", "[data-input]", (event, input) => {
+ const out = $(`[data-out="${input.dataset.input}"]`, root);
+ if (out) out.textContent = input.value;
+ if (input.name === "units") {
+ const per = $('[data-input="perBuyer"]', root);
+ per.max = Math.min(10, Number(input.value));
+ if (Number(per.value) > Number(per.max)) {
+ per.value = per.max;
+ $('[data-out="perBuyer"]', root).textContent = per.max;
+ }
+ }
+ });
+
+ delegate(root, "click", '[data-action="launch"]', async (event, button) => {
+ const value = (name) => $(`[data-input="${name}"]`, root).value;
+ try {
+ const drop = await withBusy(button, () =>
+ api.drops.create({
+ productId: value("product"),
+ totalUnits: Number(value("units")),
+ perBuyerLimit: Number(value("perBuyer")),
+ holdSeconds: Number(value("hold")),
+ opensInSeconds: 0
+ })
+ );
+ await openDrop(drop.id);
+ } catch (error) {
+ toastError(error, "No se pudo lanzar el drop");
+ }
+ });
+
+ delegate(root, "click", "[data-open]", (event, node) => openDrop(node.dataset.open));
+
+ const last = lastDropId();
+ if (last) await openDrop(last);
+ else await showLauncher();
+ }
+};
diff --git a/NainOrder.Application/Common/DropDefaults.cs b/NainOrder.Application/Common/DropDefaults.cs
new file mode 100644
index 0000000..75b60a0
--- /dev/null
+++ b/NainOrder.Application/Common/DropDefaults.cs
@@ -0,0 +1,10 @@
+namespace NainOrder.Application.Common;
+
+public static class DropDefaults
+{
+ ///
+ /// The one customer every drop order belongs to: buyers are anonymous visitors identified
+ /// only by a token (Q-005). Fixed so the seeder and the use case agree on it.
+ ///
+ public static readonly Guid AnonymousBuyerId = Guid.Parse("5d0c6f0e-7b0a-4f3e-9a3e-0d0e5a1b2c3d");
+}
diff --git a/NainOrder.Application/DTOs/DropDtos.cs b/NainOrder.Application/DTOs/DropDtos.cs
new file mode 100644
index 0000000..97856fd
--- /dev/null
+++ b/NainOrder.Application/DTOs/DropDtos.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace NainOrder.Application.DTOs;
+
+///
+/// Creates a drop from a catalogue product. OpensInSeconds is relative so the client's
+/// clock never decides when a drop opens.
+///
+public record CreateDropRequest(
+ [Required] Guid ProductId,
+ [Range(1, 1_000)] int TotalUnits,
+ [Range(1, 1_000)] int PerBuyerLimit,
+ [Range(5, 3_600)] int HoldSeconds,
+ [Range(0, 604_800)] int OpensInSeconds = 0);
+
+public record TakeUnitsRequest([Range(1, 1_000)] int Quantity);
+
+public record HoldDto(Guid Id, int Quantity, DateTime ExpiresAt, string State, Guid? OrderId);
+
+///
+/// Live state of a drop. Every figure is computed by the server at ServerTime; the panel
+/// draws it and derives nothing.
+///
+public record DropDto(
+ Guid Id,
+ Guid ProductId,
+ string ProductName,
+ string ProductSku,
+ decimal UnitPrice,
+ int TotalUnits,
+ int Available,
+ int Held,
+ int Sold,
+ string Status,
+ DateTime OpensAt,
+ int PerBuyerLimit,
+ int HoldSeconds,
+ DateTime ServerTime,
+ IReadOnlyList MyHolds);
diff --git a/NainOrder.Application/DependencyInjection.cs b/NainOrder.Application/DependencyInjection.cs
index 87e8621..541f0d9 100644
--- a/NainOrder.Application/DependencyInjection.cs
+++ b/NainOrder.Application/DependencyInjection.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using NainOrder.Application.Interfaces;
using NainOrder.Application.Services;
@@ -13,6 +14,10 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+
+ // Time is injected so drop expiry can be tested with a clock the test moves by hand.
+ services.TryAddSingleton(TimeProvider.System);
return services;
}
diff --git a/NainOrder.Application/Interfaces/IRepositories.cs b/NainOrder.Application/Interfaces/IRepositories.cs
index db1cabf..bc5fae1 100644
--- a/NainOrder.Application/Interfaces/IRepositories.cs
+++ b/NainOrder.Application/Interfaces/IRepositories.cs
@@ -38,6 +38,19 @@ Task> GetAllAsync(
Task AddAsync(Product product, CancellationToken cancellationToken = default);
}
+public interface IDropRepository
+{
+ /// Loads the drop with its holds, tracked: it is about to be mutated.
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ Task> GetRecentAsync(int count, CancellationToken cancellationToken = default);
+
+ /// Drops that still carry an active hold whose window has ended at .
+ Task> GetWithDueHoldsAsync(DateTime now, CancellationToken cancellationToken = default);
+
+ Task AddAsync(Drop drop, CancellationToken cancellationToken = default);
+}
+
public interface ICustomerRepository
{
Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
diff --git a/NainOrder.Application/Interfaces/IServices.cs b/NainOrder.Application/Interfaces/IServices.cs
index 5b1ac35..c476cd1 100644
--- a/NainOrder.Application/Interfaces/IServices.cs
+++ b/NainOrder.Application/Interfaces/IServices.cs
@@ -35,6 +35,18 @@ public interface ICustomerService
Task> GetAllCustomersAsync(CancellationToken cancellationToken = default);
}
+public interface IDropService
+{
+ Task CreateAsync(CreateDropRequest request, CancellationToken cancellationToken = default);
+ Task GetAsync(Guid dropId, string? buyerToken, CancellationToken cancellationToken = default);
+ Task> ListAsync(CancellationToken cancellationToken = default);
+ Task TakeAsync(Guid dropId, string buyerToken, TakeUnitsRequest request, CancellationToken cancellationToken = default);
+ Task ConfirmAsync(Guid dropId, Guid holdId, string buyerToken, CancellationToken cancellationToken = default);
+
+ /// Expires every hold whose window has ended, across all drops. Returns how many.
+ Task ExpireDueHoldsAsync(CancellationToken cancellationToken = default);
+}
+
public interface IDashboardService
{
Task GetStatsAsync(CancellationToken cancellationToken = default);
diff --git a/NainOrder.Application/Services/DropService.cs b/NainOrder.Application/Services/DropService.cs
new file mode 100644
index 0000000..f8e3f8f
--- /dev/null
+++ b/NainOrder.Application/Services/DropService.cs
@@ -0,0 +1,127 @@
+using NainOrder.Application.Common;
+using NainOrder.Application.DTOs;
+using NainOrder.Application.Exceptions;
+using NainOrder.Application.Interfaces;
+using NainOrder.Domain.Entities;
+
+namespace NainOrder.Application.Services;
+
+///
+/// Orchestrates drops. Every rule lives in ; this class loads the aggregate,
+/// supplies the clock, owns the transaction and projects to DTOs.
+///
+public sealed class DropService : IDropService
+{
+ private readonly IDropRepository _drops;
+ private readonly IProductRepository _products;
+ private readonly IOrderRepository _orders;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly TimeProvider _clock;
+
+ public DropService(
+ IDropRepository drops, IProductRepository products, IOrderRepository orders,
+ IUnitOfWork unitOfWork, TimeProvider clock)
+ {
+ _drops = drops;
+ _products = products;
+ _orders = orders;
+ _unitOfWork = unitOfWork;
+ _clock = clock;
+ }
+
+ private DateTime Now => _clock.GetUtcNow().UtcDateTime;
+
+ public async Task CreateAsync(CreateDropRequest request, CancellationToken cancellationToken = default)
+ {
+ var product = await _products.GetByIdAsync(request.ProductId, cancellationToken)
+ ?? throw new NotFoundException("Product", request.ProductId);
+
+ var drop = new Drop(
+ product.Id, product.Name, product.Sku, product.Price,
+ request.TotalUnits, Now.AddSeconds(request.OpensInSeconds), request.PerBuyerLimit, request.HoldSeconds);
+
+ await _drops.AddAsync(drop, cancellationToken);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return MapToDto(drop, null);
+ }
+
+ public async Task GetAsync(Guid dropId, string? buyerToken, CancellationToken cancellationToken = default) =>
+ MapToDto(await LoadAsync(dropId, cancellationToken), buyerToken);
+
+ public async Task> ListAsync(CancellationToken cancellationToken = default) =>
+ (await _drops.GetRecentAsync(20, cancellationToken)).Select(d => MapToDto(d, null)).ToList();
+
+ public async Task TakeAsync(
+ Guid dropId, string buyerToken, TakeUnitsRequest request, CancellationToken cancellationToken = default)
+ {
+ var drop = await LoadAsync(dropId, cancellationToken);
+
+ return await _unitOfWork.ExecuteInTransactionAsync(async ct =>
+ {
+ var hold = drop.Take(buyerToken, request.Quantity, Now);
+ await _unitOfWork.SaveChangesAsync(ct);
+ return MapHold(hold);
+ }, cancellationToken);
+ }
+
+ public async Task ConfirmAsync(
+ Guid dropId, Guid holdId, string buyerToken, CancellationToken cancellationToken = default)
+ {
+ var drop = await LoadAsync(dropId, cancellationToken);
+
+ return await _unitOfWork.ExecuteInTransactionAsync(async ct =>
+ {
+ var hold = drop.Holds.FirstOrDefault(h => h.Id == holdId && h.BuyerToken == buyerToken);
+
+ // The order is built first so the hold can record it; nothing is persisted unless
+ // Confirm accepts, because the whole transaction rolls back on a rejection.
+ var order = new Order(DropDefaults.AnonymousBuyerId);
+ order.AddItem(drop.ProductId, drop.ProductName, drop.ProductSku, drop.UnitPrice, hold?.Quantity ?? 1);
+ order.MarkAsPaid();
+
+ drop.Confirm(holdId, buyerToken, Now, order.Id);
+
+ await _orders.AddAsync(order, ct);
+ await _unitOfWork.SaveChangesAsync(ct);
+ return MapOrder(order);
+ }, cancellationToken);
+ }
+
+ public async Task ExpireDueHoldsAsync(CancellationToken cancellationToken = default)
+ {
+ var now = Now;
+ var due = await _drops.GetWithDueHoldsAsync(now, cancellationToken);
+ if (due.Count == 0) return 0;
+
+ var expired = due.Sum(d => d.ExpireHolds(now));
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+ return expired;
+ }
+
+ private async Task LoadAsync(Guid dropId, CancellationToken cancellationToken) =>
+ await _drops.GetByIdAsync(dropId, cancellationToken) ?? throw new NotFoundException("Drop", dropId);
+
+ private DropDto MapToDto(Drop drop, string? buyerToken)
+ {
+ var now = Now;
+ var mine = buyerToken is null
+ ? []
+ : drop.Holds.Where(h => h.BuyerToken == buyerToken).OrderBy(h => h.CreatedAt).Select(MapHold).ToList();
+
+ return new DropDto(
+ drop.Id, drop.ProductId, drop.ProductName, drop.ProductSku, drop.UnitPrice,
+ drop.TotalUnits, drop.AvailableAt(now), drop.HeldAt(now), drop.SoldUnits,
+ drop.StatusAt(now).ToString(), drop.OpensAt, drop.PerBuyerLimit, drop.HoldSeconds, now, mine);
+ }
+
+ private static HoldDto MapHold(Hold hold) =>
+ new(hold.Id, hold.Quantity, hold.ExpiresAt, hold.State.ToString(), hold.OrderId);
+
+ private static OrderDto MapOrder(Order order) => new(
+ order.Id, order.Reference, order.CustomerId, null, null, order.Status.ToString(),
+ order.TotalAmount, order.TotalUnits, order.IsEditable,
+ order.NextStates.Select(s => s.ToString()).ToList(),
+ order.CreatedAt, order.PaidAt, order.ShippedAt, order.CancelledAt,
+ order.Items.Select(i => new OrderItemDto(i.ProductId, i.ProductName, i.ProductSku, i.UnitPrice, i.Quantity, i.TotalPrice)).ToList());
+}
diff --git a/NainOrder.Domain/Entities/Drop.cs b/NainOrder.Domain/Entities/Drop.cs
new file mode 100644
index 0000000..a23a79d
--- /dev/null
+++ b/NainOrder.Domain/Entities/Drop.cs
@@ -0,0 +1,131 @@
+using NainOrder.Domain.Enums;
+using NainOrder.Domain.Exceptions;
+using NainOrder.Domain.Primitives;
+
+namespace NainOrder.Domain.Entities;
+
+///
+/// A limited sale of a fixed number of units. The drop owns its units for as long as it is live
+/// ([ADR-0008]): it never borrows , so "exactly N units" cannot
+/// be disturbed by the catalogue. Time always comes from the caller, which keeps the aggregate
+/// deterministic and expiry testable without sleeping.
+///
+public class Drop : AggregateRoot
+{
+ public const int MaxUnits = 1_000;
+ public const int MinHoldSeconds = 5;
+ public const int MaxHoldSeconds = 3_600;
+
+ public Guid ProductId { get; private set; }
+ public string ProductName { get; private set; } = null!;
+ public string ProductSku { get; private set; } = null!;
+ public decimal UnitPrice { get; private set; }
+ public int TotalUnits { get; private set; }
+ public DateTime OpensAt { get; private set; }
+ public int PerBuyerLimit { get; private set; }
+ public int HoldSeconds { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+
+ /// Optimistic concurrency token, bumped on every mutation (same pattern as ADR-0007).
+ public int Version { get; private set; }
+
+ private readonly List _holds = new();
+ public IReadOnlyCollection Holds => _holds.AsReadOnly();
+
+ private Drop() { }
+
+ public Drop(Guid productId, string productName, string productSku, decimal unitPrice,
+ int totalUnits, DateTime opensAt, int perBuyerLimit, int holdSeconds)
+ : base(Guid.NewGuid())
+ {
+ if (string.IsNullOrWhiteSpace(productName))
+ throw new InvalidDomainDataException(nameof(productName), "Product name is required.");
+ if (unitPrice < 0)
+ throw new InvalidDomainDataException(nameof(unitPrice), "Price cannot be negative.");
+ if (totalUnits is < 1 or > MaxUnits)
+ throw new InvalidDomainDataException(nameof(totalUnits), $"A drop holds 1 to {MaxUnits} units.");
+ if (perBuyerLimit < 1 || perBuyerLimit > totalUnits)
+ throw new InvalidDomainDataException(nameof(perBuyerLimit), "The per-buyer limit must be between 1 and the drop's units.");
+ if (holdSeconds is < MinHoldSeconds or > MaxHoldSeconds)
+ throw new InvalidDomainDataException(nameof(holdSeconds), $"A hold lasts {MinHoldSeconds} to {MaxHoldSeconds} seconds.");
+
+ ProductId = productId;
+ ProductName = productName.Trim();
+ ProductSku = productSku.Trim().ToUpperInvariant();
+ UnitPrice = decimal.Round(unitPrice, 2, MidpointRounding.AwayFromZero);
+ TotalUnits = totalUnits;
+ OpensAt = opensAt;
+ PerBuyerLimit = perBuyerLimit;
+ HoldSeconds = holdSeconds;
+ CreatedAt = DateTime.UtcNow;
+ Version = 1;
+ }
+
+ public int SoldUnits => _holds.Where(h => h.State == HoldState.Confirmed).Sum(h => h.Quantity);
+
+ public int HeldAt(DateTime now) => _holds.Where(h => h.IsLiveAt(now)).Sum(h => h.Quantity);
+
+ ///
+ /// Units still on offer. An unswept hold whose window has passed does not count, so
+ /// correctness never depends on a background job having run (REQ-083).
+ ///
+ public int AvailableAt(DateTime now) => TotalUnits - SoldUnits - HeldAt(now);
+
+ public DropStatus StatusAt(DateTime now) =>
+ now < OpensAt ? DropStatus.Scheduled
+ : AvailableAt(now) == 0 ? DropStatus.SoldOut
+ : DropStatus.Live;
+
+ /// Reserves units for a buyer (REQ-081, REQ-082, REQ-088).
+ public Hold Take(string buyerToken, int quantity, DateTime now)
+ {
+ if (string.IsNullOrWhiteSpace(buyerToken))
+ throw new InvalidDomainDataException(nameof(buyerToken), "A buyer token is required.");
+ if (quantity < 1)
+ throw new InvalidDomainDataException(nameof(quantity), "Must take a positive quantity.");
+ if (now < OpensAt)
+ throw new DropNotLiveException(OpensAt);
+
+ var owned = _holds
+ .Where(h => h.BuyerToken == buyerToken && (h.IsLiveAt(now) || h.State == HoldState.Confirmed))
+ .Sum(h => h.Quantity);
+ if (owned + quantity > PerBuyerLimit)
+ throw new BuyerLimitReachedException(PerBuyerLimit, owned);
+
+ var available = AvailableAt(now);
+ if (quantity > available)
+ throw new InsufficientStockException(quantity, available);
+
+ var hold = new Hold(Id, buyerToken, quantity, now, HoldSeconds);
+ _holds.Add(hold);
+ Version++;
+ return hold;
+ }
+
+ /// Turns an unexpired hold into a sale (REQ-084).
+ public Hold Confirm(Guid holdId, string buyerToken, DateTime now, Guid orderId)
+ {
+ // A hold that belongs to someone else is indistinguishable from one that does not exist.
+ var hold = _holds.FirstOrDefault(h => h.Id == holdId && h.BuyerToken == buyerToken)
+ ?? throw new HoldNotFoundException(holdId);
+
+ if (hold.State == HoldState.Confirmed)
+ throw new HoldNotActiveException(holdId);
+ if (hold.State == HoldState.Expired || hold.ExpiresAt <= now)
+ throw new HoldExpiredException(holdId);
+
+ hold.Confirm(orderId);
+ Version++;
+ return hold;
+ }
+
+ /// Marks holds whose window has ended as expired and reports how many (REQ-083).
+ public int ExpireHolds(DateTime now)
+ {
+ var due = _holds.Where(h => h.State == HoldState.Active && h.ExpiresAt <= now).ToList();
+ foreach (var hold in due) hold.Expire();
+
+ if (due.Count > 0) Version++;
+ return due.Count;
+ }
+}
diff --git a/NainOrder.Domain/Entities/Hold.cs b/NainOrder.Domain/Entities/Hold.cs
new file mode 100644
index 0000000..fbc5f39
--- /dev/null
+++ b/NainOrder.Domain/Entities/Hold.cs
@@ -0,0 +1,45 @@
+using NainOrder.Domain.Enums;
+using NainOrder.Domain.Primitives;
+
+namespace NainOrder.Domain.Entities;
+
+///
+/// A short reservation of drop units for one buyer. It only changes through its
+/// , which is the authority on the drop's unit count.
+///
+public class Hold : Entity
+{
+ public Guid DropId { get; private set; }
+ public string BuyerToken { get; private set; } = null!;
+ public int Quantity { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+ public DateTime ExpiresAt { get; private set; }
+ public HoldState State { get; private set; }
+
+ /// The paid order a confirmed hold became.
+ public Guid? OrderId { get; private set; }
+
+ private Hold() { }
+
+ internal Hold(Guid dropId, string buyerToken, int quantity, DateTime now, int holdSeconds)
+ : base(Guid.NewGuid())
+ {
+ DropId = dropId;
+ BuyerToken = buyerToken;
+ Quantity = quantity;
+ CreatedAt = now;
+ ExpiresAt = now.AddSeconds(holdSeconds);
+ State = HoldState.Active;
+ }
+
+ /// True while the hold still reserves units at .
+ internal bool IsLiveAt(DateTime now) => State == HoldState.Active && ExpiresAt > now;
+
+ internal void Confirm(Guid orderId)
+ {
+ State = HoldState.Confirmed;
+ OrderId = orderId;
+ }
+
+ internal void Expire() => State = HoldState.Expired;
+}
diff --git a/NainOrder.Domain/Enums/DropEnums.cs b/NainOrder.Domain/Enums/DropEnums.cs
new file mode 100644
index 0000000..fb36840
--- /dev/null
+++ b/NainOrder.Domain/Enums/DropEnums.cs
@@ -0,0 +1,16 @@
+namespace NainOrder.Domain.Enums;
+
+/// Where a drop stands on the clock and its counters. Derived, never stored.
+public enum DropStatus
+{
+ Scheduled = 1,
+ Live = 2,
+ SoldOut = 3
+}
+
+public enum HoldState
+{
+ Active = 1,
+ Confirmed = 2,
+ Expired = 3
+}
diff --git a/NainOrder.Domain/Exceptions/DomainException.cs b/NainOrder.Domain/Exceptions/DomainException.cs
index 9fddb3d..e3be25c 100644
--- a/NainOrder.Domain/Exceptions/DomainException.cs
+++ b/NainOrder.Domain/Exceptions/DomainException.cs
@@ -68,6 +68,58 @@ public DuplicateOrderItemException(Guid productId)
=> ProductId = productId;
}
+/// The drop has not opened yet.
+public sealed class DropNotLiveException : DomainException
+{
+ public override string Code => "drop_not_live";
+
+ public DateTime OpensAt { get; }
+
+ public DropNotLiveException(DateTime opensAt)
+ : base($"The drop opens at {opensAt:O}.")
+ => OpensAt = opensAt;
+}
+
+/// The buyer already holds or bought as many units as one buyer may.
+public sealed class BuyerLimitReachedException : DomainException
+{
+ public override string Code => "buyer_limit_reached";
+
+ public int Limit { get; }
+ public int Owned { get; }
+
+ public BuyerLimitReachedException(int limit, int owned)
+ : base($"Limit of {limit} per buyer reached ({owned} already held or bought).")
+ {
+ Limit = limit;
+ Owned = owned;
+ }
+}
+
+/// The hold's window ended before it was confirmed.
+public sealed class HoldExpiredException : DomainException
+{
+ public override string Code => "hold_expired";
+
+ public HoldExpiredException(Guid holdId) : base($"Hold {holdId} has expired.") { }
+}
+
+/// The hold exists but can no longer be confirmed (already confirmed).
+public sealed class HoldNotActiveException : DomainException
+{
+ public override string Code => "hold_not_active";
+
+ public HoldNotActiveException(Guid holdId) : base($"Hold {holdId} is no longer active.") { }
+}
+
+/// No hold with that id belongs to this buyer in this drop.
+public sealed class HoldNotFoundException : DomainException
+{
+ public override string Code => "hold_not_found";
+
+ public HoldNotFoundException(Guid holdId) : base($"Hold {holdId} was not found.") { }
+}
+
/// La línea de pedido referenciada no pertenece al pedido.
public sealed class OrderItemNotFoundException : DomainException
{
diff --git a/NainOrder.Infrastructure/DependencyInjection.cs b/NainOrder.Infrastructure/DependencyInjection.cs
index daf75a6..a986bc7 100644
--- a/NainOrder.Infrastructure/DependencyInjection.cs
+++ b/NainOrder.Infrastructure/DependencyInjection.cs
@@ -20,6 +20,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
diff --git a/NainOrder.Infrastructure/Persistence/Configurations/DropConfigurations.cs b/NainOrder.Infrastructure/Persistence/Configurations/DropConfigurations.cs
new file mode 100644
index 0000000..bc08937
--- /dev/null
+++ b/NainOrder.Infrastructure/Persistence/Configurations/DropConfigurations.cs
@@ -0,0 +1,63 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NainOrder.Domain.Entities;
+using NainOrder.Infrastructure.Persistence.Converters;
+
+namespace NainOrder.Infrastructure.Persistence.Configurations;
+
+public class DropConfiguration : IEntityTypeConfiguration
+{
+ internal static readonly ValueConverter AsUtc =
+ new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(d => d.Id);
+ builder.Property(d => d.Id).ValueGeneratedNever();
+
+ builder.Property(d => d.ProductName).IsRequired().HasMaxLength(Product.MaxNameLength);
+ builder.Property(d => d.ProductSku).IsRequired().HasMaxLength(Product.MaxSkuLength);
+ builder.Property(d => d.UnitPrice).HasConversion();
+
+ // SQLite returns DateTime as Unspecified; without UTC the API would serialise a bare
+ // timestamp that browsers read as local time and every countdown would be off by the offset.
+ builder.Property(d => d.OpensAt).HasConversion(AsUtc);
+ builder.Property(d => d.CreatedAt).HasConversion(AsUtc);
+
+ // Same pattern as ADR-0007: a losing writer fails the UPDATE instead of overwriting.
+ builder.Property(d => d.Version).IsConcurrencyToken();
+
+ builder.HasMany(d => d.Holds)
+ .WithOne()
+ .HasForeignKey(h => h.DropId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Holds change only through the aggregate, so EF reads and writes the backing field.
+ builder.Navigation(d => d.Holds).UsePropertyAccessMode(PropertyAccessMode.Field);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(d => d.ProductId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(d => d.CreatedAt);
+ }
+}
+
+public class HoldConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(h => h.Id);
+ builder.Property(h => h.Id).ValueGeneratedNever();
+
+ builder.Property(h => h.BuyerToken).IsRequired().HasMaxLength(64);
+ builder.Property(h => h.State).HasConversion();
+ builder.Property(h => h.CreatedAt).HasConversion(DropConfiguration.AsUtc);
+ builder.Property(h => h.ExpiresAt).HasConversion(DropConfiguration.AsUtc);
+
+ builder.HasIndex(h => new { h.DropId, h.BuyerToken });
+ builder.HasIndex(h => new { h.State, h.ExpiresAt });
+ }
+}
diff --git a/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.Designer.cs b/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.Designer.cs
new file mode 100644
index 0000000..2625103
--- /dev/null
+++ b/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.Designer.cs
@@ -0,0 +1,323 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NainOrder.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace NainOrder.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(NainOrderDbContext))]
+ [Migration("20261020152656_AddDrops")]
+ partial class AddDrops
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Customer", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Customers");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("HoldSeconds")
+ .HasColumnType("INTEGER");
+
+ b.Property("OpensAt")
+ .HasColumnType("TEXT");
+
+ b.Property("PerBuyerLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductSku")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("TotalUnits")
+ .HasColumnType("INTEGER");
+
+ b.Property("UnitPrice")
+ .HasColumnType("INTEGER");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("Drops");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Hold", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("BuyerToken")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DropId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("OrderId")
+ .HasColumnType("TEXT");
+
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("State")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DropId", "BuyerToken");
+
+ b.HasIndex("State", "ExpiresAt");
+
+ b.ToTable("Holds");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CancelledAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CustomerId")
+ .HasColumnType("TEXT");
+
+ b.Property("PaidAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Reference")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ShippedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("TotalAmount")
+ .HasColumnType("INTEGER");
+
+ b.Property("TotalUnits")
+ .HasColumnType("INTEGER");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("Reference")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("Orders");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.OrderItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("OrderId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductSku")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("UnitPrice")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProductId");
+
+ b.HasIndex("OrderId", "ProductId")
+ .IsUnique();
+
+ b.ToTable("OrderItems");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Product", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(60)
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Price")
+ .HasColumnType("INTEGER");
+
+ b.Property("Sku")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("StockQuantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category");
+
+ b.HasIndex("Sku")
+ .IsUnique();
+
+ b.ToTable("Products");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Hold", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Drop", null)
+ .WithMany("Holds")
+ .HasForeignKey("DropId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Customer", null)
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.OrderItem", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Order", null)
+ .WithMany("Items")
+ .HasForeignKey("OrderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NainOrder.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.Navigation("Holds");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
+ {
+ b.Navigation("Items");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.cs b/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.cs
new file mode 100644
index 0000000..227b3c1
--- /dev/null
+++ b/NainOrder.Infrastructure/Persistence/Migrations/20261020152656_AddDrops.cs
@@ -0,0 +1,96 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace NainOrder.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddDrops : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Drops",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ ProductId = table.Column(type: "TEXT", nullable: false),
+ ProductName = table.Column(type: "TEXT", maxLength: 200, nullable: false),
+ ProductSku = table.Column(type: "TEXT", maxLength: 50, nullable: false),
+ UnitPrice = table.Column(type: "INTEGER", nullable: false),
+ TotalUnits = table.Column(type: "INTEGER", nullable: false),
+ OpensAt = table.Column(type: "TEXT", nullable: false),
+ PerBuyerLimit = table.Column(type: "INTEGER", nullable: false),
+ HoldSeconds = table.Column(type: "INTEGER", nullable: false),
+ CreatedAt = table.Column(type: "TEXT", nullable: false),
+ Version = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Drops", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Drops_Products_ProductId",
+ column: x => x.ProductId,
+ principalTable: "Products",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Holds",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ DropId = table.Column(type: "TEXT", nullable: false),
+ BuyerToken = table.Column(type: "TEXT", maxLength: 64, nullable: false),
+ Quantity = table.Column(type: "INTEGER", nullable: false),
+ CreatedAt = table.Column(type: "TEXT", nullable: false),
+ ExpiresAt = table.Column(type: "TEXT", nullable: false),
+ State = table.Column(type: "INTEGER", nullable: false),
+ OrderId = table.Column(type: "TEXT", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Holds", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Holds_Drops_DropId",
+ column: x => x.DropId,
+ principalTable: "Drops",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Drops_CreatedAt",
+ table: "Drops",
+ column: "CreatedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Drops_ProductId",
+ table: "Drops",
+ column: "ProductId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Holds_DropId_BuyerToken",
+ table: "Holds",
+ columns: new[] { "DropId", "BuyerToken" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Holds_State_ExpiresAt",
+ table: "Holds",
+ columns: new[] { "State", "ExpiresAt" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Holds");
+
+ migrationBuilder.DropTable(
+ name: "Drops");
+ }
+ }
+}
diff --git a/NainOrder.Infrastructure/Persistence/Migrations/NainOrderDbContextModelSnapshot.cs b/NainOrder.Infrastructure/Persistence/Migrations/NainOrderDbContextModelSnapshot.cs
index f66ee59..0f1c693 100644
--- a/NainOrder.Infrastructure/Persistence/Migrations/NainOrderDbContextModelSnapshot.cs
+++ b/NainOrder.Infrastructure/Persistence/Migrations/NainOrderDbContextModelSnapshot.cs
@@ -43,6 +43,92 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Customers");
});
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("HoldSeconds")
+ .HasColumnType("INTEGER");
+
+ b.Property("OpensAt")
+ .HasColumnType("TEXT");
+
+ b.Property("PerBuyerLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProductId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductSku")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("TotalUnits")
+ .HasColumnType("INTEGER");
+
+ b.Property("UnitPrice")
+ .HasColumnType("INTEGER");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("Drops");
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Hold", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("BuyerToken")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DropId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("OrderId")
+ .HasColumnType("TEXT");
+
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("State")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DropId", "BuyerToken");
+
+ b.HasIndex("State", "ExpiresAt");
+
+ b.ToTable("Holds");
+ });
+
modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
{
b.Property("Id")
@@ -177,6 +263,24 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Products");
});
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Product", null)
+ .WithMany()
+ .HasForeignKey("ProductId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("NainOrder.Domain.Entities.Hold", b =>
+ {
+ b.HasOne("NainOrder.Domain.Entities.Drop", null)
+ .WithMany("Holds")
+ .HasForeignKey("DropId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
{
b.HasOne("NainOrder.Domain.Entities.Customer", null)
@@ -201,6 +305,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired();
});
+ modelBuilder.Entity("NainOrder.Domain.Entities.Drop", b =>
+ {
+ b.Navigation("Holds");
+ });
+
modelBuilder.Entity("NainOrder.Domain.Entities.Order", b =>
{
b.Navigation("Items");
diff --git a/NainOrder.Infrastructure/Persistence/NainOrderDbContext.cs b/NainOrder.Infrastructure/Persistence/NainOrderDbContext.cs
index 69524da..00fc2c1 100644
--- a/NainOrder.Infrastructure/Persistence/NainOrderDbContext.cs
+++ b/NainOrder.Infrastructure/Persistence/NainOrderDbContext.cs
@@ -9,6 +9,8 @@ public class NainOrderDbContext : DbContext
public DbSet OrderItems => Set();
public DbSet Products => Set();
public DbSet Customers => Set();
+ public DbSet Drops => Set();
+ public DbSet Holds => Set();
public NainOrderDbContext(DbContextOptions options) : base(options)
{
diff --git a/NainOrder.Infrastructure/Persistence/Repositories/DropRepository.cs b/NainOrder.Infrastructure/Persistence/Repositories/DropRepository.cs
new file mode 100644
index 0000000..785e780
--- /dev/null
+++ b/NainOrder.Infrastructure/Persistence/Repositories/DropRepository.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore;
+using NainOrder.Application.Interfaces;
+using NainOrder.Domain.Entities;
+using NainOrder.Domain.Enums;
+
+namespace NainOrder.Infrastructure.Persistence.Repositories;
+
+public class DropRepository : IDropRepository
+{
+ private readonly NainOrderDbContext _context;
+
+ public DropRepository(NainOrderDbContext context) => _context = context;
+
+ public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) =>
+ _context.Drops
+ .Include(d => d.Holds)
+ .FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
+
+ public async Task> GetRecentAsync(int count, CancellationToken cancellationToken = default) =>
+ await _context.Drops
+ .AsNoTracking()
+ .Include(d => d.Holds)
+ .OrderByDescending(d => d.CreatedAt)
+ .Take(count)
+ .ToListAsync(cancellationToken);
+
+ public async Task> GetWithDueHoldsAsync(DateTime now, CancellationToken cancellationToken = default) =>
+ await _context.Drops
+ .Include(d => d.Holds)
+ .Where(d => d.Holds.Any(h => h.State == HoldState.Active && h.ExpiresAt <= now))
+ .ToListAsync(cancellationToken);
+
+ public async Task AddAsync(Drop drop, CancellationToken cancellationToken = default) =>
+ await _context.Drops.AddAsync(drop, cancellationToken);
+}
diff --git a/NainOrder.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs b/NainOrder.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs
index 79f2ed8..c2d76db 100644
--- a/NainOrder.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs
+++ b/NainOrder.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using NainOrder.Application.Common;
using NainOrder.Domain.Entities;
namespace NainOrder.Infrastructure.Persistence.Seeding;
@@ -41,6 +42,7 @@ private static readonly (string Name, string Email)[] CustomerSeed =
public static async Task SeedAsync(NainOrderDbContext context, CancellationToken cancellationToken = default)
{
var changed = await SeedDemoCustomerAsync(context, cancellationToken);
+ changed |= await SeedAnonymousBuyerAsync(context, cancellationToken);
changed |= await SeedCustomersAsync(context, cancellationToken);
changed |= await SeedCatalogAsync(context, cancellationToken);
@@ -63,6 +65,18 @@ private static async Task SeedDemoCustomerAsync(NainOrderDbContext context
return true;
}
+ /// The single customer all drop orders belong to: buyers are anonymous (Q-005).
+ private static async Task SeedAnonymousBuyerAsync(NainOrderDbContext context, CancellationToken cancellationToken)
+ {
+ if (await context.Customers.AnyAsync(c => c.Id == DropDefaults.AnonymousBuyerId, cancellationToken))
+ return false;
+
+ var entry = context.Customers.Add(new Customer("Anonymous drop buyer", "drop-buyers@naindev.com"));
+ entry.Property(nameof(Customer.Id)).CurrentValue = DropDefaults.AnonymousBuyerId;
+
+ return true;
+ }
+
private static async Task SeedCustomersAsync(NainOrderDbContext context, CancellationToken cancellationToken)
{
var existing = await context.Customers
diff --git a/NainOrder.Tests/Api/DropFlowTests.cs b/NainOrder.Tests/Api/DropFlowTests.cs
new file mode 100644
index 0000000..e96e270
--- /dev/null
+++ b/NainOrder.Tests/Api/DropFlowTests.cs
@@ -0,0 +1,260 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NainOrder.Application.DTOs;
+
+namespace NainOrder.Tests.Api;
+
+/// A clock the test moves by hand, so hold expiry is exercised without sleeping.
+public sealed class TestClock : TimeProvider
+{
+ private DateTimeOffset _now = DateTimeOffset.UtcNow;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan by) => _now += by;
+}
+
+/// REQ-080 to REQ-086 and REQ-088 over HTTP, on the real application.
+public class DropFlowTests : IClassFixture
+{
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ private readonly TestClock _clock = new();
+ private readonly WebApplicationFactory _factory;
+ private readonly HttpClient _client;
+
+ public DropFlowTests(NainOrderApiFactory factory)
+ {
+ _factory = factory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(_clock);
+ }));
+ _client = _factory.CreateClient();
+ }
+
+ private async Task CreateProductAsync() =>
+ (await (await _client.PostAsJsonAsync("/api/products",
+ new CreateProductRequest($"NO-DROP-{Guid.NewGuid():N}"[..20], "Limited sneaker", 129.90m, 1), Json))
+ .Content.ReadFromJsonAsync(Json))!.Id;
+
+ private async Task CreateDropAsync(int units, int perBuyer = 1, int holdSeconds = 60, int opensInSeconds = 0)
+ {
+ var response = await _client.PostAsJsonAsync("/api/drops", new
+ {
+ productId = await CreateProductAsync(),
+ totalUnits = units,
+ perBuyerLimit = perBuyer,
+ holdSeconds,
+ opensInSeconds
+ }, Json);
+
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(Json))!;
+ }
+
+ private static HttpRequestMessage Post(string url, string buyer, object? body = null)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body ?? new { }, options: Json) };
+ request.Headers.Add("X-Buyer-Token", buyer);
+ return request;
+ }
+
+ private async Task TakeAsync(Guid dropId, string buyer, int quantity = 1) =>
+ await _client.SendAsync(Post($"/api/drops/{dropId}/holds", buyer, new { quantity }));
+
+ private async Task GetAsync(Guid dropId, string? buyer = null)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, $"/api/drops/{dropId}");
+ if (buyer is not null) request.Headers.Add("X-Buyer-Token", buyer);
+ return (await (await _client.SendAsync(request)).Content.ReadFromJsonAsync(Json))!;
+ }
+
+ private static async Task CodeOf(HttpResponseMessage response) =>
+ (await response.Content.ReadFromJsonAsync(Json)).GetProperty("code").GetString();
+
+ [Fact]
+ public async Task A_new_drop_reports_its_units_and_goes_live()
+ {
+ var drop = await CreateDropAsync(units: 8, perBuyer: 2);
+
+ Assert.Equal("Live", drop.Status);
+ Assert.Equal(8, drop.TotalUnits);
+ Assert.Equal(8, drop.Available);
+ Assert.Equal(0, drop.Held + drop.Sold);
+ }
+
+ [Fact]
+ public async Task Timestamps_are_serialised_as_utc_so_browsers_do_not_read_them_as_local_time()
+ {
+ var drop = await CreateDropAsync(units: 3);
+ await TakeAsync(drop.Id, "buyer-utc");
+
+ var json = await (await _client.SendAsync(
+ new HttpRequestMessage(HttpMethod.Get, $"/api/drops/{drop.Id}") { Headers = { { "X-Buyer-Token", "buyer-utc" } } }))
+ .Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+
+ Assert.EndsWith("Z", doc.RootElement.GetProperty("opensAt").GetString());
+ Assert.EndsWith("Z", doc.RootElement.GetProperty("serverTime").GetString());
+ Assert.EndsWith("Z", doc.RootElement.GetProperty("myHolds")[0].GetProperty("expiresAt").GetString());
+ }
+
+ [Fact]
+ public async Task A_scheduled_drop_rejects_takes_until_it_opens()
+ {
+ var drop = await CreateDropAsync(units: 5, opensInSeconds: 120);
+
+ var early = await TakeAsync(drop.Id, "buyer-a");
+ Assert.Equal(HttpStatusCode.Conflict, early.StatusCode);
+ Assert.Equal("drop_not_live", await CodeOf(early));
+
+ _clock.Advance(TimeSpan.FromSeconds(121));
+ Assert.Equal(HttpStatusCode.OK, (await TakeAsync(drop.Id, "buyer-a")).StatusCode);
+ }
+
+ [Fact]
+ public async Task Taking_and_confirming_turns_a_hold_into_a_paid_order()
+ {
+ var drop = await CreateDropAsync(units: 5, perBuyer: 2);
+
+ var take = await TakeAsync(drop.Id, "buyer-a", 2);
+ var hold = (await take.Content.ReadFromJsonAsync(Json))!;
+
+ var confirm = await _client.SendAsync(Post($"/api/drops/{drop.Id}/holds/{hold.Id}/confirm", "buyer-a"));
+ confirm.EnsureSuccessStatusCode();
+ var order = (await confirm.Content.ReadFromJsonAsync(Json))!;
+
+ Assert.Equal("Paid", order.Status);
+ Assert.Equal(2, order.TotalUnits);
+ Assert.Equal(259.80m, order.TotalAmount);
+
+ var after = await GetAsync(drop.Id, "buyer-a");
+ Assert.Equal(2, after.Sold);
+ Assert.Equal(3, after.Available);
+ }
+
+ [Fact]
+ public async Task A_buyer_over_the_limit_is_rejected_with_409()
+ {
+ var drop = await CreateDropAsync(units: 5, perBuyer: 1);
+ await TakeAsync(drop.Id, "buyer-a");
+
+ var second = await TakeAsync(drop.Id, "buyer-a");
+
+ Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
+ Assert.Equal("buyer_limit_reached", await CodeOf(second));
+ }
+
+ [Fact]
+ public async Task An_unconfirmed_hold_expires_and_returns_its_units()
+ {
+ var drop = await CreateDropAsync(units: 2, perBuyer: 2, holdSeconds: 30);
+ var hold = (await (await TakeAsync(drop.Id, "buyer-a", 2)).Content.ReadFromJsonAsync(Json))!;
+ Assert.Equal("SoldOut", (await GetAsync(drop.Id)).Status);
+
+ _clock.Advance(TimeSpan.FromSeconds(31));
+
+ var after = await GetAsync(drop.Id);
+ Assert.Equal("Live", after.Status);
+ Assert.Equal(2, after.Available);
+
+ var late = await _client.SendAsync(Post($"/api/drops/{drop.Id}/holds/{hold.Id}/confirm", "buyer-a"));
+ Assert.Equal(HttpStatusCode.Conflict, late.StatusCode);
+ Assert.Equal("hold_expired", await CodeOf(late));
+ }
+
+ [Fact]
+ public async Task Sweeping_persists_the_expiry_of_due_holds()
+ {
+ var drop = await CreateDropAsync(units: 3, perBuyer: 3, holdSeconds: 30);
+ await TakeAsync(drop.Id, "buyer-a", 2);
+ _clock.Advance(TimeSpan.FromSeconds(31));
+
+ // The background sweeper may get there first; either way the hold must end up Expired.
+ using (var scope = _factory.Services.CreateScope())
+ await scope.ServiceProvider.GetRequiredService().ExpireDueHoldsAsync();
+
+ var after = await GetAsync(drop.Id, "buyer-a");
+ Assert.Equal("Expired", Assert.Single(after.MyHolds).State);
+ Assert.Equal(3, after.Available);
+ }
+
+ [Fact]
+ public async Task A_hold_of_another_buyer_cannot_be_confirmed()
+ {
+ var drop = await CreateDropAsync(units: 3);
+ var hold = (await (await TakeAsync(drop.Id, "buyer-a")).Content.ReadFromJsonAsync(Json))!;
+
+ var stolen = await _client.SendAsync(Post($"/api/drops/{drop.Id}/holds/{hold.Id}/confirm", "buyer-b"));
+
+ Assert.Equal(HttpStatusCode.NotFound, stolen.StatusCode);
+ Assert.Equal("hold_not_found", await CodeOf(stolen));
+ }
+
+ [Fact]
+ public async Task Taking_without_a_buyer_token_is_a_400()
+ {
+ var drop = await CreateDropAsync(units: 3);
+
+ var response = await _client.PostAsJsonAsync($"/api/drops/{drop.Id}/holds", new { quantity = 1 }, Json);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ /// REQ-085 under a real crowd: 40 buyers, 10 units, conflicts retried like a client would.
+ [Fact]
+ public async Task A_crowd_never_holds_or_sells_more_units_than_the_drop_contains()
+ {
+ const int units = 10;
+ const int buyers = 40;
+ var drop = await CreateDropAsync(units, perBuyer: 1);
+ var startGun = new TaskCompletionSource();
+
+ var attempts = Enumerable.Range(0, buyers).Select(index => Task.Run(async () =>
+ {
+ await startGun.Task;
+ for (var attempt = 0; attempt < 60; attempt++)
+ {
+ var response = await TakeAsync(drop.Id, $"crowd-{index}");
+ if (response.IsSuccessStatusCode) return "won";
+
+ var code = await CodeOf(response);
+ if (code != "concurrency_conflict") return code!;
+ }
+
+ return "gave_up";
+ })).ToList();
+
+ startGun.SetResult();
+ var outcomes = await Task.WhenAll(attempts);
+
+ var after = await GetAsync(drop.Id);
+ Assert.Equal(units, outcomes.Count(o => o == "won"));
+ Assert.All(outcomes.Where(o => o != "won"), o => Assert.Equal("insufficient_stock", o));
+ Assert.Equal(units, after.Held + after.Sold);
+ Assert.Equal(0, after.Available);
+ Assert.Equal("SoldOut", after.Status);
+ }
+
+ [Theory]
+ [InlineData(0, 1, 60)]
+ [InlineData(5, 6, 60)]
+ [InlineData(5, 1, 1)]
+ public async Task An_invalid_drop_configuration_is_rejected_with_400(int units, int perBuyer, int holdSeconds)
+ {
+ var response = await _client.PostAsJsonAsync("/api/drops", new
+ {
+ productId = await CreateProductAsync(),
+ totalUnits = units,
+ perBuyerLimit = perBuyer,
+ holdSeconds
+ }, Json);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+}
diff --git a/NainOrder.Tests/Domain/DropTests.cs b/NainOrder.Tests/Domain/DropTests.cs
new file mode 100644
index 0000000..c1f2ca7
--- /dev/null
+++ b/NainOrder.Tests/Domain/DropTests.cs
@@ -0,0 +1,228 @@
+using FsCheck.Xunit;
+using NainOrder.Domain.Entities;
+using NainOrder.Domain.Enums;
+using NainOrder.Domain.Exceptions;
+
+namespace NainOrder.Tests.Domain;
+
+///
+/// REQ-080 to REQ-086 and REQ-088 on the aggregate alone, with an explicit clock so expiry
+/// is tested without sleeping.
+///
+public class DropTests
+{
+ private static readonly DateTime Opens = new(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc);
+ private static DateTime At(int seconds) => Opens.AddSeconds(seconds);
+
+ private static Drop NewDrop(int units = 10, int perBuyer = 2, int holdSeconds = 60, DateTime? opensAt = null) =>
+ new(Guid.NewGuid(), "Last-unit sneaker", "NO-DROP-001", 129.90m, units, opensAt ?? Opens, perBuyer, holdSeconds);
+
+ [Fact]
+ public void A_drop_is_scheduled_until_it_opens_and_live_afterwards()
+ {
+ var drop = NewDrop();
+
+ Assert.Equal(DropStatus.Scheduled, drop.StatusAt(Opens.AddSeconds(-1)));
+ Assert.Equal(DropStatus.Live, drop.StatusAt(Opens));
+ }
+
+ [Fact]
+ public void Taking_from_a_drop_that_is_not_live_is_rejected()
+ {
+ var drop = NewDrop();
+
+ var error = Assert.Throws(() => drop.Take("buyer-a", 1, At(-5)));
+
+ Assert.Equal("drop_not_live", error.Code);
+ Assert.Equal(10, drop.AvailableAt(At(-5)));
+ }
+
+ [Fact]
+ public void Taking_units_creates_a_hold_that_reduces_availability()
+ {
+ var drop = NewDrop();
+
+ var hold = drop.Take("buyer-a", 2, At(1));
+
+ Assert.Equal(HoldState.Active, hold.State);
+ Assert.Equal(2, hold.Quantity);
+ Assert.Equal(At(1).AddSeconds(60), hold.ExpiresAt);
+ Assert.Equal(8, drop.AvailableAt(At(2)));
+ Assert.Equal(2, drop.HeldAt(At(2)));
+ }
+
+ [Fact]
+ public void A_buyer_cannot_exceed_the_per_buyer_limit_across_holds()
+ {
+ var drop = NewDrop(perBuyer: 2);
+ drop.Take("buyer-a", 1, At(1));
+
+ var error = Assert.Throws(() => drop.Take("buyer-a", 2, At(2)));
+
+ Assert.Equal("buyer_limit_reached", error.Code);
+ Assert.Equal(9, drop.AvailableAt(At(2)));
+ }
+
+ [Fact]
+ public void Asking_for_more_than_is_available_is_rejected_and_leaves_the_drop_untouched()
+ {
+ var drop = NewDrop(units: 3, perBuyer: 3);
+ drop.Take("buyer-a", 2, At(1));
+
+ var error = Assert.Throws(() => drop.Take("buyer-b", 2, At(2)));
+
+ Assert.Equal(1, error.Available);
+ Assert.Equal(1, drop.AvailableAt(At(2)));
+ }
+
+ [Fact]
+ public void An_unconfirmed_hold_returns_its_units_when_its_window_ends()
+ {
+ var drop = NewDrop(units: 3, perBuyer: 3, holdSeconds: 60);
+ drop.Take("buyer-a", 3, At(0));
+ Assert.Equal(DropStatus.SoldOut, drop.StatusAt(At(30)));
+
+ // Lazy correctness: the units are free by the clock even before anything sweeps the hold.
+ Assert.Equal(3, drop.AvailableAt(At(61)));
+ Assert.Equal(DropStatus.Live, drop.StatusAt(At(61)));
+ }
+
+ [Fact]
+ public void Sweeping_marks_expired_holds_and_reports_how_many()
+ {
+ var drop = NewDrop(units: 5, perBuyer: 5, holdSeconds: 60);
+ var hold = drop.Take("buyer-a", 2, At(0));
+ drop.Take("buyer-b", 1, At(50));
+
+ var swept = drop.ExpireHolds(At(70));
+
+ Assert.Equal(1, swept);
+ Assert.Equal(HoldState.Expired, hold.State);
+ Assert.Equal(4, drop.AvailableAt(At(70)));
+ }
+
+ [Fact]
+ public void Confirming_a_hold_in_time_sells_the_units_for_good()
+ {
+ var drop = NewDrop(units: 5, perBuyer: 5);
+ var hold = drop.Take("buyer-a", 2, At(0));
+ var orderId = Guid.NewGuid();
+
+ drop.Confirm(hold.Id, "buyer-a", At(10), orderId);
+
+ Assert.Equal(HoldState.Confirmed, hold.State);
+ Assert.Equal(orderId, hold.OrderId);
+ Assert.Equal(2, drop.SoldUnits);
+ // A confirmed hold never expires.
+ Assert.Equal(3, drop.AvailableAt(At(10_000)));
+ }
+
+ [Fact]
+ public void Confirming_after_the_window_is_rejected()
+ {
+ var drop = NewDrop();
+ var hold = drop.Take("buyer-a", 1, At(0));
+
+ var error = Assert.Throws(() => drop.Confirm(hold.Id, "buyer-a", At(61), Guid.NewGuid()));
+
+ Assert.Equal("hold_expired", error.Code);
+ Assert.Equal(0, drop.SoldUnits);
+ }
+
+ [Fact]
+ public void Only_the_owner_can_confirm_a_hold()
+ {
+ var drop = NewDrop();
+ var hold = drop.Take("buyer-a", 1, At(0));
+
+ Assert.Throws(() => drop.Confirm(hold.Id, "buyer-b", At(5), Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void A_hold_cannot_be_confirmed_twice()
+ {
+ var drop = NewDrop();
+ var hold = drop.Take("buyer-a", 1, At(0));
+ drop.Confirm(hold.Id, "buyer-a", At(5), Guid.NewGuid());
+
+ var error = Assert.Throws(() => drop.Confirm(hold.Id, "buyer-a", At(6), Guid.NewGuid()));
+
+ Assert.Equal("hold_not_active", error.Code);
+ Assert.Equal(1, drop.SoldUnits);
+ }
+
+ [Fact]
+ public void Expired_units_do_not_count_against_the_buyer_limit()
+ {
+ var drop = NewDrop(perBuyer: 1, holdSeconds: 60);
+ drop.Take("buyer-a", 1, At(0));
+
+ var second = drop.Take("buyer-a", 1, At(61));
+
+ Assert.Equal(HoldState.Active, second.State);
+ }
+
+ [Fact]
+ public void A_drop_rejects_invalid_configuration()
+ {
+ Assert.Throws(() => NewDrop(units: 0));
+ Assert.Throws(() => NewDrop(units: 5, perBuyer: 6));
+ Assert.Throws(() => NewDrop(perBuyer: 0));
+ Assert.Throws(() => NewDrop(holdSeconds: 1));
+ }
+
+ [Fact]
+ public void Every_mutation_moves_the_concurrency_token_forward()
+ {
+ var drop = NewDrop();
+ var version = drop.Version;
+
+ var hold = drop.Take("buyer-a", 1, At(0));
+ Assert.True(drop.Version > version);
+
+ version = drop.Version;
+ drop.Confirm(hold.Id, "buyer-a", At(1), Guid.NewGuid());
+ Assert.True(drop.Version > version);
+ }
+
+ /// REQ-085: whatever the sequence, held plus sold never exceeds the units of the drop.
+ [Property]
+ public void Held_plus_sold_never_exceeds_the_units_of_the_drop(int[] seeds)
+ {
+ var drop = NewDrop(units: 20, perBuyer: 4, holdSeconds: 30);
+ var holds = new List<(Hold Hold, string Buyer)>();
+ var clock = 0;
+
+ foreach (var seed in seeds.Take(60))
+ {
+ var step = Math.Abs(seed % 1000);
+ clock += step % 15;
+ var now = At(clock);
+ var buyer = $"buyer-{step % 7}";
+
+ try
+ {
+ switch (step % 3)
+ {
+ case 0:
+ holds.Add((drop.Take(buyer, step % 4 + 1, now), buyer));
+ break;
+ case 1 when holds.Count > 0:
+ var pick = holds[step % holds.Count];
+ drop.Confirm(pick.Hold.Id, pick.Buyer, now, Guid.NewGuid());
+ break;
+ default:
+ drop.ExpireHolds(now);
+ break;
+ }
+ }
+ catch (DomainException)
+ {
+ // A rejected operation is correct behaviour; only the invariant is under test.
+ }
+
+ Assert.True(drop.HeldAt(now) + drop.SoldUnits <= drop.TotalUnits);
+ Assert.True(drop.AvailableAt(now) >= 0);
+ }
+ }
+}
diff --git a/README.md b/README.md
index 96074e8..dac0c14 100644
--- a/README.md
+++ b/README.md
@@ -6,46 +6,45 @@
# NainOrder
-**An order engine where a unit of stock can never be sold twice — and you can watch it prove it.**
+**Sell a limited drop to a crowd — exactly N units, never oversold, no stock stuck in abandoned carts.**
[](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml)
[](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml)

-
+

[](https://order.naindev.com/)
-
+
-> **Stock race** — 24 buyers hit 5 units at the same instant. Exactly 5 win, 19 get a `409`, and
-> *sold + remaining = in stock* holds. Press the button in the panel and see it yourself.
+> **56 units. 40 buyers released at once.** Each unit is *available*, *held* for 60 seconds, or *sold*.
+> Hold it and don't confirm: it goes back to the crowd. Press **Soltar 40 compradores** and watch.
---
-## What it does
+## The problem it solves
-| 🛒 Orders | 📦 Inventory | 🔒 Guarantees |
-| :--- | :--- | :--- |
-| Basket → paid → processing → shipped, or cancelled | Adding a line **reserves** stock in the same transaction | No oversell under concurrency |
-| Only legal transitions; the API publishes them | Cancelling **returns** every unit | Money in integer cents, never floats |
-| Totals always equal the sum of the lines | Quantity changes adjust stock by the exact difference | Business failure `4xx`, technical failure `500` |
+Sneaker releases, merch drops and ticket sales fail the same way: a crowd arrives in one second, and
+the shop either **oversells** or **locks stock behind abandoned carts**. NainOrder is the engine that
+prevents both.
```mermaid
-stateDiagram-v2
- direction LR
- [*] --> PendingPayment
- PendingPayment --> Paid
- Paid --> Processing
- Processing --> Shipped
- Shipped --> [*]
- PendingPayment --> Cancelled
- Paid --> Cancelled
- Processing --> Cancelled
- Cancelled --> [*]
+flowchart LR
+ A([Take a unit]) --> B[Hold 30–3600 s]
+ B -->|Confirm in time| C([Paid order])
+ B -->|Window ends| D([Unit back to the drop])
+ D -.-> A
```
+| Guarantee | How it is enforced |
+| :--- | :--- |
+| Never more units held + sold than the drop has | One aggregate, one concurrency token |
+| A holder can't hog the drop | Per-buyer limit across holds and orders |
+| Abandoned holds free themselves | Expiry applied on every read, swept every second |
+| Money is exact | Integer cents, never floats |
+
---
## Architecture
@@ -58,11 +57,12 @@ The rule is **enforced, not declared**: `NetArchTest` fails the build if `Domain
`Application` learns about EF Core.
+
-
-The client panel is plain ES modules served by the API itself: no npm, no build step, no CORS.
+The panel is plain ES modules served by the API itself: no npm, no build step, no CORS. It also ships a
+**stock race** (many buyers, few units, one verdict) and the full order back office.
---
@@ -70,10 +70,10 @@ The client panel is plain ES modules served by the API itself: no npm, no build
| Claim | Proof |
| :--- | :--- |
+| A crowd never holds or sells more than the drop contains | `DropFlowTests` (40 concurrent buyers), `DropTests` (property-based) |
+| Holds expire without a sleeping test | `DropTests`, `DropFlowTests` with a hand-moved clock |
| Stock is never oversold, even in a race | `StockRaceTests`, `ConcurrencyTests` |
-| Stock never goes negative, for any sequence | `DomainInvariantProperties` (FsCheck) |
| Two writers on one order never lose an update | `OrderAggregateConcurrencyProbe` |
-| Cancelling returns every unit | `OrderFlowTests` |
| Domain has zero dependencies | `CleanArchitectureTests` |
| The shipped container works | CI builds the image and probes it |
@@ -88,26 +88,27 @@ git clone https://github.com/Nain9Dev/NainOrder.git
cd NainOrder/NainOrder.Api && dotnet run
```
-Open — panel, [`/swagger`](http://localhost:8170/swagger) and `/health` are all there.
-No database to install: SQLite creates itself.
+Open → **Drop** → *Lanzar drop*. Also there: [`/swagger`](http://localhost:8170/swagger)
+and `/health`. No database to install: SQLite creates itself.
```bash
-dotnet test NainOrder.slnx -c Release # 82 tests
+dotnet test NainOrder.slnx -c Release # 110 tests
docker build -t nainorder . && docker run -p 8080:8080 nainorder
```
> **About the live demo.** [order.naindev.com](https://order.naindev.com/) is a static page, so the panel runs
-> on an in-browser engine and says so. The concurrency guarantees above run against the .NET API — locally.
+> on an in-browser engine and says so: one thread can't collide with itself. The concurrency guarantees
+> run against the .NET API — locally.
---
## Go deeper
[Charter](docs/00-charter.md) · [Requirements](docs/10-requirements.md) · [Architecture](docs/20-architecture.md) ·
-[Data model](docs/21-data-model.md) · [7 ADRs](docs/30-decisions/) · [Threat model](docs/22-threat-model.md) ·
-[Traceability](docs/50-traceability.md) · [OpenAPI](docs/openapi.json)
+[Data model](docs/21-data-model.md) · [9 ADRs](docs/30-decisions/) · [Threat model](docs/22-threat-model.md) ·
+[Traceability](docs/50-traceability.md) · [OpenAPI](docs/openapi.json) · [Drops spec](specs/002-drops/spec.md)
-**Stack** — C# 13 · .NET 10 · ASP.NET Core · EF Core · SQLite · xUnit · FsCheck · ES modules
+**Stack** — C# 13 · .NET 10 · ASP.NET Core · EF Core · SQLite (WAL) · xUnit · FsCheck · ES modules
---
diff --git a/docs/10-requirements.md b/docs/10-requirements.md
index 658575e..455ca22 100644
--- a/docs/10-requirements.md
+++ b/docs/10-requirements.md
@@ -64,6 +64,22 @@ says.
| REQ-072 | The system shall report `sold + remaining = units`, `sold <= units` and `remaining >= 0` as a `balanced` verdict computed from the persisted product, not from the buyers' answers. |
| REQ-073 | If `buyers` is outside 2..64 or `units` is outside 1..32, then the system shall reject the request with `400` before any data is created. |
+## Drops
+
+Phase 1 of [specs/002-drops](../specs/002-drops/spec.md). REQ-087 (live stream) is specified there and
+enters this table when it is implemented and tested.
+
+| ID | Requirement |
+| :--- | :--- |
+| REQ-080 | When a shop owner creates a drop with a product, a number of units, an opening time and a per-buyer limit, the system shall publish it as `Scheduled` until it opens. |
+| REQ-081 | While a drop is not `Live`, the system shall reject any attempt to take a unit with `409 drop_not_live`. |
+| REQ-082 | When a buyer takes units from a live drop, the system shall create a hold of at most the per-buyer limit, reserving those units atomically. |
+| REQ-083 | When a hold is not confirmed within its time window, the system shall expire it and return its units to the drop. |
+| REQ-084 | When a buyer confirms an unexpired hold, the system shall turn it into a paid order for that buyer. |
+| REQ-085 | The system shall never hold or sell more units than the drop contains, whatever the concurrency. |
+| REQ-086 | When a drop's units are all sold or held, the system shall report it `SoldOut`; when expired holds free units again, it shall report it `Live`. |
+| REQ-088 | If a buyer exceeds the per-buyer limit across holds and orders of one drop, then the system shall reject the request with `409 buyer_limit_reached`. |
+
## HTTP contract
| ID | Requirement |
diff --git a/docs/11-open-questions.md b/docs/11-open-questions.md
index 2b20da9..99fcffb 100644
--- a/docs/11-open-questions.md
+++ b/docs/11-open-questions.md
@@ -4,7 +4,24 @@
Historical log of design questions and their resolved decisions.
-## Q-001 — Test coverage gaps recorded in traceability
+## Resolved
+
+### Q-005 — Buyer identity without accounts
+
+**Resolution:** Option (a) approved by owner. An anonymous per-visitor token, sent as `X-Buyer-Token`
+and generated by the client; limits are enforced per token, and the existing per-IP rate limiter
+covers the rest. All drop orders belong to one seeded anonymous customer.
+
+### Q-006 — Payment
+
+**Resolution:** Approved by owner. `confirm` is the existing `MarkAsPaid` transition; no payment
+gateway. The product makes no claim of real payment capture.
+
+### Q-007 — Hold persistence
+
+**Resolution:** Approved by owner. Holds are persisted with their drop, so a restart loses nothing.
+
+### Q-001 — Test coverage gaps recorded in traceability
**Resolution:** Resolved via automated test suite expansion.
- `REQ-004` is covered by `StockLevelsTests.Classify_maps_stock_quantities_to_expected_classification`.
@@ -13,17 +30,17 @@ Historical log of design questions and their resolved decisions.
- `REQ-051` is covered by `GlobalExceptionHandlerTests` asserting status 500, code `internal_error`, and internal exception message suppression outside development.
- `REQ-054` is covered by `OrderFlowTests.Order_list_endpoint_honors_pagination_contract_and_filters`.
-## Q-002 — Business rule: a repeated product in an order
+### Q-002 — Business rule: a repeated product in an order
**Resolution:** Option (a) approved by owner.
`Order.AddItem` continues to strictly enforce line uniqueness within an order (`DuplicateOrderItemException`), keeping the domain invariant explicit. Quantity modifications are performed via `ChangeItemQuantity`.
-## Q-003 — Stock is reserved on adding a line, not on payment
+### Q-003 — Stock is reserved on adding a line, not on payment
**Resolution:** Option (a) approved by owner.
Units continue to be reserved immediately upon line addition within the transaction, demonstrating deterministic reservation and restitution upon cancellation for this bounded scope.
-## Q-004 — Where money is rounded
+### Q-004 — Where money is rounded
**Resolution:** Option (b) approved by owner.
Keep both domain rounding on entity construction and `MoneyConverter` rounding as defense in depth, ensuring exact integer cent representation across all boundaries.
diff --git a/docs/30-decisions/ADR-0008-drops-with-expiring-holds.md b/docs/30-decisions/ADR-0008-drops-with-expiring-holds.md
new file mode 100644
index 0000000..894644e
--- /dev/null
+++ b/docs/30-decisions/ADR-0008-drops-with-expiring-holds.md
@@ -0,0 +1,39 @@
+# ADR-0008 — Drops with expiring holds
+
+**Status:** Approved
+
+## Context
+
+NainOrder reserves stock when a line is added to an order and never releases it unless the order is
+cancelled. That is correct for a basket, but a limited launch needs the opposite pressure: units must
+be held only briefly while the buyer decides, then return to the crowd. Without expiry, abandoned
+carts lock stock and the drop never sells out honestly.
+
+## Decision
+
+Introduce a `Drop` aggregate that owns a fixed number of units, and a `Hold` that reserves some of
+them for a short window. A confirmed hold becomes an order through the existing `OrderService`;
+an unconfirmed one expires and its units return to the drop.
+
+- **Where the stock lives.** The drop owns its units; it does not borrow from `Product.StockQuantity`
+ while the drop is live. This keeps catalogue stock and drop stock from interfering.
+- **Concurrency.** `Drop.Version` is an optimistic concurrency token, the same pattern as
+ [ADR-0007](ADR-0007-optimistic-concurrency-on-order-aggregate.md). A losing writer gets
+ `concurrency_conflict` and retries, as in the stock race.
+- **Expiry.** Checked lazily on every read and write of the drop, and swept every second by a
+ background service. Correctness depends only on the lazy check; the sweeper keeps the live state fresh.
+- **Time.** All decisions use an injected `TimeProvider`, so expiry is tested without sleeping.
+
+## Alternatives considered
+
+- **Reuse `Product.StockQuantity` for drops.** Rejected: a drop would race the catalogue's own
+ reservations and could not guarantee "exactly N units".
+- **Expiry only in a background job.** Rejected: a stopped job would make units unavailable forever.
+- **Redis with key TTLs.** Rejected for now: adds a service and breaks the single-process,
+ zero-cost deployment constraint in the charter.
+
+## Consequences
+
+- Scope grows from a basket engine to a launch engine; the charter's objective needs a matching update.
+- A new aggregate, migration and background service to maintain and test.
+- Restarting the process must not lose holds, so they are persisted, not kept in memory (Q-007).
diff --git a/docs/30-decisions/ADR-0009-sqlite-wal-mode.md b/docs/30-decisions/ADR-0009-sqlite-wal-mode.md
new file mode 100644
index 0000000..24ffca7
--- /dev/null
+++ b/docs/30-decisions/ADR-0009-sqlite-wal-mode.md
@@ -0,0 +1,30 @@
+# ADR-0009 — SQLite in WAL mode
+
+**Status:** Proposed
+
+## Context
+
+Drops bring many buyers writing to one database at once, plus a background sweeper. With SQLite's
+default rollback journal a committing writer blocks readers, and under a 40-buyer crowd the
+application answered `500` with `SQLite Error 5: database is locked`. That breaks the rule that a
+business outcome is `4xx` and only a technical failure is `500`: a busy database is not the
+buyer's fault and should not reach them at all.
+
+## Decision
+
+Run `PRAGMA journal_mode=WAL` at start-up, after migrations. Readers then proceed while a writer
+commits; writers still serialise, and optimistic concurrency keeps resolving conflicts as `409`.
+The mode is stored in the database file, so repeating the statement on later starts changes nothing.
+
+## Alternatives considered
+
+- **Raise the busy timeout.** Rejected as the only fix: it hides contention behind latency and does
+ not remove the reader/writer block.
+- **Move to PostgreSQL.** Right for sustained production load, rejected here: it breaks the charter's
+ single-process, zero-cost deployment. The runbook already records this as the known limit.
+
+## Consequences
+
+- Two extra files, `-wal` and `-shm`, appear beside the database. Back up and move all three together.
+- WAL needs a local filesystem; it is not supported on network shares.
+- Sustained write traffic still needs a different engine (see the runbook).
diff --git a/docs/30-decisions/README.md b/docs/30-decisions/README.md
index b8a2e9c..49e5823 100644
--- a/docs/30-decisions/README.md
+++ b/docs/30-decisions/README.md
@@ -11,3 +11,5 @@ Records of significant architectural and design decisions for NainOrder.
| [ADR-0005](ADR-0005-problem-details-error-contract.md) | RFC 7807 Problem Details error contract | Proposed |
| [ADR-0006](ADR-0006-dependency-free-client-panel.md) | Dependency-free client panel | Proposed |
| [ADR-0007](ADR-0007-optimistic-concurrency-on-order-aggregate.md) | Optimistic concurrency on the order aggregate | Proposed |
+| [ADR-0008](ADR-0008-drops-with-expiring-holds.md) | Drops with expiring holds | Approved |
+| [ADR-0009](ADR-0009-sqlite-wal-mode.md) | SQLite in WAL mode | Proposed |
diff --git a/docs/50-traceability.md b/docs/50-traceability.md
index 9d3180d..0c059c4 100644
--- a/docs/50-traceability.md
+++ b/docs/50-traceability.md
@@ -1,6 +1,6 @@
# Traceability
-**Status:** Active · verified against a green run of `dotnet test NainOrder.slnx -c Release` (82/82 tests)
+**Status:** Active · verified against a green run of `dotnet test NainOrder.slnx -c Release` (110/110 tests)
Every requirement in [10-requirements.md](10-requirements.md) and the test that demonstrates it.
A requirement with no test is not done, whatever the code says.
@@ -15,6 +15,8 @@ Test suites:
| `Api.OrderFlowTests` | Integration over HTTP | The real application with its migrations and middleware |
| `Api.ConcurrencyTests` | Integration, concurrent | Simultaneous requests competing for the same units |
| `Api.OrderAggregateConcurrencyProbe` | Concurrency, aggregate | Multi-context concurrent aggregate mutations and optimistic concurrency conflict enforcement |
+| `Domain.DropTests` | Example-based + property-based | The drop aggregate with an explicit clock: holds, expiry, limits, the held-plus-sold invariant |
+| `Api.DropFlowTests` | Integration, concurrent | Drops over HTTP with a hand-moved clock, a 40-buyer crowd and UTC serialisation |
| `Api.StockRaceTests` | Integration, concurrent | The on-demand race: many buyers, few units, verdict from persisted stock |
| `Api.GlobalExceptionHandlerTests` | Contract/Unit | RFC 7807 error translation and production 500 detail sanitization |
@@ -76,6 +78,19 @@ Test suites:
| REQ-072 | `StockRaceTests.A_race_never_sells_more_units_than_exist_and_balances_to_the_unit` |
| REQ-073 | `StockRaceTests.A_race_outside_the_allowed_range_is_rejected_with_400` (four boundary cases) |
+## Drops
+
+| Requirement | Test |
+| :--- | :--- |
+| REQ-080 | `DropTests.A_drop_is_scheduled_until_it_opens_and_live_afterwards`, `A_drop_rejects_invalid_configuration`; `DropFlowTests.A_new_drop_reports_its_units_and_goes_live`, `An_invalid_drop_configuration_is_rejected_with_400` |
+| REQ-081 | `DropTests.Taking_from_a_drop_that_is_not_live_is_rejected`; `DropFlowTests.A_scheduled_drop_rejects_takes_until_it_opens` |
+| REQ-082 | `DropTests.Taking_units_creates_a_hold_that_reduces_availability`, `Asking_for_more_than_is_available_is_rejected_and_leaves_the_drop_untouched`; `DropFlowTests.Taking_and_confirming_turns_a_hold_into_a_paid_order` |
+| REQ-083 | `DropTests.An_unconfirmed_hold_returns_its_units_when_its_window_ends`, `Sweeping_marks_expired_holds_and_reports_how_many`; `DropFlowTests.An_unconfirmed_hold_expires_and_returns_its_units`, `Sweeping_persists_the_expiry_of_due_holds` |
+| REQ-084 | `DropTests.Confirming_a_hold_in_time_sells_the_units_for_good`, `Confirming_after_the_window_is_rejected`, `Only_the_owner_can_confirm_a_hold`, `A_hold_cannot_be_confirmed_twice`; `DropFlowTests.Taking_and_confirming_turns_a_hold_into_a_paid_order`, `A_hold_of_another_buyer_cannot_be_confirmed` |
+| REQ-085 | `DropTests.Held_plus_sold_never_exceeds_the_units_of_the_drop` (property); `DropFlowTests.A_crowd_never_holds_or_sells_more_units_than_the_drop_contains` (40 concurrent buyers) |
+| REQ-086 | `DropTests.An_unconfirmed_hold_returns_its_units_when_its_window_ends`; `DropFlowTests.An_unconfirmed_hold_expires_and_returns_its_units` |
+| REQ-088 | `DropTests.A_buyer_cannot_exceed_the_per_buyer_limit_across_holds`, `Expired_units_do_not_count_against_the_buyer_limit`; `DropFlowTests.A_buyer_over_the_limit_is_rejected_with_409` |
+
## HTTP contract
| Requirement | Test |
diff --git a/docs/60-runbook.md b/docs/60-runbook.md
index 9c01866..b662ada 100644
--- a/docs/60-runbook.md
+++ b/docs/60-runbook.md
@@ -61,6 +61,12 @@ bypass it.
- **Ephemeral data.** With no persistent volume the database is recreated on every deploy. The seed
makes the instance usable immediately, so this is acceptable for a demonstration and not for
production.
+- **WAL files.** The database runs in WAL mode ([ADR-0009](30-decisions/ADR-0009-sqlite-wal-mode.md)),
+ so `nainorder.db-wal` and `-shm` sit beside it: copy all three together.
+- **Drops have a larger rate limit** (1,200 requests per minute per address on `/api/drops`, 120
+ elsewhere) because a drop page polls every second and its crowd simulator bursts.
+- **Unconfirmed drop holds expire** after their window and a background service sweeps them every
+ second; reads also apply the clock, so a stopped sweeper never leaves units locked.
- **SQLite serialises writes.** Fine at demonstration load. Sustained concurrent write traffic needs
a different engine; that is a `UseSqlite` change plus a migration, not a domain change.
- **No authentication.** Every endpoint is public by design — see the non-goals in
diff --git a/docs/90-changelog.md b/docs/90-changelog.md
index 2b618fb..615c6f8 100644
--- a/docs/90-changelog.md
+++ b/docs/90-changelog.md
@@ -8,6 +8,11 @@ Notable changes. Format loosely follows Keep a Changelog; dates come from git.
### Added
+- Drops (phase 1 of `specs/002-drops`, ADR-0008): a fixed number of units sold to a crowd through
+ short expiring holds, with `/api/drops` endpoints, a `Drop` panel view with a crowd simulator, and
+ an in-browser engine for the static demo. REQ-080 to REQ-086 and REQ-088.
+- SQLite runs in WAL mode so a crowd of buyers no longer surfaces `database is locked` as a `500`
+ ([ADR-0009](30-decisions/ADR-0009-sqlite-wal-mode.md)); drops get a larger rate-limit window.
- Stock race: `POST /api/demo/stock-race` and a panel view that run the "never oversell" guarantee
live — N buyers against M units, conflicts retried, verdict computed from persisted stock
(`specs/001-stock-race`, REQ-070 to REQ-073).
diff --git a/docs/assets/drop-live.png b/docs/assets/drop-live.png
new file mode 100644
index 0000000..8da72c7
Binary files /dev/null and b/docs/assets/drop-live.png differ
diff --git a/docs/client.ts b/docs/client.ts
index 8d3508c..c5b650f 100644
--- a/docs/client.ts
+++ b/docs/client.ts
@@ -27,6 +27,14 @@ export interface CreateCustomerRequest {
email?: string;
}
+export interface CreateDropRequest {
+ productId?: string;
+ totalUnits?: number;
+ perBuyerLimit?: number;
+ holdSeconds?: number;
+ opensInSeconds?: number;
+}
+
export interface CreateOrderRequest {
customerId?: string;
}
@@ -61,6 +69,32 @@ export interface DashboardStatsDto {
topProducts?: TopProductDto[];
}
+export interface DropDto {
+ id?: string;
+ productId?: string;
+ productName?: string;
+ productSku?: string;
+ unitPrice?: number;
+ totalUnits?: number;
+ available?: number;
+ held?: number;
+ sold?: number;
+ status?: string;
+ opensAt?: string;
+ perBuyerLimit?: number;
+ holdSeconds?: number;
+ serverTime?: string;
+ myHolds?: HoldDto[];
+}
+
+export interface HoldDto {
+ id?: string;
+ quantity?: number;
+ expiresAt?: string;
+ state?: string;
+ orderId?: string;
+}
+
export interface OrderDto {
id?: string;
reference?: string;
@@ -192,6 +226,10 @@ export interface StockRaceResultDto {
results?: StockRaceBuyerDto[];
}
+export interface TakeUnitsRequest {
+ quantity?: number;
+}
+
export interface TopProductDto {
productId?: string;
name?: string;
diff --git a/docs/openapi.json b/docs/openapi.json
index 8a1f2b6..c765405 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -165,6 +165,237 @@
}
}
},
+ "/api/Drops": {
+ "get": {
+ "tags": [
+ "Drops"
+ ],
+ "summary": "Most recent drops.",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/DropDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Drops"
+ ],
+ "summary": "Creates a drop for a catalogue product. It opens after \u0060opensInSeconds\u0060.",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDropRequest"
+ }
+ },
+ "text/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDropRequest"
+ }
+ },
+ "application/*\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDropRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DropDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/Drops/{id}": {
+ "get": {
+ "tags": [
+ "Drops"
+ ],
+ "summary": "Live state of a drop. With \u0060X-Buyer-Token\u0060, also the caller\u0027s own holds.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DropDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/Drops/{id}/holds": {
+ "post": {
+ "tags": [
+ "Drops"
+ ],
+ "summary": "Takes units from a live drop: an expiring hold for the caller\u0027s buyer token.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TakeUnitsRequest"
+ }
+ },
+ "text/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TakeUnitsRequest"
+ }
+ },
+ "application/*\u002Bjson": {
+ "schema": {
+ "$ref": "#/components/schemas/TakeUnitsRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HoldDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/Drops/{id}/holds/{holdId}/confirm": {
+ "post": {
+ "tags": [
+ "Drops"
+ ],
+ "summary": "Confirms an unexpired hold; it becomes a paid order.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "holdId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrderDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/Meta/order-state-machine": {
"get": {
"tags": [
@@ -1043,6 +1274,32 @@
},
"additionalProperties": false
},
+ "CreateDropRequest": {
+ "type": "object",
+ "properties": {
+ "productId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "totalUnits": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "perBuyerLimit": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "holdSeconds": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "opensInSeconds": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "additionalProperties": false
+ },
"CreateOrderRequest": {
"type": "object",
"properties": {
@@ -1164,6 +1421,102 @@
},
"additionalProperties": false
},
+ "DropDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "productId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "productName": {
+ "type": "string",
+ "nullable": true
+ },
+ "productSku": {
+ "type": "string",
+ "nullable": true
+ },
+ "unitPrice": {
+ "type": "number",
+ "format": "double"
+ },
+ "totalUnits": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "available": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "held": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "sold": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "status": {
+ "type": "string",
+ "nullable": true
+ },
+ "opensAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "perBuyerLimit": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "holdSeconds": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "serverTime": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "myHolds": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/HoldDto"
+ },
+ "nullable": true
+ }
+ },
+ "additionalProperties": false
+ },
+ "HoldDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "quantity": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "state": {
+ "type": "string",
+ "nullable": true
+ },
+ "orderId": {
+ "type": "string",
+ "format": "uuid",
+ "nullable": true
+ }
+ },
+ "additionalProperties": false
+ },
"OrderDto": {
"type": "object",
"properties": {
@@ -1626,6 +1979,16 @@
"additionalProperties": false,
"description": "Verdict of a stock race. \u0060Balanced\u0060 is computed from the persisted product."
},
+ "TakeUnitsRequest": {
+ "type": "object",
+ "properties": {
+ "quantity": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ "additionalProperties": false
+ },
"TopProductDto": {
"type": "object",
"properties": {
@@ -1686,6 +2049,9 @@
{
"name": "Demo"
},
+ {
+ "name": "Drops"
+ },
{
"name": "Meta"
},
diff --git a/specs/002-drops/plan.md b/specs/002-drops/plan.md
new file mode 100644
index 0000000..618996b
--- /dev/null
+++ b/specs/002-drops/plan.md
@@ -0,0 +1,36 @@
+# 002 — Plan
+
+## Domain (no packages)
+
+- `Drop` aggregate: `Id`, `ProductId`, `TotalUnits`, `OpensAt`, `PerBuyerLimit`, `HoldSeconds`, `Version`.
+ Status is derived from the clock and the counters, never stored.
+- `Hold` entity: `DropId`, `BuyerToken`, `Quantity`, `ExpiresAt`, `State` (`Active`, `Confirmed`, `Expired`).
+- Invariant: `active holds + confirmed units <= TotalUnits`, enforced inside `Drop`.
+
+## Application
+
+`DropService`: create, get, take (creates a hold), confirm (creates and pays the order through the
+existing `OrderService`), and `ExpireHolds`. One unit of work per use case, like the rest.
+
+## Infrastructure
+
+- EF Core mapping, migration `AddDrops`, `Drop.Version` as concurrency token (same pattern as ADR-0007).
+- `HoldExpiryService : BackgroundService` sweeps expired holds every second. Expiry is also checked
+ lazily when a drop is read, so correctness never depends on the sweeper running.
+
+## Api
+
+`POST /api/drops`, `GET /api/drops/{id}`, `POST /api/drops/{id}/holds`,
+`POST /api/drops/{id}/holds/{holdId}/confirm`, `GET /api/drops/{id}/stream` (SSE, phase 2).
+Errors keep the RFC 7807 contract with new codes: `drop_not_live`, `buyer_limit_reached`, `hold_expired`.
+
+## Panel
+
+New `Drop` view with the layout in the spec's visual contract. Renders API state only; computes
+nothing (AGENTS.md rule). The existing stock race becomes the crowd simulator of a drop.
+
+## Verification
+
+Test first, per task: a concurrency test with many buyers taking from one drop (never more than
+`TotalUnits` held plus sold), a clock-controlled test for hold expiry (inject `TimeProvider`),
+and property tests for the invariant in `Drop`.
diff --git a/specs/002-drops/spec.md b/specs/002-drops/spec.md
new file mode 100644
index 0000000..3c5acd1
--- /dev/null
+++ b/specs/002-drops/spec.md
@@ -0,0 +1,49 @@
+# 002 — Drops
+
+**Status:** Approved by the owner. Changes the product scope: see [ADR-0008](../../docs/30-decisions/ADR-0008-drops-with-expiring-holds.md); Q-005 to Q-007 are resolved in `docs/11-open-questions.md`.
+
+## Problem
+
+Limited-stock launches (sneakers, merch, tickets) fail in one predictable way: when thousands arrive at
+the same second, the shop oversells, or locks stock behind abandoned carts. Shops with a generic
+platform have no tool that guarantees *exactly N units, no more, no stuck stock*.
+
+## Product
+
+NainOrder becomes a **drop engine**: a headless service plus a live page that sells a fixed number of
+units to a crowd, fairly and without overselling. The existing guarantees (reservation, optimistic
+concurrency, state machine) are the engine; this feature adds the commercial surface.
+
+## Users
+
+- **Shop owner** — configures a drop and watches it sell out.
+- **Buyer** — takes a unit, then has a short window to confirm it.
+
+## Requirements (EARS)
+
+- **REQ-080** — When a shop owner creates a drop with a product, a number of units, an opening time and a per-buyer limit, the system shall publish it as `Scheduled` until it opens.
+- **REQ-081** — While a drop is not `Live`, the system shall reject any attempt to take a unit with `409 drop_not_live`.
+- **REQ-082** — When a buyer takes units from a live drop, the system shall create a hold of at most the per-buyer limit, reserving those units atomically.
+- **REQ-083** — When a hold is not confirmed within its time window, the system shall expire it and return its units to the drop.
+- **REQ-084** — When a buyer confirms an unexpired hold, the system shall turn it into a paid order for that buyer.
+- **REQ-085** — The system shall never hold or sell more units than the drop contains, whatever the concurrency.
+- **REQ-086** — When a drop's units are all sold or held, the system shall report it `SoldOut`; when expired holds free units again, it shall report it `Live`.
+- **REQ-087** — The system shall publish the live state of a drop (available, held, sold) as a stream that clients can subscribe to.
+- **REQ-088** — If a buyer exceeds the per-buyer limit across holds and orders of one drop, then the system shall reject the request with `409 buyer_limit_reached`.
+
+## Visual contract (panel)
+
+One page per drop: a countdown, a grid with one cell per unit coloured *available / held / sold*,
+and a single button. No explanatory text beyond labels. It updates from the stream (REQ-087).
+
+## Phases
+
+1. **MVP** — REQ-080 to REQ-086, REQ-088; live page polling the drop state.
+2. **Live** — REQ-087 (stream); crowd simulator on the page (extends the stock race).
+3. **Integrate** — webhooks (`drop.sold_out`, `hold.expired`) and an embeddable widget. Needs Q-005.
+
+## Non-goals
+
+- Real payment capture: `confirm` stays the existing `MarkAsPaid` transition (Q-006).
+- Accounts and login. Buyers are identified by a per-visitor token (Q-005).
+- Queues ordered by arrival (waiting-room fairness). Revisit after MVP.
diff --git a/specs/002-drops/tasks.md b/specs/002-drops/tasks.md
new file mode 100644
index 0000000..1539e74
--- /dev/null
+++ b/specs/002-drops/tasks.md
@@ -0,0 +1,24 @@
+# 002 — Tasks
+
+Spec and [ADR-0008](../../docs/30-decisions/ADR-0008-drops-with-expiring-holds.md) approved; Q-005 to Q-007
+resolved. Phase 1 is T-040 to T-045. Labels: `[A]` agent alone, `[M]` mixed, `[H]` owner only.
+
+| ID | Task | Label | Done when |
+| :--- | :--- | :--- | :--- |
+| T-040 | Approve scope change, ADR-0008 and Q-005 to Q-007 | `[H]` | ✅ Statuses updated in `docs/` |
+| T-041 | `Drop` and `Hold` domain with the clock supplied by the caller, tests first | `[A]` | ✅ Invariant REQ-085 holds for generated sequences |
+| T-042 | `DropService` and persistence, migration `AddDrops` | `[A]` | ✅ 40-buyer test: never more than `TotalUnits` held plus sold |
+| T-043 | Hold expiry, lazy plus background sweeper | `[A]` | ✅ Clock-controlled tests return units after the window |
+| T-044 | Drop endpoints and error codes | `[A]` | ✅ `openapi.json` regenerated, error contract tests green |
+| T-045 | Drop view in the panel, minimal text, crowd simulator | `[A]` | ✅ Verified in a browser against the real API and the in-browser engine |
+| T-046 | Live stream (SSE) instead of 1 s polling | `[A]` | Page updates without polling |
+| T-047 | Webhooks and embeddable widget | `[M]` | Receiver URL supplied by the owner |
+| T-048 | README rebuilt around the drop | `[A]` | ✅ Product understood from the first screen |
+
+## Handoff
+
+Phase 1 is done: 110 tests green, REQ-080 to REQ-086 and REQ-088 traced. Found and fixed while
+verifying in a browser: timestamps serialised without UTC (countdowns off by the timezone offset),
+`database is locked` under a crowd (ADR-0009, WAL), and the 120/min rate limit starving the crowd
+simulator (larger window for `/api/drops`). Next: T-046 (SSE) — REQ-087 enters
+`docs/10-requirements.md` with its test.