8c5cdb646876e132a32c8c40869c3fe39f3762a3
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3afac078d4 |
Add sandbox flag + per-key à-la-carte scopes (payment-connect foundation)
Foundation for agent-delegable payment-provider connect (plans/agent-payment-connect-scope.md, slices 1-2 of 5). Not yet wired to any connect endpoint — the gate (require_provider_connect + BTCPay non-mainnet network check) is a follow-up. - Config.sandbox_mode from KEYSAT_SANDBOX_MODE (daemon-level, never settable via any API); surfaced read-only in /v1/admin/tier as "sandbox". - Migration 0024: additive scoped_api_keys.extra_scopes column (JSON array). - Per-key à-la-carte scopes: require_scope grants via role OR a key's extra_scopes; GRANTABLE_EXTRA_SCOPES allowlist (payment_providers:write only), validated on create and echoed in create/list responses. - payment_providers:write is in NO role: grants() carves the à-la-carte set out of full-admin's wildcard, so even a scoped full-admin key can't reach it through its role — only a per-key grant does. extra_scopes parsing fails closed (NULL/malformed -> no grant). - Tests: invariant (no role grants the à-la-carte set), fail-closed parsing, create/list round-trip, reject ungrantable scope. Suite green: lib 13, api 59. |
||
|
|
0508690d5a |
Wire scoped API keys and add advisory settle-amount tripwire
Scoped API keys (P1): migrate 58 admin endpoints from require_admin to
require_scope so ks_ keys with Read-only/License-issuer/Support/Full-admin roles
work as intended. 12 sensitive endpoints stay master-key-only (issuer key,
provider connect/disconnect, web password, api-key CRUD, db-info, operator-name,
per-license tier change). require_scope is re-exported from api::admin so both
auth gates import from one place. Adds role-boundary tests.
Settle-amount tripwire (P1): get_invoice_status now returns
ProviderInvoiceSnapshot { status, amount }. On a confirmed settle,
audit_settle_amount (shared by the webhook and reconcile issue paths) compares
the provider-reported sat amount against the invoice's amount_sats and, on drift,
logs a warning + writes an invoice.amount_mismatch audit row, then issues anyway.
Advisory by design: a hard gate would fight an operator's BTCPay payment
tolerance, and Settled already implies paid-in-full. SAT-only — skips non-SAT
settles (fiat subscription renewals) and unparseable amounts.
|
||
|
|
783372c03b |
Confirm settle with provider API before issuing; add test-injection seam
The settle-webhook honored payment on the webhook body's claim alone. Zaprite webhooks carry no signature, so a forged order.change/status=PAID POST with a buyer-visible order id minted a signed license without payment. handle_inner now re-fetches provider.get_invoice_status and requires Settled before persisting "settled" or taking any settle-derived action (issuance, tier-change, subscription renewal — the guard precedes all of them). On a provider-API error it acks 200 without issuing, so a transient outage can't trigger a webhook retry storm; the reconcile loop re-confirms and issues later. Adds the always-compiled AppState::provider_override seam (None in prod), honored by provider_from_row at every resolution site, so integration tests drive the real resolver with a MockPaymentProvider. Greens the two paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip, and adds forged-settle + provider-unreachable regression tests. api 47/47. Not addressed: a literal paid-amount/currency check (needs a trait change). |
||
|
|
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.
|
||
|
|
f8affdb11f |
Tier upgrades Phase 2 — quote logic + apply step
Builds on
|