crowdtime-cli 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/.gitignore +1 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/client.py +16 -0
- crowdtime_cli-0.6.0/src/crowdtime_cli/commands/billing_cmd.py +446 -0
- crowdtime_cli-0.6.0/src/crowdtime_cli/commands/payroll_cmd.py +839 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/config.py +17 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/main.py +15 -3
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/models.py +44 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +47 -1
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +264 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +166 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/LICENSE +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/README.md +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.4.0 → crowdtime_cli-0.6.0}/src/crowdtime_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
|
|
5
5
|
Project-URL: Homepage, https://crowdtime.lat
|
|
6
6
|
Project-URL: Documentation, https://crowdtime.lat/docs
|
|
@@ -135,6 +135,22 @@ class CrowdTimeClient:
|
|
|
135
135
|
console.print("[red]Permission denied.[/red] You don't have access to this resource.")
|
|
136
136
|
raise SystemExit(1)
|
|
137
137
|
|
|
138
|
+
if response.status_code == 402:
|
|
139
|
+
try:
|
|
140
|
+
detail = response.json()
|
|
141
|
+
except Exception:
|
|
142
|
+
detail = {}
|
|
143
|
+
msg = (
|
|
144
|
+
detail.get("detail", "")
|
|
145
|
+
if isinstance(detail, dict)
|
|
146
|
+
else str(detail)
|
|
147
|
+
) or "Your organization's subscription is inactive."
|
|
148
|
+
console.print(
|
|
149
|
+
f"[yellow]{msg}[/yellow]\n"
|
|
150
|
+
"Run [bold]ct billing portal[/bold] to update your billing."
|
|
151
|
+
)
|
|
152
|
+
raise APIError(msg, status_code=402, detail=detail)
|
|
153
|
+
|
|
138
154
|
if response.status_code == 404:
|
|
139
155
|
raise APIError("Not found", status_code=404)
|
|
140
156
|
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Billing commands: status, portal, plan, reactivate, events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import webbrowser
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ..client import APIError, CrowdTimeClient
|
|
15
|
+
from ..formatters import format_error, format_success, print_json
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="billing", help="Manage subscription and billing.")
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ─── Helpers ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
STATUS_COLORS = {
|
|
25
|
+
"active": "green",
|
|
26
|
+
"trialing": "blue",
|
|
27
|
+
"past_due": "red",
|
|
28
|
+
"unpaid": "red",
|
|
29
|
+
"canceled": "yellow",
|
|
30
|
+
"paused": "yellow",
|
|
31
|
+
"incomplete": "yellow",
|
|
32
|
+
"incomplete_expired": "red",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
STATUS_LABELS = {
|
|
36
|
+
"active": "ACTIVE",
|
|
37
|
+
"trialing": "TRIALING",
|
|
38
|
+
"past_due": "PAST DUE",
|
|
39
|
+
"unpaid": "UNPAID",
|
|
40
|
+
"canceled": "CANCELED",
|
|
41
|
+
"paused": "PAUSED",
|
|
42
|
+
"incomplete": "INCOMPLETE",
|
|
43
|
+
"incomplete_expired": "EXPIRED",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
PLAN_LABELS = {
|
|
47
|
+
"monthly": "Monthly",
|
|
48
|
+
"annual": "Annual",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cents_to_dollars(cents: int | str | None) -> Decimal:
|
|
53
|
+
"""Convert an amount in cents to dollars."""
|
|
54
|
+
if cents is None:
|
|
55
|
+
return Decimal("0")
|
|
56
|
+
try:
|
|
57
|
+
return Decimal(str(cents)) / Decimal("100")
|
|
58
|
+
except Exception:
|
|
59
|
+
return Decimal("0")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _format_currency(amount: Decimal) -> str:
|
|
63
|
+
"""Format a Decimal dollar amount for display."""
|
|
64
|
+
return f"${amount:,.2f}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_date(date_str: str | None) -> str:
|
|
68
|
+
"""Format an ISO date string for display (YYYY-MM-DD)."""
|
|
69
|
+
if not date_str:
|
|
70
|
+
return ""
|
|
71
|
+
# Handle datetime strings by taking just the date part
|
|
72
|
+
return date_str[:10]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ─── Commands ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.callback(invoke_without_command=True)
|
|
79
|
+
def billing_default(
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Show subscription status or manage billing."""
|
|
84
|
+
if ctx.invoked_subcommand is None:
|
|
85
|
+
status(output_json=output_json)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("status")
|
|
89
|
+
def status(
|
|
90
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Show current subscription status.
|
|
93
|
+
|
|
94
|
+
Displays plan, seats, pricing, and next billing date.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
ct billing
|
|
98
|
+
ct billing status
|
|
99
|
+
ct billing status --json
|
|
100
|
+
"""
|
|
101
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
data = client.get("/billing/")
|
|
105
|
+
except APIError as e:
|
|
106
|
+
if e.status_code == 402:
|
|
107
|
+
console.print(
|
|
108
|
+
"[yellow]No active subscription.[/yellow] "
|
|
109
|
+
"Run [bold]ct billing portal[/bold] to manage your billing."
|
|
110
|
+
)
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
if e.status_code == 404:
|
|
113
|
+
console.print(
|
|
114
|
+
"[dim]No subscription found for this organization.[/dim]\n"
|
|
115
|
+
"Run [bold]ct billing checkout[/bold] to start a free trial (owner only)."
|
|
116
|
+
)
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
format_error(e.message)
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
if output_json:
|
|
122
|
+
print_json(data)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
sub_status = data.get("status", "unknown")
|
|
126
|
+
plan = data.get("plan", "")
|
|
127
|
+
seat_count = data.get("seat_count", 0)
|
|
128
|
+
|
|
129
|
+
# Amounts come from the serializer in cents
|
|
130
|
+
base_dollars = _cents_to_dollars(data.get("base_amount"))
|
|
131
|
+
per_seat_dollars = _cents_to_dollars(data.get("per_seat_amount"))
|
|
132
|
+
total_dollars = _cents_to_dollars(data.get("total_amount"))
|
|
133
|
+
|
|
134
|
+
period_end = _format_date(data.get("current_period_end"))
|
|
135
|
+
cancel_at_end = data.get("cancel_at_period_end", False)
|
|
136
|
+
trial_ends = _format_date(data.get("trial_ends_at"))
|
|
137
|
+
|
|
138
|
+
# Calculate next billing amount (total * months in period)
|
|
139
|
+
# For monthly it's the same; for annual it's total * 12
|
|
140
|
+
if plan == "annual":
|
|
141
|
+
next_amount = total_dollars * 12
|
|
142
|
+
else:
|
|
143
|
+
next_amount = total_dollars
|
|
144
|
+
|
|
145
|
+
# Status styling
|
|
146
|
+
color = STATUS_COLORS.get(sub_status, "yellow")
|
|
147
|
+
label = STATUS_LABELS.get(sub_status, sub_status.upper())
|
|
148
|
+
|
|
149
|
+
# Build the info table (no header, simple key-value)
|
|
150
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
151
|
+
table.add_column("Key", style="dim", width=12)
|
|
152
|
+
table.add_column("Value")
|
|
153
|
+
|
|
154
|
+
table.add_row("Status", f"[{color}]{label}[/{color}]")
|
|
155
|
+
table.add_row("Plan", PLAN_LABELS.get(plan, plan.capitalize()))
|
|
156
|
+
table.add_row("Seats", str(seat_count))
|
|
157
|
+
table.add_row("Base", f"{_format_currency(base_dollars)}/mo")
|
|
158
|
+
table.add_row("Per seat", f"{_format_currency(per_seat_dollars)}/mo")
|
|
159
|
+
table.add_row("Total", f"{_format_currency(total_dollars)}/mo")
|
|
160
|
+
|
|
161
|
+
if sub_status == "trialing" and trial_ends:
|
|
162
|
+
table.add_row("Trial ends", trial_ends)
|
|
163
|
+
elif period_end:
|
|
164
|
+
table.add_row("Next bill", period_end)
|
|
165
|
+
table.add_row("Next amount", _format_currency(next_amount))
|
|
166
|
+
|
|
167
|
+
if cancel_at_end:
|
|
168
|
+
table.add_row("", "[yellow]Cancels at period end[/yellow]")
|
|
169
|
+
|
|
170
|
+
panel = Panel(
|
|
171
|
+
table,
|
|
172
|
+
title="[bold]Billing[/bold]",
|
|
173
|
+
border_style=color,
|
|
174
|
+
padding=(1, 1),
|
|
175
|
+
)
|
|
176
|
+
console.print(panel)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command("portal")
|
|
180
|
+
def portal(
|
|
181
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Open the Stripe billing portal in your browser.
|
|
184
|
+
|
|
185
|
+
Allows you to update payment methods, view invoices,
|
|
186
|
+
and manage your subscription. Requires owner or admin role.
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
ct billing portal
|
|
190
|
+
"""
|
|
191
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
192
|
+
|
|
193
|
+
# Build the return URL pointing to the frontend billing page
|
|
194
|
+
org_slug = client.org_slug
|
|
195
|
+
frontend_url = client.config.frontend_url
|
|
196
|
+
return_url = f"{frontend_url}/{org_slug}/settings/billing"
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
data = client.post("/billing/portal/", data={"return_url": return_url})
|
|
200
|
+
except APIError as e:
|
|
201
|
+
if e.status_code == 403:
|
|
202
|
+
format_error(
|
|
203
|
+
"Permission denied. Only organization owners and admins "
|
|
204
|
+
"can access the billing portal."
|
|
205
|
+
)
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
if e.status_code == 404:
|
|
208
|
+
format_error(
|
|
209
|
+
"No subscription found. An organization owner must set up "
|
|
210
|
+
"billing from the web dashboard first."
|
|
211
|
+
)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
format_error(e.message)
|
|
214
|
+
raise typer.Exit(1)
|
|
215
|
+
|
|
216
|
+
portal_url = data.get("portal_url", data.get("url", ""))
|
|
217
|
+
|
|
218
|
+
if not portal_url:
|
|
219
|
+
format_error("No portal URL returned from the server.")
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
|
|
222
|
+
if output_json:
|
|
223
|
+
print_json({"portal_url": portal_url})
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Open in default browser
|
|
227
|
+
webbrowser.open(portal_url)
|
|
228
|
+
|
|
229
|
+
format_success("Billing portal opened in your browser.")
|
|
230
|
+
console.print(f"\n [dim]URL:[/dim] {portal_url}")
|
|
231
|
+
console.print(" [dim]If the browser didn't open, copy the URL above.[/dim]")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command("checkout")
|
|
235
|
+
def checkout(
|
|
236
|
+
plan_choice: str = typer.Argument(
|
|
237
|
+
"monthly", help="Plan to subscribe to: 'monthly' or 'annual'."
|
|
238
|
+
),
|
|
239
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Start a new subscription via Stripe Checkout.
|
|
242
|
+
|
|
243
|
+
Opens the Stripe Checkout page in your browser to start a free
|
|
244
|
+
trial or subscribe. Requires owner role.
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
ct billing checkout
|
|
248
|
+
ct billing checkout annual
|
|
249
|
+
"""
|
|
250
|
+
plan_choice = plan_choice.lower()
|
|
251
|
+
if plan_choice not in ("monthly", "annual"):
|
|
252
|
+
format_error("Plan must be 'monthly' or 'annual'.")
|
|
253
|
+
raise typer.Exit(1)
|
|
254
|
+
|
|
255
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
256
|
+
|
|
257
|
+
org_slug = client.org_slug
|
|
258
|
+
frontend_url = client.config.frontend_url
|
|
259
|
+
billing_url = f"{frontend_url}/{org_slug}/settings/billing"
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
data = client.post("/billing/checkout/", data={
|
|
263
|
+
"plan": plan_choice,
|
|
264
|
+
"success_url": f"{billing_url}?checkout=success",
|
|
265
|
+
"cancel_url": f"{billing_url}?checkout=canceled",
|
|
266
|
+
})
|
|
267
|
+
except APIError as e:
|
|
268
|
+
if e.status_code == 400:
|
|
269
|
+
format_error(e.message or "Cannot create checkout session.")
|
|
270
|
+
raise typer.Exit(1)
|
|
271
|
+
if e.status_code == 403:
|
|
272
|
+
format_error("Permission denied. Only the organization owner can start a subscription.")
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
format_error(e.message)
|
|
275
|
+
raise typer.Exit(1)
|
|
276
|
+
|
|
277
|
+
checkout_url = data.get("checkout_url", "")
|
|
278
|
+
|
|
279
|
+
if not checkout_url:
|
|
280
|
+
format_error("No checkout URL returned from the server.")
|
|
281
|
+
raise typer.Exit(1)
|
|
282
|
+
|
|
283
|
+
if output_json:
|
|
284
|
+
print_json({"checkout_url": checkout_url})
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
webbrowser.open(checkout_url)
|
|
288
|
+
|
|
289
|
+
format_success("Stripe Checkout opened in your browser.")
|
|
290
|
+
console.print(f"\n [dim]URL:[/dim] {checkout_url}")
|
|
291
|
+
console.print(" [dim]If the browser didn't open, copy the URL above.[/dim]")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command("plan")
|
|
295
|
+
def plan(
|
|
296
|
+
new_plan: str = typer.Argument(
|
|
297
|
+
..., help="Plan to switch to: 'monthly' or 'annual'."
|
|
298
|
+
),
|
|
299
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Switch billing plan between monthly and annual.
|
|
302
|
+
|
|
303
|
+
Requires owner role. The change takes effect immediately with
|
|
304
|
+
prorated credit for the remainder of the current period.
|
|
305
|
+
|
|
306
|
+
Examples:
|
|
307
|
+
ct billing plan annual
|
|
308
|
+
ct billing plan monthly
|
|
309
|
+
"""
|
|
310
|
+
new_plan = new_plan.lower()
|
|
311
|
+
if new_plan not in ("monthly", "annual"):
|
|
312
|
+
format_error("Plan must be 'monthly' or 'annual'.")
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
|
|
315
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
data = client.patch("/billing/plan/", data={"plan": new_plan})
|
|
319
|
+
except APIError as e:
|
|
320
|
+
if e.status_code == 400:
|
|
321
|
+
format_error(e.message or "Invalid plan change request.")
|
|
322
|
+
raise typer.Exit(1)
|
|
323
|
+
if e.status_code == 403:
|
|
324
|
+
format_error("Permission denied. Only the organization owner can switch plans.")
|
|
325
|
+
raise typer.Exit(1)
|
|
326
|
+
if e.status_code == 404:
|
|
327
|
+
format_error("No subscription found.")
|
|
328
|
+
raise typer.Exit(1)
|
|
329
|
+
format_error(e.message)
|
|
330
|
+
raise typer.Exit(1)
|
|
331
|
+
|
|
332
|
+
if output_json:
|
|
333
|
+
print_json(data)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
plan_label = PLAN_LABELS.get(new_plan, new_plan.capitalize())
|
|
337
|
+
format_success(f"Switched to {plan_label} billing.")
|
|
338
|
+
|
|
339
|
+
total = _cents_to_dollars(data.get("total_amount"))
|
|
340
|
+
if new_plan == "annual":
|
|
341
|
+
console.print(f" [dim]Billed annually at {_format_currency(total * 12)}/yr[/dim]")
|
|
342
|
+
else:
|
|
343
|
+
console.print(f" [dim]Billed at {_format_currency(total)}/mo[/dim]")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command("reactivate")
|
|
347
|
+
def reactivate(
|
|
348
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Reactivate a canceled subscription.
|
|
351
|
+
|
|
352
|
+
Undoes a pending cancellation so the subscription continues
|
|
353
|
+
at the end of the current billing period. Requires owner role.
|
|
354
|
+
|
|
355
|
+
Examples:
|
|
356
|
+
ct billing reactivate
|
|
357
|
+
"""
|
|
358
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
data = client.post("/billing/reactivate/", data={})
|
|
362
|
+
except APIError as e:
|
|
363
|
+
if e.status_code == 400:
|
|
364
|
+
format_error(e.message or "Subscription is not scheduled for cancellation.")
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
if e.status_code == 403:
|
|
367
|
+
format_error("Permission denied. Only the organization owner can reactivate.")
|
|
368
|
+
raise typer.Exit(1)
|
|
369
|
+
if e.status_code == 404:
|
|
370
|
+
format_error("No subscription found.")
|
|
371
|
+
raise typer.Exit(1)
|
|
372
|
+
format_error(e.message)
|
|
373
|
+
raise typer.Exit(1)
|
|
374
|
+
|
|
375
|
+
if output_json:
|
|
376
|
+
print_json(data)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
format_success("Subscription reactivated.")
|
|
380
|
+
period_end = _format_date(data.get("current_period_end"))
|
|
381
|
+
if period_end:
|
|
382
|
+
console.print(f" [dim]Next renewal: {period_end}[/dim]")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@app.command("events")
|
|
386
|
+
def events(
|
|
387
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Number of events to show."),
|
|
388
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Show billing event audit log.
|
|
391
|
+
|
|
392
|
+
Displays recent Stripe webhook events processed for this
|
|
393
|
+
organization. Requires owner role.
|
|
394
|
+
|
|
395
|
+
Examples:
|
|
396
|
+
ct billing events
|
|
397
|
+
ct billing events -n 50
|
|
398
|
+
ct billing events --json
|
|
399
|
+
"""
|
|
400
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
data = client.get("/billing/events/")
|
|
404
|
+
except APIError as e:
|
|
405
|
+
if e.status_code == 403:
|
|
406
|
+
format_error("Permission denied. Only the organization owner can view billing events.")
|
|
407
|
+
raise typer.Exit(1)
|
|
408
|
+
if e.status_code == 404:
|
|
409
|
+
format_error("No subscription found.")
|
|
410
|
+
raise typer.Exit(1)
|
|
411
|
+
format_error(e.message)
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
|
|
414
|
+
# Handle both paginated and flat list responses
|
|
415
|
+
event_list = data if isinstance(data, list) else data.get("results", data.get("data", []))
|
|
416
|
+
event_list = event_list[:limit]
|
|
417
|
+
|
|
418
|
+
if output_json:
|
|
419
|
+
print_json(event_list)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
if not event_list:
|
|
423
|
+
console.print("[dim]No billing events found.[/dim]")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
table = Table(title="Billing Events", show_lines=False)
|
|
427
|
+
table.add_column("Date", style="dim", width=10)
|
|
428
|
+
table.add_column("Event", width=40)
|
|
429
|
+
table.add_column("Status", width=10)
|
|
430
|
+
|
|
431
|
+
for event in event_list:
|
|
432
|
+
date = _format_date(event.get("created_at"))
|
|
433
|
+
event_type = event.get("event_type", "unknown")
|
|
434
|
+
processed = event.get("processed", False)
|
|
435
|
+
error = event.get("error", "")
|
|
436
|
+
|
|
437
|
+
if error:
|
|
438
|
+
status_str = "[red]FAILED[/red]"
|
|
439
|
+
elif processed:
|
|
440
|
+
status_str = "[green]OK[/green]"
|
|
441
|
+
else:
|
|
442
|
+
status_str = "[yellow]PENDING[/yellow]"
|
|
443
|
+
|
|
444
|
+
table.add_row(date, event_type, status_str)
|
|
445
|
+
|
|
446
|
+
console.print(table)
|