"""software monthly billing snapshot table Revision ID: 063 Revises: 062 Create Date: 2026-04-24 Note on 063 numbering: originally landed as 063 in PR #30. Collided with a sibling 063 (``063_backfill_discovered_grant_expires``, PR #31) 15 seconds later and was renumbered to 065 in PR #34 — at the same time PR #33 had already renumbered the cmdb-grants one to 065, so main ended up with two 065 files referencing missing parents 064/063. This PR restores the software-billing files to their original 063/064 numbers; the cmdb-grants file stays at 065 but now correctly depends on 064 (the seed job) as PR #33 intended. Final chain: 062 → 063 (this) → 064 (seed) → 065 (cmdb-grants). Adds ``software_monthly_bills`` — a per-(period, allocation) snapshot table that freezes plan price + billing-cycle + software/plan/user denormalised metadata at the moment a month is "closed" (finalised). Motivation: today's cost views are computed live from ``software_seat_allocations`` × current ``software_license_plans.price``. Two problems: 1. Churn — people get revoked/hired mid-month and we had no way to say "April's bill was $X" other than inferring from ``allocated_at`` / ``revoked_at`` timestamps, which the cost stats endpoint didn't even do (it filtered on allocated_at only, not overlap). 2. Price edits — when an admin updates a plan's price, the cost query retroactively applies the new price to EVERY historical allocation. There was no "the April bill was $X, don't touch it" row anywhere. Design (see `docs/ops/software-billing-ops.md` for full doc): * Granularity: one row per (period, allocation_id). A seat that was active at any point during the month shows up. A one_time seat only appears in its allocation month. * Snapshots frozen at close time: price, billing_cycle, software_name, plan_name, user_name, department. Future name/price edits don't affect finalised rows. * Amount for the period — monthly: full price; yearly: price/12 (spread across every month of active tenure); one_time: full price in allocation month, $0 otherwise (and we skip $0 rows to keep the table lean). * Close is a manual admin action (``POST .../close``). An admin can also ``.../reopen`` a period, which simply deletes its rows so a subsequent close regenerates them. No edits in place — that way "what changed?" is always answerable by comparing two close events. The unique key ``(period, allocation_id)`` makes close idempotent within a period and lets us ``INSERT ... ON DUPLICATE KEY UPDATE`` if we ever want incremental close (not in scope right now — reopen+close covers correction flows). """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "063" down_revision = "062" branch_labels = None depends_on = None def upgrade() -> None: ID = id_type_for("asset_software_catalog") PERSON_ID = id_type_for("identity_persons") KW = table_kw_for("asset_software_catalog") op.create_table( "software_monthly_bills", sa.Column("id", ID, nullable=False), # Period is "YYYY-MM" — kept as VARCHAR(7) rather than a Date so # listings / filters are string-exact without having to care about # timezones or day-of-month placement. sa.Column("period", sa.String(7), nullable=False), sa.Column("allocation_id", ID, nullable=False), # Denormalised FKs — kept even though they're reachable via # allocation_id → plan → software, because (a) the allocation # row can be deleted in theory (CASCADE risk) and we still want # a historical bill; (b) period-filtered queries on a per-user # or per-software basis get a direct index hit instead of a # three-table join through mutable tables. sa.Column("software_id", ID, nullable=False), sa.Column("plan_id", ID, nullable=False), sa.Column("user_id", PERSON_ID, nullable=False), # Name snapshots — so rename-after-close doesn't retroactively # alter what the close generated. Stored as VARCHAR matching # the source columns. sa.Column("software_name", sa.String(128), nullable=True), sa.Column("plan_name", sa.String(128), nullable=True), sa.Column("user_name", sa.String(128), nullable=True), sa.Column("department", sa.String(128), nullable=True), # Snapshot of the allocation's ``reason`` — denormalised so a # reopen/close flow doesn't lose context if the allocation row # is later edited or the seat request UI changes wording. TEXT # because the original column is TEXT; bills display uses # ellipsis so length isn't a render concern. sa.Column("reason", sa.Text(), nullable=True), # Price + cycle snapshot at close time. These are the # load-bearing fields for "what did the bill say" auditing — # plan.price can drift afterwards and we still show the old value. sa.Column("price", sa.Numeric(12, 2), nullable=False), sa.Column("billing_cycle", sa.String(16), nullable=False), # ``amount`` is what actually counts toward the period total: # monthly → price; yearly → price/12; one_time → price. Stored # explicitly rather than derived so downstream SUM queries # don't need to branch on billing_cycle. sa.Column("amount", sa.Numeric(12, 2), nullable=False), # Allocation timestamps as they were at close time. Lets a # future UI show "seat was active from 3rd to 28th" even if # the allocation row later gets another revoke/restore. sa.Column("allocated_at", sa.DateTime(timezone=True), nullable=False), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("finalized_by", PERSON_ID, nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.PrimaryKeyConstraint("id"), # Idempotency: closing the same period twice must not double-row. sa.UniqueConstraint("period", "allocation_id", name="uq_software_monthly_bills_period_alloc"), **KW, ) # List-by-period (admin picks "2026-04" from a month dropdown) is # the hottest read; user / software scope queries come second. op.create_index("ix_software_monthly_bills_period", "software_monthly_bills", ["period"]) op.create_index("ix_software_monthly_bills_user_id", "software_monthly_bills", ["user_id"]) op.create_index("ix_software_monthly_bills_software_id", "software_monthly_bills", ["software_id"]) def downgrade() -> None: op.drop_table("software_monthly_bills")