Skip to content

How to Build a Flash Sale System with NENE2

This guide walks through building a time-limited, quantity-constrained flash sale system where users can purchase a product at a discounted price during a sale window.

Field Trial: FT140
NENE2 version: ^1.5
Covered topics: time window validation, stock counting with COUNT(*), UNIQUE constraint for one-purchase-per-user, match expression for status, cracker-mindset attack testing


What we're building

  • POST /products — create a product
  • POST /sales — create a flash sale (product_id, price, quantity, starts_at, ends_at)
  • GET /sales/{saleId} — view sale details with remaining count and status
  • POST /sales/{saleId}/purchase — purchase during active window (one per user)
  • GET /sales/{saleId}/purchases — list all buyers

Database schema

sql
CREATE TABLE flash_sales (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    product_id INTEGER NOT NULL,
    price      INTEGER NOT NULL,
    quantity   INTEGER NOT NULL,
    starts_at  TEXT    NOT NULL,
    ends_at    TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    CHECK (quantity > 0),
    CHECK (price >= 0),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

CREATE TABLE purchases (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    sale_id      INTEGER NOT NULL,
    user_id      INTEGER NOT NULL,
    purchased_at TEXT    NOT NULL,
    UNIQUE (sale_id, user_id),
    FOREIGN KEY (sale_id) REFERENCES flash_sales(id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

UNIQUE (sale_id, user_id) prevents a user from purchasing the same sale twice, even under concurrent requests.


Time window validation

php
$now = date('c');

if ($now < $sale['starts_at']) {
    return $this->responseFactory->create(['error' => 'sale has not started yet'], 422);
}

if ($now > $sale['ends_at']) {
    return $this->responseFactory->create(['error' => 'sale has ended'], 422);
}

Store starts_at / ends_at as ISO 8601 strings. String comparison works correctly for ISO 8601 because the format is lexicographically ordered.


Stock counting with COUNT(*)

Instead of maintaining a mutable remaining column, count actual purchases:

php
public function countPurchases(int $saleId): int
{
    $row = $this->executor->fetchOne(
        'SELECT COUNT(*) as cnt FROM purchases WHERE sale_id = ?',
        [$saleId],
    );

    return isset($row['cnt']) ? (int) $row['cnt'] : 0;
}

Then check:

php
$purchased = $this->repository->countPurchases($saleId);

if ($purchased >= $sale['quantity']) {
    return $this->responseFactory->create(['error' => 'sold out'], 422);
}

remaining is derived at read time: $sale['quantity'] - $purchased. Clamp to max(0, $remaining) to avoid negative display.


One purchase per user — UNIQUE constraint

UNIQUE (sale_id, user_id) prevents duplicates at the DB level. DatabaseConstraintException maps to a 409:

php
public function purchase(int $saleId, int $userId, string $now): bool
{
    try {
        $this->executor->execute(
            'INSERT INTO purchases (sale_id, user_id, purchased_at) VALUES (?, ?, ?)',
            [$saleId, $userId, $now],
        );

        return true;
    } catch (DatabaseConstraintException) {
        return false;
    }
}

The handler returns 409 when purchase() returns false.


Sale status with match expression

php
$status = match (true) {
    $now < $sale['starts_at'] => 'upcoming',
    $now > $sale['ends_at']   => 'ended',
    default                   => 'active',
};

Three states: upcoming, active, ended. The match expression is exhaustive because default covers all other cases.


Cracker attack test results (FT140)

IDAttackExpectedResult
ATK-01SQL injection in product name201 (verbatim stored)Pass
ATK-02Purchase without X-User-Id400Pass
ATK-03Non-numeric X-User-Idnot 201Pass
ATK-04Negative saleId in URLnot 201Pass
ATK-05Buy before sale starts422Pass
ATK-06Buy after sale ends422Pass
ATK-07Double-purchase same sale409 on secondPass
ATK-08Exhaust stock then buy422 sold outPass
ATK-09Create sale with quantity=0422Pass
ATK-10Create sale with negative price422Pass
ATK-11Purchase as non-existent user404Pass
ATK-12ends_at before starts_at422Pass

All 12 attack tests pass.


Common pitfalls

PitfallFix
Mutable remaining column drifts under concurrencyCount from purchases table, derive remaining at read time
Allowing quantity=0 via APIValidate $quantity > 0 in handler; also CHECK (quantity > 0) in schema
Negative price slips throughValidate $price >= 0; also CHECK (price >= 0) in schema
User buying same sale twiceUNIQUE (sale_id, user_id) + DatabaseConstraintException → 409
Time comparison on non-ISO stringsUse ISO 8601 (e.g. date('c')) — lexicographic ordering is correct
ends_at inverted with starts_atValidate $starts_at < $ends_at before INSERT

Released under the MIT License.