Skip to content

Period lifecycle (minimal: Open ↔ Closed)#22

Merged
pbudzik merged 1 commit into
mainfrom
feat/period-lifecycle
May 16, 2026
Merged

Period lifecycle (minimal: Open ↔ Closed)#22
pbudzik merged 1 commit into
mainfrom
feat/period-lifecycle

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

Lands the spec §13 closed-period semantics that operators need before invoicing: once a billing period is closed, new Usage events for that (account, year, month) tuple are rejected at ingest. Correction and Retraction events are still accepted and become post-close adjustments.

Intermediate states (Closing / Invoiced / Adjusted) and frozen-snapshot query semantics are explicitly out of scope — they need design + a separate effort. This PR lands the foundation the rest will build on.

API

POST /v1/accounts/{id}/periods/{YYYY-MM}/close
   Marks the period closed. Idempotent — re-closing returns
   `already_closed=true` without changing state.

POST /v1/accounts/{id}/periods/{YYYY-MM}/reopen
   Removes the marker. Returns `removed=true` if anything was undone.

GET /v1/accounts/{id}/periods/{YYYY-MM}
   {
     "state": "Open" | "Closed",
     "closed_at_ms": null | <unix ms>,
     "from_ms": ..., "to_ms": ...,
     "total_quantity": "<i128>"      // live total, not a frozen snapshot yet
   }

Behavior

Event kind In closed period Result
Usage Yes Rejected (counted in rejected)
Usage No Accepted
Correction Yes Accepted (adjustment)
Correction No Accepted
Retraction Yes Accepted (adjustment)
Retraction No Accepted

Only the (account, period) tuple is checked. A close for acc_x 2026-05 doesn't affect acc_y 2026-05 or acc_x 2026-06.

Plumbing

  • Manifest gains closed_periods: Vec<ClosedPeriod> with #[serde(default)] so old manifests deserialize unchanged.
  • New src/period.rs module: period_for_ts, parse_period (YYYY-MM), is_period_closed, find_closed.
  • handle_ingest snapshots closed_periods under the manifest read lock at the start of the batch, then rejects each Usage event whose (account, year, month) matches an entry. Per-event check is O(closed_periods.len()) — tiny in practice. Correction/Retraction events bypass the check.

Tests

12 new tests in tests/period_lifecycle.rs:

  • Period helpers: period_for_ts, parse_period (incl. YYYY-13 rejected), is_period_closed
  • close_period_persists_in_manifest
  • close_period_is_idempotent (no duplicate entries)
  • reopen_period_removes_marker
  • get_period_returns_state_and_total (open → closed transition)
  • close_period_rejects_invalid_format
  • closed_period_rejects_usage_events
  • closed_period_only_rejects_target_account — other accounts unaffected
  • closed_period_only_rejects_in_period_timestamps — other months unaffected
  • closed_period_accepts_corrections — adjustments path works

Test plan

  • cargo build --all-targets clean with -D warnings
  • cargo test --all-targets — 134 tests pass (was 122; +12)
  • CI green

Backlog after this

  • Frozen-snapshot semantics for closed periods (closed-period queries return totals as they stood at close-time, not live)
  • Intermediate states (Closing / Invoiced / Adjusted)
  • Admin CLI: usagedb close-period --account --period, reopen-period, etc.
  • Generalize period to non-monthly (weekly / quarterly)

🤖 Generated with Claude Code

Adds the spec §13 / §19.10 closed-period semantics that operators
need before invoicing: once a billing period is closed, new Usage
events for that (account, year, month) tuple are rejected at ingest;
Correction and Retraction events are still accepted and become
post-close adjustments.

Future intermediate states (Closing / Invoiced / Adjusted) and
frozen-snapshot query semantics are explicitly out of scope for this
PR — they need design + a separate effort. This lands the foundation
the rest will build on.

API:

  POST /v1/accounts/{id}/periods/{YYYY-MM}/close
    Marks the period closed. Idempotent: re-closing returns
    already_closed=true without changing state.

  POST /v1/accounts/{id}/periods/{YYYY-MM}/reopen
    Removes the marker. Returns removed=true if anything was undone.

  GET /v1/accounts/{id}/periods/{YYYY-MM}
    Returns:
      state: "Open" | "Closed"
      closed_at_ms: i64 or null
      from_ms, to_ms: period bounds in unix ms
      total_quantity: current SUM(quantity) for the period
                       (live, not a frozen snapshot — see TODO)

Plumbing:
  - Manifest gains `closed_periods: Vec<ClosedPeriod>` with
    #[serde(default)] so old manifests deserialize unchanged.
  - New src/period.rs module: period_for_ts, parse_period (YYYY-MM),
    is_period_closed, find_closed.
  - handle_ingest snapshots the closed_periods list under the
    manifest read lock at the start of the batch, then rejects each
    Usage event whose (account, year, month) matches an entry.
    Per-event check is O(closed_periods.len()) — tiny in practice.
    Correction/Retraction events bypass the check.

Tests (tests/period_lifecycle.rs, 12 tests):
  - period_for_ts_returns_utc_year_month
  - parse_period_round_trips (incl. validation errors)
  - is_period_closed_finds_match
  - close_period_persists_in_manifest
  - close_period_is_idempotent (no duplicate entries)
  - reopen_period_removes_marker
  - get_period_returns_state_and_total (open then closed)
  - close_period_rejects_invalid_format
  - closed_period_rejects_usage_events
  - closed_period_only_rejects_target_account (other accounts OK)
  - closed_period_only_rejects_in_period_timestamps (other months OK)
  - closed_period_accepts_corrections (adjustments path)

Total tests: 134 (was 122; +12). Clean under RUSTFLAGS=-D warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pbudzik pbudzik merged commit 5ebdc65 into main May 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant