
    `gjn                       U d Z ddlmZ ddlZddlZddlZddlZddlZddl	m
Z
mZ dZdZdZ G d d	e      Z G d
 de      Z G d de      Z G d de      Z G d de      Z G d de      Z G d de      Z G d de      Z G d de      Zd4d5dZd6dZdZdaded<   d7dZd4d8d Zd!d"d9d#Z d:d$Z!	 d4	 	 	 	 	 	 	 d;d%Z"dded&d'	 	 	 	 	 	 	 	 	 	 	 	 	 d<d(Z#ed)d=d*Z$ed)	 	 	 	 	 	 	 	 	 d>d+Z%ed)	 	 	 	 	 	 	 d?d,Z&ed)	 	 	 	 	 d@d-Z'ed)d=d.Z(ed)	 	 	 	 	 dAd/Z)dd&ed0	 	 	 	 	 	 	 dBd1Z*ed)	 	 	 d=d2Z+ed)	 	 	 	 	 	 	 dCd3Z,y)Du]  Nous Portal terminal-billing HTTP client (Phase 2b).

Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal
billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns
read-only entitlement/balance) — this module owns the *write* side: buy credits,
poll a charge, configure auto-reload.

Design rules:

- **Money is decimal, never float.** The server emits decimal STRINGS
  (``"142.5"`` — not fixed 2dp). We parse with :class:`decimal.Decimal` and never
  round-trip through float.
- **This client raises typed exceptions; it does NOT fail open.** Fail-open is the
  *caller's* job (the ``agent/billing_view.py`` builders) so each surface can
  decide how to degrade. A raw network/HTTP error here surfaces as
  :class:`BillingError` (or a subclass) carrying the parsed server ``error`` code,
  HTTP status, ``portalUrl`` deep-link, and ``retry_after``.
- **Auth** = the OAuth bearer JWT Hermes already holds for inference
  (``get_provider_auth_state("nous")["access_token"]``). No API-key auth on these.
- **Portal base URL** resolves with the same precedence as the device-flow login
  (``auth.py``): ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → the
  stored auth-state ``portal_base_url`` → the registry default. This is how the
  E2E run points the client at a preview deployment with zero code change.
    )annotationsN)AnyOptionalzhttps://portal.nousresearch.comg      .@zbilling:managec            	      ^     e Zd ZdZddddddddd	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 d fdZ xZS )BillingErrorae  A billing HTTP call failed.

    Carries everything a surface needs to render the right message + affordance:
    the server ``error`` code, HTTP ``status``, an optional human ``message``, the
    ``portalUrl`` deep-link (present on every gate denial), and ``retry_after``
    seconds (429/503). ``payload`` is the full parsed JSON body when available.
    Nstatuserror
portal_urlretry_afterpayloadactorcoderecoveryc                   t         
|   |       || _        || _        || _        || _        |xs i | _        || _        || _        |	| _	        y N)
super__init__r	   r
   r   r   r   r   r   r   )selfmessager	   r
   r   r   r   r   r   r   	__class__s             J/root/.hermes/venv/lib/python3.12/site-packages/hermes_cli/nous_billing.pyr   zBillingError.__init__=   sQ     	!
$&}"
 
	     )r   strr	   Optional[int]r
   Optional[str]r   r   r   r   r   Optional[dict[str, Any]]r   r   r   r   r   r   returnNone)__name__
__module____qualname____doc__r   __classcell__)r   s   @r   r   r   4   s     !%#$(%),0#""&!! 	!
 ! "! #! *! ! !  ! 
! !r   r   c                      e Zd ZdZy)BillingScopeRequiredu`  ``403 insufficient_scope`` — the held token lacks ``billing:manage``.

    The lazy step-up trigger: catching this kicks off a fresh device-connect that
    requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow
    terminal billing"). Also fires mid-session if the scope is stripped on refresh
    after the user loses ADMIN.
    Nr    r!   r"   r#    r   r   r&   r&   Y       r   r&   c                      e Zd ZdZy)BillingAuthErroruC   ``401`` — missing/invalid bearer token (not logged in / expired).Nr'   r(   r   r   r+   r+   c   s    Mr   r+   c                      e Zd ZdZy)BillingRemoteSpendingRevokedu"  ``403 remote_spending_revoked`` — THIS terminal's spending was revoked.

    Distinct from ``insufficient_scope`` (never had the grant) and from
    ``session_revoked`` (full logout). The terminal stays logged in; only the
    money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as
    ``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal
    MUST disable charge/auto-reload immediately, without waiting for the next
    token refresh (the current token still claims the scope for ~15 min).
    Nr'   r(   r   r   r-   r-   g       r   r-   c                      e Zd ZdZy)BillingSessionRevokedu^  ``401 session_revoked`` — the whole session was logged out.

    Stronger than a spend-revoke: recovery is **re-login** (full device-auth),
    not just reconnect. Subclass of :class:`BillingAuthError` so existing 401
    handling still treats it as not-logged-in, but the typed code lets the
    surface route to re-login with the right copy.
    Nr'   r(   r   r   r0   r0   s   r)   r   r0   c                      e Zd ZdZy)BillingTransientu"  A deterministic non-charge outcome: the request definitely did NOT
    reach/complete at Stripe, so it's always safe to retry after backoff —
    never the "maybe charged" ambiguity of a real 5xx/timeout. Covers
    429 rate limiting, 503 gate-unavailable, Stripe being down, and the
    daily upgrade cap — distinct failure modes that share this one
    contract property. Catch this (not the old ad-hoc subclass hierarchy)
    wherever the intent is "any transient, definitely-not-charged billing
    failure, back off and retry/poll".
    Nr'   r(   r   r   r2   r2   }   r.   r   r2   c                      e Zd ZdZy)BillingRateLimiteduf  ``429 rate_limited`` or ``503 temporarily_unavailable``.

    NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell
    the user "try again in N min"; never auto-retry-spam (the limiter is
    5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend
    failing closed — back off, do NOT treat as revoked.
    Nr'   r(   r   r   r4   r4      r)   r   r4   c                      e Zd ZdZy)BillingStripeUnavailableu  ``503 stripe_unavailable`` — Stripe itself is down.

    TRANSIENT: back off and retry using Retry-After; this is NOT the same as
    being throttled by our own rate limiter, so surfaces must not render "rate
    limited" copy for it — they should read ``.error`` to tell the two apart.
    A BillingTransient sibling of BillingRateLimited (not a subclass) — surfaces
    must not render "rate limited" copy for it; read ``.error`` to distinguish it.
    Nr'   r(   r   r   r6   r6      s    r   r6   c                      e Zd ZdZy)BillingUpgradeCapExceededua  ``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap.

    Distinct from the hourly ``rate_limited`` charge cap (same HTTP status,
    different meaning + no useful short-Retry-After backoff). A BillingTransient
    sibling of BillingRateLimited (not a subclass) — surfaces must read ``.error``
    to distinguish the failure mode.
    Nr'   r(   r   r   r8   r8      r)   r   r8   c                l   t        j                  d      xs t        j                  d      }|r/|j                         r|j                         j                  d      S | rP| j	                  d      }t        |t              r/|j                         r|j                         j                  d      S t        S )u   Resolve the portal base URL with login-time precedence.

    ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → stored auth-state
    ``portal_base_url`` → registry default. Trailing slash stripped.
    HERMES_PORTAL_BASE_URLNOUS_PORTAL_BASE_URL/portal_base_url)osgetenvstriprstripget
isinstancer   DEFAULT_PORTAL_BASE_URL)stateenvstoreds      r   resolve_portal_base_urlrH      s     )),
-
R;Q1RC
syy{yy{!!#&&,-fc"v||~<<>((--""r   c                    t        | t              r| j                         s| S t               }t        j
                  j                  |j                  d      dz   |       S )u  Resolve a (possibly relative) server portalUrl to an absolute URL.

    The server emits ``portalUrl`` relative by design (e.g. ``/billing?topup=open``)
    — it doesn't know which deployment the client points at. Resolve it against the
    client's portal base (preview / staging / prod) so deep-links are clickable.
    Idempotent: an already-absolute URL is returned unchanged (urljoin keeps it).
    r<   )rC   r   r@   rH   urllibparseurljoinrA   )r   bases     r   _absolutize_portal_urlrN      sN     z3'J,<,<,>"$D <<C 03 6
CCr   g      >@ztuple[float, str, str] | None_token_cachec                     da y)u  Bust the 30s token cache so post-step-up replays use the freshly-scoped token.

    ``_request`` only self-busts the cache on a 401 (an expired/invalid
    token), not on a 403 scope denial — so after a step-up grant, the
    cache would otherwise still hold the pre-grant unscoped token and
    the immediate replay would 403 again. Callers outside this module
    (e.g. the CLI's scope step-up flow) call this instead of poking
    the private ``_token_cache`` global directly.
    N)rO   r(   r   r   invalidate_cached_tokenrQ      s	     Lr   c                4    t        ddd      }| | |_        |S )zEBuild the canonical 'not logged in' BillingAuthError (single source).u>   Not logged into Nous Portal — run `hermes portal` to log in.  invalid_token)r	   r
   )r+   	__cause__)excerrs     r   _billing_not_logged_inrX      s(    
HC
 Jr   T	use_cachec                P   ddl }| r.t        (t        \  }}}|j                         |z
  t        k  r||fS 	 ddlm}  |d      xs i }t        |      }	 ddlm}m} 	  |       }|j                         |f}	|j                         g|	a|	S # t
        $ r i }Y Qw xY w# t        $ rf |j                  d      }t        |t              r:|j                         r*|j                         |f}	|j                         g|	a|	cY S t               w xY w# |$ r}
t        |
      |
d}
~
ww xY w)a  Return ``(access_token, portal_base_url)`` for billing calls.

    Uses the same refresh-aware resolver the inference path uses
    (``resolve_nous_access_token``), so a short-lived (~15 min) access token that
    has expired is transparently refreshed via the stored ``refresh_token``
    instead of failing as "not logged in". Raises :class:`BillingAuthError` only
    when there is no usable Nous session at all.

    The result is cached for ``_TOKEN_CACHE_TTL_SECONDS`` to keep the charge poll
    loop from re-locking + re-reading the auth store on every 2s tick. Pass
    ``use_cache=False`` to force a fresh resolution (e.g. after a 401).
    r   N)get_provider_auth_statenous)	AuthErrorresolve_nous_access_tokenaccess_token)timerO   _TOKEN_CACHE_TTL_SECONDShermes_cli.authr\   	ExceptionrH   r^   r_   ImportErrorrB   rC   r   r@   rX   )rZ   _time	cached_attokenrM   r\   rE   r^   r_   resolvedrV   s              r   _resolve_token_and_baserj      s0    \-!-	5$JJL9$(@@$;;'/52 #5)D	'H3)+ t$HJJL,8,LO-    '		.)eS!ekkmt,H!JJL484LO$&&'  3$S)s23s<   B B D BBA"DDD%D  D%c                    | y	 | j                  d      }|y	 t        t        |      j	                               S # t        $ r d}Y 3w xY w# t
        t        f$ r Y yw xY w)uH   Parse a ``Retry-After`` header (integer seconds) — None if absent/bad.NzRetry-After)rB   rd   intr   r@   	TypeError
ValueError)headersraws     r   _retry_after_secondsrq   *  sp    kk-( {3s8>>#$$   z" s    < !A A
	A
AAc           	        t        |t              r|j                  d      nd}t        |t              r|j                  d      nd}t        |t              r|j                  d      nd}t        |t              r|j                  d      nd}t        |t              r|j                  d      nd}t        t        |t              r|j                  d      nd      }t	        |      }	| |||	t        |t              r|nd|||d}
|d	k(  rt        |xs d
fi |
|dk(  rt        |xs dfi |
| dk(  r%|dk(  rt        |xs dfi |
t        |xs dfi |
| dk(  r>|dk(  rt        |xs dfi |
|dk(  rt        |xs dfi |
t        |xs |xs dfi |
| dv rt        |xs dfi |
t        |xs
 |xs d|  dfi |
)uk  Map an HTTP error response to the right typed :class:`BillingError`.

    Recognizes the Remote-Spending gate contract (NAS PR #481):
    403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect),
    401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable``
    (gate fail-closed → back off, NOT revoked). The business-denial codes
    (``cli_billing_disabled`` + dual ``code:remote_spending_disabled``,
    ``role_required``, ``idempotency_conflict``, …) flow through as a generic
    BillingError carrying ``error``/``code``/``recovery`` for the surface to map.
    r
   Nr   r   r   r   	portalUrlr   stripe_unavailableu8   Stripe is temporarily unavailable — try again shortly.upgrade_cap_exceededu7   Daily plan-change limit reached — try again tomorrow.rS   session_revokedu-   Your session was logged out — log in again.zAuthentication required.i  remote_spending_revokedz.Remote Spending was revoked for this terminal.insufficient_scopez+This action needs the billing:manage scope.zBilling request denied.)i  i  u#   Rate limited — try again shortly.zBilling request failed (z).)rC   dictrB   rN   rq   r6   r8   r0   r+   r-   r&   r   r4   )r	   r   ro   r
   r   r   r   r   r   r   commons              r   _raise_for_errorr{   :  s9    %/w$=GKK 4E(27D(Agkk)$tG",Wd";7;;vD$.w$=GKK 4E*4Wd*Cw{{:&H'$.w$=K 4J 'w/K  "($77T	F $$&QQ
U[
 	
 &&'PP
TZ
 	
 } %%'JJNT  wD*DOOO} --.KKOU  ((&HHLR 
 7HeH/HSFSS <<
@F
 	
 wP%P-EfXR+P
[TZ
[[r   Fbodyextra_headerstimeout_retried_authc                  t        |       \  }}| | }d| dd}	|d|	d<   |r|	j                  |       |$t        j                  |      j	                  d      nd}
t
        j                  j                  ||
|	|       }	 t
        j                  j                  ||	      5 }|j                         j                  d      }|j                         si cddd       S 	 t        j                  |      cddd       S # t        j                  $ r}t        d
dt        |dd            |d}~ww xY w# 1 sw Y   yxY w# t
        j                   j"                  $ r}|j$                  dk(  r|sdat)        | ||||d      cY d}~S d}	 |j                         j                  d      }n# t*        $ r d}Y nw xY w	 |j                         rt        j                  |      ni }n# t        j                  $ r i }Y nw xY wt-        |j$                  |t        |dd              d}~wt
        j                   j.                  $ r }t        d|j0                   d      |d}~wt2        $ r}t        dd      |d}~ww xY w)a  Make an authenticated billing request; return the parsed JSON dict.

    Raises a typed :class:`BillingError` on any non-2xx response (or transport
    failure). 2xx with an empty body returns ``{}``. A 401 triggers exactly one
    retry with a freshly-resolved token (bypassing the short token cache) so a
    cached-but-just-expired token self-heals instead of failing the call.
    rY   zBearer zapplication/json)AuthorizationAcceptNzContent-Typezutf-8)dataro   methodr   z[Billing endpoint returned a non-JSON response (it may not be available on this deployment).endpoint_unavailabler	   )r
   r	   rS   Tr|    ro   zCould not reach Nous Portal: network_errorr
   z&Could not reach Nous Portal: timed out)rj   updatejsondumpsencoderJ   requestRequesturlopenreaddecoder@   loadsJSONDecodeErrorr   getattrr
   	HTTPErrorr   rO   _requestrd   r{   URLErrorreasonTimeoutError)r   pathr}   r~   r   r   rh   rM   urlro   r   reqresprp   rV   r   s                   r   r   r     st     *4EFKE4F4&/C"5'*$G "4}%/3/?4::d""7+TD
..
 
 4
 
PC7^^##C#9 	T))+$$W-C99;	 	zz#	 	 ''  #D0"448	
 	 	& <<!!  88s?=L+"  	((*##G,C 	C		),djjo"G## 	G	7GCD,IJ<<   +CJJ<8
	   4O
		s   !E &1D5	E "D 6	E  D2D--D22D55D>:E >E I<#H"I<H"
F*)H"*F85H"7F88H"<'G$#H"$G<9H";G<<&H"" I<II<)I77I<r   c                    t        dd|       S )uH   ``GET /api/billing/state`` — role-tiered overview (no scope required).GETz/api/billing/stater   r   r   s    r   get_billing_stater     s    E/AAr   c           	     ^    t        ddt        |       t        |      t        |      d|      S )u   ``PATCH /api/billing/auto-top-up`` — configure auto-reload (scope required).

    Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method)
    are rejected with 400. Numbers are sent as JSON numbers per the contract.
    PATCHz/api/billing/auto-top-up)enabled	thresholdtopUpAmountr}   r   )r   boolfloat)r   r   top_up_amountr   s       r   patch_auto_top_upr     s8     "G}y) /

 	 	r   c                    t        |t              r|j                         st        dd      t	        dddt        |       id|j                         i|      S )	uR  ``POST /api/billing/charge`` — buy credits (scope required).

    ``Idempotency-Key`` header is MANDATORY (a missing header is a server 400, not
    a default): generate a UUID per user-confirmed purchase and reuse it on retry.
    Returns ``202 {chargeId}`` — money is NOT confirmed yet; poll with
    :func:`get_charge_status`.
    z)Idempotency-Key is required for a charge.idempotency_key_requiredr   POSTz/api/billing/charge	amountUsdIdempotency-Keyr}   r~   r   )rC   r   r@   r   r   r   )
amount_usdidempotency_keyr   s      r   post_charger     sb     ,1F1F1H7,
 	
 5,-(/*?*?*AB r   c                   t        | t              r| j                         st        dd      t        j
                  j                  | j                         d      }t        dd| |      S )	uG  ``GET /api/billing/charge/{id}`` — poll a charge (scope required).

    Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign
    id returns ``{status:"pending"}`` (never 404, never another org's data) — so a
    ``pending`` that never resolves past the 5-min cap is a *timeout*, not an error.
    zA charge id is required.invalid_charge_idr   r   )safer   z/api/billing/charge/r   )rC   r   r@   r   rJ   rK   quoter   )	charge_idr   safe_ids      r   get_charge_statusr     s_     y#&9??+<5=PQQ ll  !2 <GE1';WMMr   c                    t        dd|       S )u  ``GET /api/billing/subscription`` — current plan, tiers, usage (no scope).

    Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no
    ``billing:manage`` scope required. Raises :class:`BillingAuthError`
    on 401 and :class:`BillingError` on other non-2xx.
    r   z/api/billing/subscriptionr   r   r   s    r   get_subscription_stater   (  s     E6HHr   c                $    t        ddd| i|      S )u  ``POST /api/billing/subscription/preview`` — a chargeless effect quote.

    Quotes a change to ``subscription_type_id`` without mutating anything:
    ``effect`` is ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated
    upfront charge), ``scheduled`` (a downgrade → ``effectiveAt`` is period end),
    ``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit
    would be refused). Also returns the current + target tier and the monthly-credit
    delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration
    quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts).
    r   z!/api/billing/subscription/previewsubscriptionTypeIdr   r   )subscription_type_idr   s     r   post_subscription_previewr   A  s#     +"$89	 r   )r   cancelr   c                    |rddi}n@t        | t              r| j                         st        dd      d| j                         d}t	        dd	||
      S )u2  ``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent.

    A subscription has at most one pending disposition. Pass ``cancel=True`` to
    schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade /
    same-price change. UPGRADES are rejected here (they charge immediately — use
    :func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``.
    Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or
    ``{rail, cancelAtPeriodEnd, message}`` for a cancellation.
    typecancellationz:A subscription tier is required to schedule a plan change.invalid_subscription_typer   tier_change)r   r   PUT(/api/billing/subscription/pending-changer   rC   r   r@   r   r   )r   r   r   r}   s       r   put_subscription_pending_changer   V  sp      &7 +S16J6P6P6RL1 
 ""6"<"<">
 2	 r   c                    t        dd|       S )u  ``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo).

    Removes a scheduled downgrade OR cancellation in one call, restoring the live
    active tier and recurring renewal. Chargeless, but it re-enables recurring
    spend, so it requires ``billing:manage`` and is honored by the org kill-switch.
    Returns ``{rail, cancelAtPeriodEnd: false, message}``.
    DELETEr   r   r   r   s    r   "delete_subscription_pending_changer   {  s     2 r   c                    t        |t              r|j                         st        dd      t	        ddd| id|j                         i|      S )	uw  ``POST /api/billing/subscription/upgrade`` — immediate paid upgrade.

    The SINGLE money route: one Stripe op prorates, charges the card already on the
    subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing
    header is a server 400, not a default) — reuse the same key on retry so a replay
    cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on
    success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}``
    when the charge needs 3DS / was declined and must be finished in the portal at
    ``recoveryUrl``. Requires ``billing:manage``.
    z+Idempotency-Key is required for an upgrade.r   r   r   z!/api/billing/subscription/upgrader   r   r   r   )r   r   r   s      r   post_subscription_upgrader     s^      ,1F1F1H9,
 	
 +"$89(/*?*?*AB r   r   )rE   r   r   r   )r   r   r   r   )r   r   )rV   zOptional[BaseException]r   z'BillingAuthError')rZ   r   r   ztuple[str, str])ro   r   r   r   )r	   rl   r   dict[str, Any]ro   r   r   r   )r   r   r   r   r}   r   r~   zOptional[dict[str, str]]r   r   r   r   r   r   )r   r   r   r   )
r   r   r   float | strr   r   r   r   r   r   )r   r   r   r   r   r   r   r   )r   r   r   r   r   r   )r   r   r   r   r   r   )r   z
str | Noner   r   r   r   r   r   )r   r   r   r   r   r   r   r   )-r#   
__future__r   r   r>   urllib.errorrJ   urllib.parseurllib.requesttypingr   r   rD   DEFAULT_TIMEOUTBILLING_MANAGE_SCOPErd   r   r&   r+   r-   r0   r2   r4   r6   r8   rH   rN   rb   rO   __annotations__rQ   rX   rj   rq   r{   r   r   r   r   r   r   r   r   r   r   r(   r   r   <module>r      s  2 #  	     ;  
 ( "!9 "!J< N| N	< 	, 	| 	) /  0 # D0   .2+ 2	 26 /n" :>E\E\(E\36E\	E\X &*.2$UU
U #	U
 ,U U U Uz +: B %  	
  8 %	  	
 8 )8NN %NN" 0? I4 4C +0. (,$	"$" " 	"
 "L (* %	  	
 r   