Europe/Paris
Posts

Chronos Billing Engine: building a usage-based billing API that never double-charges

July 18, 2026 · 9 min de lecture
Chronos Billing Engine is a personal project, not a production product with real customers. I present it for what it is: an R&D lab for putting into practice, on a concrete case, topics I work with regularly on client engagements but rarely all together within the same small scope: hexagonal architecture (keeping business logic isolated from frameworks and infrastructure so it stays testable and swappable — I cover it in more depth in another article), high-volume asynchronous ingestion, idempotency, API security, and TDD. The use case is deliberately simple to state, but not trivial to get right: expose a POST /api/v1/events endpoint capable of ingesting "thousands of events per second" (in the style of Stripe or AWS usage-based billing), then generate a monthly invoice — with one non-negotiable rule: the same event must never be billed twice, even if the network retries the request or a worker crashes mid-processing. This article is a guided tour written for two audiences: junior devs who want to see what "the real thing" looks like behind articles about DDD (Domain-Driven Design — modeling code around business concepts and vocabulary) or TDD, and recruiters who want to quickly understand what this repo says about the way I code. A junior's first instinct would be to do all the work (validation, database write, calculation) synchronously inside the controller. The problem: on an endpoint advertised for high volume, every HTTP request would stay open for as long as it takes to write to the database, which caps throughput at the speed of the database. The solution chosen here: the controller does only three things, then responds immediately.
Php
final class EventController
{
    public function __construct(
        private readonly MessageBusInterface $messageBus,
        private readonly RateLimiterFactory $eventIngestionLimiter,
        private readonly LoggerInterface $logger,
    ) {
    }

    #[Route('/api/v1/events', name: 'events_ingest', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $limiter = $this->eventIngestionLimiter->create($request->getClientIp() ?? 'unknown');
        if (!$limiter->consume(1)->isAccepted()) {
            return new JsonResponse(['error' => 'Too many requests.'], 429);
        }

        $payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
        $clientId = (string) ($payload['client_id'] ?? '');
        $endpoint = (string) ($payload['endpoint'] ?? '');

        $idempotencyKey = $request->headers->has('Idempotency-Key')
            ? $request->headers->get('Idempotency-Key')
            : IdempotencyKey::fromPayload($clientId, $endpoint, $request->getContent())->value();

        $this->messageBus->dispatch(new LogEventMessage($clientId, $endpoint, (string) $idempotencyKey));

        return new JsonResponse(status: 202); // Accepted : traité de façon asynchrone
    }
}
What this controller does, in order: it rate-limits the request (caps how many requests a single client can send in a given time window, to protect the endpoint from being overwhelmed — details in the security section), it validates just enough of the JSON to avoid crashing, it dispatches a message onto the Symfony Messenger bus, then it responds with 202 Accepted without waiting for anything to be written to the database. The actual business processing (full validation, idempotency check, persistence) happens later, in LogEventMessageHandler, run by a worker consuming the queue in the background. This decoupling has a direct benefit: during a traffic spike, you add more workers rather than degrading the client's response time. It's the same principle as a queue in front of a checkout counter: the ticket is issued immediately, and the actual service happens as soon as a counter is free. This is the most subtle part of the project, and probably the most interesting one for a junior to understand: how do you guarantee that an event is never counted twice, when HTTP requests can themselves be replayed (client retries, "at-least-once" redelivery from a queue — a delivery guarantee that a message will arrive, but possibly more than once, so the consumer has to cope with duplicates — or a resent webhook)? An operation is called idempotent when repeating it any number of times produces the same result as running it once; guaranteeing that property for event ingestion is exactly the problem this part solves. The answer boils down to a value object — a small immutable class that wraps a primitive value (here, a string) to enforce its validation rules wherever it's used; PHP's readonly keyword, used in the class below, makes its properties impossible to change after construction — and a database constraint:
Php
final readonly class IdempotencyKey
{
    private string $value;

    public function __construct(string $value)
    {
        $trimmed = trim($value);

        if ($trimmed === '' || strlen($trimmed) > 128) {
            throw new \InvalidArgumentException('Idempotency key must be between 1 and 128 characters.');
        }

        $this->value = $trimmed;
    }

    public static function fromPayload(string $clientId, string $endpoint, string $rawBody): self
    {
        return new self(hash('sha256', $clientId . '|' . $endpoint . '|' . $rawBody));
    }

    public function value(): string
    {
        return $this->value;
    }
}
If the client doesn't provide an idempotency key, one is derived by hashing the request content: two strictly identical requests produce the same key, with no extra effort required on the client side. The application use case then relies on this key before any write happens:
Php
final readonly class LogClientEventUseCase
{
    public function __construct(
        private EventRepositoryInterface $eventRepository,
        private IdempotencyGuardInterface $idempotencyGuard,
        private ?LoggerInterface $logger = null,
    ) {
    }

    public function execute(LogEventCommand $command): void
    {
        $idempotencyKey = new IdempotencyKey($command->idempotencyKey);

        if ($this->idempotencyGuard->hasBeenProcessed($idempotencyKey)) {
            throw new DuplicateEventException(
                sprintf('Event with idempotency key "%s" was already processed.', $idempotencyKey->value())
            );
        }

        $event = new Event(/* ... */);

        $this->eventRepository->save($event);
        $this->idempotencyGuard->markAsProcessed($idempotencyKey);
    }
}
The point I want to highlight for juniors: a simple SELECT to check "does this key already exist?" followed by an INSERT is not enough. Under heavy load, two concurrent requests can both pass the SELECT before either one has performed its INSERT — this is a classic race condition (a bug that only shows up when two operations run at nearly the same time and interleave unpredictably), known here as TOCTOU ("time-of-check to time-of-use"): the state can change between the moment you check it and the moment you act on it. The real guarantee comes from a UNIQUE database constraint on the idempotency key: it's PostgreSQL, not the application code, that atomically rejects the second write. The use case's job is to cleanly translate that rejection into an explicit business exception (DuplicateEventException), rather than letting a raw SQL error leak through. The repo documents its OWASP (Open Web Application Security Project — a nonprofit whose Top 10 lists are the industry-standard checklists of the most common security flaws in web apps and APIs) Top 10 and OWASP API Security Top 10 coverage in a dedicated SECURITY.md. Rather than listing everything, here are the two mechanisms I find most instructive to look at in the code. API key authentication, compared in constant time. Every write requires an X-Api-Key header:
Php
final class ApiKeyAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly string $expectedApiKeyHash,
    ) {
    }

    public function authenticate(Request $request): Passport
    {
        $providedKey = (string) $request->headers->get('X-Api-Key', '');

        if ($providedKey === '' || !hash_equals($this->expectedApiKeyHash, hash('sha256', $providedKey))) {
            throw new AuthenticationException('Invalid or missing API key.');
        }

        return new SelfValidatingPassport(
            new UserBadge('api-client', fn () => new InMemoryUser('api-client', null, ['ROLE_API_CLIENT']))
        );
    }
}
The detail that matters here, and that a junior often misses: the comparison uses hash_equals(), not ===. A plain === on two strings compares character by character and stops at the first mismatch — so the response time varies very slightly depending on how many leading characters of the key are correct. At scale, that's enough to enable a timing attack that reconstructs the key one character at a time. hash_equals() always compares in constant time, regardless of the outcome. Rate limiting on the ingestion endpoint, to cover OWASP API4:2023 (Unrestricted Resource Consumption):
Yaml
framework:
    rate_limiter:
        event_ingestion:
            policy: 'sliding_window'
            limit: 200
            interval: '60 seconds'
200 requests per minute per IP, using a sliding window rather than a fixed window — which avoids the classic edge case of a fixed window, where a client could send 200 requests in the last second of one minute and then 200 more in the first second of the next, for 400 requests within a single real-world second. The rest of the OWASP coverage is more conventional but just as real in the code: JSON_THROW_ON_ERROR (a flag that makes json_decode throw a catchable exception on malformed JSON instead of silently returning null) instead of a silent json_decode, value objects that make an invalid state unrepresentable (a ClientId or a negative Price simply cannot exist), a Docker container that runs as a non-root user, and composer audit run in CI on every push to catch vulnerable dependencies. The project is developed using TDD with PHPUnit — not BDD with Behat/Gherkin, and I'd rather state that clearly than let the ambiguity linger, since the two are often conflated in discussions. TDD (Test-Driven Development) is a writing discipline: you write a failing test (red), write the minimum code to make it pass (green), then refactor. It's a developer practice, focused on code design. BDD (Behavior-Driven Development) is a step further: formalizing expected behavior in a language readable by a non-technical business stakeholder (Given / When / Then scenarios, typically written in Gherkin with Behat in PHP), so the specifications also serve as living documentation shared with non-developers. Chronos only does the former. But the tests are named and structured so they remain readable as specifications, which is a good trade-off at the scale of a small project:
Php
final class BillingCalculatorTest extends TestCase
{
    private BillingCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new BillingCalculator();
    }

    public function test_it_returns_zero_price_below_free_tier_threshold(): void
    {
        $invoice = $this->buildInvoiceWithEvents(500);

        $price = $this->calculator->calculate($invoice);

        self::assertSame(0, $price->amountInCents());
    }

    public function test_it_bills_only_events_exceeding_free_tier(): void
    {
        $invoice = $this->buildInvoiceWithEvents(1200);

        $price = $this->calculator->calculate($invoice);

        self::assertSame(200 * 2, $price->amountInCents()); // 200 événements facturables x 2 centimes
    }
}
What I find pedagogically valuable about this example for a junior: this test touches neither the database nor the Symfony framework. BillingCalculator is a domain class with no external dependency — so the test runs in a few milliseconds, and can run hundreds of times a minute while you code, without ever booting a Symfony kernel or a Docker container. This is exactly the concrete benefit of the hexagonal architecture mentioned in the introduction: isolating the domain lets you test it fast, which makes TDD genuinely practical day to day rather than purely theoretical. The application use case, meanwhile, is tested using mocks (fake, fully controllable stand-ins for a real dependency) of the ports (interfaces) rather than a real database:
Php
// (extrait résumé) LogClientEventUseCaseTest
public function test_it_rejects_a_duplicate_event_without_persisting_it_twice(): void
{
    $guard = $this->createMock(IdempotencyGuardInterface::class);
    $guard->method('hasBeenProcessed')->willReturn(true);

    $repository = $this->createMock(EventRepositoryInterface::class);
    $repository->expects(self::never())->method('save');

    $this->expectException(DuplicateEventException::class);

    // ... exécution du use case avec une clé déjà traitée
}
$repository->expects(self::never())->method('save') is the important line: the test doesn't just check that an exception is thrown, it actively proves that the repository was never called in the duplicate case. That's the difference between "it looks like it works" and "I have automated proof that the business rule is respected." Once events are ingested without duplicates, billing applies a pricing grid that's simple to understand but illustrates well how to isolate a business rule inside a single class:
Php
final class BillingCalculator
{
    private const PRICE_PER_EVENT_CENTS = 2; // 0,02 € par événement ingéré
    private const FREE_TIER_THRESHOLD = 1000;

    public function calculate(Invoice $invoice): Price
    {
        $billableEvents = max(0, $invoice->totalEvents() - self::FREE_TIER_THRESHOLD);

        return Price::fromCents($billableEvents * self::PRICE_PER_EVENT_CENTS);
    }
}
1000 calls included per month, then €0.02 per call. The entire rule fits in a single method, with no dependency at all on Symfony or Doctrine — and it's this class that's covered by the tests shown above. In keeping with the rest of my blog, I'd rather own the limits than sell this project as more finished than it actually is:
  • Limited test coverage. Only two test files exist (BillingCalculatorTest, LogClientEventUseCaseTest). The controller, the Doctrine repositories, and the Messenger handler have no integration tests at this stage — that's the logical next step for the project, not a finished aspect of it.
  • No tooled BDD. As explained above, adding Behat with a handful of Gherkin scenarios covering the "ingestion → invoice" journey would be the natural next step to document behavior from a business perspective, beyond the unit tests.
  • Deliberately simplified authentication. The API key is compared against a static hash in configuration; SECURITY.md itself documents that a real production rollout would require storage in a Secret Manager (a dedicated service — e.g. AWS Secrets Manager or HashiCorp Vault — for storing and automatically rotating credentials outside the application's own configuration) with rotation, rather than a single static value.
  • This is a lab, not a system that has seen real traffic. The figures ("thousands of events per second") describe the design intent, not a load actually measured in production with real customers.
The full code, ARCHITECTURE.md, and SECURITY.md are on github.com/Rajekevin/chronos-billing-engine. If you're a junior dev, I'd suggest starting your reading with src/Domain/ — it's the easiest part to understand since it depends on no framework at all, before moving up to Application/ and then Infrastructure/. If you're recruiting, tests/Unit/ and SECURITY.md are probably the two fastest entry points to judge the rigor of the code.
On this page