diff --git a/NainOrder.Api/Controllers/DropsController.cs b/NainOrder.Api/Controllers/DropsController.cs new file mode 100644 index 0000000..9876a2a --- /dev/null +++ b/NainOrder.Api/Controllers/DropsController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Mvc; +using NainOrder.Application.DTOs; +using NainOrder.Application.Interfaces; + +namespace NainOrder.Api.Controllers; + +/// Limited-stock drops: a fixed number of units sold to a crowd with short expiring holds. +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class DropsController : ControllerBase +{ + private const string BuyerTokenHeader = "X-Buyer-Token"; + + private readonly IDropService _dropService; + + public DropsController(IDropService dropService) => _dropService = dropService; + + /// Most recent drops. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) => + Ok(await _dropService.ListAsync(cancellationToken)); + + /// Live state of a drop. With X-Buyer-Token, also the caller's own holds. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) => + Ok(await _dropService.GetAsync(id, BuyerToken(), cancellationToken)); + + /// Creates a drop for a catalogue product. It opens after opensInSeconds. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Create(CreateDropRequest request, CancellationToken cancellationToken) + { + var drop = await _dropService.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = drop.Id }, drop); + } + + /// Takes units from a live drop: an expiring hold for the caller's buyer token. + [HttpPost("{id:guid}/holds")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Take( + Guid id, TakeUnitsRequest request, CancellationToken cancellationToken) + { + if (BuyerToken() is not { } token) return MissingToken(); + return Ok(await _dropService.TakeAsync(id, token, request, cancellationToken)); + } + + /// Confirms an unexpired hold; it becomes a paid order. + [HttpPost("{id:guid}/holds/{holdId:guid}/confirm")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Confirm(Guid id, Guid holdId, CancellationToken cancellationToken) + { + if (BuyerToken() is not { } token) return MissingToken(); + return Ok(await _dropService.ConfirmAsync(id, holdId, token, cancellationToken)); + } + + private string? BuyerToken() + { + var value = Request.Headers[BuyerTokenHeader].ToString().Trim(); + return value.Length is > 0 and <= 64 ? value : null; + } + + private ObjectResult MissingToken() => Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Buyer token required", + detail: $"Send a visitor token of 1 to 64 characters in the {BuyerTokenHeader} header.", + extensions: new Dictionary { ["code"] = "buyer_token_required" }); +} diff --git a/NainOrder.Api/Infrastructure/GlobalExceptionHandler.cs b/NainOrder.Api/Infrastructure/GlobalExceptionHandler.cs index 1ec5196..7d2b708 100644 --- a/NainOrder.Api/Infrastructure/GlobalExceptionHandler.cs +++ b/NainOrder.Api/Infrastructure/GlobalExceptionHandler.cs @@ -70,6 +70,12 @@ public async ValueTask TryHandleAsync( { NotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Resource not found"), OrderItemNotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Order line not found"), + HoldNotFoundException e => (StatusCodes.Status404NotFound, e.Code, "Hold not found"), + + DropNotLiveException e => (StatusCodes.Status409Conflict, e.Code, "Drop is not live"), + BuyerLimitReachedException e => (StatusCodes.Status409Conflict, e.Code, "Buyer limit reached"), + HoldExpiredException e => (StatusCodes.Status409Conflict, e.Code, "Hold expired"), + HoldNotActiveException e => (StatusCodes.Status409Conflict, e.Code, "Hold not active"), ConflictException e => (StatusCodes.Status409Conflict, e.Code, "Conflict"), InsufficientStockException e => (StatusCodes.Status409Conflict, e.Code, "Insufficient stock"), diff --git a/NainOrder.Api/Infrastructure/HoldExpirySweeper.cs b/NainOrder.Api/Infrastructure/HoldExpirySweeper.cs new file mode 100644 index 0000000..a1732dc --- /dev/null +++ b/NainOrder.Api/Infrastructure/HoldExpirySweeper.cs @@ -0,0 +1,51 @@ +using NainOrder.Application.Exceptions; +using NainOrder.Application.Interfaces; + +namespace NainOrder.Api.Infrastructure; + +/// +/// Marks expired drop holds once a second. A failed pass is logged and retried on the next tick: +/// the sweeper is an optimisation over lazy expiry, never the source of correctness (ADR-0008). +/// +public sealed class HoldExpirySweeper : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromSeconds(1); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public HoldExpirySweeper(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var drops = scope.ServiceProvider.GetRequiredService(); + var expired = await drops.ExpireDueHoldsAsync(stoppingToken); + + if (expired > 0) _logger.LogInformation("Expired {Count} drop holds", expired); + } + catch (ConflictException) + { + // A buyer wrote to the same drop first; the next tick sweeps again. + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Hold expiry sweep failed"); + } + } + } +} diff --git a/NainOrder.Api/Program.cs b/NainOrder.Api/Program.cs index 99fb514..bcb2a50 100644 --- a/NainOrder.Api/Program.cs +++ b/NainOrder.Api/Program.cs @@ -18,6 +18,10 @@ builder.Services.AddApplication(); builder.Services.AddInfrastructure(connectionString); +// Returns the units of unconfirmed drop holds shortly after their window ends. Correctness does +// not depend on it (reads apply the clock themselves); it keeps stored state and streams fresh. +builder.Services.AddHostedService(); + builder.Services.AddControllers(); builder.Services.AddMemoryCache(); @@ -46,14 +50,21 @@ { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy(RateLimitPolicies.Api, httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + { + // A drop is polled every second and its crowd simulator fires dozens of requests at + // once, so drops get their own, larger window; everything else keeps 120 per minute. + var isDrop = httpContext.Request.Path.StartsWithSegments("/api/drops"); + var address = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: isDrop ? $"drops:{address}" : address, factory: _ => new FixedWindowRateLimiterOptions { - PermitLimit = 120, + PermitLimit = isDrop ? 1_200 : 120, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 - })); + }); + }); }); // Render y cualquier PaaS terminan TLS por delante: sin esto la app vería http y una IP interna. @@ -174,6 +185,12 @@ static async Task InitializeDatabaseAsync(WebApplication app) try { await context.Database.MigrateAsync(); + + // WAL lets readers proceed while a writer commits. With the default rollback journal, a + // crowd of simultaneous buyers (plus the hold sweeper) surfaces "database is locked" as + // a 500. The mode is stored in the database file, so this is a no-op after the first run. + await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + await DatabaseSeeder.SeedAsync(context); logger.LogInformation("Database ready at {ConnectionString}", context.Database.GetConnectionString()); } diff --git a/NainOrder.Api/wwwroot/assets/css/views.css b/NainOrder.Api/wwwroot/assets/css/views.css index 28413f4..2cfad46 100644 --- a/NainOrder.Api/wwwroot/assets/css/views.css +++ b/NainOrder.Api/wwwroot/assets/css/views.css @@ -894,8 +894,112 @@ background: var(--danger); } +/* -------------------------------------------------------------------------- + Drop + -------------------------------------------------------------------------- */ +.drop { + display: grid; + gap: var(--space-5); +} + +.drop__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} + +.drop__name { + font-size: var(--text-xl); + font-weight: 700; +} + +.drop__price { + font-size: var(--text-lg); + color: var(--text-secondary); +} + +.drop__state { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.drop__clock { + font-size: var(--text-lg); + font-weight: 650; +} + +.drop__grid { + --cell: 44px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--cell), 1fr)); + gap: var(--space-2); +} + +.drop__cell, +.race__legend i[data-cell] { + border-radius: var(--radius-xs); + border: 1px solid var(--border-default); + background: var(--surface-2); +} + +.drop__cell { + aspect-ratio: 1; + transition: background var(--duration-base) var(--ease), border-color var(--duration-base) var(--ease); +} + +.drop__cell[data-cell="available"], .race__legend i[data-cell="available"] { background: color-mix(in srgb, var(--success) 18%, transparent); border-color: var(--success); } +.drop__cell[data-cell="held"], .race__legend i[data-cell="held"] { background: color-mix(in srgb, var(--warning) 38%, transparent); border-color: var(--warning); } +.drop__cell[data-cell="sold"], .race__legend i[data-cell="sold"] { background: var(--brand-2); border-color: transparent; } +.drop__cell[data-mine] { outline: 2px solid var(--brand-3); outline-offset: 1px; } + +.drop__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.drop__hold { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--warning); + background: color-mix(in srgb, var(--warning) 10%, transparent); +} + +.drop__hold[hidden] { display: none; } + +.drop__hold-bar { + flex: 1 1 140px; + height: 6px; + border-radius: var(--radius-pill); + background: var(--surface-3); + overflow: hidden; +} + +.drop__hold-bar i { + display: block; + height: 100%; + background: var(--warning); + transition: width 250ms linear; +} + +.drop-recent { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.drop-recent__item { cursor: pointer; } + @media (prefers-reduced-motion: reduce) { .race__tile { animation: none; } + .drop__cell, .drop__hold-bar i { transition: none; } } @media (max-width: 620px) { diff --git a/NainOrder.Api/wwwroot/assets/js/api.js b/NainOrder.Api/wwwroot/assets/js/api.js index 01a168d..e7fb60e 100644 --- a/NainOrder.Api/wwwroot/assets/js/api.js +++ b/NainOrder.Api/wwwroot/assets/js/api.js @@ -84,12 +84,30 @@ const buildUrl = (path, query) => { return url; }; -async function request(method, path, { body, query, signal } = {}) { +/** + * Anonymous visitor identity for drops (Q-005): a random token kept in this browser, sent as + * `X-Buyer-Token`. It identifies a visitor, never a person. + */ +export const buyerToken = () => { + try { + let token = localStorage.getItem("nainorder.buyer"); + if (!token) { + token = crypto.randomUUID(); + localStorage.setItem("nainorder.buyer", token); + } + return token; + } catch { + // Storage blocked: a per-page token still lets this visit work. + return (buyerToken.fallback ??= crypto.randomUUID()); + } +}; + +async function request(method, path, { body, query, signal, headers } = {}) { const startedAt = performance.now(); if (isStaticHost) { try { - const result = await mockDispatch(method, path, { body, query }); + const result = await mockDispatch(method, path, { body, query, headers }); const elapsedMs = performance.now() - startedAt; const queryStr = query && Object.keys(query).length > 0 ? `?${new URLSearchParams(query).toString()}` : ""; publishTrace({ @@ -122,18 +140,20 @@ async function request(method, path, { body, query, signal } = {}) { response = await fetch(url, { method, signal, - headers: body ? { "Content-Type": "application/json" } : undefined, + headers: body || headers ? { ...(body ? { "Content-Type": "application/json" } : {}), ...headers } : undefined, body: body ? JSON.stringify(body) : undefined }); } catch (networkError) { // Backend offline: conmuta con gracia al motor de dominio en memoria isStaticHost = true; - return request(method, path, { body, query, signal }); + return request(method, path, { body, query, signal, headers }); } - if (response.status === 404) { + // A 404 with a problem+json body is the API answering about a missing resource; only a 404 + // without it means there is no API behind this host (static hosting), so fall back then. + if (response.status === 404 && !response.headers.get("content-type")?.includes("problem+json")) { isStaticHost = true; - return request(method, path, { body, query, signal }); + return request(method, path, { body, query, signal, headers }); } const elapsedMs = performance.now() - startedAt; @@ -191,6 +211,19 @@ export const api = { stateMachine: () => request("GET", "/meta/order-state-machine") }, + drops: { + list: () => request("GET", "/drops"), + get: (id) => request("GET", `/drops/${id}`, { headers: { "X-Buyer-Token": buyerToken() } }), + create: (body) => request("POST", "/drops", { body }), + take: (id, quantity) => + request("POST", `/drops/${id}/holds`, { body: { quantity }, headers: { "X-Buyer-Token": buyerToken() } }), + confirm: (id, holdId) => + request("POST", `/drops/${id}/holds/${holdId}/confirm`, { headers: { "X-Buyer-Token": buyerToken() } }), + /** Takes as another visitor; used by the crowd simulator. */ + takeAs: (id, token, quantity) => + request("POST", `/drops/${id}/holds`, { body: { quantity }, headers: { "X-Buyer-Token": token } }) + }, + demo: { simulate: () => request("POST", "/demo/simulate-purchase"), stockRace: (buyers, units) => request("POST", "/demo/stock-race", { body: { buyers, units } }) diff --git a/NainOrder.Api/wwwroot/assets/js/app.js b/NainOrder.Api/wwwroot/assets/js/app.js index 83a46c1..3a0ba68 100644 --- a/NainOrder.Api/wwwroot/assets/js/app.js +++ b/NainOrder.Api/wwwroot/assets/js/app.js @@ -13,9 +13,11 @@ import { catalogView } from "./views/catalog.js"; import { ordersView } from "./views/orders.js"; import { architectureView } from "./views/architecture.js"; import { raceView } from "./views/race.js"; +import { dropView } from "./views/drop.js"; const NAV = [ { path: "/panel", label: "Panel", icon: "dashboard", view: dashboardView }, + { path: "/drop", label: "Drop", icon: "ticket", view: dropView }, { path: "/catalogo", label: "Catálogo", icon: "catalog", view: catalogView }, { path: "/pedidos", label: "Pedidos", icon: "orders", view: ordersView }, { path: "/carrera", label: "Carrera de stock", icon: "race", view: raceView }, diff --git a/NainOrder.Api/wwwroot/assets/js/dom.js b/NainOrder.Api/wwwroot/assets/js/dom.js index ff87d2e..b77f962 100644 --- a/NainOrder.Api/wwwroot/assets/js/dom.js +++ b/NainOrder.Api/wwwroot/assets/js/dom.js @@ -111,7 +111,8 @@ const ICON_PATHS = { spark: '', layers: '', filter: '', - race: '' + race: '', + ticket: '' }; /** diff --git a/NainOrder.Api/wwwroot/assets/js/mock-backend.js b/NainOrder.Api/wwwroot/assets/js/mock-backend.js index b0184fb..947f8b6 100644 --- a/NainOrder.Api/wwwroot/assets/js/mock-backend.js +++ b/NainOrder.Api/wwwroot/assets/js/mock-backend.js @@ -225,8 +225,174 @@ class MockDatabase { export const mockDb = new MockDatabase(); +/* -------------------------------------------------------------------------- + Drops en memoria. Mismas reglas y misma forma que la API .NET, pero en un solo hilo: + no demuestran concurrencia. Viven solo en la sesión de la pestaña. + -------------------------------------------------------------------------- */ +const mockDrops = new Map(); + +const dropError = (status, code, title, detail) => ({ status, code, title, detail }); + +const dropNow = () => Date.now(); + +const dropCounts = (drop, now) => { + const sold = drop.holds.filter((h) => h.state === "Confirmed").reduce((s, h) => s + h.quantity, 0); + const held = drop.holds + .filter((h) => h.state === "Active" && Date.parse(h.expiresAt) > now) + .reduce((s, h) => s + h.quantity, 0); + return { sold, held, available: drop.totalUnits - sold - held }; +}; + +const mapDrop = (drop, token) => { + const now = dropNow(); + const { sold, held, available } = dropCounts(drop, now); + const status = now < Date.parse(drop.opensAt) ? "Scheduled" : available === 0 ? "SoldOut" : "Live"; + + return { + id: drop.id, + productId: drop.productId, + productName: drop.productName, + productSku: drop.productSku, + unitPrice: drop.unitPrice, + totalUnits: drop.totalUnits, + available, + held, + sold, + status, + opensAt: drop.opensAt, + perBuyerLimit: drop.perBuyerLimit, + holdSeconds: drop.holdSeconds, + serverTime: new Date(now).toISOString(), + myHolds: token + ? drop.holds.filter((h) => h.buyerToken === token).map(({ buyerToken, ...hold }) => ({ + ...hold, + state: hold.state === "Active" && Date.parse(hold.expiresAt) <= now ? "Expired" : hold.state + })) + : [] + }; +}; + +const dispatchDrops = (method, pathname, { body, headers }) => { + const token = headers?.["X-Buyer-Token"] || null; + + if (method === "GET" && pathname === "/drops") { + return [...mockDrops.values()].reverse().slice(0, 20).map((d) => mapDrop(d, null)); + } + + if (method === "POST" && pathname === "/drops") { + const product = mockDb.products.find((p) => p.id === body?.productId); + if (!product) throw dropError(404, "not_found", "Resource not found", "Producto no encontrado."); + + const { totalUnits, perBuyerLimit, holdSeconds } = body; + if (!(totalUnits >= 1 && totalUnits <= 1000 && perBuyerLimit >= 1 && perBuyerLimit <= totalUnits && + holdSeconds >= 5 && holdSeconds <= 3600)) { + throw dropError(400, "invalid_data", "Invalid request data", "Configuración de drop no válida."); + } + + const drop = { + id: mockDb.generateId(), + productId: product.id, + productName: product.name, + productSku: product.sku, + unitPrice: product.price, + totalUnits, + perBuyerLimit, + holdSeconds, + opensAt: new Date(dropNow() + (body.opensInSeconds || 0) * 1000).toISOString(), + holds: [] + }; + mockDrops.set(drop.id, drop); + return mapDrop(drop, null); + } + + const match = pathname.match(/^\/drops\/([^/]+)(?:\/holds(?:\/([^/]+)\/confirm)?)?$/); + if (!match) return undefined; + + const drop = mockDrops.get(match[1]); + if (!drop) throw dropError(404, "not_found", "Resource not found", `Drop ${match[1]} no existe.`); + + if (method === "GET" && pathname === `/drops/${drop.id}`) return mapDrop(drop, token); + + if (!token) throw dropError(400, "buyer_token_required", "Buyer token required", "Falta el identificador de visitante."); + const now = dropNow(); + + if (method === "POST" && pathname === `/drops/${drop.id}/holds`) { + const quantity = Number(body?.quantity); + if (!(quantity >= 1)) throw dropError(400, "invalid_data", "Invalid request data", "Cantidad no válida."); + if (now < Date.parse(drop.opensAt)) { + throw dropError(409, "drop_not_live", "Drop is not live", "El drop aún no ha abierto."); + } + + const owned = drop.holds + .filter((h) => h.buyerToken === token && + (h.state === "Confirmed" || (h.state === "Active" && Date.parse(h.expiresAt) > now))) + .reduce((s, h) => s + h.quantity, 0); + if (owned + quantity > drop.perBuyerLimit) { + throw dropError(409, "buyer_limit_reached", "Buyer limit reached", `Límite de ${drop.perBuyerLimit} por comprador.`); + } + + const { available } = dropCounts(drop, now); + if (quantity > available) { + throw dropError(409, "insufficient_stock", "Insufficient stock", `Solo quedan ${available} unidades.`); + } + + const hold = { + id: mockDb.generateId(), + buyerToken: token, + quantity, + createdAt: new Date(now).toISOString(), + expiresAt: new Date(now + drop.holdSeconds * 1000).toISOString(), + state: "Active", + orderId: null + }; + drop.holds.push(hold); + const { buyerToken, ...dto } = hold; + return dto; + } + + const holdId = match[2]; + if (method === "POST" && holdId) { + const hold = drop.holds.find((h) => h.id === holdId && h.buyerToken === token); + if (!hold) throw dropError(404, "hold_not_found", "Hold not found", "Reserva no encontrada."); + if (hold.state === "Confirmed") throw dropError(409, "hold_not_active", "Hold not active", "La reserva ya está confirmada."); + if (hold.state === "Expired" || Date.parse(hold.expiresAt) <= now) { + throw dropError(409, "hold_expired", "Hold expired", "La reserva ha caducado."); + } + + const customer = mockDb.customers[0]; + const order = { + id: mockDb.generateId(), + reference: mockDb.generateReference(), + customerId: customer.id, + customerName: customer.name, + customerEmail: customer.email, + status: "Paid", + createdAt: new Date(now).toISOString(), + paidAt: new Date(now).toISOString(), + shippedAt: null, + cancelledAt: null, + items: [{ + productId: drop.productId, + productName: drop.productName, + productSku: drop.productSku, + unitPrice: drop.unitPrice, + quantity: hold.quantity, + totalPrice: drop.unitPrice * hold.quantity + }] + }; + mockDb.orders.unshift(order); + mockDb.save(); + + hold.state = "Confirmed"; + hold.orderId = order.id; + return mockDb.mapOrderDto(order); + } + + return undefined; +}; + /** Despachador de peticiones simuladas en memoria. */ -export async function mockDispatch(method, path, { body, query } = {}) { +export async function mockDispatch(method, path, { body, query, headers } = {}) { // Simulación de latencia realista en cliente (8-24 ms) await new Promise((resolve) => setTimeout(resolve, 8 + Math.random() * 16)); @@ -234,6 +400,11 @@ export async function mockDispatch(method, path, { body, query } = {}) { const pathname = url.pathname.replace(/^\/api/, ""); const searchParams = query ? new URLSearchParams(query) : url.searchParams; + if (pathname === "/drops" || pathname.startsWith("/drops/")) { + const result = dispatchDrops(method, pathname, { body, headers }); + if (result !== undefined) return result; + } + // -------------------------------------------------------------------------- // PRODUCTS // -------------------------------------------------------------------------- diff --git a/NainOrder.Api/wwwroot/assets/js/views/drop.js b/NainOrder.Api/wwwroot/assets/js/views/drop.js new file mode 100644 index 0000000..4b79978 --- /dev/null +++ b/NainOrder.Api/wwwroot/assets/js/views/drop.js @@ -0,0 +1,353 @@ +/** + * Drop: venta de unidades limitadas a una multitud. La vista pinta lo que devuelve la API + * (disponibles, reservadas, vendidas, estado) y no calcula ninguna regla: quién puede comprar, + * cuándo caduca una reserva y si el drop está agotado lo decide el servidor. + */ + +import { html, icon, raw, render, $, delegate } from "../dom.js"; +import { api, ApiError, isDemoMode } from "../api.js"; +import { toast, toastError } from "../toast.js"; +import { withBusy } from "../overlay.js"; +import { fmtMoney, fmtNumber } from "../format.js"; + +const LAST_DROP_KEY = "nainorder.drop"; +const POLL_MS = 1000; +const CROWD = 40; + +const STATUS_LABEL = { Scheduled: "Programado", Live: "En vivo", SoldOut: "Agotado" }; + +const MESSAGES = { + drop_not_live: "El drop aún no ha abierto", + buyer_limit_reached: "Ya tienes el máximo permitido", + insufficient_stock: "Agotado: otra persona fue más rápida", + concurrency_conflict: "Mucha demanda, inténtalo otra vez", + hold_expired: "Tu reserva caducó" +}; + +const remember = (id) => { + try { + id ? localStorage.setItem(LAST_DROP_KEY, id) : localStorage.removeItem(LAST_DROP_KEY); + } catch { + /* El almacenamiento puede estar bloqueado: se pierde solo el atajo. */ + } +}; + +const lastDropId = () => { + try { + return localStorage.getItem(LAST_DROP_KEY); + } catch { + return null; + } +}; + +const clock = (milliseconds) => { + const total = Math.max(0, Math.ceil(milliseconds / 1000)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +}; + +const slider = (name, label, min, max, value, unit = "") => html` + +`; + +const launcher = (products, recent) => html` +
+
+ + ${raw(slider("units", "Unidades", 1, 200, 24))} + ${raw(slider("perBuyer", "Máximo por persona", 1, 10, 2))} + ${raw(slider("hold", "Reserva", 10, 120, 30, " s"))} + +
+ ${raw( + recent.length + ? html`
${raw( + recent + .map( + (d) => html`` + ) + .join("") + )}
` + : "" + )} +
+`; + +const stage = () => html` +
+
+
+

+

+
+
+ + +
+
+ +
+ +
+ +
+ + + +
+ + + +
    +
  • disponible
  • +
  • reservada
  • +
  • vendida
  • +
+
+`; + +const cellsFor = (drop, mine) => { + const states = [ + ...Array(drop.sold).fill("sold"), + ...Array(drop.held).fill("held"), + ...Array(drop.available).fill("available") + ]; + let owned = mine; + return states + .map((state, index) => { + const own = state === "held" && owned-- > 0; + return ``; + }) + .join(""); +}; + +export const dropView = { + title: "Drop", + subtitle: "Unidades limitadas, una multitud, cero sobreventa", + + destroy() { + clearInterval(this._poll); + clearInterval(this._tick); + this._poll = this._tick = null; + }, + + async render(outlet) { + this.destroy(); + + const header = html` +
+
+

Drop

+

Lanza una venta limitada y suelta una multitud sobre ella.

+
+
+ ${raw(isDemoMode() + ? html`
+ ${raw(icon("info", { size: 15 }))} +

Aquí corre en el navegador. Las colisiones reales + entre compradores simultáneos se ven con la API .NET en marcha.

+
` + : "")} + `; + + const root = render(outlet, html`${raw(header)}
`); + const body = $("[data-body]", root); + + const showLauncher = async () => { + remember(null); + this.destroy(); + + let products = []; + let recent = []; + try { + [products, recent] = await Promise.all([api.products.list(), api.drops.list()]); + } catch (error) { + toastError(error, "No se pudo cargar el catálogo"); + return; + } + + body.innerHTML = launcher(products, recent); + }; + + const openDrop = async (id) => { + let drop; + try { + drop = await api.drops.get(id); + } catch { + await showLauncher(); + return; + } + + remember(id); + body.innerHTML = stage(); + const stageNode = $("[data-stage]", body); + let current = drop; + let skew = Date.parse(drop.serverTime) - Date.now(); + let busy = false; + + const cell = drop.totalUnits <= 48 ? 44 : drop.totalUnits <= 200 ? 26 : 14; + $("[data-grid]", stageNode).style.setProperty("--cell", `${cell}px`); + $("[data-name]", stageNode).textContent = drop.productName; + $("[data-price]", stageNode).textContent = fmtMoney(drop.unitPrice); + + const paint = () => { + const mine = current.myHolds.find((h) => h.state === "Active"); + const now = Date.now() + skew; + + $("[data-grid]", stageNode).innerHTML = cellsFor(current, mine?.quantity ?? 0); + + const badge = $("[data-badge]", stageNode); + badge.textContent = STATUS_LABEL[current.status] ?? current.status; + badge.dataset.status = { Scheduled: "PendingPayment", Live: "Paid", SoldOut: "Cancelled" }[current.status]; + + $("[data-stats]", stageNode).innerHTML = html` +
${fmtNumber(current.available)}disponibles
+
${fmtNumber(current.held)}reservadas
+
${fmtNumber(current.sold)}vendidas
+ `; + + $('[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.** [![ci](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml) [![CodeQL](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml) ![.NET 10](https://img.shields.io/badge/.NET-10-512BD4) -![Tests](https://img.shields.io/badge/tests-82%20passing-2ea44f) +![Tests](https://img.shields.io/badge/tests-110%20passing-2ea44f) ![Client dependencies](https://img.shields.io/badge/client%20dependencies-0-22d3ee) [![Live demo](https://img.shields.io/badge/live%20demo-order.naindev.com-8b5cf6)](https://order.naindev.com/)

- Stock race: 24 buyers compete for 5 units; 5 win, 19 are rejected, stock balances to the unit + A live drop: 56 units, 2 sold, 42 held by a crowd of 40 buyers, 12 still available

-> **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.

+ Stock race: 24 buyers compete for 5 units; 5 win, 19 are rejected Dashboard with revenue, order states and top products - Architecture view with live request trace and state machine

-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.