Coverage for src/alprina_cli/billing.py: 16%

86 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Billing integration module for Alprina CLI. 

3Handles Stripe integration and usage metering. 

4""" 

5 

6import os 

7from typing import Optional 

8import httpx 

9from rich.console import Console 

10from rich.panel import Panel 

11from rich.table import Table 

12 

13from .auth import is_authenticated, get_auth_headers, load_token 

14 

15console = Console() 

16 

17 

18def get_backend_url() -> str: 

19 """Get backend URL from environment or use default.""" 

20 return os.getenv("ALPRINA_BACKEND", "https://api.alprina.com/v1") 

21 

22 

23def billing_status_command(): 

24 """Display billing status and usage information.""" 

25 if not is_authenticated(): 

26 console.print("[red]Please login first: alprina auth login[/red]") 

27 return 

28 

29 console.print(Panel("💳 Fetching billing information...", title="Billing Status")) 

30 

31 try: 

32 backend_url = get_backend_url() 

33 headers = get_auth_headers() 

34 

35 response = httpx.get( 

36 f"{backend_url}/billing/usage", 

37 headers=headers, 

38 timeout=10.0 

39 ) 

40 

41 if response.status_code == 200: 

42 data = response.json() 

43 _display_billing_info(data) 

44 else: 

45 console.print(f"[red]Failed to fetch billing info: {response.text}[/red]") 

46 

47 except httpx.ConnectError: 

48 console.print("[yellow]Could not connect to backend. Displaying local info.[/yellow]") 

49 _display_local_billing_info() 

50 except Exception as e: 

51 console.print(f"[red]Error: {e}[/red]") 

52 

53 

54def _display_billing_info(data: dict): 

55 """Display billing information from API response.""" 

56 user = data.get("user", {}) 

57 usage = data.get("usage", {}) 

58 plan = data.get("plan", {}) 

59 

60 # Create info panel 

61 info = f"""[bold]Plan:[/bold] {plan.get('name', 'Free')} 

62[bold]Status:[/bold] {plan.get('status', 'Active')} 

63""" 

64 

65 if plan.get('billing_cycle'): 

66 info += f"[bold]Billing Cycle:[/bold] {plan['billing_cycle']}\n" 

67 

68 console.print(Panel(info, title="Subscription Info")) 

69 

70 # Create usage table 

71 table = Table(title="Usage Statistics", show_header=True, header_style="bold cyan") 

72 table.add_column("Metric", style="bold") 

73 table.add_column("Used", justify="right") 

74 table.add_column("Limit", justify="right") 

75 table.add_column("Status", justify="center") 

76 

77 scans_used = usage.get("scans_used", 0) 

78 scans_limit = plan.get("scans_limit", 10) 

79 scans_pct = (scans_used / scans_limit * 100) if scans_limit > 0 else 0 

80 

81 status_color = "green" if scans_pct < 80 else "yellow" if scans_pct < 100 else "red" 

82 

83 table.add_row( 

84 "Scans (today)", 

85 str(scans_used), 

86 str(scans_limit), 

87 f"[{status_color}]{scans_pct:.0f}%[/{status_color}]" 

88 ) 

89 

90 console.print(table) 

91 

92 # Show upgrade message if on free plan 

93 if plan.get('name') == 'Free': 

94 console.print("\n[cyan]💡 Upgrade to Pro for unlimited scans and advanced features![/cyan]") 

95 console.print("[dim]Visit https://alprina.ai/pricing[/dim]") 

96 

97 

98def _display_local_billing_info(): 

99 """Display billing info from local token when offline.""" 

100 auth_data = load_token() 

101 

102 if not auth_data: 

103 console.print("[red]Not authenticated[/red]") 

104 return 

105 

106 user = auth_data.get("user", {}) 

107 plan = user.get("plan", "free") 

108 

109 console.print(Panel( 

110 f"[bold]Plan:[/bold] {plan.title()}\n" 

111 f"[yellow]Offline mode - showing cached information[/yellow]", 

112 title="Billing Status" 

113 )) 

114 

115 

116def check_scan_quota() -> bool: 

117 """ 

118 Check if user has remaining scan quota. 

119 

120 Returns: 

121 True if user can run a scan, False otherwise 

122 """ 

123 if not is_authenticated(): 

124 return False 

125 

126 try: 

127 backend_url = get_backend_url() 

128 headers = get_auth_headers() 

129 

130 response = httpx.get( 

131 f"{backend_url}/billing/usage", 

132 headers=headers, 

133 timeout=5.0 

134 ) 

135 

136 if response.status_code == 200: 

137 data = response.json() 

138 usage = data.get("usage", {}) 

139 plan = data.get("plan", {}) 

140 

141 scans_used = usage.get("scans_used", 0) 

142 scans_limit = plan.get("scans_limit", 10) 

143 

144 if scans_used >= scans_limit: 

145 console.print(f"[red]Scan quota exceeded ({scans_used}/{scans_limit})[/red]") 

146 console.print("[yellow]Upgrade your plan to continue scanning[/yellow]") 

147 return False 

148 

149 return True 

150 

151 except Exception: 

152 # Allow scans if backend is unreachable 

153 return True 

154 

155 return True 

156 

157 

158def increment_usage(scan_type: str = "scan"): 

159 """ 

160 Increment usage counter after a successful scan. 

161 

162 Args: 

163 scan_type: Type of scan performed 

164 """ 

165 try: 

166 backend_url = get_backend_url() 

167 headers = get_auth_headers() 

168 

169 httpx.post( 

170 f"{backend_url}/billing/usage/increment", 

171 headers=headers, 

172 json={"type": scan_type}, 

173 timeout=5.0 

174 ) 

175 

176 except Exception: 

177 # Silently fail if backend is unreachable 

178 pass