crowdtime-cli 0.14.0__tar.gz → 0.15.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 (42) hide show
  1. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/PKG-INFO +3 -2
  2. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/pyproject.toml +13 -3
  3. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
  5. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
  6. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/projects_cmd.py +144 -4
  7. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/pto_cmd.py +19 -2
  8. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
  9. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/formatters.py +20 -8
  10. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/models.py +5 -0
  11. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -2
  12. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +114 -16
  13. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +69 -1
  14. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/.gitignore +0 -0
  15. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/LICENSE +0 -0
  16. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/README.md +0 -0
  17. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/auth.py +0 -0
  18. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/client.py +0 -0
  19. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  20. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  21. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  22. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  23. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  24. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  25. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  26. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  27. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  28. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  29. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  30. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  31. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  32. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  33. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  34. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  35. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  36. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/config.py +0 -0
  37. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/main.py +0 -0
  38. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/oauth.py +0 -0
  39. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/resolvers.py +0 -0
  40. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
  41. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/utils.py +0 -0
  42. {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.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.14.0
3
+ Version: 0.15.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
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Office/Business
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
+ Requires-Dist: click>=8.0
24
25
  Requires-Dist: httpx>=0.27.0
25
26
  Requires-Dist: humanize>=4.0
26
27
  Requires-Dist: keyring>=25.0
@@ -29,7 +30,7 @@ Requires-Dist: pydantic>=2.0
29
30
  Requires-Dist: python-dateutil>=2.9
30
31
  Requires-Dist: rich>=13.0
31
32
  Requires-Dist: tomlkit>=0.12.0
32
- Requires-Dist: typer[all]>=0.12.0
33
+ Requires-Dist: typer>=0.12.0
33
34
  Description-Content-Type: text/markdown
34
35
 
35
36
  # CrowdTime CLI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.14.0"
3
+ version = "0.15.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"}
@@ -21,7 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "typer[all]>=0.12.0",
24
+ # Not typer[all]: that extra no longer exists (typer warns on every
25
+ # install), and what it used to pull in — rich, shellingham — typer
26
+ # requires outright now.
27
+ "typer>=0.12.0",
28
+ # Imported directly by commands/org_cmd.py. It used to arrive through
29
+ # typer and stopped when typer 0.27 dropped it, which broke `ct` on
30
+ # every fresh install: importing a package means declaring it.
31
+ "click>=8.0",
25
32
  "rich>=13.0",
26
33
  "httpx>=0.27.0",
27
34
  "pydantic>=2.0",
@@ -42,7 +49,10 @@ crowdtime = "crowdtime_cli.main:_original_main"
42
49
  ct = "crowdtime_cli.main:_original_main"
43
50
 
44
51
  [build-system]
45
- requires = ["hatchling"]
52
+ # Pinned below 1.28: from there hatchling stamps Metadata-Version 2.5,
53
+ # which twine refuses to upload ("not a valid metadata version"). 0.14.0
54
+ # shipped 2.4. Lift once the packaging toolchain accepts 2.5.
55
+ requires = ["hatchling<1.28"]
46
56
  build-backend = "hatchling.build"
47
57
 
48
58
  [tool.hatch.build.targets.wheel]
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.15.0"
@@ -143,6 +143,8 @@ def status(
143
143
  table.add_column("Value")
144
144
 
145
145
  table.add_row("Status", f"[{color}]{label}[/{color}]")
146
+ if data.get("billing_exempt"):
147
+ table.add_row("Exempt", "[green]Billing exempt — full access, not billed[/green]")
146
148
  table.add_row("Plan", PLAN_LABELS.get(plan, plan.capitalize()))
147
149
  table.add_row("Seats", str(seat_count))
148
150
  table.add_row("Base", f"{format_currency(base_dollars)}/mo")
@@ -89,6 +89,7 @@ def _resolve_payment_terms(value: str) -> int | None:
89
89
  """
90
90
  mapping = {
91
91
  "receipt": 0,
92
+ "net7": 7,
92
93
  "net15": 15,
93
94
  "net30": 30,
94
95
  }
@@ -580,6 +581,14 @@ def create_invoice(
580
581
  None, "--period", "-P",
581
582
  help="Preset period: last-week, last-2-weeks, last-month, this-month.",
582
583
  ),
584
+ projects: Optional[list[str]] = typer.Option(
585
+ None, "--project", "-p",
586
+ help=(
587
+ "Limit the invoice to specific project ID(s). Repeat for multiple. "
588
+ "Required to bill a fixed-fee project, which is never included "
589
+ "in a client-wide invoice."
590
+ ),
591
+ ),
583
592
  group_by: Optional[str] = typer.Option(
584
593
  None, "--group-by", "-g", help="Group line items by: project, task, user, date, none."
585
594
  ),
@@ -590,7 +599,17 @@ def create_invoice(
590
599
  terms: Optional[str] = typer.Option(None, "--terms", help="Payment terms text."),
591
600
  payment_terms: Optional[str] = typer.Option(
592
601
  None, "--payment-terms",
593
- help="Payment terms: receipt, net15, net30, or number of days.",
602
+ help=(
603
+ "Payment terms: receipt, net15, net30, or number of days. "
604
+ "Omit to use the client's configured terms, then the org default."
605
+ ),
606
+ ),
607
+ issue_date: Optional[str] = typer.Option(
608
+ None, "--issue-date", help="Invoice date. Default: today."
609
+ ),
610
+ due_date: Optional[str] = typer.Option(
611
+ None, "--due-date",
612
+ help="Explicit due date. Default: issue date + payment terms.",
594
613
  ),
595
614
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
596
615
  ) -> None:
@@ -600,11 +619,17 @@ def create_invoice(
600
619
  the specified date range. Use --period for preset ranges or
601
620
  --from/--to for custom dates.
602
621
 
622
+ The due date is derived from the invoice date, never from the end of the
623
+ billing period — invoicing a period weeks after it closed would otherwise
624
+ produce an invoice that is already overdue.
625
+
603
626
  Examples:
604
627
  ct invoice create --client <id> --from 2026-03-01 --to 2026-03-31
605
628
  ct invoice create --client <id> --period last-month --group-by project
606
629
  ct invoice create --client <id> --period last-2-weeks --payment-terms net30
607
630
  ct invoice create --client <id> --from monday --to friday --payment-terms 15
631
+ ct invoice create --client <id> --period last-month --due-date 2026-09-15
632
+ ct invoice create --client <id> --period last-month --project <fixed-project-id>
608
633
  """
609
634
  # Resolve period dates
610
635
  if period:
@@ -638,6 +663,20 @@ def create_invoice(
638
663
  )
639
664
  raise typer.Exit(1)
640
665
 
666
+ # Resolve explicit invoice dates
667
+ try:
668
+ issue_date_str = format_date(parse_date(issue_date)) if issue_date else None
669
+ due_date_str = format_date(parse_date(due_date)) if due_date else None
670
+ except ValueError as e:
671
+ format_error(str(e))
672
+ raise typer.Exit(1)
673
+
674
+ if issue_date_str and due_date_str and due_date_str < issue_date_str:
675
+ format_error(
676
+ f"--due-date {due_date_str} is before --issue-date {issue_date_str}."
677
+ )
678
+ raise typer.Exit(1)
679
+
641
680
  client = CrowdTimeClient(require_auth=True, require_org=True)
642
681
 
643
682
  payload: dict = {
@@ -645,6 +684,12 @@ def create_invoice(
645
684
  "date_from": start,
646
685
  "date_to": end,
647
686
  }
687
+ if issue_date_str:
688
+ payload["issue_date"] = issue_date_str
689
+ if due_date_str:
690
+ payload["due_date"] = due_date_str
691
+ if projects:
692
+ payload["project_ids"] = list(projects)
648
693
  if group_by:
649
694
  payload["group_by"] = group_by
650
695
  if tax_rate is not None:
@@ -796,7 +841,7 @@ def update_invoice(
796
841
  tax_rate: Optional[float] = typer.Option(None, "--tax-rate", help="Tax rate percentage (e.g. 21 for 21%)."),
797
842
  payment_terms: Optional[str] = typer.Option(
798
843
  None, "--payment-terms",
799
- help="Payment terms: receipt, net15, net30, or number of days.",
844
+ help="Payment terms: receipt, net7, net15, net30, or number of days.",
800
845
  ),
801
846
  from_name: Optional[str] = typer.Option(None, "--from-name", help="Issuer/sender name."),
802
847
  from_email: Optional[str] = typer.Option(None, "--from-email", help="Issuer/sender email."),
@@ -906,6 +951,10 @@ def send_invoice(
906
951
  None, "--cc",
907
952
  help="Contact ID(s) to CC. Repeat for multiple.",
908
953
  ),
954
+ keep_dates: bool = typer.Option(
955
+ False, "--keep-dates",
956
+ help="Send with the drafted dates as-is instead of re-stamping a stale issue date to today.",
957
+ ),
909
958
  force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
910
959
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
911
960
  ) -> None:
@@ -915,10 +964,15 @@ def send_invoice(
915
964
  Use --to to specify contact(s) or it defaults to the client's primary contact.
916
965
  This action cannot be undone (use void instead).
917
966
 
967
+ If the draft's issue date has fallen into the past, it is re-stamped to
968
+ today and the due date shifts by the same number of days, so the invoice
969
+ doesn't arrive already overdue. Pass --keep-dates to send it as drafted.
970
+
918
971
  Examples:
919
972
  ct invoice send <invoice-id>
920
973
  ct invoice send <invoice-id> --to <contact-id>
921
974
  ct invoice send <invoice-id> --to <contact-id-1> --to <contact-id-2> --cc <contact-id-3>
975
+ ct invoice send <invoice-id> --keep-dates
922
976
  ct invoice send <invoice-id> --force
923
977
  """
924
978
  if not force:
@@ -933,6 +987,8 @@ def send_invoice(
933
987
  payload["contact_ids"] = to
934
988
  if cc:
935
989
  payload["cc_contact_ids"] = cc
990
+ if keep_dates:
991
+ payload["keep_dates"] = True
936
992
 
937
993
  try:
938
994
  data = client.post(f"/invoices/{invoice_id}/send/", data=payload if payload else None)
@@ -1305,6 +1361,10 @@ def show_recurring(
1305
1361
  console.print(f" Currency: {data.get('currency', 'USD')}")
1306
1362
  console.print(f" Payment Terms: {data.get('payment_terms_days', 30)} days")
1307
1363
  console.print(f" Next Run: {data.get('next_run_date', '') or 'Not scheduled'}")
1364
+ next_start = data.get("next_period_start")
1365
+ next_end = data.get("next_period_end")
1366
+ if next_start and next_end:
1367
+ console.print(f" Next Period: {next_start} to {next_end}")
1308
1368
  console.print(f" Last Run: {data.get('last_run_date', '') or 'Never'}")
1309
1369
  console.print(f" Total Generated: {data.get('total_generated', 0)}")
1310
1370
  console.print(f" Auto-send: {'Yes' if data.get('auto_send') else 'No'}")
@@ -1482,25 +1542,80 @@ def delete_recurring(
1482
1542
  @recurring_app.command("generate-now")
1483
1543
  def generate_now(
1484
1544
  template_id: str = typer.Argument(..., help="Recurring template ID."),
1545
+ period_start: Optional[str] = typer.Option(
1546
+ None, "--period-start", help="Start of the period to bill (YYYY-MM-DD)."
1547
+ ),
1548
+ period_end: Optional[str] = typer.Option(
1549
+ None, "--period-end", help="End of the period to bill (YYYY-MM-DD)."
1550
+ ),
1551
+ issue_date: Optional[str] = typer.Option(
1552
+ None, "--issue-date", help="Invoice date (YYYY-MM-DD). Defaults to today."
1553
+ ),
1554
+ allow_overlap: bool = typer.Option(
1555
+ False, "--allow-overlap", help="Bill a period this template already invoiced."
1556
+ ),
1485
1557
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
1486
1558
  ) -> None:
1487
1559
  """Manually generate an invoice from a recurring template.
1488
1560
 
1561
+ With no dates the template bills its scheduled period, dated today —
1562
+ the same invoice the nightly job would raise, and it advances the
1563
+ schedule. Naming a period bills that one instead as a one-off and
1564
+ leaves the schedule untouched. The invoice date is separate from the
1565
+ period: an invoice raised today can bill a period that closed months
1566
+ ago (`ct invoice recurring show` prints both).
1567
+
1489
1568
  Examples:
1490
1569
  ct invoice recurring generate-now <template-id>
1570
+ ct invoice recurring generate-now <template-id> --period-start 2026-03-01 --period-end 2026-03-31
1571
+ ct invoice recurring generate-now <template-id> --period-start 2026-03-01 --period-end 2026-03-31 --issue-date 2026-08-20
1491
1572
  """
1573
+ if bool(period_start) != bool(period_end):
1574
+ format_error("Give both --period-start and --period-end, or neither.")
1575
+ raise typer.Exit(1)
1576
+
1492
1577
  client = CrowdTimeClient(require_auth=True, require_org=True)
1493
1578
 
1579
+ payload: dict = {}
1580
+ if period_start:
1581
+ payload["period_start"] = period_start
1582
+ payload["period_end"] = period_end
1583
+ if issue_date:
1584
+ payload["issue_date"] = issue_date
1585
+ if allow_overlap:
1586
+ payload["allow_overlap"] = True
1587
+
1494
1588
  try:
1495
- data = client.post(f"/invoices/recurring-templates/{template_id}/generate-now/")
1589
+ data = client.post(
1590
+ f"/invoices/recurring-templates/{template_id}/generate-now/", data=payload
1591
+ )
1496
1592
 
1497
1593
  if output_json:
1498
1594
  print_json(data)
1499
1595
  else:
1596
+ invoice = data.get("invoice") or {}
1500
1597
  format_success(data.get("detail", "Invoice generated successfully."))
1598
+ if invoice:
1599
+ console.print(f" Invoice: {invoice.get('invoice_number', '')}")
1600
+ console.print(f" ID: {invoice.get('id', '')}")
1601
+ console.print(
1602
+ f" Period: {invoice.get('period_start', '')} to {invoice.get('period_end', '')}"
1603
+ )
1604
+ console.print(f" Issued: {invoice.get('issue_date', '')}")
1501
1605
  except APIError as e:
1502
1606
  if e.status_code == 404:
1503
1607
  format_error(f"Recurring template '{template_id}' not found.")
1608
+ elif e.status_code == 409:
1609
+ # The period is already billed, or is about to be by the scheduled
1610
+ # run. Fixed lines go out in full every run, so say what clashes.
1611
+ format_error(e.message)
1612
+ body = e.detail if isinstance(e.detail, dict) else {}
1613
+ for inv in body.get("overlapping_invoices", []):
1614
+ console.print(
1615
+ f" {inv.get('invoice_number', '')} ({inv.get('status', '')}) "
1616
+ f"— {inv.get('period_start', '')} to {inv.get('period_end', '')}"
1617
+ )
1618
+ console.print("[dim]Re-run with --allow-overlap to bill it anyway.[/dim]")
1504
1619
  else:
1505
1620
  format_error(e.message)
1506
1621
  raise typer.Exit(1)
@@ -21,6 +21,44 @@ console = Console()
21
21
 
22
22
  BILLING_MODES = ["per_task", "per_person", "custom"]
23
23
 
24
+ # WHAT the client is charged. Distinct from BILLING_MODES, which only decides
25
+ # HOW an hourly rate is resolved and is irrelevant for fixed-fee work.
26
+ BILLING_TYPES = ["time_and_materials", "fixed_fee"]
27
+ # Friendly aliases so `--billing-type fixed` and `--billing-type t&m` work.
28
+ BILLING_TYPE_ALIASES = {
29
+ "tm": "time_and_materials",
30
+ "t&m": "time_and_materials",
31
+ "time": "time_and_materials",
32
+ "hourly": "time_and_materials",
33
+ "time_and_materials": "time_and_materials",
34
+ "fixed": "fixed_fee",
35
+ "fixed_fee": "fixed_fee",
36
+ "fixedfee": "fixed_fee",
37
+ }
38
+
39
+
40
+ def _resolve_billing_type(value: str) -> str | None:
41
+ """Normalise a --billing-type value, or None if unrecognised."""
42
+ return BILLING_TYPE_ALIASES.get(value.strip().lower().replace("-", "_"))
43
+
44
+
45
+ PAYMENT_TERMS_ALIASES = {"receipt": 0, "net7": 7, "net15": 15, "net30": 30}
46
+
47
+
48
+ def _resolve_payment_terms_opt(value: str) -> int | None:
49
+ """Resolve a --payment-terms value to days, or None if unrecognised.
50
+
51
+ Accepts the same presets as the invoice commands plus a raw day count.
52
+ """
53
+ lower = value.strip().lower()
54
+ if lower in PAYMENT_TERMS_ALIASES:
55
+ return PAYMENT_TERMS_ALIASES[lower]
56
+ try:
57
+ days = int(lower)
58
+ except ValueError:
59
+ return None
60
+ return days if days >= 0 else None
61
+
24
62
 
25
63
  def _resolve_or_create_client(api_client: CrowdTimeClient, client_name: str) -> str:
26
64
  """Look up a client by name; create one if it doesn't exist. Returns the client UUID."""
@@ -101,7 +139,17 @@ def show(
101
139
  console.print(f" Client: {project.client_name or project.client or '-'}")
102
140
  console.print(f" Status: {project.status}")
103
141
  console.print(f" Billable: {'Yes' if project.is_billable else 'No'}")
104
- console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
142
+ if project.billing_type == "fixed_fee":
143
+ currency = data.get("currency", "USD")
144
+ fee = (
145
+ format_currency(project.fixed_fee_amount, currency)
146
+ if project.fixed_fee_amount is not None
147
+ else "[red]not set[/red]"
148
+ )
149
+ console.print(f" Billing: Fixed Fee — {fee}")
150
+ else:
151
+ console.print(" Billing: Time & Materials")
152
+ console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
105
153
  if project.budget_type and project.budget_type != "none" and project.budget_amount:
106
154
  currency = data.get("currency", "USD")
107
155
  budget_label = f"{project.budget_amount}h" if project.budget_type == "hours" else format_currency(project.budget_amount, currency)
@@ -126,7 +174,22 @@ def create(
126
174
  help="Mark as billable."),
127
175
  billing_mode: Optional[str] = typer.Option(
128
176
  None, "--billing-mode", "-m",
129
- help="Billing mode: per_task, per_person, custom.",
177
+ help="Rate resolution for time & materials work: per_task, per_person, custom.",
178
+ ),
179
+ billing_type: Optional[str] = typer.Option(
180
+ None, "--billing-type",
181
+ help="What the client is charged: time_and_materials (default) or fixed_fee.",
182
+ ),
183
+ fixed_fee: Optional[float] = typer.Option(
184
+ None, "--fixed-fee",
185
+ help="Agreed price billed instead of logged hours. Implies --billing-type fixed_fee.",
186
+ ),
187
+ payment_terms: Optional[str] = typer.Option(
188
+ None, "--payment-terms",
189
+ help=(
190
+ "Payment terms for this project: receipt, net7, net15, net30, or a "
191
+ "number of days. Omit to inherit the client/org default."
192
+ ),
130
193
  ),
131
194
  color: Optional[str] = typer.Option(None, "--color", help="Project color hex code (e.g. #FF5733)."),
132
195
  budget: Optional[float] = typer.Option(None, "--budget", help="Budget amount (hours or money depending on --budget-type)."),
@@ -137,13 +200,39 @@ def create(
137
200
  code: Optional[str] = typer.Option(None, "--code", help="Short project code."),
138
201
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
139
202
  ) -> None:
140
- """Create a new project."""
203
+ """Create a new project.
204
+
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.
208
+ """
141
209
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
142
210
 
143
211
  if billing_mode and billing_mode not in BILLING_MODES:
144
212
  format_error(f"Invalid billing mode. Choose from: {', '.join(BILLING_MODES)}")
145
213
  raise typer.Exit(1)
146
214
 
215
+ # --fixed-fee alone is enough to mean "this is a fixed-fee project".
216
+ resolved_billing_type = None
217
+ if billing_type:
218
+ resolved_billing_type = _resolve_billing_type(billing_type)
219
+ if resolved_billing_type is None:
220
+ format_error(f"Invalid billing type. Choose from: {', '.join(BILLING_TYPES)}")
221
+ raise typer.Exit(1)
222
+ elif fixed_fee is not None:
223
+ resolved_billing_type = "fixed_fee"
224
+
225
+ if resolved_billing_type == "fixed_fee":
226
+ if fixed_fee is None:
227
+ format_error("--fixed-fee is required for fixed-fee projects.")
228
+ raise typer.Exit(1)
229
+ if fixed_fee <= 0:
230
+ format_error("--fixed-fee must be greater than zero.")
231
+ raise typer.Exit(1)
232
+ elif fixed_fee is not None:
233
+ format_error("--fixed-fee only applies to fixed-fee projects.")
234
+ raise typer.Exit(1)
235
+
147
236
  budget_types = ["hours", "money", "none"]
148
237
  if budget_type and budget_type not in budget_types:
149
238
  format_error(f"Invalid budget type. Choose from: {', '.join(budget_types)}")
@@ -158,6 +247,19 @@ def create(
158
247
  payload: dict = {"name": name, "is_billable": billable}
159
248
  if billing_mode:
160
249
  payload["billing_mode"] = billing_mode
250
+ if resolved_billing_type:
251
+ payload["billing_type"] = resolved_billing_type
252
+ if fixed_fee is not None:
253
+ payload["fixed_fee_amount"] = fixed_fee
254
+ if payment_terms is not None:
255
+ resolved_terms = _resolve_payment_terms_opt(payment_terms)
256
+ if resolved_terms is None:
257
+ format_error(
258
+ f"Invalid --payment-terms: '{payment_terms}'. "
259
+ "Use: receipt, net7, net15, net30, or a non-negative number of days."
260
+ )
261
+ raise typer.Exit(1)
262
+ payload["payment_terms_days"] = resolved_terms
161
263
  if color:
162
264
  payload["color"] = color
163
265
  if budget is not None:
@@ -229,7 +331,19 @@ def update(
229
331
  help="Mark as billable or not."),
230
332
  billing_mode: Optional[str] = typer.Option(
231
333
  None, "--billing-mode", "-m",
232
- help="Billing mode: per_task, per_person, custom.",
334
+ help="Rate resolution for time & materials work: per_task, per_person, custom.",
335
+ ),
336
+ billing_type: Optional[str] = typer.Option(
337
+ None, "--billing-type",
338
+ help="What the client is charged: time_and_materials or fixed_fee.",
339
+ ),
340
+ fixed_fee: Optional[float] = typer.Option(
341
+ None, "--fixed-fee",
342
+ help="Agreed price billed instead of logged hours.",
343
+ ),
344
+ payment_terms: Optional[str] = typer.Option(
345
+ None, "--payment-terms",
346
+ help="Payment terms: receipt, net7, net15, net30, or a number of days.",
233
347
  ),
234
348
  color: Optional[str] = typer.Option(None, "--color", help="Project color hex code (e.g. #FF5733)."),
235
349
  budget: Optional[float] = typer.Option(None, "--budget", help="Budget amount (hours or money depending on --budget-type)."),
@@ -273,6 +387,19 @@ def update(
273
387
  format_error(f"Invalid status. Choose from: {', '.join(valid_statuses)}")
274
388
  raise typer.Exit(1)
275
389
 
390
+ resolved_billing_type = None
391
+ if billing_type:
392
+ resolved_billing_type = _resolve_billing_type(billing_type)
393
+ if resolved_billing_type is None:
394
+ format_error(f"Invalid billing type. Choose from: {', '.join(BILLING_TYPES)}")
395
+ raise typer.Exit(1)
396
+ elif fixed_fee is not None:
397
+ resolved_billing_type = "fixed_fee"
398
+
399
+ if fixed_fee is not None and fixed_fee <= 0:
400
+ format_error("--fixed-fee must be greater than zero.")
401
+ raise typer.Exit(1)
402
+
276
403
  payload: dict = {}
277
404
  if name is not None:
278
405
  payload["name"] = name
@@ -280,6 +407,19 @@ def update(
280
407
  payload["is_billable"] = billable
281
408
  if billing_mode:
282
409
  payload["billing_mode"] = billing_mode
410
+ if resolved_billing_type:
411
+ payload["billing_type"] = resolved_billing_type
412
+ if fixed_fee is not None:
413
+ payload["fixed_fee_amount"] = fixed_fee
414
+ if payment_terms is not None:
415
+ resolved_terms = _resolve_payment_terms_opt(payment_terms)
416
+ if resolved_terms is None:
417
+ format_error(
418
+ f"Invalid --payment-terms: '{payment_terms}'. "
419
+ "Use: receipt, net7, net15, net30, or a non-negative number of days."
420
+ )
421
+ raise typer.Exit(1)
422
+ payload["payment_terms_days"] = resolved_terms
283
423
  if color:
284
424
  payload["color"] = color
285
425
  if budget is not None:
@@ -597,9 +597,19 @@ def calendar_cmd(
597
597
  from_date: Optional[str] = typer.Option(None, "--from", help="Start date. Default: first of current month."),
598
598
  to_date: Optional[str] = typer.Option(None, "--to", help="End date. Default: last day of current month."),
599
599
  user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter to a single user (email or UUID)."),
600
+ status: Optional[str] = typer.Option(
601
+ None,
602
+ "--status",
603
+ "-s",
604
+ help=(
605
+ "Statuses to include, comma-separated "
606
+ "(pending, approved, rejected, canceled). Default: approved. "
607
+ "Use 'all' for every status."
608
+ ),
609
+ ),
600
610
  output_json: bool = typer.Option(False, "--json", help="Output raw API response as JSON."),
601
611
  ) -> None:
602
- """Render an approved-PTO calendar grid for the given window."""
612
+ """Render a PTO calendar grid for the given window (approved by default)."""
603
613
  today = date.today()
604
614
  try:
605
615
  if from_date:
@@ -631,6 +641,12 @@ def calendar_cmd(
631
641
  except APIError as e:
632
642
  format_error(e.message)
633
643
  raise typer.Exit(1)
644
+ if status:
645
+ params["status"] = (
646
+ "pending,approved,rejected,canceled"
647
+ if status.strip().lower() == "all"
648
+ else status
649
+ )
634
650
 
635
651
  try:
636
652
  data = client.get("/time-off/calendar/", params=params)
@@ -645,7 +661,8 @@ def calendar_cmd(
645
661
  rows = data if isinstance(data, list) else extract_results(data)
646
662
  items = [TimeOffCalendarItem(**item) for item in rows]
647
663
  if not items:
648
- console.print("[dim]No approved PTO in this window.[/dim]")
664
+ scope = "PTO" if status else "approved PTO"
665
+ console.print(f"[dim]No {scope} in this window.[/dim]")
649
666
  return
650
667
 
651
668
  console.print(format_timeoff_calendar(items, f_date, t_date))
@@ -1,4 +1,4 @@
1
- """Timesheet commands: list, submit, approve, reject, team overview."""
1
+ """Timesheet commands: list, submit, approve, reject, recall, reopen, team overview."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -425,6 +425,55 @@ def recall_timesheet(
425
425
  raise typer.Exit(1)
426
426
 
427
427
 
428
+ @app.command("reopen")
429
+ def reopen_timesheet(
430
+ timesheet_id: str = typer.Argument(..., help="Timesheet ID to reopen."),
431
+ notes: str = typer.Option(
432
+ ..., "--notes", "-n", help="Reason for reopening (required)."
433
+ ),
434
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
435
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
436
+ ) -> None:
437
+ """Reopen an APPROVED timesheet back to draft (project_manager+).
438
+
439
+ The reviewer-side undo for an approval granted in error: clears the
440
+ approval, unlocks the period's time entries and returns the timesheet to
441
+ draft so the user can fix it and resubmit. Blocked if any of that time is
442
+ already on a finalized invoice.
443
+
444
+ Examples:
445
+ ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
446
+ ct timesheet reopen <timesheet-id> --notes "Wrong project on Tuesday" --force
447
+ """
448
+ client = CrowdTimeClient(require_auth=True, require_org=True)
449
+
450
+ if not force and not output_json:
451
+ console.print(
452
+ "[yellow]Reopening clears the approval and unlocks every entry in the period "
453
+ "so the user can edit and resubmit.[/yellow]"
454
+ )
455
+ if not typer.confirm(f"Reopen timesheet {timesheet_id}?"):
456
+ console.print("[dim]Cancelled.[/dim]")
457
+ raise typer.Exit(0)
458
+
459
+ try:
460
+ result = client.post(f"/timesheets/{timesheet_id}/reopen/", data={"notes": notes})
461
+
462
+ if output_json:
463
+ print_json(result)
464
+ else:
465
+ user_name = result.get("user_name", "user")
466
+ format_success(
467
+ f"Timesheet from {user_name} reopened to draft. Entries are unlocked for editing."
468
+ )
469
+ except APIError as e:
470
+ if e.status_code == 404:
471
+ format_error(f"Timesheet '{timesheet_id}' not found.")
472
+ else:
473
+ format_error(e.message)
474
+ raise typer.Exit(1)
475
+
476
+
428
477
  @app.command("team")
429
478
  def team_overview(
430
479
  period_start: Optional[str] = typer.Option(
@@ -728,6 +777,7 @@ def timesheet_history(
728
777
  "approved": "green",
729
778
  "rejected": "red",
730
779
  "recalled": "yellow",
780
+ "reopened": "magenta",
731
781
  }
732
782
  style = event_styles.get(event_type, "dim")
733
783
 
@@ -768,15 +768,25 @@ def format_timeoff_calendar(
768
768
  else:
769
769
  cursor = cursor.replace(month=cursor.month + 1)
770
770
 
771
- # Legend
772
- legend_parts: list[str] = []
773
- for kind, glyph in PTO_KIND_GLYPHS.items():
774
- style = _pto_kind_style(kind)
775
- legend_parts.append(f"[{style}]{glyph}[/{style}] {kind}")
776
- legend = " ".join(legend_parts)
771
+ # Legend — two axes: the letter is the kind, the colour is the status.
772
+ # Only list statuses actually present, so the default approved-only view
773
+ # doesn't advertise four colours it never renders.
774
+ present_statuses = [
775
+ s for s in PTO_STATUS_STYLES if any((i.status or "") == s for i in items)
776
+ ]
777
+ kind_legend = " ".join(
778
+ f"{glyph} {kind}" for kind, glyph in PTO_KIND_GLYPHS.items()
779
+ )
780
+ status_legend = " ".join(
781
+ f"[{_pto_status_style(s)}]■[/{_pto_status_style(s)}] {s}"
782
+ for s in present_statuses
783
+ )
777
784
 
778
785
  from rich.console import Group
779
- body = Group(*month_blocks, Text(""), Text.from_markup(legend))
786
+ legend_lines = [Text.from_markup(f"[dim]kind[/dim] {kind_legend}")]
787
+ if status_legend:
788
+ legend_lines.append(Text.from_markup(f"[dim]status[/dim] {status_legend}"))
789
+ body = Group(*month_blocks, Text(""), *legend_lines)
780
790
  return Panel(
781
791
  body,
782
792
  title=f"PTO Calendar — {from_date.isoformat()} → {to_date.isoformat()}",
@@ -817,8 +827,10 @@ def _render_pto_month(
817
827
  day_label = f"[bold]{day:>2}[/bold]" if in_window else f"[dim]{day:>2}[/dim]"
818
828
  markers: list[str] = []
819
829
  for item in by_day.get(d, []):
830
+ # Letter = kind, colour = status (mirrors the web calendar, where
831
+ # the chip letter is the kind and the fill is the status).
820
832
  glyph = PTO_KIND_GLYPHS.get(item.kind, "?")
821
- style = _pto_kind_style(item.kind)
833
+ style = _pto_status_style(item.status)
822
834
  markers.append(f"[{style}]{glyph}[/{style}]")
823
835
  marker_str = "".join(markers[:6]) # cap to avoid overflow
824
836
  cells.append(f"{day_label} {marker_str}".rstrip())
@@ -38,6 +38,9 @@ class Project(BaseModel):
38
38
  client_name: str | None = None
39
39
  color: str = "#3B82F6"
40
40
  billing_mode: str = "custom"
41
+ billing_type: str = "time_and_materials"
42
+ fixed_fee_amount: Decimal | None = None
43
+ payment_terms_days: int | None = None
41
44
  status: str = "active"
42
45
  is_billable: bool = True
43
46
  budget_type: str | None = "none"
@@ -342,4 +345,6 @@ class TimeOffCalendarItem(BaseModel):
342
345
  start_date: Optional[dt.date] = None
343
346
  end_date: Optional[dt.date] = None
344
347
  kind: str = "vacation"
348
+ status: str = "approved"
349
+ status_display: str = ""
345
350
  resolved_hours_per_day: Decimal | None = None
@@ -126,6 +126,7 @@ ct projects list # List all projects
126
126
  ct projects show <id> # Project details
127
127
  ct projects create "New Project" --client "Acme" --budget 100 --budget-type hours -b
128
128
  ct projects create "New Project" --client "Acme" --billing-mode per_person # Set billing mode
129
+ ct projects create "Redesign" --client "Acme" --fixed-fee 10000 # Fixed price instead of hours
129
130
  ct projects archive <id> # Archive a project
130
131
  ct projects switch <slug> # Set default project
131
132
 
@@ -147,7 +148,11 @@ ct projects add-tasks <project-id> --task <tid1> --task <tid2> # Bulk assign ta
147
148
 
148
149
  **Note:** `--client` on `projects create` accepts a client name. If the client doesn't exist yet, it will be auto-created.
149
150
 
150
- **Billing modes:** `custom` (default), `per_task` (rate per task), `per_person` (rate per person).
151
+ **Billing types** (*what* is charged): `time_and_materials` (default) or `fixed_fee` (an agreed price via `--fixed-fee`, billed instead of logged hours).
152
+
153
+ **Billing modes** (*how* an hourly rate resolves, T&M only): `custom` (default), `per_task` (rate per task), `per_person` (rate per person).
154
+
155
+ 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.
151
156
 
152
157
  **Rate waterfall** (order depends on billing mode):
153
158
  - `custom`: Entry → ProjectMember → ProjectTask → Project → Task → Membership → User
@@ -245,6 +250,7 @@ ct timesheet approve <id> --project <project-id> # Approve one project's portio
245
250
  ct timesheet approve <id> --force # Skip confirmation prompt
246
251
  ct timesheet reject <id> --notes "Missing entries for Wednesday" # Reject with notes
247
252
  ct timesheet reject <id> --project <project-id> --notes "Hours too high" # Reject one project portion
253
+ ct timesheet reopen <id> --notes "Approved too early" # Undo an approval — back to draft, entries unlocked (manager+)
248
254
  ct timesheet approvals <id> # Show per-project approval status
249
255
  ct timesheet team # Team overview with capacity % (manager+)
250
256
  ct timesheet team --from 2026-03-10 --to 2026-03-16 # Custom period
@@ -353,7 +359,7 @@ ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day
353
359
  ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or manager+ any)
354
360
  ct pto approve <id> [--notes "..."] # Approve a pending entry (manager+)
355
361
  ct pto reject <id> --notes "..." # Reject a pending entry — notes required (manager+)
356
- ct pto calendar [--from --to --user] # Render-ready slice of approved PTO for a date window
362
+ ct pto calendar [--from --to --user --status] # PTO slice for a date window (approved only unless --status)
357
363
  ct pto summary [--from --to --user] # Aggregate approved PTO hours/days, by kind and by user
358
364
  ```
359
365
 
@@ -946,7 +946,10 @@ ct projects create NAME [options]
946
946
  | `--client`, `-c` | string | Client name (auto-creates if not found) |
947
947
  | `--billable`, `-b` | flag | Billable (default: true) |
948
948
  | `--no-billable`, `-B` | flag | Non-billable |
949
- | `--billing-mode` | string | Billing mode: `per_task`, `per_person`, `custom` (default) |
949
+ | `--billing-type` | string | What the client is charged: `time_and_materials` (default) or `fixed_fee`. Accepts `tm` / `fixed` as aliases |
950
+ | `--fixed-fee` | float | Agreed price billed instead of logged hours. Implies `--billing-type fixed_fee` |
951
+ | `--billing-mode` | string | Rate resolution for T&M work: `per_task`, `per_person`, `custom` (default). Ignored for fixed-fee projects |
952
+ | `--payment-terms` | string | Terms for this project: `receipt`, `net7`, `net15`, `net30`, or a number of days. Omit to inherit the client, then org, default |
950
953
  | `--color` | string | Hex color code |
951
954
  | `--budget` | float | Budget amount (hours or money depending on `--budget-type`) |
952
955
  | `--budget-type` | string | Budget type: `hours` (default when `--budget` given), `money`, `none` |
@@ -954,13 +957,32 @@ ct projects create NAME [options]
954
957
  | `--code` | string | Short project code |
955
958
  | `--json` | flag | JSON output |
956
959
 
957
- **Billing modes:**
960
+ **Billing types** — *what* the client is charged:
961
+ | Type | Description |
962
+ |------|-------------|
963
+ | `time_and_materials` | Default. Billed from logged hours at the resolved rate |
964
+ | `fixed_fee` | Billed at `--fixed-fee`, an agreed price. Logged hours are tracked but never charged by the hour |
965
+
966
+ > **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:
967
+ > 1. the project is named explicitly — `ct invoice create --client <id> --project <fixed-project-id>`, or
968
+ > 2. a recurring template lists the project.
969
+ >
970
+ > 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.
971
+
972
+ **Billing modes** — *how* an hourly rate is resolved (T&M only):
958
973
  | Mode | Description |
959
974
  |------|-------------|
960
975
  | `custom` | Default. Full rate waterfall: Entry → ProjectMember → ProjectTask → Project → Task → Membership → User |
961
976
  | `per_task` | Task-first waterfall: Entry → ProjectTask → Task → ProjectMember → Membership → User → Project (last) |
962
977
  | `per_person` | Person-first waterfall: Entry → ProjectMember → Membership → User → ProjectTask → Task → Project (last) |
963
978
 
979
+ **Fixed-fee examples:**
980
+ ```bash
981
+ ct projects create "Rediseño Web" --client "Acme" --fixed-fee 10000
982
+ ct projects update redesign --billing-type fixed --fixed-fee 12000
983
+ ct projects update redesign --billing-type tm --billing-mode per_person
984
+ ```
985
+
964
986
  Endpoint: `POST /projects/`
965
987
 
966
988
  ### ct projects update
@@ -1694,6 +1716,8 @@ ct timesheet approve TIMESHEET_ID [--project PROJECT_ID] [--notes NOTES] [--forc
1694
1716
 
1695
1717
  Without `--project`: approves ALL project portions at once (manager+ role). With `--project`: approves only that project's portion — PM for that project can do this. When all project portions are approved, the timesheet auto-transitions to approved.
1696
1718
 
1719
+ Self-approval: admins and owners can approve their own timesheet (nobody above them can review it). Project managers and managers cannot — the API returns 400 and they must ask an admin or owner.
1720
+
1697
1721
  Endpoints:
1698
1722
  - Bulk: `POST /timesheets/{id}/approve/`
1699
1723
  - Per-project: `POST /timesheets/{id}/approvals/{project_id}/approve/`
@@ -1713,6 +1737,8 @@ ct timesheet reject TIMESHEET_ID [--project PROJECT_ID] --notes NOTES [--force]
1713
1737
 
1714
1738
  Rejecting ANY project portion rejects the entire timesheet — the user must fix entries and resubmit. Notes are required.
1715
1739
 
1740
+ Self-rejection follows the same rule as approval: admins and owners can reject their own timesheet; everyone else should use `ct timesheet recall` instead.
1741
+
1716
1742
  Endpoints:
1717
1743
  - Bulk: `POST /timesheets/{id}/reject/`
1718
1744
  - Per-project: `POST /timesheets/{id}/approvals/{project_id}/reject/`
@@ -1732,6 +1758,26 @@ ct timesheet recall TIMESHEET_ID [--notes NOTES] [--force] [--json]
1732
1758
  Recalls a submitted timesheet back to draft so you can edit and resubmit. Only the timesheet owner can recall, and only before it's been approved or rejected. Notes are optional but recorded in the audit trail.
1733
1759
  Endpoint: `POST /timesheets/{id}/recall/`
1734
1760
 
1761
+ Once a timesheet is APPROVED the owner can no longer recall it — use `ct timesheet reopen` (reviewer side) instead.
1762
+
1763
+ ### ct timesheet reopen
1764
+
1765
+ ```
1766
+ ct timesheet reopen TIMESHEET_ID --notes NOTES [--force] [--json]
1767
+ ```
1768
+
1769
+ | Option | Type | Description |
1770
+ |--------|------|-------------|
1771
+ | `--notes`, `-n` | string (required) | Reason for reopening |
1772
+ | `--force`, `-f` | flag | Skip confirmation prompt |
1773
+ | `--json` | flag | JSON output |
1774
+
1775
+ The reviewer-side undo of an approval, for a mistake caught only after the timesheet was approved. Returns the timesheet to **draft**, clears the approval and its per-project records, and unlocks every time entry in the period so the user can fix and resubmit. Requires the same approval authority as `approve` (project_manager+ with visibility; admin/owner anywhere), and a reason is mandatory — it's written to the audit trail with the original approver and approval time.
1776
+
1777
+ Only APPROVED timesheets can be reopened (400 otherwise). Returns 409 if any time in the period is already on a finalized invoice (sent/viewed/paid/partially paid/overdue) — void or credit the invoice first. Draft invoices don't block it.
1778
+
1779
+ Endpoint: `POST /timesheets/{id}/reopen/`
1780
+
1735
1781
  ### ct timesheet team
1736
1782
 
1737
1783
  ```
@@ -1898,7 +1944,7 @@ Shows invoice details including line items, payments, and totals. Endpoint: `GET
1898
1944
  ### ct invoice create
1899
1945
 
1900
1946
  ```
1901
- ct invoice create --client CLIENT --from DATE --to DATE [--period PERIOD] [--group-by GROUP] [--tax-rate RATE] [--payment-terms TERMS] [--notes NOTES] [--terms TEXT] [--json]
1947
+ ct invoice create --client CLIENT --from DATE --to DATE [--period PERIOD] [--group-by GROUP] [--tax-rate RATE] [--payment-terms TERMS] [--issue-date DATE] [--due-date DATE] [--notes NOTES] [--terms TEXT] [--json]
1902
1948
  ```
1903
1949
 
1904
1950
  Creates an invoice from tracked time entries. Requires either `--from`/`--to` or `--period`.
@@ -1909,21 +1955,31 @@ Creates an invoice from tracked time entries. Requires either `--from`/`--to` or
1909
1955
  | `--from` | string | Period start date |
1910
1956
  | `--to` | string | Period end date |
1911
1957
  | `--period` | string | Preset: `last-week`, `last-2-weeks`, `last-month`, `this-month` |
1958
+ | `--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 |
1912
1959
  | `--group-by` | string | Group line items by: `project`, `task`, `user`, `date`, `none` |
1913
1960
  | `--tax-rate` | float | Tax rate percentage (e.g. 21 for 21%) |
1914
- | `--payment-terms` | string | Payment terms: `receipt`, `net15`, `net30`, or number of days |
1961
+ | `--payment-terms` | string | Payment terms: `receipt` (0 days), `net7`, `net15`, `net30`, or number of days. **Omit** to resolve from the waterfall below |
1962
+ | `--issue-date` | string | Invoice date. Default: today |
1963
+ | `--due-date` | string | Explicit due date. Default: issue date + payment terms |
1915
1964
  | `--notes` | string | Invoice notes |
1916
1965
  | `--terms` | string | Payment terms text |
1917
1966
  | `--json` | flag | JSON output |
1918
1967
 
1919
1968
  Billable time entries and expenses from the period are automatically imported as line items.
1920
1969
 
1970
+ > **Dates:** the due date is derived from the **invoice date**, never from the end of the billing period. Invoicing a period weeks after it closed would otherwise produce an invoice that is already overdue the moment it is sent. `--payment-terms receipt` (0 days) means due on the issue date, and is preserved rather than treated as "unset".
1971
+ >
1972
+ > **Payment terms waterfall:** `--payment-terms` > project > client > org default > 15 days. The **project tier only applies when the invoice covers exactly one project** (`--project <id>` given once) — a multi-project invoice resolves from the client instead, so one project's negotiated terms never silently govern another project's work.
1973
+
1921
1974
  **Usage examples:**
1922
1975
  ```bash
1923
1976
  ct invoice create --client <id> --period last-month --payment-terms net30
1924
1977
  ct invoice create --client <id> --from 2026-03-01 --to 2026-03-31
1925
1978
  ct invoice create --client <id> --period last-2-weeks --group-by project
1926
1979
  ct invoice create --client <id> --period last-month --group-by user # One line item per team member
1980
+ ct invoice create --client <id> --period last-month # Uses the client's own terms
1981
+ ct invoice create --client <id> --period last-month --due-date 2026-09-15
1982
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the fixed fee
1927
1983
  ```
1928
1984
 
1929
1985
  Endpoint: `POST /invoices/from-time-entries/`
@@ -1983,18 +2039,21 @@ Adds a line item to a draft invoice. Endpoint: `POST /invoices/{id}/line-items/`
1983
2039
  ### ct invoice send
1984
2040
 
1985
2041
  ```
1986
- ct invoice send INVOICE_ID [--to CONTACT_ID]... [--cc CONTACT_ID]... [--force/-f] [--json]
2042
+ ct invoice send INVOICE_ID [--to CONTACT_ID]... [--cc CONTACT_ID]... [--keep-dates] [--force/-f] [--json]
1987
2043
  ```
1988
2044
 
1989
2045
  | Option | Type | Description |
1990
2046
  |--------|------|-------------|
1991
2047
  | `--to` | string (repeatable) | Contact ID(s) to send to. Repeat for multiple. Default: primary contact |
1992
2048
  | `--cc` | string (repeatable) | Contact ID(s) to CC. Repeat for multiple |
2049
+ | `--keep-dates` | flag | Send with the drafted dates as-is, skipping the stale-date re-stamp below |
1993
2050
  | `--force`, `-f` | flag | Skip confirmation prompt |
1994
2051
  | `--json` | flag | JSON output |
1995
2052
 
1996
2053
  Transitions invoice from draft to sent. This action cannot be undone (use void instead). Endpoint: `POST /invoices/{id}/send/`
1997
2054
 
2055
+ > **Stale drafts:** if the draft's issue date has fallen into the past, sending re-stamps it to today and shifts the due date by the same number of days, so a custom due-date offset survives and the invoice never arrives already overdue. Pass `--keep-dates` when the issue date must stay frozen (e.g. it belongs to a closed accounting period).
2056
+
1998
2057
  ### ct invoice pay
1999
2058
 
2000
2059
  ```
@@ -2036,7 +2095,9 @@ Endpoint: `GET /invoices/recurring-templates/`
2036
2095
  ct invoice recurring show TEMPLATE_ID [--json]
2037
2096
  ```
2038
2097
 
2039
- Shows full details of a recurring template including line items.
2098
+ Shows full details of a recurring template including line items. Prints both
2099
+ `Next Run` (when the invoice is raised) and `Next Period` (the work it bills) —
2100
+ they are different dates.
2040
2101
  Endpoint: `GET /invoices/recurring-templates/{id}/`
2041
2102
 
2042
2103
  ### ct invoice recurring create
@@ -2094,10 +2155,39 @@ Endpoint: `DELETE /invoices/recurring-templates/{id}/`
2094
2155
  ### ct invoice recurring generate-now
2095
2156
 
2096
2157
  ```
2097
- ct invoice recurring generate-now TEMPLATE_ID [--json]
2158
+ ct invoice recurring generate-now TEMPLATE_ID [--period-start DATE] [--period-end DATE] [--issue-date DATE] [--json]
2098
2159
  ```
2099
2160
 
2161
+ | Option | Type | Description |
2162
+ |--------|------|-------------|
2163
+ | `--period-start` | date | Start of the period to bill (YYYY-MM-DD) |
2164
+ | `--period-end` | date | End of the period to bill (YYYY-MM-DD) |
2165
+ | `--issue-date` | date | Invoice date (YYYY-MM-DD). Defaults to today |
2166
+ | `--allow-overlap` | flag | Bill a period this template already invoiced |
2167
+ | `--json` | flag | JSON output |
2168
+
2100
2169
  Manually triggers invoice generation from a template, regardless of schedule.
2170
+ Returns the created invoice (number, ID, period, issue date).
2171
+
2172
+ **The period and the invoice date are separate.** With no dates the template
2173
+ bills its scheduled period (`next_period_start` / `next_period_end` on the
2174
+ template), dated today, and the schedule advances one cycle. Passing a period
2175
+ bills that one as a one-off and leaves the schedule exactly where it was —
2176
+ use it to bill a period that closed months ago without disturbing the cycle.
2177
+ `--period-start` and `--period-end` go together; passing one alone is an error.
2178
+
2179
+ Deleting or voiding an invoice that consumed the scheduled run hands that run
2180
+ back to the template, so the period can be generated again.
2181
+
2182
+ **Overlaps are refused with 409.** A template's fixed lines and fixed-fee
2183
+ projects are billed in full on *every* run regardless of the period, so billing
2184
+ a period twice duplicates them (logged hours are safe — they are settled on the
2185
+ invoice that pulled them). The refusal covers both a period another invoice from
2186
+ this template already bills, and a period the next scheduled run will bill — the
2187
+ response carries `requested_period`, `scheduled_period` and `overlapping_invoices`.
2188
+ Pass `--allow-overlap` for a deliberate re-bill, or set the period to the
2189
+ scheduled one so it consumes the run instead of adding to it.
2190
+
2101
2191
  Endpoint: `POST /invoices/recurring-templates/{id}/generate-now/`
2102
2192
 
2103
2193
  ---
@@ -2892,40 +2982,48 @@ ct pto reject 3a9b4f22-1c5d-4e78-bf2a-0d1e2f3a4b5c --notes "Necesitamos verifica
2892
2982
  ### ct pto calendar
2893
2983
 
2894
2984
  ```
2895
- ct pto calendar --from DATE --to DATE [--user/-u USER] [--json]
2985
+ ct pto calendar --from DATE --to DATE [--user/-u USER] [--status/-s STATUSES] [--json]
2896
2986
  ```
2897
2987
 
2898
- Render-ready calendar slice of **approved** PTO entries overlapping the specified date window. Returns only approved entries. Scoped to visible users.
2988
+ Render-ready calendar slice of PTO entries overlapping the specified date window, scoped to visible users. **Returns approved entries only unless `--status` is passed.**
2899
2989
 
2900
- **Who can call it:** any active member (sees own approved entries); manager+ sees entries for their visibility scope; admin/owner sees all.
2990
+ **Who can call it:** any active member (sees own entries); manager+ sees entries for their visibility scope; admin/owner sees all.
2901
2991
 
2902
2992
  | Option | Type | Required | Description |
2903
2993
  |--------|------|----------|-------------|
2904
2994
  | `--from` | string | **yes** | Window start date `YYYY-MM-DD` |
2905
2995
  | `--to` | string | **yes** | Window end date `YYYY-MM-DD` |
2906
2996
  | `--user`, `-u` | string | no | Filter to a specific user (email or UUID) |
2997
+ | `--status`, `-s` | string | no | Comma-separated statuses to include: `pending`, `approved`, `rejected`, `canceled`. Or `all` for every status. **Default: `approved` only** |
2907
2998
  | `--json` | flag | no | JSON output |
2908
2999
 
2909
- **Output (table):** User, Start, End, Kind, Hours/day. Each row is one PTO block (may span multiple days).
3000
+ **Output (table):** a month grid. Each marker's **letter is the kind** (V/S/H/P/O) and its **colour is the status** (yellow pending, green approved, red rejected, dim canceled). A legend below the grid spells out both axes.
2910
3001
 
2911
- **Response (JSON):** flat array — id, user, user_name, start_date, end_date, kind, resolved_hours_per_day.
3002
+ **Response (JSON):** flat array — id, user, user_name, start_date, end_date, kind, status, status_display, resolved_hours_per_day.
2912
3003
 
2913
3004
  **Errors:**
2914
- - `400` — `--from` or `--to` missing, malformed, or `to < from`.
3005
+ - `400` — `--from` or `--to` missing, malformed, `to < from`, or `--status` contains an unrecognised value.
2915
3006
 
2916
3007
  **Usage examples:**
2917
3008
 
2918
3009
  ```bash
2919
- # Team calendar for June 2026
3010
+ # Team calendar for June 2026 (approved only — the default)
2920
3011
  ct pto calendar --from 2026-06-01 --to 2026-06-30
2921
3012
 
2922
3013
  # Just Jane's PTO for Q3
2923
3014
  ct pto calendar --from 2026-07-01 --to 2026-09-30 --user jane@example.com
2924
3015
 
2925
- # This week's approved time-off (for capacity planning)
2926
- ct pto calendar --from 2026-05-25 --to 2026-05-31 --json
3016
+ # Capacity planning: who is out OR has asked to be out
3017
+ ct pto calendar --from 2026-06-01 --to 2026-06-30 --status pending,approved
3018
+
3019
+ # Everything, including rejected and canceled requests
3020
+ ct pto calendar --from 2026-06-01 --to 2026-06-30 --status all --json
2927
3021
  ```
2928
3022
 
3023
+ > Use `--status pending,approved` for capacity planning: approved time off is
3024
+ > committed, pending is at risk of becoming time off. Plain `--status pending`
3025
+ > answers "what is waiting on my approval this month?".
3026
+
2929
3027
  **Endpoint:** `GET /api/v1/organizations/<slug>/time-off/calendar/`
2930
3028
 
2931
3029
  ---
@@ -577,6 +577,8 @@ ct timesheet list
577
577
  ct timesheet recall <timesheet-id> --notes "Need to fix Wednesday entries"
578
578
  ct timesheet recall <timesheet-id> --force # Skip confirmation
579
579
 
580
+ # Already approved? You can't recall it — ask a manager to `ct timesheet reopen <id>`
581
+
580
582
  # Fix and resubmit
581
583
  ct l -p project-alpha -d friday 2h "missed entry"
582
584
  ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
@@ -621,6 +623,10 @@ ct timesheet reject <timesheet-id> --notes "Missing entries for Wednesday — pl
621
623
  # Reject multiple timesheets at once
622
624
  ct timesheet bulk-reject <id1> <id2> --notes "Incomplete entries for the week"
623
625
 
626
+ # Approved it too early? Reopen — back to draft, entries unlocked, user fixes & resubmits
627
+ ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
628
+ ct timesheet reopen <timesheet-id> --notes "Wrong project on Tuesday" --force
629
+
624
630
  # View full audit trail for a timesheet (submissions, approvals, rejections)
625
631
  ct timesheet history <timesheet-id>
626
632
  ```
@@ -1049,13 +1055,69 @@ ct invoice recurring update <template-id> --active
1049
1055
  # Change schedule
1050
1056
  ct invoice recurring update <template-id> --schedule weekly --next-run 2026-04-14
1051
1057
 
1052
- # Manually generate an invoice from a template (out-of-cycle)
1058
+ # Run the template now for its scheduled period (advances the schedule)
1053
1059
  ct invoice recurring generate-now <template-id>
1054
1060
 
1061
+ # Bill a specific period as a one-off — the schedule stays where it is
1062
+ ct invoice recurring generate-now <template-id> \
1063
+ --period-start 2026-03-01 --period-end 2026-03-31
1064
+
1065
+ # Same, dated today rather than inside the period it bills
1066
+ ct invoice recurring generate-now <template-id> \
1067
+ --period-start 2026-03-01 --period-end 2026-03-31 --issue-date 2026-08-20
1068
+
1055
1069
  # Delete a template
1056
1070
  ct invoice recurring delete <template-id>
1057
1071
  ```
1058
1072
 
1073
+ ### Billing a Period Again
1074
+
1075
+ A template's schedule moves on when it generates for its scheduled period, so
1076
+ the run and the period it billed are held by that invoice. Deleting or voiding
1077
+ it hands both back:
1078
+
1079
+ ```bash
1080
+ # Wrong period, or a bad draft — delete it
1081
+ ct invoice delete <invoice-id>
1082
+
1083
+ # The template's next run is back where it was; check before regenerating
1084
+ ct invoice recurring show <template-id> # Next Run / Next Period
1085
+
1086
+ # Bill it again
1087
+ ct invoice recurring generate-now <template-id>
1088
+ ```
1089
+
1090
+ The hours and expenses on that invoice go back to being uninvoiced too, so
1091
+ they are pulled in again by the next invoice covering their dates. Voiding a
1092
+ sent invoice releases exactly the same things.
1093
+
1094
+ Note the one asymmetry worth knowing: deleting a *voided* invoice releases
1095
+ nothing, because voiding already did. That is deliberate — the work may since
1096
+ have been billed on another invoice.
1097
+
1098
+ ### Why an Overlapping Period Is Refused
1099
+
1100
+ `generate-now` returns 409 if the period is already billed by another invoice
1101
+ from the template, or is one the next scheduled run will bill:
1102
+
1103
+ ```bash
1104
+ # Refused: Jun 1-20 overlaps the scheduled Jun 1-30 run, which would bill the
1105
+ # template's fixed lines a second time
1106
+ ct invoice recurring generate-now <template-id> \
1107
+ --period-start 2026-06-01 --period-end 2026-06-20
1108
+
1109
+ # Either bill the scheduled period exactly, which uses up that run...
1110
+ ct invoice recurring generate-now <template-id>
1111
+
1112
+ # ...or say you mean it
1113
+ ct invoice recurring generate-now <template-id> \
1114
+ --period-start 2026-06-01 --period-end 2026-06-20 --allow-overlap
1115
+ ```
1116
+
1117
+ Logged hours cannot be double-billed — they are settled on the invoice that
1118
+ pulled them. Fixed lines and fixed-fee projects can: they are billed in full on
1119
+ every run whatever the period, which is what the guard exists for.
1120
+
1059
1121
  ---
1060
1122
 
1061
1123
  ## Retainer Management
@@ -1221,6 +1283,12 @@ ct billing
1221
1283
  ct billing --json
1222
1284
  ```
1223
1285
 
1286
+ > **Billing-exempt orgs:** Some organizations (internal/comp accounts) are flagged
1287
+ > billing-exempt by a platform admin. `ct billing` shows an "Exempt" indicator and
1288
+ > `is_usable` is `true` regardless of subscription status — these orgs have full
1289
+ > access and are never charged. This flag can only be set in the Django admin, not
1290
+ > via the CLI.
1291
+
1224
1292
  ### Start a Subscription
1225
1293
 
1226
1294
  ```bash
File without changes
File without changes