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.
Files changed (43) hide show
  1. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/clients_cmd.py +2 -2
  5. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/expense_cmd.py +38 -7
  6. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/invoice_cmd.py +19 -5
  7. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/org_cmd.py +1 -1
  8. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/projects_cmd.py +156 -7
  9. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/pto_cmd.py +1 -1
  10. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
  11. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/tasks_cmd.py +1 -1
  12. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/team_cmd.py +5 -5
  13. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timer_cmd.py +17 -3
  14. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timesheet_cmd.py +4 -4
  15. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/formatters.py +4 -4
  16. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/main.py +2 -1
  17. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/models.py +5 -0
  18. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +15 -8
  19. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +29 -11
  20. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +2 -2
  21. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +29 -6
  22. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/.gitignore +0 -0
  23. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/LICENSE +0 -0
  24. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/README.md +0 -0
  25. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/auth.py +0 -0
  26. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/client.py +0 -0
  27. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  28. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  29. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  30. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  31. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  32. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  33. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  34. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  35. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  36. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  37. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  38. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  39. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/config.py +0 -0
  40. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.21.0 → crowdtime_cli-0.22.0}/src/crowdtime_cli/utils.py +0 -0
  43. {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.21.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.21.0"
3
+ version = "0.22.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.21.0"
3
+ __version__ = "0.22.0"
@@ -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
- table.add_column("ID", style="dim")
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
- list_expenses()
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
- console.print(f" Billing: Fixed Fee — {fee}")
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. That fee
206
- is never billed automatically — only when the project is selected
207
- explicitly on an invoice, or pulled in by a recurring template.
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", width=36)
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
- # Fetch recent entries to find the last non-running one
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
- data = client.get("/time/", params={"page_size": "1", "ordering": "-start_time"})
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", width=36)
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 list # expense list
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 # Fixed price instead of hours
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 billed automatically — client-wide invoices skip fixed-fee projects. Bill it by naming the project explicitly (`ct invoice create --project <id>`) or via a recurring template.
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 # List your timesheets
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+ any)
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 the most recently stopped entry. Great for resuming after a break. Use `--billable`/`--no-billable` to override the original entry's billable status.
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/` (fetch last entry) + `POST /time/start/`
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 managers also see those on projects they manage; account managers and above see all.
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 ex create --amount 125 "Client lunch" --category "Meals" --vendor "La Trattoria" --billable --notes "Q1 planning lunch"
606
- ct ex create --amount 29.99 "Adobe subscription" -p design-project --category "Software" -d yesterday -b
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
- > **How a fixed fee gets invoiced.** It is *never* billed automatically. 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:
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 fixed fee
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. Defaults to showing your own entries if `--mine` is passed, or all entries you can see if `--team` is passed. Without either flag, shows all visible 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
 
@@ -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); manager+ can edit dates/hours (audit-logged) |
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 ex create --amount 85 "Client dinner" --category "Meals" --vendor "La Trattoria" -d yesterday --billable -p acme-project
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-fee projects can: they are billed in full on
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