Files
keysat/licensing-service/tests
Grant c5d716a6d4 Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.

New endpoint:
- POST /v1/admin/licenses/:id/change-tier
  Body: { to_policy_slug, skip_payment: bool, reason?: string }

  skip_payment=true (comp upgrade / support fix-up): apply
  immediately, write a tier_changes row with proration=0 and
  invoice_id=NULL, fire the license.tier_changed webhook, audit-log
  with actor=admin_api_key.

  skip_payment=false: same as buyer's /v1/upgrade — create a
  provider invoice for the prorated charge, persist the local
  invoice + a tier_changes row tied to it, return the checkout URL.
  Operator forwards it to the buyer through whatever channel they
  use. Webhook applies on settle.

  Bypasses ladder rules entirely (sideways, perpetual downgrade,
  recurring → perpetual all OK). Same-product / different-policy /
  active-target checks still apply.

QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
  from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.

Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
  apply_pending_tier_changes(state, sub). This finds tier_changes
  rows for the sub's license where effective_at <= now AND
  invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
  scheduled comp/admin changes that haven't been applied yet).
  Each pending change is applied via apply_tier_change (which
  also rewrites the sub's policy_id / listed_value / period_days).
  After applying, the worker re-fetches the sub and prices the
  next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
  the cycle boundary (admin records "Pro → Standard at next
  renewal", the worker applies it, the new invoice bills at
  Standard's price).
- Idempotent: re-running the hook on a license already on the
  target tier finds zero pending rows (the policy_id != check
  filters them out).

Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
  flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
  buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
  attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
  the headline behavior: a pending downgrade tier_change with
  effective_at=now causes the next renewal to bill at the new
  (lower) tier's price, NOT the old one. Uses a CapturingProvider
  mock that stashes the last sat amount it saw so the assertion
  is on what the worker actually billed.
2026-05-08 20:12:44 -05:00
..