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.
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/.gitignore +1 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/PKG-INFO +4 -2
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/README.md +3 -1
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/client.py +16 -0
- crowdtime_cli-0.5.0/src/crowdtime_cli/commands/billing_cmd.py +446 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/org_cmd.py +76 -2
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/config.py +17 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/formatters.py +14 -2
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/main.py +13 -3
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +30 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +131 -1
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +121 -8
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/LICENSE +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.3.0 → crowdtime_cli-0.5.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.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
|
|
|
@@ -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=
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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",
|
|
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
|
|
177
|
+
# Invite a regular team member
|
|
177
178
|
ct org invite newdev@company.com -r member
|
|
178
179
|
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|