wafer-cli 0.2.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wafer/billing.py ADDED
@@ -0,0 +1,233 @@
1
+ """Billing CLI - Manage credits and subscription.
2
+
3
+ This module provides the implementation for the `wafer billing` subcommand.
4
+ """
5
+
6
+ import json
7
+
8
+ import httpx
9
+
10
+ from .api_client import get_api_url
11
+ from .auth import get_auth_headers
12
+
13
+
14
+ def _get_client() -> tuple[str, dict[str, str]]:
15
+ """Get API URL and auth headers."""
16
+ api_url = get_api_url()
17
+ headers = get_auth_headers()
18
+
19
+ assert api_url, "API URL must be configured"
20
+ assert api_url.startswith("http"), "API URL must be a valid HTTP(S) URL"
21
+
22
+ return api_url, headers
23
+
24
+
25
+ def format_cents(cents: int) -> str:
26
+ """Format cents as a dollar amount.
27
+
28
+ Args:
29
+ cents: Amount in cents (e.g., 2500 for $25.00)
30
+
31
+ Returns:
32
+ Formatted string (e.g., "$25.00")
33
+ """
34
+ dollars = cents / 100
35
+ return f"${dollars:.2f}"
36
+
37
+
38
+ def validate_topup_amount(amount_cents: int) -> None:
39
+ """Validate topup amount is within allowed range.
40
+
41
+ Args:
42
+ amount_cents: Amount in cents
43
+
44
+ Raises:
45
+ ValueError: If amount is out of range ($10-$500)
46
+ """
47
+ min_cents = 1000 # $10
48
+ max_cents = 50000 # $500
49
+
50
+ if amount_cents < min_cents:
51
+ raise ValueError(f"Amount must be at least ${min_cents // 100}")
52
+
53
+ if amount_cents > max_cents:
54
+ raise ValueError(f"Amount must be at most ${max_cents // 100}")
55
+
56
+
57
+ def format_usage_text(usage: dict) -> str:
58
+ """Format billing usage as human-readable text.
59
+
60
+ Args:
61
+ usage: Usage data from API
62
+
63
+ Returns:
64
+ Formatted multi-line string
65
+ """
66
+ tier = usage.get("tier", "unknown")
67
+ status = usage.get("status", "unknown")
68
+ credits_used = usage.get("credits_used_cents", 0)
69
+ credits_limit = usage.get("credits_limit_cents", 0)
70
+ credits_remaining = usage.get("credits_remaining_cents", 0)
71
+ topup_balance = usage.get("topup_balance_cents", 0)
72
+ has_hw_counters = usage.get("has_hardware_counters", False)
73
+ has_slack = usage.get("has_slack_access", False)
74
+ period_ends = usage.get("period_ends_at")
75
+
76
+ # Capitalize tier for display
77
+ tier_display = tier.capitalize()
78
+
79
+ lines = [
80
+ "Billing Summary",
81
+ "===============",
82
+ "",
83
+ f" Tier: {tier_display}",
84
+ f" Status: {status}",
85
+ ]
86
+
87
+ # Status warnings
88
+ if status == "past_due":
89
+ lines.append(" ⚠ Warning: Payment past due. Please update payment method.")
90
+
91
+ lines.append("")
92
+
93
+ # Credits section - different handling for enterprise (unlimited)
94
+ if credits_limit == -1 or tier.lower() == "enterprise":
95
+ lines.extend([
96
+ "Credits:",
97
+ f" Used this period: {format_cents(credits_used)}",
98
+ " Limit: Unlimited",
99
+ ])
100
+ else:
101
+ lines.extend([
102
+ "Credits:",
103
+ f" Used: {format_cents(credits_used)}",
104
+ f" Limit: {format_cents(credits_limit)}",
105
+ f" Remaining: {format_cents(credits_remaining)}",
106
+ ])
107
+
108
+ # Topup balance
109
+ if topup_balance > 0:
110
+ lines.append(f" Topup balance: {format_cents(topup_balance)}")
111
+
112
+ lines.append("")
113
+
114
+ # Features
115
+ lines.append("Features:")
116
+ lines.append(f" Hardware counters: {'Yes' if has_hw_counters else 'No'}")
117
+ lines.append(f" Slack support: {'Yes' if has_slack else 'No'}")
118
+
119
+ # Period end date
120
+ if period_ends:
121
+ lines.append("")
122
+ lines.append(f"Period ends: {period_ends}")
123
+
124
+ # Upgrade prompt for Start tier
125
+ if tier.lower() == "start":
126
+ lines.extend([
127
+ "",
128
+ "Upgrade to Pro for hardware counters and credit topups:",
129
+ " wafer billing portal",
130
+ ])
131
+
132
+ return "\n".join(lines)
133
+
134
+
135
+ def get_usage(json_output: bool = False) -> str:
136
+ """Get billing usage information.
137
+
138
+ Args:
139
+ json_output: If True, return raw JSON; otherwise return formatted text
140
+
141
+ Returns:
142
+ Usage info as string (JSON or formatted text)
143
+
144
+ Raises:
145
+ RuntimeError: On authentication or API errors
146
+ """
147
+ api_url, headers = _get_client()
148
+
149
+ try:
150
+ with httpx.Client(timeout=30.0, headers=headers) as client:
151
+ response = client.get(f"{api_url}/v1/billing/usage")
152
+ response.raise_for_status()
153
+ usage = response.json()
154
+ except httpx.HTTPStatusError as e:
155
+ if e.response.status_code == 401:
156
+ raise RuntimeError("Not authenticated. Run: wafer login") from e
157
+ raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") from e
158
+ except httpx.RequestError as e:
159
+ raise RuntimeError(f"Could not reach API: {e}") from e
160
+
161
+ if json_output:
162
+ return json.dumps(usage, indent=2)
163
+
164
+ return format_usage_text(usage)
165
+
166
+
167
+ def create_topup(amount_cents: int) -> dict:
168
+ """Create a topup checkout session.
169
+
170
+ Args:
171
+ amount_cents: Amount to add in cents (1000-50000)
172
+
173
+ Returns:
174
+ Dict with checkout_url and session_id
175
+
176
+ Raises:
177
+ RuntimeError: On authentication, validation, or API errors
178
+ """
179
+ api_url, headers = _get_client()
180
+
181
+ try:
182
+ with httpx.Client(timeout=30.0, headers=headers) as client:
183
+ response = client.post(
184
+ f"{api_url}/v1/billing/topup",
185
+ json={"amount_cents": amount_cents},
186
+ )
187
+ response.raise_for_status()
188
+ return response.json()
189
+ except httpx.HTTPStatusError as e:
190
+ if e.response.status_code == 401:
191
+ raise RuntimeError("Not authenticated. Run: wafer login") from e
192
+ if e.response.status_code == 400:
193
+ # Invalid amount
194
+ try:
195
+ detail = e.response.json().get("detail", e.response.text)
196
+ except Exception:
197
+ detail = e.response.text
198
+ raise RuntimeError(f"Invalid amount: {detail}") from e
199
+ if e.response.status_code == 403:
200
+ # Start tier or other restriction
201
+ raise RuntimeError(
202
+ "Topup not available for your subscription tier.\n"
203
+ "Upgrade your subscription first: wafer billing portal"
204
+ ) from e
205
+ if e.response.status_code == 503:
206
+ raise RuntimeError("Billing service temporarily unavailable. Please try again later.") from e
207
+ raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") from e
208
+ except httpx.RequestError as e:
209
+ raise RuntimeError(f"Could not reach API: {e}") from e
210
+
211
+
212
+ def get_portal_url() -> dict:
213
+ """Get Stripe billing portal URL.
214
+
215
+ Returns:
216
+ Dict with portal_url
217
+
218
+ Raises:
219
+ RuntimeError: On authentication or API errors
220
+ """
221
+ api_url, headers = _get_client()
222
+
223
+ try:
224
+ with httpx.Client(timeout=30.0, headers=headers) as client:
225
+ response = client.post(f"{api_url}/v1/billing/portal")
226
+ response.raise_for_status()
227
+ return response.json()
228
+ except httpx.HTTPStatusError as e:
229
+ if e.response.status_code == 401:
230
+ raise RuntimeError("Not authenticated. Run: wafer login") from e
231
+ raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") from e
232
+ except httpx.RequestError as e:
233
+ raise RuntimeError(f"Could not reach API: {e}") from e