crowdtime-cli 0.3.0__tar.gz → 0.5.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.
Files changed (35) hide show
  1. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/.gitignore +1 -0
  2. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/PKG-INFO +4 -2
  3. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/README.md +3 -1
  4. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/pyproject.toml +1 -1
  5. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/__init__.py +1 -1
  6. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/client.py +16 -0
  7. crowdtime_cli-0.5.0/src/crowdtime_cli/commands/billing_cmd.py +446 -0
  8. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/org_cmd.py +76 -2
  9. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/config.py +17 -0
  10. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/formatters.py +14 -2
  11. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/main.py +13 -3
  12. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +30 -0
  13. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +131 -1
  14. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +121 -8
  15. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/LICENSE +0 -0
  16. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/auth.py +0 -0
  17. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  18. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  19. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  20. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  21. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  22. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  23. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  24. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  25. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  26. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
  27. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  28. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  29. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  30. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  31. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  32. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/models.py +0 -0
  33. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/oauth.py +0 -0
  34. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/resolvers.py +0 -0
  35. {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/utils.py +0 -0
@@ -58,3 +58,4 @@ RECOVERY.md
58
58
  coolify_deployment.md
59
59
  pypi_deployment.md
60
60
  sentry-workflow.md
61
+ linear-workflow.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -118,7 +118,9 @@ ct report --week
118
118
  | `ct org list` | — | List your organizations |
119
119
  | `ct org switch <slug>` | — | Switch active organization |
120
120
  | `ct org members` | — | List organization members |
121
- | `ct org invite <email>` | — | Invite a new member |
121
+ | `ct org invite <email>` | — | Invite a new member (roles: viewer, member, project_manager, manager, admin) |
122
+ | `ct org invitations` | — | List pending invitations |
123
+ | `ct org resend-invite <id>` | — | Resend a pending invitation email |
122
124
 
123
125
  ### Reporting & AI
124
126
 
@@ -84,7 +84,9 @@ ct report --week
84
84
  | `ct org list` | — | List your organizations |
85
85
  | `ct org switch <slug>` | — | Switch active organization |
86
86
  | `ct org members` | — | List organization members |
87
- | `ct org invite <email>` | — | Invite a new member |
87
+ | `ct org invite <email>` | — | Invite a new member (roles: viewer, member, project_manager, manager, admin) |
88
+ | `ct org invitations` | — | List pending invitations |
89
+ | `ct org resend-invite <id>` | — | Resend a pending invitation email |
88
90
 
89
91
  ### Reporting & AI
90
92
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
@@ -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)
@@ -43,7 +43,7 @@ def list_orgs(
43
43
  table.add_column("", width=3)
44
44
  table.add_column("Name", width=20)
45
45
  table.add_column("Slug", width=15)
46
- table.add_column("Role", width=10)
46
+ table.add_column("Role", width=18)
47
47
  table.add_column("Members", justify="right", width=8)
48
48
 
49
49
  for org in orgs:
@@ -113,7 +113,10 @@ def members(
113
113
  @app.command()
114
114
  def invite(
115
115
  email: str = typer.Argument(..., help="Email address to invite."),
116
- role: str = typer.Option("member", "--role", "-r", help="Role: admin, manager, member."),
116
+ role: str = typer.Option(
117
+ "member", "--role", "-r",
118
+ help="Role: admin, manager (account), project_manager, member, viewer.",
119
+ ),
117
120
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
118
121
  ) -> None:
119
122
  """Invite a member to the current organization."""
@@ -132,3 +135,74 @@ def invite(
132
135
  except APIError as e:
133
136
  format_error(e.message)
134
137
  raise typer.Exit(1)
138
+
139
+
140
+ @app.command("resend-invite")
141
+ def resend_invite(
142
+ invitation_id: str = typer.Argument(..., help="Invitation ID to resend."),
143
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
144
+ ) -> None:
145
+ """Resend a pending invitation email."""
146
+ client = CrowdTimeClient(require_auth=True, require_org=True)
147
+
148
+ try:
149
+ data = client.post(f"/invitations/{invitation_id}/resend/")
150
+
151
+ if output_json:
152
+ print_json(data)
153
+ else:
154
+ format_success("Invitation email resent.")
155
+ except APIError as e:
156
+ format_error(e.message)
157
+ raise typer.Exit(1)
158
+
159
+
160
+ @app.command()
161
+ def invitations(
162
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
163
+ ) -> None:
164
+ """List pending invitations for the current organization."""
165
+ client = CrowdTimeClient(require_auth=True, require_org=True)
166
+
167
+ try:
168
+ data = client.get("/invitations/")
169
+ inv_list = data if isinstance(data, list) else data.get("results", [])
170
+
171
+ if output_json:
172
+ print_json(inv_list)
173
+ return
174
+
175
+ pending = [i for i in inv_list if i.get("status") == "pending"]
176
+ if not pending:
177
+ console.print("[dim]No pending invitations.[/dim]")
178
+ return
179
+
180
+ table = Table(show_header=True, header_style="bold")
181
+ table.add_column("ID", style="dim")
182
+ table.add_column("Email", width=25)
183
+ table.add_column("Role", width=18)
184
+ table.add_column("Status", width=10)
185
+ table.add_column("Expires", width=12)
186
+
187
+ role_labels = {
188
+ "owner": "Owner",
189
+ "admin": "Admin",
190
+ "manager": "Account Manager",
191
+ "project_manager": "Project Manager",
192
+ "member": "Member",
193
+ "viewer": "Viewer",
194
+ }
195
+
196
+ for inv in pending:
197
+ table.add_row(
198
+ inv.get("id", ""),
199
+ inv.get("email", ""),
200
+ role_labels.get(inv.get("role", ""), inv.get("role", "")),
201
+ inv.get("status", ""),
202
+ inv.get("expires_at", "")[:10] if inv.get("expires_at") else "",
203
+ )
204
+
205
+ console.print(table)
206
+ except APIError as e:
207
+ format_error(e.message)
208
+ raise typer.Exit(1)
@@ -99,6 +99,23 @@ class CrowdTimeConfig:
99
99
  def server_url(self) -> str:
100
100
  return str(self.get("server.url", "https://api.crowdtime.lat")).rstrip("/")
101
101
 
102
+ @property
103
+ def frontend_url(self) -> str:
104
+ """Frontend URL, derived from server URL if not explicitly set.
105
+
106
+ Default derivation: https://api.crowdtime.lat → https://app.crowdtime.lat
107
+ Local dev: http://localhost:8001 → http://localhost:3000
108
+ """
109
+ explicit = self.get("server.frontend_url", "")
110
+ if explicit:
111
+ return str(explicit).rstrip("/")
112
+ url = self.server_url
113
+ if "://api." in url:
114
+ return url.replace("://api.", "://app.")
115
+ if "localhost:8001" in url:
116
+ return url.replace("localhost:8001", "localhost:3000")
117
+ return url
118
+
102
119
  @property
103
120
  def organization(self) -> str:
104
121
  return str(self.get("defaults.organization", ""))
@@ -362,6 +362,16 @@ def format_report(data: list[dict[str, Any]], group_by: str = "project") -> None
362
362
  console.print(table)
363
363
 
364
364
 
365
+ ROLE_LABELS = {
366
+ "owner": "Owner",
367
+ "admin": "Admin",
368
+ "manager": "Account Manager",
369
+ "project_manager": "Project Manager",
370
+ "member": "Member",
371
+ "viewer": "Viewer",
372
+ }
373
+
374
+
365
375
  def format_members_table(members: list[dict[str, Any]]) -> None:
366
376
  """Display organization members in a table."""
367
377
  if not members:
@@ -371,15 +381,17 @@ def format_members_table(members: list[dict[str, Any]]) -> None:
371
381
  table = Table(show_header=True, header_style="bold")
372
382
  table.add_column("Name", width=20)
373
383
  table.add_column("Email", width=25)
374
- table.add_column("Role", width=10)
384
+ table.add_column("Role", width=18)
375
385
  table.add_column("Status", width=10)
376
386
 
377
387
  for m in members:
378
388
  status = "[green]Active[/green]" if m.get("is_active", True) else "[dim]Inactive[/dim]"
389
+ role = m.get("role", "member")
390
+ role_label = ROLE_LABELS.get(role, role)
379
391
  table.add_row(
380
392
  m.get("user_name", ""),
381
393
  m.get("user_email", ""),
382
- m.get("role", "member"),
394
+ role_label,
383
395
  status,
384
396
  )
385
397
 
@@ -22,6 +22,7 @@ from .models import TimeEntry, User
22
22
  from .commands import (
23
23
  ai_cmd,
24
24
  auth_cmd,
25
+ billing_cmd,
25
26
  clients_cmd,
26
27
  config_cmd,
27
28
  expense_cmd,
@@ -49,6 +50,7 @@ app = typer.Typer(
49
50
 
50
51
  # Register command groups
51
52
  app.add_typer(auth_cmd.app, name="auth")
53
+ app.add_typer(billing_cmd.app, name="billing")
52
54
  app.add_typer(timer_cmd.app, name="timer")
53
55
  app.add_typer(log_cmd.app, name="log")
54
56
  app.add_typer(projects_cmd.app, name="projects")
@@ -277,6 +279,14 @@ def projects_list_alias(
277
279
  projects_cmd.list_projects(archived=archived, output_json=output_json)
278
280
 
279
281
 
282
+ @app.command("b", hidden=True)
283
+ def billing_alias(
284
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
285
+ ) -> None:
286
+ """Alias for 'billing status'."""
287
+ billing_cmd.status(output_json=output_json)
288
+
289
+
280
290
  @app.command("ex", hidden=True)
281
291
  def expense_list_alias(
282
292
  from_date: Optional[str] = typer.Option(None, "--from"),
@@ -301,10 +311,10 @@ def expense_list_alias(
301
311
  def _is_known_command(arg: str) -> bool:
302
312
  """Check if an argument is a known command or subcommand."""
303
313
  known = {
304
- "auth", "timer", "log", "projects", "clients", "tasks", "report", "ai",
305
- "org", "config", "favorites", "skill", "timesheet", "invoice", "expense",
314
+ "auth", "billing", "timer", "log", "projects", "clients", "tasks", "report",
315
+ "ai", "org", "config", "favorites", "skill", "timesheet", "invoice", "expense",
306
316
  "status", "s", "t", "ts", "tx",
307
- "l", "ll", "r", "p", "ex", "--help", "-h", "--version", "-v", "--json",
317
+ "l", "ll", "r", "p", "b", "ex", "--help", "-h", "--version", "-v", "--json",
308
318
  }
309
319
  return arg in known
310
320
 
@@ -208,14 +208,44 @@ ct invoice retainer withdrawals <id> # View withdrawal history
208
208
  ct invoice retainer reverse-withdrawal <id> -w <wd-id> -r "reason" # Reverse a withdrawal
209
209
  ```
210
210
 
211
+ ### Billing
212
+ ```bash
213
+ ct billing # Show subscription status (plan, seats, pricing, next bill)
214
+ ct billing status # Same as bare ct billing
215
+ ct billing checkout # Start subscription via Stripe Checkout in browser (owner only)
216
+ ct billing checkout annual # Start with annual plan
217
+ ct billing portal # Open Stripe billing portal in browser (admin+ only)
218
+ ct billing plan annual # Switch to annual billing (owner only)
219
+ ct billing plan monthly # Switch to monthly billing (owner only)
220
+ ct billing reactivate # Undo a pending cancellation (owner only)
221
+ ct billing events # View billing event audit log (owner only)
222
+ ct billing events -n 50 # Show last 50 events
223
+
224
+ # Short alias
225
+ ct b # billing status
226
+ ```
227
+
211
228
  ### Organizations
212
229
  ```bash
213
230
  ct org list # List your organizations
214
231
  ct org switch <slug> # Switch active organization
215
232
  ct org members # List members
233
+ ct org invitations # List pending invitations
216
234
  ct org invite user@example.com -r member
235
+ ct org invite client@acme.com -r viewer # External client stakeholder (read-only)
236
+ ct org invite pm@company.com -r project_manager # Project manager (no financial access)
237
+ ct org invite billing@company.com -r manager # Account manager (full financial access)
238
+ ct org resend-invite <invitation-id> # Resend a pending invitation
217
239
  ```
218
240
 
241
+ **Role hierarchy** (lowest → highest):
242
+ - **viewer** — external client stakeholder, read-only access scoped to assigned projects
243
+ - **member** — can log time, manage own entries
244
+ - **project_manager** — manage projects, tasks, timesheets, team time; no financial data
245
+ - **manager** (Account Manager) — everything project_manager does + invoices, rates, clients, billing
246
+ - **admin** — manage org settings, members, integrations
247
+ - **owner** — full control, can delete org
248
+
219
249
  ### Configuration
220
250
  ```bash
221
251
  ct config list # Show all config
@@ -11,6 +11,7 @@ Every command, subcommand, argument, flag, and option in the CrowdTime CLI.
11
11
  - [ct log](#ct-log)
12
12
  - [ct clients](#ct-clients)
13
13
  - [ct expense](#ct-expense)
14
+ - [ct billing](#ct-billing)
14
15
  - [ct projects](#ct-projects)
15
16
  - [ct tasks](#ct-tasks)
16
17
  - [ct report](#ct-report)
@@ -471,6 +472,111 @@ Endpoint: `POST /expenses/categories/`
471
472
 
472
473
  ---
473
474
 
475
+ ## ct billing
476
+
477
+ Short alias: `ct b`
478
+
479
+ ### ct billing (bare / status)
480
+
481
+ ```
482
+ ct billing [--json]
483
+ ct billing status [--json]
484
+ ct b [--json] # alias
485
+ ```
486
+
487
+ Shows subscription status in a Rich panel: status, plan (monthly/annual), seat count, base price, per-seat price, total monthly cost, next billing date, and next billing amount.
488
+
489
+ **Status colors:**
490
+ | Status | Color |
491
+ |--------|-------|
492
+ | ACTIVE | green |
493
+ | TRIALING | blue |
494
+ | PAST_DUE, UNPAID | red |
495
+ | CANCELED, PAUSED | yellow |
496
+
497
+ Handles 402 (subscription inactive) and 404 (no subscription) gracefully.
498
+
499
+ Endpoint: `GET /billing/`
500
+
501
+ ### ct billing portal
502
+
503
+ ```
504
+ ct billing portal [--json]
505
+ ```
506
+
507
+ Opens the Stripe Customer Portal in the default browser. Allows updating payment methods, viewing Stripe invoices, and managing the subscription.
508
+
509
+ ### ct billing checkout
510
+
511
+ ```
512
+ ct billing checkout [monthly|annual] [--json]
513
+ ```
514
+
515
+ Opens Stripe Checkout in the browser to start a new subscription (with free trial). Defaults to monthly plan.
516
+
517
+ - Requires **owner** role
518
+ - Opens the Stripe-hosted checkout page in the default browser
519
+ - After checkout, redirects to the billing settings page
520
+ - `--json` outputs `{"checkout_url": "..."}` instead of opening the browser
521
+ - Errors if the organization already has an active subscription
522
+
523
+ Endpoint: `POST /billing/checkout/`
524
+
525
+ ### ct billing portal
526
+
527
+ - Requires **admin+** role (API enforces this; shows friendly error on 403)
528
+ - Prints the portal URL for manual access if the browser doesn't open
529
+ - `--json` outputs `{"portal_url": "..."}` instead of opening the browser
530
+
531
+ Endpoint: `POST /billing/portal/`
532
+
533
+ ### ct billing plan
534
+
535
+ ```
536
+ ct billing plan <monthly|annual> [--json]
537
+ ```
538
+
539
+ Switches the billing plan between monthly and annual. The change takes effect immediately with prorated credit for the remainder of the current period.
540
+
541
+ - Requires **owner** role
542
+ - Shows the new billing amount after switching
543
+ - Errors if already on the target plan
544
+
545
+ Endpoint: `PATCH /billing/plan/`
546
+
547
+ ### ct billing reactivate
548
+
549
+ ```
550
+ ct billing reactivate [--json]
551
+ ```
552
+
553
+ Undoes a pending cancellation so the subscription continues at the end of the current billing period.
554
+
555
+ - Requires **owner** role
556
+ - Only works when `cancel_at_period_end` is true
557
+ - Shows the next renewal date after reactivation
558
+
559
+ Endpoint: `POST /billing/reactivate/`
560
+
561
+ ### ct billing events
562
+
563
+ ```
564
+ ct billing events [--limit/-n N] [--json]
565
+ ```
566
+
567
+ | Option | Type | Default | Description |
568
+ |--------|------|---------|-------------|
569
+ | `--limit`, `-n` | int | 20 | Number of events to show |
570
+ | `--json` | flag | | JSON output |
571
+
572
+ Shows recent Stripe webhook events processed for this organization. Each event shows date, event type, and processing status (OK, FAILED, PENDING).
573
+
574
+ - Requires **owner** role
575
+
576
+ Endpoint: `GET /billing/events/`
577
+
578
+ ---
579
+
474
580
  ## ct projects
475
581
 
476
582
  ### ct projects list
@@ -745,10 +851,33 @@ ct org invite EMAIL [--role/-r ROLE] [--json]
745
851
 
746
852
  | Option | Type | Description |
747
853
  |--------|------|-------------|
748
- | `--role`, `-r` | string | `admin`, `manager`, `member` (default) |
854
+ | `--role`, `-r` | string | `admin`, `manager`, `project_manager`, `member` (default), `viewer` |
855
+
856
+ **Roles:**
857
+ - `viewer` — external client stakeholder, read-only access scoped to assigned projects
858
+ - `member` — can log time, manage own entries
859
+ - `project_manager` — manage projects, tasks, timesheets, team time; no financial data (rates, invoices)
860
+ - `manager` — Account Manager: project management + invoices, rates, clients, billing
861
+ - `admin` — manage org settings, members, integrations
749
862
 
750
863
  Endpoint: `POST /members/invite/`
751
864
 
865
+ ### ct org invitations
866
+
867
+ ```
868
+ ct org invitations [--json]
869
+ ```
870
+
871
+ Lists pending invitations for the current organization. Endpoint: `GET /invitations/`
872
+
873
+ ### ct org resend-invite
874
+
875
+ ```
876
+ ct org resend-invite INVITATION_ID [--json]
877
+ ```
878
+
879
+ Resends the invitation email for a pending invitation. Endpoint: `POST /invitations/{id}/resend/`
880
+
752
881
  ---
753
882
 
754
883
  ## ct config
@@ -1049,5 +1178,6 @@ Endpoint: `POST /invoices/retainers/{id}/withdrawals/{wid}/reverse/`
1049
1178
  | `ct ll` | `ct log list` |
1050
1179
  | `ct p` | `ct projects list` |
1051
1180
  | `ct r` | `ct report` |
1181
+ | `ct b` | `ct billing status` |
1052
1182
  | `ct ex` | `ct expense` |
1053
1183
  | `ct "text"` | `ct ai parse "text"` |
@@ -15,6 +15,7 @@ Multi-step workflow patterns for real-world time tracking scenarios.
15
15
  - [Client Contact Management](#client-contact-management)
16
16
  - [Project Budget Tracking](#project-budget-tracking)
17
17
  - [Invoicing and Billing](#invoicing-and-billing)
18
+ - [Subscription and Billing](#subscription-and-billing)
18
19
  - [Troubleshooting](#troubleshooting)
19
20
 
20
21
  ---
@@ -173,18 +174,43 @@ ct l -p acme -d 2026-03-05 3h "client meeting"
173
174
  ### Onboard a New Team Member
174
175
 
175
176
  ```bash
176
- # Invite them
177
+ # Invite a regular team member
177
178
  ct org invite newdev@company.com -r member
178
179
 
179
- # They need to:
180
- # 1. Accept the invitation (via email link)
181
- # 2. Install CLI: pip install crowdtime-cli
182
- # 3. Configure:
183
- # ct config set server.url https://your-server.com
184
- # ct auth login
185
- # ct org switch your-org
180
+ # Invite a project manager (can manage projects/tasks/timesheets, no financial access)
181
+ ct org invite pm@company.com -r project_manager
182
+
183
+ # Invite an account manager (full financial access: invoices, rates, clients)
184
+ ct org invite billing@company.com -r manager
185
+
186
+ # Invite an external client stakeholder (read-only, scoped to their projects)
187
+ ct org invite client@acme.com -r viewer
188
+
189
+ # They receive an email with an "Accept Invitation" link
190
+ # New users can sign up with email/password or Google OAuth
186
191
  ```
187
192
 
193
+ ### Manage Invitations
194
+
195
+ ```bash
196
+ # List pending invitations
197
+ ct org invitations
198
+
199
+ # Resend an invitation email
200
+ ct org resend-invite <invitation-id>
201
+ ```
202
+
203
+ ### Role Reference
204
+
205
+ | Role | Description |
206
+ |------|-------------|
207
+ | `viewer` | External client: read-only, scoped to assigned projects |
208
+ | `member` | Logs time, manages own entries |
209
+ | `project_manager` | Manages projects, tasks, timesheets, team time. No financial data |
210
+ | `manager` | Account Manager: everything above + invoices, rates, clients, billing |
211
+ | `admin` | Manages org settings, members, integrations |
212
+ | `owner` | Full control including org deletion |
213
+
188
214
  ### Check Team Activity
189
215
 
190
216
  ```bash
@@ -602,6 +628,93 @@ ct invoice retainer withdraw <retainer-id> --invoice <standard-invoice-id> --amo
602
628
 
603
629
  ---
604
630
 
631
+ ## Subscription and Billing
632
+
633
+ ### Check Subscription Status
634
+
635
+ ```bash
636
+ # Quick check
637
+ ct billing
638
+
639
+ # Machine-readable
640
+ ct billing --json
641
+ ```
642
+
643
+ ### Start a Subscription
644
+
645
+ ```bash
646
+ # Start a free trial (opens Stripe Checkout in browser)
647
+ ct billing checkout
648
+
649
+ # Start with annual plan (20% savings)
650
+ ct billing checkout annual
651
+ ```
652
+
653
+ ### Manage Subscription via Stripe Portal
654
+
655
+ ```bash
656
+ # Open Stripe billing portal (admin+ only)
657
+ ct billing portal
658
+
659
+ # From the portal you can:
660
+ # - Update payment method
661
+ # - View past Stripe invoices
662
+ # - Cancel subscription
663
+ ```
664
+
665
+ ### Switch Billing Plan
666
+
667
+ ```bash
668
+ # Switch to annual billing (saves 20%)
669
+ ct billing plan annual
670
+
671
+ # Switch back to monthly
672
+ ct billing plan monthly
673
+
674
+ # Change takes effect immediately with prorated credit
675
+ ```
676
+
677
+ ### Reactivate a Canceled Subscription
678
+
679
+ ```bash
680
+ # If subscription is set to cancel at period end:
681
+ ct billing reactivate
682
+
683
+ # Verify it's active again
684
+ ct billing
685
+ ```
686
+
687
+ ### View Billing Event History
688
+
689
+ ```bash
690
+ # Show recent billing events (webhook audit log)
691
+ ct billing events
692
+
693
+ # Show more events
694
+ ct billing events -n 50
695
+
696
+ # Machine-readable
697
+ ct billing events --json
698
+ ```
699
+
700
+ ### Handle Subscription Issues
701
+
702
+ ```bash
703
+ # If you see "subscription is inactive" errors on other commands:
704
+ ct billing # Check current status
705
+ ct billing portal # Open portal to update payment or resubscribe
706
+
707
+ # If subscription is canceled but still within billing period:
708
+ ct billing reactivate # Undo the cancellation
709
+
710
+ # If you get "permission denied":
711
+ # - billing portal requires admin+ role
712
+ # - plan changes and reactivation require owner role
713
+ # Ask your org owner to manage the subscription.
714
+ ```
715
+
716
+ ---
717
+
605
718
  ## Troubleshooting
606
719
 
607
720
  ### "Not authenticated" Error
File without changes