Period lifecycle (minimal: Open ↔ Closed)#22
Merged
Conversation
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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands the spec §13 closed-period semantics that operators need before invoicing: once a billing period is closed, new
Usageevents for that(account, year, month)tuple are rejected at ingest.CorrectionandRetractionevents 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
Behavior
Usagerejected)UsageCorrectionCorrectionRetractionRetractionOnly the (account, period) tuple is checked. A close for
acc_x2026-05 doesn't affectacc_y2026-05 oracc_x2026-06.Plumbing
closed_periods: Vec<ClosedPeriod>with#[serde(default)]so old manifests deserialize unchanged.src/period.rsmodule:period_for_ts,parse_period(YYYY-MM),is_period_closed,find_closed.handle_ingestsnapshotsclosed_periodsunder the manifest read lock at the start of the batch, then rejects eachUsageevent 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_for_ts,parse_period(incl.YYYY-13rejected),is_period_closedclose_period_persists_in_manifestclose_period_is_idempotent(no duplicate entries)reopen_period_removes_markerget_period_returns_state_and_total(open → closed transition)close_period_rejects_invalid_formatclosed_period_rejects_usage_eventsclosed_period_only_rejects_target_account— other accounts unaffectedclosed_period_only_rejects_in_period_timestamps— other months unaffectedclosed_period_accepts_corrections— adjustments path worksTest plan
cargo build --all-targetsclean with-D warningscargo test --all-targets— 134 tests pass (was 122; +12)Backlog after this
usagedb close-period --account --period,reopen-period, etc.🤖 Generated with Claude Code