
    `gjA                       d Z ddlmZ ddlZddlZddlmZ ddlmZ ddl	m
Z
mZ ddlmZ  ej                  e      Z ed	       G d
 d             Z ed	       G d d             Z ed	       G d d             Z ed	       G d d             Zd dZd!dZd"dZ	 	 	 	 d#dZdd	 	 	 	 	 d$dZddd%dZd&dZdZd'dZd(dZd)dZy)*u  Surface-agnostic core for the ``/subscription`` TUI screen.

Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.

The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
previews the effect, then schedules a downgrade / cancellation / resume
(chargeless) or applies an upgrade (charges the card on the subscription). The
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
fallback for an upgrade that needs 3DS / was declined.

WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s — the builder returns
``logged_in=False`` and the surface degrades gracefully.
    )annotationsN)	dataclass)Decimal)AnyOptional)parse_moneyT)frozenc                      e Zd ZU dZdZded<   dZded<   dZded<   dZded<   dZ	ded	<   dZ
ded
<   dZded<   dZded<   dZded<   y)CurrentSubscriptionaI  The user's active subscription. ``None`` (not this object) = no plan.

    When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
    ``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
    fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
    fields are optional.
    NOptional[str]tier_id	tier_nameOptional[Decimal]monthly_creditscredits_remainingcycle_ends_atpending_downgrade_tier_namepending_downgrade_atFboolcancel_at_period_endcancellation_effective_at)__name__
__module____qualname____doc__r   __annotations__r   r   r   r   r   r   r   r        J/root/.hermes/venv/lib/python3.12/site-packages/agent/subscription_view.pyr   r   %   sj     "G]!#I}#)-O&-+/(/#'M='155*.-.!&$&/3}3r   r   c                  l    e Zd ZU dZded<   ded<   dZded<   dZd	ed
<   dZd	ed<   dZded<   dZ	ded<   y)SubscriptionTieru  A selectable plan in the catalog — one row of the in-terminal tier picker.

    Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
    (shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
    user is on but that can no longer be selected. ``tier_order`` sorts the picker
    and drives the upgrade-vs-downgrade direction hint.
    strr   namer   int
tier_orderNr   dollars_per_monthr   Fr   
is_currentT
is_enabled)
r   r   r   r   r   r%   r&   r   r'   r(   r   r   r   r!   r!   :   sH     L
IJ+/(/)-O&-JJr   r!   c                      e Zd ZU dZded<   dZded<   dZded<   dZded<   dZded	<   dZ	ded
<   dZ
ded<   dZded<   dZded<   y)SubscriptionChangePreviewu  Parsed ``POST /api/billing/subscription/preview`` — what a change would do.

    ``effect`` is the disposition the commit would take:
      - ``charge_now`` → an upgrade; ``amount_due_now_cents`` is the prorated charge.
      - ``scheduled``  → a downgrade / same-price change at ``effective_at`` (period end).
      - ``no_op``      → already on the target tier.
      - ``blocked``    → the commit would be refused; ``reason`` says why.
    r"   effectNr   reasoncurrent_tier_idcurrent_tier_nametarget_tier_idtarget_tier_namer   monthly_credits_deltazOptional[int]amount_due_now_centseffective_at)r   r   r   r   r   r,   r-   r.   r/   r0   r1   r2   r3   r   r   r   r*   r*   M   sc     K FM %)O])'+}+$(NM(&*m*/3,3*.-."&L-&r   r*   c                      e Zd ZU dZded<   dZded<   dZded<   dZded<   dZd	ed
<   dZ	ded<   dZ
ded<   dZded<   dZded<   dZded<   edd       Zedd       Zy)SubscriptionStateu   Parsed ``GET /api/billing/subscription`` — the overview screen's data.

    Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
    portal is unreachable.
    r   	logged_inNr   org_nameorg_idrolezOptional[bool]can_change_plan_rawpersonalr"   contextOptional[CurrentSubscription]currentr   tuple[SubscriptionTier, ...]tiers
portal_urlerrorc                B    | j                   xs dj                         dv S )u   Deprecated/display only — a legacy OWNER/ADMIN check.

        NOT a capability check; use :attr:`can_change_plan` for gating billing
        plan-change actions.
         )OWNERADMIN)r9   upperselfs    r   is_adminzSubscriptionState.is_adminw   s!     		R&&(,>>>r   c                J    | j                   | j                   S | j                  S )zDServer capability when supplied; otherwise the legacy role fallback.)r:   rJ   rH   s    r   can_change_planz!SubscriptionState.can_change_plan   s&     ##/+++}}r   )returnr   )r   r   r   r   r   r7   r8   r9   r:   r<   r>   r@   rA   rB   propertyrJ   rL   r   r   r   r5   r5   c   s     O"Hm" FM D-*..GS-1G*1*,E', $J$E=? ?  r   r5   c                   t        | t              sy | j                  d      xs | j                  d      }|sy t        || j                  d      xs | j                  d      t	        | j                  d            t	        | j                  d            | j                  d      | j                  d      | j                  d	      t        | j                  d
            | j                  d      xs d 	      S )NtierIdidtierNamer#   monthlyCreditscreditsRemainingcycleEndsAtpendingDowngradeTierNamependingDowngradeAtcancelAtPeriodEndcancellationEffectiveAt)	r   r   r   r   r   r   r   r   r   )
isinstancedictgetr   r   r   rawr   s     r   _parse_currentr_      s     c4 ggh03774=G''*%8#CGG,<$=>%cgg.@&ABggm,$'GG,F$G WW%9:!#''*=">?"%''*C"D"L
 
r   c                     | D ]  }||c S  y)zFirst non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).

    NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
    ``x or default`` would drop those, so coalesce on ``None`` specifically.
    Nr   )valsvs     r   	_coalescerc      s"      =H r   c                   t        | t              sy| j                  d      xs | j                  d      }|syt        || j                  d      xs dt	        t        | j                  d      d            t        | j                  d            t        | j                  d	            t        | j                  d
            t        t        | j                  d      d                  S )zMMap one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`.NrP   rQ   r#   rD   	tierOrderr   dollarsPerMonthDisplayrS   	isCurrent	isEnabledTr   r#   r%   r&   r   r'   r(   )rZ   r[   r\   r!   r$   rc   r   r   r]   s     r   _parse_tierrj      s    c4 ggh03774=GWWV_"y!5q9:%cgg.F&GH#CGG,<$=>,-	#''+"6=> r   c                   | j                  d      }| j                  d      }t        t        |t              r|nd| j                  d      xs d| j                  d      | j                  d      | j                  d      | j                  d	      t	        | j                  d
            t        |t
        t        f      rt        |      nd| j                  d      xs d	      S )zVMap a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`.r+   amountDueNowCentsblockedr,   NcurrentTierIdcurrentTierNametargetTierIdtargetTierNamemonthlyCreditsDeltaeffectiveAt)	r+   r,   r-   r.   r/   r0   r1   r2   r3   )r\   r*   rZ   r"   r   r$   float)payloadr+   centss      r   (subscription_change_preview_from_payloadrw      s     [["FKK+,E$ $FC0vi{{8$,O4!++&78{{>2 %56)'++6K*LM+5ec5\+JSZPT[[/74 r   rA   c                  | j                  d      }t        |t              r|ni }| j                  d      }|dv r|nd}| j                  d      }t        |t              rt	        d d |D        D              nd}t        d	|j                  d
      |j                  d      xs d|j                  d      t        | j                  d      t              r| j                  d      nd|t        | j                  d            ||	      S )zRMap a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`.orgr<   )r;   teamr;   r@   c              3  &   K   | ]	  }||  y wNr   ).0ts     r   	<genexpr>z2subscription_state_from_payload.<locals>.<genexpr>   s     NAaNs   c              3  2   K   | ]  }t        |        y wr}   )rj   )r~   xs     r   r   z2subscription_state_from_payload.<locals>.<genexpr>   s     <Q+a.<s   r   Tr#   rQ   Nr9   canChangePlanr>   )	r6   r7   r8   r9   r:   r<   r>   r@   rA   )r\   rZ   r[   listtupler5   r   r_   )ru   rA   raw_orgrz   raw_contextr<   	raw_tiersr@   s           r   subscription_state_from_payloadr      s     kk% G%/%>'BC++i(K(,@@kjGG$I i& 	N<)<NN 
 wwt}$WWV_ '++o6= KK(w{{956 r   g      .@timeoutc                4   t               }||S 	 ddlm}m}m}m}m} 	  ||       }t        |t              r|j                  d      nd}	|	r ||	      nd}
|
s	  |       }
t        ||
      S # t        $ r t        dd      cY S w xY w# |$ r t        d      cY S |$ r7}t        j                  d	d
       t        dt        |            cY d}~S d}~wt        $ r' t        j                  dd
       t        dd      cY S w xY w# t        $ r d}
Y w xY w)ux  Fetch + parse ``GET /api/billing/subscription``. Fail-open.

    Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
    portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
    surface can show a clear message rather than crashing.

    Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
    ``/subscription`` renders from that fixture instead of the real portal — so
    every plan/cancel/downgrade/team/not-admin state is testable on both
    the CLI and TUI without a live account. Throwaway scaffolding; see
    :func:`dev_fixture_subscription_state`.
    Nr   )BillingAuthErrorBillingError_absolutize_portal_urlget_subscription_stateresolve_portal_base_urlFzbilling client unavailabler6   rB   r   r6   u0   subscription ▸ /state fetch failed (fail-open)T)exc_infou4   subscription ▸ /state unexpected error (fail-open)z!could not load subscription state	portalUrlrx   )dev_fixture_subscription_statehermes_cli.nous_billingr   r   r   r   r   	Exceptionr5   loggerdebugr"   rZ   r[   r\   r   )r   fixturer   r   r   r   r   ru   exc
raw_portalrA   s              r   build_subscription_stater      s(    -.G	V	
 	
	](9 .8-F[)DJ7A'
3tJ	02J +7zJJ-  V 58TUUV
  2 511 BGRVW 5CAA ]KVZ[ 58[\\]  	J	sL   A. 	B
 D	 .BB
DD!,CD0DD	DDc                ,   ddl m}m}m} | j                  sy	  || j                        }|j                  r|j                  sy| j                  r |d| j                  i      nd} ||j                  |j                  d|df      S # t
        $ r Y yw xY w)u  Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.

    Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
    target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
    Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
    internally. ``org_id`` pins the page to the right account in multi-org
    situations. Returns ``None`` when no portal URL is resolvable.
    r   )	urlencodeurlsplit
urlunsplitNr8   rD   z/manage-subscription)	urllib.parser   r   r   rA   r   schemenetlocr8   )stater   r   r   partsquerys         r   subscription_manage_urlr   /  s     =<))* <<u||38<<Ix./REu||U\\3I5RTUVV  s   B 	BBz'https://portal.nousresearch.com/billingc                 ~    t        ddt        d      t        d      d      }|j                  |        t        di |S )NplusPlus1000420
2026-07-01)r   r   r   r   r   r   )r[   r   updater   )overbases     r   _dev_currentr   P  s?    !%."D 	KK&&&r   c                0     d}t         fd|D              S )zMA sample plan catalog for fixtures (marks ``current_id`` as the active tier).))freeFreer   0r   )r   r      20r   )superSuper   403000)ultraUltra   807000c           
   3  x   K   | ]1  \  }}}}}t        |||t        |      t        |      |k(  d        3 yw)Tri   N)r!   r   )r~   tidr#   orderdpmmc
current_ids         r   r   z_dev_tiers.<locals>.<genexpr>d  sR       &Cuc2 	)#.'Oz)	
 	
s   7:)r   )r   specss   ` r   
_dev_tiersr   \  s(    E   */  r   c                 &   t        j                  d      xs dj                         j                         } | syt	        dddt
              }| dv rt        d	
      S | dk(  rt        d*ddt        d      d|S | dv r t        d*dt               t        d      d|S | dv r7t        d*dt        ddt        d      t        d            t        d      d|S | dv r&t        d*dt               t        d      di |ddiS | dk(  r9t        d*dt        ddt        d      t        d      dd       t        d      d|S | d!k(  r#t        d*dt        dd"#      t        d      d|S | d$k(  rt        dd$dd%d&dt
        '      S t        d	d(|  )      S )+u  Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.

    Lets every CLI/TUI subscription state be exercised without a live portal:

        free | mid | top | not-admin | downgrade | cancel | team |
        logged-out

    Returns ``None`` when the env var is unset/empty (the real portal path runs).
    Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
    HERMES_DEV_SUBSCRIPTION_FIXTURErD   NzAcme Incorg_acmerE   )r7   r8   r9   rA   )z
logged-out
logged_out	loggedoutFr   r   T)r6   r>   r@   )midzmid-tierr   )topztop-tierr   r   r   5000)r   r   r   r   )z	not-adminmemberr9   MEMBER	downgrader   r   r   1500r   z
2026-07-15)r   r   r   r   r   r   cancelr   )r   r   r{   zAcme Engineeringorg_eng)r6   r<   r>   r7   r8   r9   rA   z)unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: r   r   )
osgetenvstriplowerr[   _DEV_FIXTURE_PORTALr5   r   r   r   )r#   commons     r   r   r   r  s;    II78>BEEGMMOD:jwSfgF88 511v~ `4ZPTEU`Y_``"" l4zZ`Oalekll""  
 GU\]cUdx  AG  yH  IW%
 	
 	
 &&   C4zZ`Oa  C  fBhn  fBpv  yA  fB  C  	C{  
 GU\]cUdx  AG  yH  fl  CO  PW%
 	
 	
 x  
 dVbcV$
 	
 	
 v~ 4Xjs|  DK  Xk  l  	l u6_`d_e4fggr   )r^   r   rM   r=   )ra   r   rM   r   )r^   r   rM   zOptional[SubscriptionTier])ru   dict[str, Any]rM   r*   )ru   r   rA   r   rM   r5   )r   rt   rM   r5   )r   r5   rM   r   )r   r   rM   r   )r   r   rM   r?   )rM   zOptional[SubscriptionState]) r   
__future__r   loggingr   dataclassesr   decimalr   typingr   r   agent.billing_viewr   	getLoggerr   r   r   r!   r*   r5   r_   rc   rj   rw   r   r   r   r   r   r   r   r   r   r   <module>r      s  $ #  	 !    *			8	$ $4 4 4( $  $ $' ' '* $! ! !R,	$, =A,9N 26 /KdW< @ 	',2hr   