crowdtime-cli 0.21.0__tar.gz → 0.22.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.21.0 → crowdtime_cli-0.22.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/clients_cmd.py +2 -2
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/expense_cmd.py +38 -7
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/invoice_cmd.py +19 -5
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/org_cmd.py +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/projects_cmd.py +156 -7
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/pto_cmd.py +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/tasks_cmd.py +1 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/team_cmd.py +5 -5
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timer_cmd.py +17 -3
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timesheet_cmd.py +4 -4
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/formatters.py +4 -4
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/main.py +2 -1
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/models.py +5 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +15 -8
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +29 -11
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +2 -2
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +29 -6
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/.gitignore +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/LICENSE +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/README.md +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.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
|
|
@@ -75,7 +75,7 @@ def list_clients(
|
|
|
75
75
|
return
|
|
76
76
|
|
|
77
77
|
table = Table(show_header=True, header_style="bold")
|
|
78
|
-
table.add_column("ID", style="dim")
|
|
78
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
79
79
|
table.add_column("Name")
|
|
80
80
|
table.add_column("Contact")
|
|
81
81
|
table.add_column("Projects", justify="right")
|
|
@@ -355,7 +355,7 @@ def list_contacts(
|
|
|
355
355
|
return
|
|
356
356
|
|
|
357
357
|
table = Table(show_header=True, header_style="bold")
|
|
358
|
-
table.add_column("ID", style="dim")
|
|
358
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
359
359
|
table.add_column("Name", width=20)
|
|
360
360
|
table.add_column("Email", width=25)
|
|
361
361
|
table.add_column("Phone", width=15)
|
|
@@ -21,16 +21,22 @@ console = Console()
|
|
|
21
21
|
# ─── Helpers ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def _print_expenses_table(expenses: list[dict]) -> None:
|
|
25
|
-
"""Print expenses in a Rich table."""
|
|
24
|
+
def _print_expenses_table(expenses: list[dict], me: str | None = None) -> None:
|
|
25
|
+
"""Print expenses in a Rich table, with a User column whenever one isn't yours."""
|
|
26
26
|
if not expenses:
|
|
27
27
|
console.print("[dim]No expenses found.[/dim]")
|
|
28
28
|
console.print("Run [bold]ct expense create[/bold] to log an expense.")
|
|
29
29
|
return
|
|
30
30
|
|
|
31
31
|
table = Table(show_header=True, header_style="bold")
|
|
32
|
-
|
|
32
|
+
# Full UUIDs: they are what show/edit/delete take, so never let Rich clip them.
|
|
33
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
33
34
|
table.add_column("Date", width=12)
|
|
35
|
+
# The list holds every expense the caller can see — colleagues' too for
|
|
36
|
+
# managers and project leads — so mark whose each one is (CRO-93).
|
|
37
|
+
show_user = any(str(exp.get("user")) != me for exp in expenses)
|
|
38
|
+
if show_user:
|
|
39
|
+
table.add_column("User", width=16)
|
|
34
40
|
table.add_column("Description", width=25)
|
|
35
41
|
table.add_column("Category", width=14)
|
|
36
42
|
table.add_column("Project", style="cyan", width=15)
|
|
@@ -51,9 +57,14 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
51
57
|
is_billable = exp.get("is_billable", False)
|
|
52
58
|
is_invoiced = exp.get("is_invoiced", False)
|
|
53
59
|
|
|
60
|
+
user_cell = (
|
|
61
|
+
["You" if str(exp.get("user")) == me else exp.get("user_name", "") or ""]
|
|
62
|
+
if show_user else []
|
|
63
|
+
)
|
|
54
64
|
table.add_row(
|
|
55
65
|
str(exp.get("id", "")),
|
|
56
66
|
exp.get("date", ""),
|
|
67
|
+
*user_cell,
|
|
57
68
|
exp.get("description", ""),
|
|
58
69
|
exp.get("category_name", exp.get("category", "")) or "",
|
|
59
70
|
exp.get("project_name", exp.get("project", "")) or "",
|
|
@@ -65,6 +76,8 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
65
76
|
|
|
66
77
|
console.print(table)
|
|
67
78
|
console.print(f"\n[bold]Total:[/bold] {format_currency(total)} ({len(expenses)} expenses)")
|
|
79
|
+
if show_user:
|
|
80
|
+
console.print("[dim]Includes other people's expenses. Use --mine for only yours.[/dim]")
|
|
68
81
|
|
|
69
82
|
|
|
70
83
|
# ─── Commands ─────────────────────────────────────────────────────
|
|
@@ -74,7 +87,12 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
74
87
|
def expense_default(ctx: typer.Context) -> None:
|
|
75
88
|
"""Manage expenses."""
|
|
76
89
|
if ctx.invoked_subcommand is None:
|
|
77
|
-
|
|
90
|
+
# Called directly, so every option needs a real value — Typer's
|
|
91
|
+
# defaults are OptionInfo objects outside the CLI parser.
|
|
92
|
+
list_expenses(
|
|
93
|
+
from_date=None, to_date=None, project=None, category=None,
|
|
94
|
+
billable=None, invoiced=None, mine=False, output_json=False,
|
|
95
|
+
)
|
|
78
96
|
|
|
79
97
|
|
|
80
98
|
@app.command("list")
|
|
@@ -85,22 +103,35 @@ def list_expenses(
|
|
|
85
103
|
category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by category."),
|
|
86
104
|
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", help="Filter by billable status."),
|
|
87
105
|
invoiced: Optional[bool] = typer.Option(None, "--invoiced/--uninvoiced", help="Filter by invoiced status."),
|
|
106
|
+
mine: bool = typer.Option(
|
|
107
|
+
False, "--mine",
|
|
108
|
+
help="Only your own expenses. Managers and project leads otherwise see colleagues' too.",
|
|
109
|
+
),
|
|
88
110
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
89
111
|
) -> None:
|
|
90
112
|
"""List expenses with optional filters.
|
|
91
113
|
|
|
92
|
-
By default, shows the current month's expenses
|
|
114
|
+
By default, shows the current month's expenses — every expense you can
|
|
115
|
+
see. The table shows a User column whenever one isn't yours.
|
|
93
116
|
|
|
94
117
|
Examples:
|
|
95
118
|
ct expense list
|
|
96
119
|
ct expense list --from 2026-03-01 --to 2026-03-31
|
|
97
120
|
ct expense list --project myproject --billable
|
|
98
121
|
ct expense list --category travel --uninvoiced
|
|
122
|
+
ct expense list --mine
|
|
99
123
|
"""
|
|
100
124
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
101
125
|
|
|
102
126
|
params: dict = {}
|
|
103
127
|
|
|
128
|
+
me = client.current_user_id()
|
|
129
|
+
if mine:
|
|
130
|
+
if not me:
|
|
131
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
params["user"] = me
|
|
134
|
+
|
|
104
135
|
# Default to current month if no date filters provided
|
|
105
136
|
if not from_date and not to_date:
|
|
106
137
|
today = date_type.today()
|
|
@@ -137,7 +168,7 @@ def list_expenses(
|
|
|
137
168
|
if output_json:
|
|
138
169
|
print_json(results)
|
|
139
170
|
else:
|
|
140
|
-
_print_expenses_table(results)
|
|
171
|
+
_print_expenses_table(results, me)
|
|
141
172
|
except APIError as e:
|
|
142
173
|
format_error(e.message)
|
|
143
174
|
raise typer.Exit(1)
|
|
@@ -385,7 +416,7 @@ def list_categories(
|
|
|
385
416
|
return
|
|
386
417
|
|
|
387
418
|
table = Table(show_header=True, header_style="bold")
|
|
388
|
-
table.add_column("ID", style="dim")
|
|
419
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
389
420
|
table.add_column("Name", width=25)
|
|
390
421
|
|
|
391
422
|
for cat in results:
|
|
@@ -589,6 +589,14 @@ def create_invoice(
|
|
|
589
589
|
"in a client-wide invoice."
|
|
590
590
|
),
|
|
591
591
|
),
|
|
592
|
+
payments: Optional[list[str]] = typer.Option(
|
|
593
|
+
None, "--payment",
|
|
594
|
+
help=(
|
|
595
|
+
"One-off fixed-fee payment ID(s) to bill (see `ct projects show`). "
|
|
596
|
+
"Repeat for multiple. Without it a selected one-off project bills "
|
|
597
|
+
"its next unbilled payment; each payment is billed only once."
|
|
598
|
+
),
|
|
599
|
+
),
|
|
592
600
|
group_by: Optional[str] = typer.Option(
|
|
593
601
|
None, "--group-by", "-g", help="Group line items by: project, task, user, date, none."
|
|
594
602
|
),
|
|
@@ -630,6 +638,7 @@ def create_invoice(
|
|
|
630
638
|
ct invoice create --client <id> --from monday --to friday --payment-terms 15
|
|
631
639
|
ct invoice create --client <id> --period last-month --due-date 2026-09-15
|
|
632
640
|
ct invoice create --client <id> --period last-month --project <fixed-project-id>
|
|
641
|
+
ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
|
|
633
642
|
"""
|
|
634
643
|
# Resolve period dates
|
|
635
644
|
if period:
|
|
@@ -690,6 +699,11 @@ def create_invoice(
|
|
|
690
699
|
payload["due_date"] = due_date_str
|
|
691
700
|
if projects:
|
|
692
701
|
payload["project_ids"] = list(projects)
|
|
702
|
+
if payments:
|
|
703
|
+
if not projects:
|
|
704
|
+
format_error("--payment needs the fixed-fee project selected with --project.")
|
|
705
|
+
raise typer.Exit(1)
|
|
706
|
+
payload["milestone_ids"] = list(payments)
|
|
693
707
|
if group_by:
|
|
694
708
|
payload["group_by"] = group_by
|
|
695
709
|
if tax_rate is not None:
|
|
@@ -1305,7 +1319,7 @@ def list_recurring(
|
|
|
1305
1319
|
return
|
|
1306
1320
|
|
|
1307
1321
|
table = Table(show_header=True, header_style="bold")
|
|
1308
|
-
table.add_column("ID", style="dim")
|
|
1322
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1309
1323
|
table.add_column("Name", width=20)
|
|
1310
1324
|
table.add_column("Client", width=18)
|
|
1311
1325
|
table.add_column("Schedule", width=14)
|
|
@@ -1674,7 +1688,7 @@ def list_retainers(
|
|
|
1674
1688
|
return
|
|
1675
1689
|
|
|
1676
1690
|
table = Table(show_header=True, header_style="bold")
|
|
1677
|
-
table.add_column("ID", style="dim")
|
|
1691
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1678
1692
|
table.add_column("Name", width=20)
|
|
1679
1693
|
table.add_column("Client", width=18)
|
|
1680
1694
|
table.add_column("Monthly", justify="right", width=12)
|
|
@@ -1878,7 +1892,7 @@ def list_withdrawals(
|
|
|
1878
1892
|
return
|
|
1879
1893
|
|
|
1880
1894
|
table = Table(show_header=True, header_style="bold")
|
|
1881
|
-
table.add_column("ID", style="dim")
|
|
1895
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1882
1896
|
table.add_column("Invoice", width=14)
|
|
1883
1897
|
table.add_column("Amount", justify="right", width=12)
|
|
1884
1898
|
table.add_column("Date", width=12)
|
|
@@ -1981,7 +1995,7 @@ def tax_rates_list(
|
|
|
1981
1995
|
return
|
|
1982
1996
|
|
|
1983
1997
|
table = Table(title="Tax Rates")
|
|
1984
|
-
table.add_column("ID", style="dim")
|
|
1998
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1985
1999
|
table.add_column("Name", style="bold")
|
|
1986
2000
|
table.add_column("Rate %", justify="right")
|
|
1987
2001
|
table.add_column("Default")
|
|
@@ -2140,7 +2154,7 @@ def uninvoiced_entries(
|
|
|
2140
2154
|
table.add_column("Entries", justify="right")
|
|
2141
2155
|
table.add_column("Hours", justify="right")
|
|
2142
2156
|
table.add_column("Amount", justify="right", style="green")
|
|
2143
|
-
table.add_column("Client ID", style="dim")
|
|
2157
|
+
table.add_column("Client ID", style="dim", no_wrap=True, min_width=36)
|
|
2144
2158
|
|
|
2145
2159
|
total_entries = 0
|
|
2146
2160
|
total_hours = Decimal("0")
|
|
@@ -383,7 +383,7 @@ def invitations(
|
|
|
383
383
|
return
|
|
384
384
|
|
|
385
385
|
table = Table(show_header=True, header_style="bold")
|
|
386
|
-
table.add_column("ID", style="dim")
|
|
386
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
387
387
|
table.add_column("Email", width=25)
|
|
388
388
|
table.add_column("Role", width=18)
|
|
389
389
|
table.add_column("Status", width=10)
|
|
@@ -42,6 +42,69 @@ def _resolve_billing_type(value: str) -> str | None:
|
|
|
42
42
|
return BILLING_TYPE_ALIASES.get(value.strip().lower().replace("-", "_"))
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
FEE_SCHEDULES = {"one-off": "one_off", "one_off": "one_off", "oneoff": "one_off",
|
|
46
|
+
"recurring": "recurring"}
|
|
47
|
+
FEE_FREQUENCIES = ["weekly", "biweekly", "monthly", "quarterly"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_payment(value: str) -> dict:
|
|
51
|
+
"""Parse a --payment value: NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD."""
|
|
52
|
+
if "=" not in value:
|
|
53
|
+
raise ValueError(f"Invalid --payment '{value}'. Use NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD.")
|
|
54
|
+
name, rest = value.rsplit("=", 1)
|
|
55
|
+
amount, _, due = rest.partition("@")
|
|
56
|
+
try:
|
|
57
|
+
amount_dec = Decimal(amount.strip())
|
|
58
|
+
except Exception:
|
|
59
|
+
raise ValueError(f"Invalid amount in --payment '{value}'.")
|
|
60
|
+
if not name.strip() or amount_dec <= 0:
|
|
61
|
+
raise ValueError(f"Invalid --payment '{value}': needs a name and an amount above zero.")
|
|
62
|
+
return {"name": name.strip(), "amount": str(amount_dec), "due_date": due.strip() or None}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fee_plan_payload(
|
|
66
|
+
fee_schedule: Optional[str],
|
|
67
|
+
fee_frequency: Optional[str],
|
|
68
|
+
payments: Optional[List[str]],
|
|
69
|
+
first_invoice: Optional[str],
|
|
70
|
+
existing: Optional[list[dict]] = None,
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""Build the fixed-fee plan fields of a project payload (CRO-61).
|
|
73
|
+
|
|
74
|
+
On update, ``existing`` milestones keep their id when a --payment names
|
|
75
|
+
them, and payments already invoiced are always kept — the API refuses a
|
|
76
|
+
plan that drops or changes them.
|
|
77
|
+
"""
|
|
78
|
+
payload: dict = {}
|
|
79
|
+
if fee_schedule is not None:
|
|
80
|
+
resolved = FEE_SCHEDULES.get(fee_schedule.strip().lower())
|
|
81
|
+
if resolved is None:
|
|
82
|
+
raise ValueError("Invalid --fee-schedule. Choose from: one-off, recurring.")
|
|
83
|
+
payload["fixed_fee_schedule"] = resolved
|
|
84
|
+
if fee_frequency is not None:
|
|
85
|
+
if fee_frequency not in FEE_FREQUENCIES:
|
|
86
|
+
raise ValueError(f"Invalid --fee-frequency. Choose from: {', '.join(FEE_FREQUENCIES)}.")
|
|
87
|
+
payload["fixed_fee_frequency"] = fee_frequency
|
|
88
|
+
payload.setdefault("fixed_fee_schedule", "recurring")
|
|
89
|
+
if first_invoice is not None:
|
|
90
|
+
payload["fixed_fee_first_invoice_date"] = first_invoice
|
|
91
|
+
if payments:
|
|
92
|
+
items = [_parse_payment(p) for p in payments]
|
|
93
|
+
by_name = {m["name"]: m for m in (existing or [])}
|
|
94
|
+
for item in items:
|
|
95
|
+
if item["name"] in by_name:
|
|
96
|
+
item["id"] = by_name[item["name"]]["id"]
|
|
97
|
+
named = {i["name"] for i in items}
|
|
98
|
+
billed = [
|
|
99
|
+
{"id": m["id"], "name": m["name"], "amount": m["amount"], "due_date": m.get("due_date")}
|
|
100
|
+
for m in (existing or [])
|
|
101
|
+
if m.get("invoice") and m["name"] not in named
|
|
102
|
+
]
|
|
103
|
+
payload["fixed_fee_milestones"] = billed + items
|
|
104
|
+
payload.setdefault("fixed_fee_schedule", "one_off")
|
|
105
|
+
return payload
|
|
106
|
+
|
|
107
|
+
|
|
45
108
|
PAYMENT_TERMS_ALIASES = {"receipt": 0, "net7": 7, "net15": 15, "net30": 30}
|
|
46
109
|
|
|
47
110
|
|
|
@@ -146,7 +209,26 @@ def show(
|
|
|
146
209
|
if project.fixed_fee_amount is not None
|
|
147
210
|
else "[red]not set[/red]"
|
|
148
211
|
)
|
|
149
|
-
|
|
212
|
+
if project.fixed_fee_schedule == "recurring":
|
|
213
|
+
freq = project.fixed_fee_frequency or "?"
|
|
214
|
+
console.print(f" Billing: Recurring fixed fee — {fee} {freq}")
|
|
215
|
+
tpl = project.fixed_fee_template
|
|
216
|
+
if tpl:
|
|
217
|
+
state = (f"next invoice {tpl['next_run_date']}" if tpl.get("is_active")
|
|
218
|
+
else "paused")
|
|
219
|
+
console.print(f" Invoiced by: {tpl['name']} ({tpl['id']}) — {state}")
|
|
220
|
+
else:
|
|
221
|
+
console.print(" Invoiced by: [yellow]no active recurring invoice[/yellow]")
|
|
222
|
+
else:
|
|
223
|
+
console.print(f" Billing: Fixed Fee (one-off) — {fee}")
|
|
224
|
+
for m in project.fixed_fee_milestones or []:
|
|
225
|
+
inv = m.get("invoice")
|
|
226
|
+
status = f"billed on {inv['invoice_number']}" if inv else "not billed"
|
|
227
|
+
due = f" due {m['due_date']}" if m.get("due_date") else ""
|
|
228
|
+
console.print(
|
|
229
|
+
f" • {m['name']}: {format_currency(Decimal(m['amount']), currency)}"
|
|
230
|
+
f"{due} — {status} [dim]{m['id']}[/dim]"
|
|
231
|
+
)
|
|
150
232
|
else:
|
|
151
233
|
console.print(" Billing: Time & Materials")
|
|
152
234
|
console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
|
|
@@ -184,6 +266,22 @@ def create(
|
|
|
184
266
|
None, "--fixed-fee",
|
|
185
267
|
help="Agreed price billed instead of logged hours. Implies --billing-type fixed_fee.",
|
|
186
268
|
),
|
|
269
|
+
fee_schedule: Optional[str] = typer.Option(
|
|
270
|
+
None, "--fee-schedule",
|
|
271
|
+
help="one-off (the price is paid in payments, default) or recurring (the price every period).",
|
|
272
|
+
),
|
|
273
|
+
fee_frequency: Optional[str] = typer.Option(
|
|
274
|
+
None, "--fee-frequency",
|
|
275
|
+
help="How often a recurring fee is billed: weekly, biweekly, monthly, quarterly.",
|
|
276
|
+
),
|
|
277
|
+
payments: Optional[List[str]] = typer.Option(
|
|
278
|
+
None, "--payment",
|
|
279
|
+
help="One-off payment as NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD (due date). Repeat; must add up to --fixed-fee.",
|
|
280
|
+
),
|
|
281
|
+
first_invoice: Optional[str] = typer.Option(
|
|
282
|
+
None, "--first-invoice",
|
|
283
|
+
help="Recurring fee: date of the first invoice (YYYY-MM-DD). Default: one period after start.",
|
|
284
|
+
),
|
|
187
285
|
payment_terms: Optional[str] = typer.Option(
|
|
188
286
|
None, "--payment-terms",
|
|
189
287
|
help=(
|
|
@@ -202,9 +300,16 @@ def create(
|
|
|
202
300
|
) -> None:
|
|
203
301
|
"""Create a new project.
|
|
204
302
|
|
|
205
|
-
Fixed-fee projects bill an agreed price instead of logged hours
|
|
206
|
-
|
|
207
|
-
|
|
303
|
+
Fixed-fee projects bill an agreed price instead of logged hours, one of two
|
|
304
|
+
ways. One-off: --fixed-fee is the whole price, paid in --payment
|
|
305
|
+
milestones (one full payment if none are given), each billed once.
|
|
306
|
+
Recurring: --fixed-fee is billed every --fee-frequency period by a
|
|
307
|
+
recurring invoice the project sets up for itself.
|
|
308
|
+
|
|
309
|
+
Examples:
|
|
310
|
+
ct projects create "Website" -c Acme --fixed-fee 5000 \
|
|
311
|
+
--payment Kickoff=1500@2026-06-01 --payment Midpoint=1500 --payment Delivery=2000
|
|
312
|
+
ct projects create "Support" -c Acme --fixed-fee 4000 --fee-frequency monthly
|
|
208
313
|
"""
|
|
209
314
|
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
210
315
|
|
|
@@ -251,6 +356,17 @@ def create(
|
|
|
251
356
|
payload["billing_type"] = resolved_billing_type
|
|
252
357
|
if fixed_fee is not None:
|
|
253
358
|
payload["fixed_fee_amount"] = fixed_fee
|
|
359
|
+
if resolved_billing_type != "fixed_fee" and (fee_schedule or fee_frequency or payments or first_invoice):
|
|
360
|
+
format_error("--fee-schedule, --fee-frequency, --payment and --first-invoice only apply to fixed-fee projects.")
|
|
361
|
+
raise typer.Exit(1)
|
|
362
|
+
try:
|
|
363
|
+
payload.update(_fee_plan_payload(fee_schedule, fee_frequency, payments, first_invoice))
|
|
364
|
+
except ValueError as e:
|
|
365
|
+
format_error(str(e))
|
|
366
|
+
raise typer.Exit(1)
|
|
367
|
+
if payload.get("fixed_fee_schedule") == "recurring" and not payload.get("fixed_fee_frequency"):
|
|
368
|
+
format_error("A recurring fee needs --fee-frequency.")
|
|
369
|
+
raise typer.Exit(1)
|
|
254
370
|
if payment_terms is not None:
|
|
255
371
|
resolved_terms = _resolve_payment_terms_opt(payment_terms)
|
|
256
372
|
if resolved_terms is None:
|
|
@@ -341,6 +457,22 @@ def update(
|
|
|
341
457
|
None, "--fixed-fee",
|
|
342
458
|
help="Agreed price billed instead of logged hours.",
|
|
343
459
|
),
|
|
460
|
+
fee_schedule: Optional[str] = typer.Option(
|
|
461
|
+
None, "--fee-schedule",
|
|
462
|
+
help="one-off (the price is paid in payments, default) or recurring (the price every period).",
|
|
463
|
+
),
|
|
464
|
+
fee_frequency: Optional[str] = typer.Option(
|
|
465
|
+
None, "--fee-frequency",
|
|
466
|
+
help="How often a recurring fee is billed: weekly, biweekly, monthly, quarterly.",
|
|
467
|
+
),
|
|
468
|
+
payments: Optional[List[str]] = typer.Option(
|
|
469
|
+
None, "--payment",
|
|
470
|
+
help="One-off payment as NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD (due date). Repeat; replaces the plan; invoiced payments are kept.",
|
|
471
|
+
),
|
|
472
|
+
first_invoice: Optional[str] = typer.Option(
|
|
473
|
+
None, "--first-invoice",
|
|
474
|
+
help="Recurring fee: date of the first invoice (YYYY-MM-DD). Moves the next invoice.",
|
|
475
|
+
),
|
|
344
476
|
payment_terms: Optional[str] = typer.Option(
|
|
345
477
|
None, "--payment-terms",
|
|
346
478
|
help="Payment terms: receipt, net7, net15, net30, or a number of days.",
|
|
@@ -364,6 +496,8 @@ def update(
|
|
|
364
496
|
ct projects update my-project --name "New Name"
|
|
365
497
|
ct projects update my-project --budget 200 --budget-type hours
|
|
366
498
|
ct projects update my-project --billing-mode per_person --status paused
|
|
499
|
+
ct projects update my-project --fixed-fee 6000 --payment Kickoff=1500 --payment Rest=4500
|
|
500
|
+
ct projects update my-project --fee-frequency quarterly
|
|
367
501
|
"""
|
|
368
502
|
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
369
503
|
|
|
@@ -411,6 +545,21 @@ def update(
|
|
|
411
545
|
payload["billing_type"] = resolved_billing_type
|
|
412
546
|
if fixed_fee is not None:
|
|
413
547
|
payload["fixed_fee_amount"] = fixed_fee
|
|
548
|
+
existing_milestones = None
|
|
549
|
+
if payments:
|
|
550
|
+
try:
|
|
551
|
+
current = api_client.get(f"/projects/{resolve_project(api_client, project_id)}/")
|
|
552
|
+
existing_milestones = current.get("fixed_fee_milestones") or []
|
|
553
|
+
except APIError as e:
|
|
554
|
+
format_error(e.message)
|
|
555
|
+
raise typer.Exit(1)
|
|
556
|
+
try:
|
|
557
|
+
payload.update(_fee_plan_payload(
|
|
558
|
+
fee_schedule, fee_frequency, payments, first_invoice, existing_milestones,
|
|
559
|
+
))
|
|
560
|
+
except ValueError as e:
|
|
561
|
+
format_error(str(e))
|
|
562
|
+
raise typer.Exit(1)
|
|
414
563
|
if payment_terms is not None:
|
|
415
564
|
resolved_terms = _resolve_payment_terms_opt(payment_terms)
|
|
416
565
|
if resolved_terms is None:
|
|
@@ -618,7 +767,7 @@ def list_members(
|
|
|
618
767
|
table.add_column("Project Role", width=16)
|
|
619
768
|
table.add_column("Org Role", width=16)
|
|
620
769
|
table.add_column("Rate", justify="right", width=10)
|
|
621
|
-
table.add_column("User ID", style="dim")
|
|
770
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
622
771
|
|
|
623
772
|
for m in members_list:
|
|
624
773
|
proj_role = m.get("project_role") or "[dim]-[/dim]"
|
|
@@ -677,7 +826,7 @@ def list_tasks(
|
|
|
677
826
|
table.add_column("Task Rate", justify="right", width=12)
|
|
678
827
|
table.add_column("Project Rate", justify="right", width=14)
|
|
679
828
|
table.add_column("Active", width=8)
|
|
680
|
-
table.add_column("Task ID", style="dim")
|
|
829
|
+
table.add_column("Task ID", style="dim", no_wrap=True, min_width=36)
|
|
681
830
|
|
|
682
831
|
for t in tasks_list:
|
|
683
832
|
billable = "[green]Yes[/green]" if t.get("task_is_billable") else "[dim]No[/dim]"
|
|
@@ -784,7 +933,7 @@ def assignable_users(
|
|
|
784
933
|
return
|
|
785
934
|
|
|
786
935
|
table = Table(title="Assignable users")
|
|
787
|
-
table.add_column("ID", style="dim")
|
|
936
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
788
937
|
table.add_column("Name")
|
|
789
938
|
table.add_column("Email")
|
|
790
939
|
table.add_column("Org role")
|
|
@@ -778,7 +778,7 @@ def allowance_list_cmd(
|
|
|
778
778
|
from rich.table import Table
|
|
779
779
|
|
|
780
780
|
table = Table(show_header=True, header_style="bold")
|
|
781
|
-
table.add_column("User ID", style="dim")
|
|
781
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
782
782
|
table.add_column("Name")
|
|
783
783
|
table.add_column("Days/year", justify="right")
|
|
784
784
|
table.add_column("Counting from")
|
|
@@ -562,7 +562,7 @@ def saved_list(
|
|
|
562
562
|
return
|
|
563
563
|
|
|
564
564
|
table = Table(show_header=True, header_style="bold", title="Saved Reports")
|
|
565
|
-
table.add_column("ID",
|
|
565
|
+
table.add_column("ID", no_wrap=True, min_width=36)
|
|
566
566
|
table.add_column("Name", style="bold", width=25)
|
|
567
567
|
table.add_column("Type", width=18)
|
|
568
568
|
table.add_column("Shared", justify="center", width=8)
|
|
@@ -195,7 +195,7 @@ def list_tasks(
|
|
|
195
195
|
return
|
|
196
196
|
|
|
197
197
|
table = Table(show_header=True, header_style="bold")
|
|
198
|
-
table.add_column("ID", style="dim")
|
|
198
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
199
199
|
table.add_column("Name", width=25)
|
|
200
200
|
table.add_column("Project", style="cyan", width=15)
|
|
201
201
|
table.add_column("Billable", justify="center", width=8)
|
|
@@ -343,7 +343,7 @@ def list_assignments(
|
|
|
343
343
|
return
|
|
344
344
|
|
|
345
345
|
table = Table(show_header=True, header_style="bold")
|
|
346
|
-
table.add_column("Assignment ID", style="dim")
|
|
346
|
+
table.add_column("Assignment ID", style="dim", no_wrap=True, min_width=36)
|
|
347
347
|
table.add_column("Manager")
|
|
348
348
|
table.add_column("Manager Email", style="dim")
|
|
349
349
|
table.add_column("Direct Report")
|
|
@@ -402,8 +402,8 @@ def reports(
|
|
|
402
402
|
table = Table(show_header=True, header_style="bold")
|
|
403
403
|
table.add_column("Direct Report")
|
|
404
404
|
table.add_column("Email", style="dim")
|
|
405
|
-
table.add_column("User ID", style="dim")
|
|
406
|
-
table.add_column("Assignment ID", style="dim")
|
|
405
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
406
|
+
table.add_column("Assignment ID", style="dim", no_wrap=True, min_width=36)
|
|
407
407
|
|
|
408
408
|
for row in rows:
|
|
409
409
|
table.add_row(
|
|
@@ -496,7 +496,7 @@ def show(
|
|
|
496
496
|
projects = profile.get("projects") or []
|
|
497
497
|
if projects:
|
|
498
498
|
table = Table(show_header=True, header_style="bold", title="Assigned projects")
|
|
499
|
-
table.add_column("Project ID", style="dim")
|
|
499
|
+
table.add_column("Project ID", style="dim", no_wrap=True, min_width=36)
|
|
500
500
|
table.add_column("Project")
|
|
501
501
|
table.add_column("Client", style="dim")
|
|
502
502
|
table.add_column("Role on project")
|
|
@@ -532,7 +532,7 @@ def show(
|
|
|
532
532
|
by_project = overview.get("by_project") or []
|
|
533
533
|
if by_project:
|
|
534
534
|
table = Table(show_header=True, header_style="bold", title="By project")
|
|
535
|
-
table.add_column("Project ID", style="dim")
|
|
535
|
+
table.add_column("Project ID", style="dim", no_wrap=True, min_width=36)
|
|
536
536
|
table.add_column("Project")
|
|
537
537
|
table.add_column("Client", style="dim")
|
|
538
538
|
table.add_column("Hours", justify="right")
|
|
@@ -281,11 +281,25 @@ def continue_timer(
|
|
|
281
281
|
"""
|
|
282
282
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
283
283
|
|
|
284
|
-
#
|
|
284
|
+
# The time list holds everything the caller can see — colleagues' entries
|
|
285
|
+
# too for project leads and managers — so ask for your own (CRO-93).
|
|
286
|
+
me = client.current_user_id()
|
|
287
|
+
if not me:
|
|
288
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
|
|
291
|
+
# Fetch recent entries to find the last non-running one. Server ordering is
|
|
292
|
+
# newest date first; future-dated entries and paid-leave rows aren't
|
|
293
|
+
# something you'd resume.
|
|
285
294
|
try:
|
|
286
|
-
|
|
295
|
+
from datetime import date as date_type
|
|
296
|
+
data = client.get("/time/", params={
|
|
297
|
+
"user": me,
|
|
298
|
+
"date_to": date_type.today().isoformat(),
|
|
299
|
+
"page_size": "10",
|
|
300
|
+
})
|
|
287
301
|
from ..formatters import extract_results
|
|
288
|
-
entries = extract_results(data)
|
|
302
|
+
entries = [e for e in extract_results(data) if e.get("source") != "time_off"]
|
|
289
303
|
if not entries:
|
|
290
304
|
format_error("No recent entries to continue from.")
|
|
291
305
|
raise typer.Exit(1)
|
|
@@ -41,7 +41,7 @@ def _print_timesheets_table(timesheets: list[dict], show_user: bool = False) ->
|
|
|
41
41
|
return
|
|
42
42
|
|
|
43
43
|
table = Table(show_header=True, header_style="bold")
|
|
44
|
-
table.add_column("ID", style="dim")
|
|
44
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
45
45
|
if show_user:
|
|
46
46
|
table.add_column("User", width=20)
|
|
47
47
|
table.add_column("Period", width=25)
|
|
@@ -684,7 +684,7 @@ def viewable_users(
|
|
|
684
684
|
return
|
|
685
685
|
|
|
686
686
|
table = Table(show_header=True, header_style="bold")
|
|
687
|
-
table.add_column("ID", style="dim")
|
|
687
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
688
688
|
table.add_column("Name", width=20)
|
|
689
689
|
table.add_column("Email", width=30)
|
|
690
690
|
|
|
@@ -1121,7 +1121,7 @@ def compliance_report(
|
|
|
1121
1121
|
title=f"[blue]Pending Approval ({len(pending)})[/blue]",
|
|
1122
1122
|
)
|
|
1123
1123
|
table.add_column("Member", width=22)
|
|
1124
|
-
table.add_column("Timesheet ID", style="dim")
|
|
1124
|
+
table.add_column("Timesheet ID", style="dim", no_wrap=True, min_width=36)
|
|
1125
1125
|
table.add_column("Hours", justify="right", width=8)
|
|
1126
1126
|
table.add_column("Submitted", width=12)
|
|
1127
1127
|
table.add_column("Days Pending", justify="right", width=13)
|
|
@@ -1147,7 +1147,7 @@ def compliance_report(
|
|
|
1147
1147
|
title=f"[red]Rejected ({len(rejected_list)})[/red]",
|
|
1148
1148
|
)
|
|
1149
1149
|
table.add_column("Member", width=22)
|
|
1150
|
-
table.add_column("Timesheet ID", style="dim")
|
|
1150
|
+
table.add_column("Timesheet ID", style="dim", no_wrap=True, min_width=36)
|
|
1151
1151
|
table.add_column("Hours", justify="right", width=8)
|
|
1152
1152
|
table.add_column("Reviewer Notes", width=35)
|
|
1153
1153
|
|
|
@@ -208,7 +208,7 @@ def format_entries_table(
|
|
|
208
208
|
return
|
|
209
209
|
|
|
210
210
|
table = Table(show_header=True, header_style="bold")
|
|
211
|
-
table.add_column("ID", style="dim")
|
|
211
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
212
212
|
if show_date:
|
|
213
213
|
table.add_column("Date", width=12)
|
|
214
214
|
if show_user:
|
|
@@ -352,7 +352,7 @@ def format_projects_table(projects: list[Project]) -> None:
|
|
|
352
352
|
return
|
|
353
353
|
|
|
354
354
|
table = Table(show_header=True, header_style="bold")
|
|
355
|
-
table.add_column("ID", style="dim")
|
|
355
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
356
356
|
table.add_column("Name", style="bold", width=20)
|
|
357
357
|
table.add_column("Code", width=8)
|
|
358
358
|
table.add_column("Client", width=15)
|
|
@@ -438,7 +438,7 @@ def format_favorites_table(favorites: list[FavoriteEntry]) -> None:
|
|
|
438
438
|
return
|
|
439
439
|
|
|
440
440
|
table = Table(show_header=True, header_style="bold")
|
|
441
|
-
table.add_column("ID", style="dim")
|
|
441
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
442
442
|
table.add_column("Project", style="cyan", width=15)
|
|
443
443
|
table.add_column("Task", width=15)
|
|
444
444
|
table.add_column("Description", width=30)
|
|
@@ -952,7 +952,7 @@ def format_timeoff_summary_panel(summary: TimeOffSummary) -> Panel:
|
|
|
952
952
|
padding=(0, 2),
|
|
953
953
|
)
|
|
954
954
|
by_user_table.add_column("User", width=24)
|
|
955
|
-
by_user_table.add_column("User ID", style="dim",
|
|
955
|
+
by_user_table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
956
956
|
by_user_table.add_column("Hours", justify="right", width=10)
|
|
957
957
|
by_user_table.add_column("Days", justify="right", width=6)
|
|
958
958
|
if summary.by_user:
|
|
@@ -428,13 +428,14 @@ def expense_list_alias(
|
|
|
428
428
|
category: Optional[str] = typer.Option(None, "--category", "-c"),
|
|
429
429
|
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable"),
|
|
430
430
|
invoiced: Optional[bool] = typer.Option(None, "--invoiced/--uninvoiced"),
|
|
431
|
+
mine: bool = typer.Option(False, "--mine"),
|
|
431
432
|
output_json: bool = typer.Option(False, "--json"),
|
|
432
433
|
) -> None:
|
|
433
434
|
"""Alias for 'expense list'."""
|
|
434
435
|
expense_cmd.list_expenses(
|
|
435
436
|
from_date=from_date, to_date=to_date, project=project,
|
|
436
437
|
category=category, billable=billable, invoiced=invoiced,
|
|
437
|
-
output_json=output_json,
|
|
438
|
+
mine=mine, output_json=output_json,
|
|
438
439
|
)
|
|
439
440
|
|
|
440
441
|
|
|
@@ -40,6 +40,11 @@ class Project(BaseModel):
|
|
|
40
40
|
billing_mode: str = "custom"
|
|
41
41
|
billing_type: str = "time_and_materials"
|
|
42
42
|
fixed_fee_amount: Decimal | None = None
|
|
43
|
+
# one_off (price paid in milestones) or recurring (amount per period).
|
|
44
|
+
fixed_fee_schedule: str = "one_off"
|
|
45
|
+
fixed_fee_frequency: str = ""
|
|
46
|
+
fixed_fee_milestones: list[dict] | None = None
|
|
47
|
+
fixed_fee_template: dict | None = None
|
|
43
48
|
payment_terms_days: int | None = None
|
|
44
49
|
status: str = "active"
|
|
45
50
|
is_billable: bool = True
|
|
@@ -140,7 +140,8 @@ ct clients remove-contact <id> <contact-id>
|
|
|
140
140
|
|
|
141
141
|
### Expenses
|
|
142
142
|
```bash
|
|
143
|
-
ct expense list # List expenses
|
|
143
|
+
ct expense list # List expenses you can see (colleagues' too if you're a manager or run a project)
|
|
144
|
+
ct expense list --mine # Only your own — use this for "my expenses" and totals
|
|
144
145
|
ct expense list --from 2026-03-01 --to 2026-03-31 --billable
|
|
145
146
|
ct expense create --amount 49.99 "Domain renewal" --project acme-website --category "Software" --vendor "Namecheap" --billable
|
|
146
147
|
ct expense edit <id> --amount 59.99 --description "Domain + SSL renewal"
|
|
@@ -148,9 +149,8 @@ ct expense delete <id>
|
|
|
148
149
|
ct expense categories # List expense categories
|
|
149
150
|
ct expense add-category "Travel" # Create a new category
|
|
150
151
|
|
|
151
|
-
# Short alias
|
|
152
|
-
ct ex
|
|
153
|
-
ct ex create --amount 25 "Lunch with client" --category "Meals" --billable
|
|
152
|
+
# Short alias (list only — takes the same options as expense list)
|
|
153
|
+
ct ex --mine # expense list --mine
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
**Who sees which expenses**: members and viewers see only their own; project managers also see expenses on projects they manage; account managers and above see all. Only the owner of an expense (or an account manager+) can edit or delete it, and not once it has been invoiced. `--project` must be a project the user is assigned to. The invoiced flag is set by invoicing and cannot be edited.
|
|
@@ -161,7 +161,9 @@ ct projects list # List all projects
|
|
|
161
161
|
ct projects show <id> # Project details
|
|
162
162
|
ct projects create "New Project" --client "Acme" --budget 100 --budget-type hours -b
|
|
163
163
|
ct projects create "New Project" --client "Acme" --billing-mode per_person # Set billing mode
|
|
164
|
-
ct projects create "Redesign" --client "Acme" --fixed-fee 10000 #
|
|
164
|
+
ct projects create "Redesign" --client "Acme" --fixed-fee 10000 # One-off price, one payment
|
|
165
|
+
ct projects create "Web" --client "Acme" --fixed-fee 5000 --payment Kickoff=1500@2026-06-01 --payment Delivery=3500
|
|
166
|
+
ct projects create "Support" --client "Acme" --fixed-fee 4000 --fee-frequency monthly # Recurring fee
|
|
165
167
|
ct projects archive <id> # Archive a project
|
|
166
168
|
ct projects switch <slug> # Set default project
|
|
167
169
|
|
|
@@ -202,9 +204,13 @@ ct projects add-tasks <project-id> --task <tid1> --task <tid2> # Bulk assign ta
|
|
|
202
204
|
|
|
203
205
|
**Billing types** (*what* is charged): `time_and_materials` (default) or `fixed_fee` (an agreed price via `--fixed-fee`, billed instead of logged hours).
|
|
204
206
|
|
|
207
|
+
**Fixed fees come in two schedules** — ask which one when the user doesn't say:
|
|
208
|
+
- **One-off** (default): `--fixed-fee` is the whole project price, split into `--payment NAME=AMOUNT[@DUE]` milestones that must add up to it (one "Full payment" if none are given). Each payment is invoiced exactly once.
|
|
209
|
+
- **Recurring**: `--fixed-fee` is charged every `--fee-frequency` (`weekly`, `biweekly`, `monthly`, `quarterly`). The project sets up its own recurring invoice automatically — don't create a template for it by hand.
|
|
210
|
+
|
|
205
211
|
**Billing modes** (*how* an hourly rate resolves, T&M only): `custom` (default), `per_task` (rate per task), `per_person` (rate per person).
|
|
206
212
|
|
|
207
|
-
A fixed fee is never
|
|
213
|
+
A fixed fee is never swept into a client-wide invoice. A one-off price is billed by naming the project (`ct invoice create --project <id>`, which bills its next unbilled payment; add `--payment <payment-id>` to pick one — IDs are in `ct projects show`). A recurring fee is billed by its own recurring invoice.
|
|
208
214
|
|
|
209
215
|
**Rate waterfall** (order depends on billing mode):
|
|
210
216
|
- `custom`: Entry → ProjectMember → ProjectTask → Project → Task → Membership → User
|
|
@@ -296,7 +302,8 @@ A favorite can only point at a project the user is assigned to (and a task on th
|
|
|
296
302
|
|
|
297
303
|
### Timesheets
|
|
298
304
|
```bash
|
|
299
|
-
ct timesheet list
|
|
305
|
+
ct timesheet list --mine # List your own timesheets
|
|
306
|
+
ct timesheet list # Everyone's you can see (a User column marks rows that aren't yours)
|
|
300
307
|
ct timesheet list --status submitted # Filter by status
|
|
301
308
|
ct timesheet submit --from 2026-03-10 --to 2026-03-16 # Submit full week
|
|
302
309
|
ct timesheet submit --from 2026-03-30 --to 2026-03-31 # Partial week (end-of-month split)
|
|
@@ -437,7 +444,7 @@ ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved
|
|
|
437
444
|
ct pto list [--mine|--team] # List PTO entries with optional filters
|
|
438
445
|
ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
|
|
439
446
|
ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day (your own: pending only)
|
|
440
|
-
ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+
|
|
447
|
+
ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+ someone else's; your own decided leave needs another approver, admins excepted)
|
|
441
448
|
ct pto approve <id> [--notes "..."] # Approve a pending entry (project manager+; never your own unless admin)
|
|
442
449
|
ct pto reject <id> --notes "..." # Reject a pending entry — notes required (project manager+; never your own unless admin)
|
|
443
450
|
ct pto calendar [--from --to --user --status] # PTO slice for a date window (approved only unless --status)
|
|
@@ -233,9 +233,9 @@ ct timer continue [--note/-n NOTE] [--billable/-b] [--no-billable/-B] [--json]
|
|
|
233
233
|
| `--no-billable`, `-B` | flag | Override billable status (mark non-billable) |
|
|
234
234
|
| `--json` | flag | Output as JSON |
|
|
235
235
|
|
|
236
|
-
Starts a new timer with the same project, task, and description as
|
|
236
|
+
Starts a new timer with the same project, task, and description as your most recent entry (up to today; paid-leave rows are skipped). Great for resuming after a break. Use `--billable`/`--no-billable` to override the original entry's billable status.
|
|
237
237
|
|
|
238
|
-
- **Endpoint:** `GET /time
|
|
238
|
+
- **Endpoint:** `GET /time/?user=<you>` (fetch your last entry) + `POST /time/start/`
|
|
239
239
|
- **Alias:** `ct c` (e.g. `ct c -b` to continue as billable)
|
|
240
240
|
|
|
241
241
|
### ct timer discard
|
|
@@ -563,10 +563,12 @@ ct expense list [options]
|
|
|
563
563
|
| `--category` | string | Filter by category |
|
|
564
564
|
| `--billable` | flag | Only billable expenses |
|
|
565
565
|
| `--invoiced` | flag | Only invoiced expenses |
|
|
566
|
+
| `--mine` | flag | Only your own expenses |
|
|
566
567
|
| `--json` | flag | JSON output |
|
|
567
568
|
|
|
568
|
-
Shows: ID, Date, Description, Amount, Category, Project, Billable, Invoiced.
|
|
569
|
-
Scope: members and viewers see their own expenses; project
|
|
569
|
+
Shows: ID, Date, User (only when a row isn't yours), Description, Amount, Category, Project, Billable, Invoiced. The total adds up every row shown.
|
|
570
|
+
Scope: members and viewers see their own expenses; anyone running a project also sees those logged on it; account managers and above see all. Pass `--mine` whenever the question is about the user's own expenses — without it, a manager's or project lead's list and total include colleagues'. In `--json` output compare each row's `user` with yours.
|
|
571
|
+
Alias: `ct ex [options]` (list only).
|
|
570
572
|
Endpoint: `GET /expenses/`
|
|
571
573
|
|
|
572
574
|
### ct expense show
|
|
@@ -602,8 +604,8 @@ ct expense create --amount AMOUNT DESCRIPTION [options]
|
|
|
602
604
|
**Usage examples:**
|
|
603
605
|
```bash
|
|
604
606
|
ct expense create --amount 49.99 "Domain renewal" --project acme-website --category "Software" --billable
|
|
605
|
-
ct
|
|
606
|
-
ct
|
|
607
|
+
ct expense create --amount 125 "Client lunch" --category "Meals" --vendor "La Trattoria" --billable --notes "Q1 planning lunch"
|
|
608
|
+
ct expense create --amount 29.99 "Adobe subscription" -p design-project --category "Software" -d yesterday -b
|
|
607
609
|
```
|
|
608
610
|
|
|
609
611
|
Endpoint: `POST /expenses/`
|
|
@@ -956,7 +958,11 @@ ct projects create NAME [options]
|
|
|
956
958
|
| `--billable`, `-b` | flag | Billable (default: true) |
|
|
957
959
|
| `--no-billable`, `-B` | flag | Non-billable |
|
|
958
960
|
| `--billing-type` | string | What the client is charged: `time_and_materials` (default) or `fixed_fee`. Accepts `tm` / `fixed` as aliases |
|
|
959
|
-
| `--fixed-fee` | float | Agreed price billed instead of logged hours. Implies `--billing-type fixed_fee
|
|
961
|
+
| `--fixed-fee` | float | Agreed price billed instead of logged hours. Implies `--billing-type fixed_fee`. The whole price for a one-off fee; the amount per period for a recurring one |
|
|
962
|
+
| `--fee-schedule` | string | `one-off` (default) or `recurring` |
|
|
963
|
+
| `--fee-frequency` | string | Recurring fee cadence: `weekly`, `biweekly`, `monthly`, `quarterly`. Implies `--fee-schedule recurring` |
|
|
964
|
+
| `--payment` | string (repeatable) | One-off payment `NAME=AMOUNT` or `NAME=AMOUNT@YYYY-MM-DD`. Must add up to `--fixed-fee`. Omitted → one "Full payment" |
|
|
965
|
+
| `--first-invoice` | string | Recurring fee: date of the first invoice. Default: one period after the project starts |
|
|
960
966
|
| `--billing-mode` | string | Rate resolution for T&M work: `per_task`, `per_person`, `custom` (default). Ignored for fixed-fee projects |
|
|
961
967
|
| `--payment-terms` | string | Terms for this project: `receipt`, `net7`, `net15`, `net30`, or a number of days. Omit to inherit the client, then org, default |
|
|
962
968
|
| `--color` | string | Hex color code |
|
|
@@ -972,9 +978,13 @@ ct projects create NAME [options]
|
|
|
972
978
|
| `time_and_materials` | Default. Billed from logged hours at the resolved rate |
|
|
973
979
|
| `fixed_fee` | Billed at `--fixed-fee`, an agreed price. Logged hours are tracked but never charged by the hour |
|
|
974
980
|
|
|
975
|
-
> **
|
|
981
|
+
> **Fixed-fee schedules.**
|
|
982
|
+
> - **One-off** — the price is paid in payments (milestones) that add up to it. Each payment is billed **once**: naming the project on an invoice bills its next unbilled payment (or the ones picked with `--payment <id>`), and a fully billed project bills nothing. Voiding or deleting the invoice frees the payment again. A recurring template that lists the project bills the payments due by the end of the period it covers; payments without a due date are only billed by naming the project on an invoice. Duplicating an invoice leaves out its payment lines. Invoiced payments cannot be changed or removed.
|
|
983
|
+
> - **Recurring** — the amount is billed every period. Creating the project (or switching it to recurring) sets up its own recurring invoice template with that cadence; changing the frequency updates it, and switching away, making it non-billable or archiving pauses it. If a hand-made template already bills the project, no second one is created.
|
|
984
|
+
>
|
|
985
|
+
> **How a fixed fee gets invoiced.** A client-wide invoice (`ct invoice create` with no project filter) deliberately skips fixed-fee projects entirely, so the fee cannot go out by accident. It is billed only when:
|
|
976
986
|
> 1. the project is named explicitly — `ct invoice create --client <id> --project <fixed-project-id>`, or
|
|
977
|
-
> 2. a recurring template lists the project.
|
|
987
|
+
> 2. a recurring template lists the project (including the one a recurring fee creates for itself).
|
|
978
988
|
>
|
|
979
989
|
> When the fee is billed, that project's hours in the period are linked to the fee line and marked invoiced, so they stop showing as outstanding work without ever being charged twice. Until then they stay uninvoiced and available.
|
|
980
990
|
|
|
@@ -990,7 +1000,12 @@ ct projects create NAME [options]
|
|
|
990
1000
|
**Fixed-fee examples:**
|
|
991
1001
|
```bash
|
|
992
1002
|
ct projects create "Rediseño Web" --client "Acme" --fixed-fee 10000
|
|
1003
|
+
ct projects create "Web" --client "Acme" --fixed-fee 5000 \
|
|
1004
|
+
--payment Kickoff=1500@2026-06-01 --payment Midpoint=1500@2026-07-01 --payment Delivery=2000@2026-08-01
|
|
1005
|
+
ct projects create "Soporte" --client "Acme" --fixed-fee 4000 --fee-frequency monthly --first-invoice 2026-12-01
|
|
993
1006
|
ct projects update redesign --billing-type fixed --fixed-fee 12000
|
|
1007
|
+
ct projects update web --fixed-fee 6000 --payment Kickoff=1500 --payment Rest=4500 # Invoiced payments are kept
|
|
1008
|
+
ct projects update soporte --fee-frequency quarterly
|
|
994
1009
|
ct projects update redesign --billing-type tm --billing-mode per_person
|
|
995
1010
|
```
|
|
996
1011
|
|
|
@@ -2026,6 +2041,7 @@ Creates an invoice from tracked time entries. Requires either `--from`/`--to` or
|
|
|
2026
2041
|
| `--to` | string | Period end date |
|
|
2027
2042
|
| `--period` | string | Preset: `last-week`, `last-2-weeks`, `last-month`, `this-month` |
|
|
2028
2043
|
| `--project`, `-p` | string (repeatable) | Limit to specific project ID(s). **Required to bill a fixed-fee project** — those are never included in a client-wide invoice |
|
|
2044
|
+
| `--payment` | string (repeatable) | One-off fixed-fee payment ID(s) to bill (from `ct projects show`). Without it, each selected one-off project bills its next unbilled payment |
|
|
2029
2045
|
| `--group-by` | string | Group line items by: `project`, `task`, `user`, `date`, `none` |
|
|
2030
2046
|
| `--tax-rate` | float | Tax rate percentage (e.g. 21 for 21%) |
|
|
2031
2047
|
| `--payment-terms` | string | Payment terms: `receipt` (0 days), `net7`, `net15`, `net30`, or number of days. **Omit** to resolve from the waterfall below |
|
|
@@ -2049,7 +2065,8 @@ ct invoice create --client <id> --period last-2-weeks --group-by project
|
|
|
2049
2065
|
ct invoice create --client <id> --period last-month --group-by user # One line item per team member
|
|
2050
2066
|
ct invoice create --client <id> --period last-month # Uses the client's own terms
|
|
2051
2067
|
ct invoice create --client <id> --period last-month --due-date 2026-09-15
|
|
2052
|
-
ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the
|
|
2068
|
+
ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the next payment / one period
|
|
2069
|
+
ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
|
|
2053
2070
|
```
|
|
2054
2071
|
|
|
2055
2072
|
Endpoint: `POST /invoices/from-time-entries/`
|
|
@@ -2860,7 +2877,7 @@ routing from the list payload.
|
|
|
2860
2877
|
ct pto list [--mine] [--team] [--status/-s STATUS] [--kind/-k KIND] [--from DATE] [--to DATE] [--user/-u USER] [--json]
|
|
2861
2878
|
```
|
|
2862
2879
|
|
|
2863
|
-
List PTO entries.
|
|
2880
|
+
List PTO entries. Shows your own entries by default (same as `--mine`); `--team` shows every entry you can see.
|
|
2864
2881
|
|
|
2865
2882
|
**Who can call it:**
|
|
2866
2883
|
- Any member — sees own entries only (scoped by server regardless of flags).
|
|
@@ -2988,6 +3005,7 @@ Cancel a PTO entry. Transitions status to `canceled`.
|
|
|
2988
3005
|
**Authorization:**
|
|
2989
3006
|
- Own entry + `status=pending` → any member.
|
|
2990
3007
|
- Manager+ → any non-canceled entry for users in their visibility scope (including approved entries).
|
|
3008
|
+
- Your own entry once it is approved or rejected → admins and owners only; a manager or project manager gets a `403` and must ask another approver (same rule as editing).
|
|
2991
3009
|
|
|
2992
3010
|
`decided_by` / `decided_at` are NOT stamped by cancel (only by approve/reject).
|
|
2993
3011
|
|
{crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
@@ -45,7 +45,7 @@ If the user doesn't specify a kind, default to `vacation` but **confirm before s
|
|
|
45
45
|
| Status | Meaning | Who can act next |
|
|
46
46
|
|--------|---------|-----------------|
|
|
47
47
|
| `pending` | Submitted, awaiting decision | Manager+ can approve or reject; owner of the entry can cancel or edit |
|
|
48
|
-
| `approved` | Approved and active | Manager+ can cancel (removes from active capacity)
|
|
48
|
+
| `approved` | Approved and active | Manager+ can cancel (removes from active capacity) or edit dates/hours (audit-logged) — but not their own entry unless they are admin/owner |
|
|
49
49
|
| `rejected` | Rejected by manager | Entry owner can submit a new request with corrected dates |
|
|
50
50
|
| `canceled` | Canceled (no longer active) | Terminal state — no further transitions |
|
|
51
51
|
|
|
@@ -93,7 +93,7 @@ Project managers and account managers using `ct pto list --team` or `ct pto add
|
|
|
93
93
|
| `ct pto add <user>` (on-behalf) | No | Within scope → approved (400 outside it) | Any user → approved |
|
|
94
94
|
| `ct pto list / show` | Own only | Within scope | Any |
|
|
95
95
|
| `ct pto edit` | Own + pending only | Within scope (any status); **their own** entry only while pending | Any |
|
|
96
|
-
| `ct pto cancel` | Own + pending only | Within scope (any non-canceled) | Any |
|
|
96
|
+
| `ct pto cancel` | Own + pending only | Within scope (any non-canceled); **their own** entry only while pending | Any |
|
|
97
97
|
| `ct pto approve` | No | Within scope + pending, **never their own** (403) | Any pending, including their own |
|
|
98
98
|
| `ct pto reject` | No | Within scope + pending, **never their own** (403) | Any pending, including their own |
|
|
99
99
|
| `ct pto calendar / summary` | Own | Within scope | Any |
|
|
@@ -278,6 +278,26 @@ ct projects update-member consulting-engagement senior@co.com --rate 200
|
|
|
278
278
|
ct projects update-member consulting-engagement junior@co.com --rate 100
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
+
### Set Up a Fixed-Fee Project
|
|
282
|
+
|
|
283
|
+
First ask which kind of fixed fee it is — the two are billed very differently:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# One project price, paid in milestones (each invoiced exactly once)
|
|
287
|
+
ct projects create "Website" --client "Acme" --fixed-fee 5000 \
|
|
288
|
+
--payment Kickoff=1500@2026-06-01 --payment Midpoint=1500@2026-07-01 --payment Delivery=2000@2026-08-01
|
|
289
|
+
|
|
290
|
+
# Bill the next payment when it's time (see `ct projects show` for payment IDs)
|
|
291
|
+
ct invoice create --client <client-id> --period this-month --project <project-id>
|
|
292
|
+
|
|
293
|
+
# The same amount every month — a recurring invoice is created for it automatically
|
|
294
|
+
ct projects create "Support" --client "Acme" --fixed-fee 4000 --fee-frequency monthly
|
|
295
|
+
ct projects show support # "Invoiced by:" names the template and its next run
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Payments must add up to the price. Invoiced payments can't be changed or removed;
|
|
299
|
+
void the invoice first if one was billed by mistake.
|
|
300
|
+
|
|
281
301
|
### Apply Task Presets to an Existing Project
|
|
282
302
|
|
|
283
303
|
```bash
|
|
@@ -577,8 +597,8 @@ ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
|
|
|
577
597
|
ct timesheet submit --from monday --to wednesday --force # Close out March
|
|
578
598
|
ct timesheet submit --from thursday --to friday --force # Start April
|
|
579
599
|
|
|
580
|
-
# Check submission status
|
|
581
|
-
ct timesheet list
|
|
600
|
+
# Check submission status (--mine: project leads and managers also see colleagues' timesheets)
|
|
601
|
+
ct timesheet list --mine
|
|
582
602
|
|
|
583
603
|
# Made a mistake? Recall it back to draft (before approval) — confirms before executing
|
|
584
604
|
ct timesheet recall <timesheet-id> --notes "Need to fix Wednesday entries"
|
|
@@ -714,7 +734,7 @@ ct expense create --amount 49.99 "Domain renewal" --project acme-website --categ
|
|
|
714
734
|
ct expense create --amount 29.99 "Team Slack subscription" --category "Software"
|
|
715
735
|
|
|
716
736
|
# Log a past expense
|
|
717
|
-
ct
|
|
737
|
+
ct expense create --amount 85 "Client dinner" --category "Meals" --vendor "La Trattoria" -d yesterday --billable -p acme-project
|
|
718
738
|
```
|
|
719
739
|
|
|
720
740
|
### Setting Up Expense Categories
|
|
@@ -734,8 +754,10 @@ ct expense add-category "Hardware"
|
|
|
734
754
|
### Reviewing Expenses
|
|
735
755
|
|
|
736
756
|
```bash
|
|
737
|
-
# List expenses for a period
|
|
757
|
+
# List expenses for a period. Managers and project leads also see colleagues'
|
|
758
|
+
# expenses here; add --mine when the user means their own
|
|
738
759
|
ct expense list --from 2026-03-01 --to 2026-03-31
|
|
760
|
+
ct expense list --mine --from 2026-03-01 --to 2026-03-31
|
|
739
761
|
|
|
740
762
|
# Filter by project and billable status
|
|
741
763
|
ct expense list --project acme-website --billable
|
|
@@ -1140,8 +1162,9 @@ ct invoice recurring generate-now <template-id> \
|
|
|
1140
1162
|
```
|
|
1141
1163
|
|
|
1142
1164
|
Logged hours cannot be double-billed — they are settled on the invoice that
|
|
1143
|
-
pulled them. Fixed lines and fixed
|
|
1144
|
-
every run whatever the period, which is what the guard exists for.
|
|
1165
|
+
pulled them. Fixed lines and recurring fixed fees can: they are billed in full
|
|
1166
|
+
on every run whatever the period, which is what the guard exists for. One-off
|
|
1167
|
+
fixed-fee payments are safe — each is billed only once.
|
|
1145
1168
|
|
|
1146
1169
|
---
|
|
1147
1170
|
|
|
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
|
|
File without changes
|