crowdtime-cli 0.14.0__tar.gz → 0.17.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.14.0 → crowdtime_cli-0.17.0}/PKG-INFO +3 -2
  2. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/pyproject.toml +13 -3
  3. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/ai_cmd.py +17 -1
  5. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
  6. crowdtime_cli-0.17.0/src/crowdtime_cli/commands/calendar_cmd.py +136 -0
  7. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
  8. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/log_cmd.py +4 -2
  9. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/projects_cmd.py +190 -4
  10. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/pto_cmd.py +42 -13
  11. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
  12. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/formatters.py +29 -9
  13. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/main.py +26 -12
  14. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/models.py +25 -0
  15. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +76 -10
  16. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +212 -24
  17. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +42 -4
  18. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +71 -3
  19. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/.gitignore +0 -0
  20. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/LICENSE +0 -0
  21. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/README.md +0 -0
  22. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/auth.py +0 -0
  23. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/client.py +0 -0
  24. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  25. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  26. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  27. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  28. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  29. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  30. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  31. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  32. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  33. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  34. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  35. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  36. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  37. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  38. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  39. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/config.py +0 -0
  40. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.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.17.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.17.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.17.0"
@@ -37,6 +37,7 @@ def parse(
37
37
  ct ai parse "2 hours on project alpha doing code review"
38
38
  ct ai parse "spent yesterday afternoon on bug fixes for client X"
39
39
  ct ai parse "30min standup" --force
40
+ ct ai parse "daily 15 min standup on argo all week" # creates 5 entries
40
41
  """
41
42
  client = CrowdTimeClient(require_auth=True, require_org=True)
42
43
 
@@ -54,8 +55,13 @@ def parse(
54
55
  console.print("[dim]Dry run - not saving.[/dim]")
55
56
  return
56
57
 
58
+ occurrence_count = len(result.occurrence_dates)
57
59
  if not force:
58
- if not typer.confirm("Create this entry?"):
60
+ prompt = (
61
+ f"Create {occurrence_count} entries?" if occurrence_count > 1
62
+ else "Create this entry?"
63
+ )
64
+ if not typer.confirm(prompt):
59
65
  console.print("[dim]Cancelled.[/dim]")
60
66
  return
61
67
 
@@ -65,6 +71,16 @@ def parse(
65
71
  **result.parsed_fields,
66
72
  })
67
73
 
74
+ # A recurring parse comes back as {count, entries}; a single date keeps
75
+ # the plain TimeEntry shape.
76
+ if "entries" in confirm_data:
77
+ entries = [TimeEntry(**e) for e in confirm_data["entries"]]
78
+ format_success(f"{confirm_data['count']} entries created from AI parse")
79
+ from ..formatters import format_entry_summary
80
+ for entry in entries:
81
+ format_entry_summary(entry)
82
+ return
83
+
68
84
  # Response may be a full TimeEntry or a minimal {detail, time_entry_id}
69
85
  if "id" in confirm_data and "project_name" in confirm_data:
70
86
  entry = TimeEntry(**confirm_data)
@@ -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")
@@ -0,0 +1,136 @@
1
+ """Calendar commands: status, events, disconnect (CRO-74).
2
+
3
+ Connecting requires a browser for the Google consent screen, so that step
4
+ lives in the web app (Settings → Integrations). Once connected, the CLI can
5
+ read the day's meetings and turn one into a time entry.
6
+
7
+ Events are fetched live from Google on every call and are never stored by
8
+ CrowdTime — only the entry you create from one is.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from ..client import APIError, CrowdTimeClient
20
+ from ..formatters import format_error, format_success, print_json
21
+ from ..utils import format_date, parse_date
22
+
23
+ app = typer.Typer(help="Google Calendar integration.")
24
+ console = Console()
25
+
26
+
27
+ @app.command("status")
28
+ def status(
29
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
30
+ ) -> None:
31
+ """Show whether a calendar is connected.
32
+
33
+ Examples:
34
+ ct calendar status
35
+ """
36
+ client = CrowdTimeClient(require_auth=True, require_org=True)
37
+
38
+ try:
39
+ data = client.get("/calendar/")
40
+ except APIError as e:
41
+ format_error(e.message)
42
+ raise typer.Exit(1)
43
+
44
+ if output_json:
45
+ print_json(data)
46
+ return
47
+
48
+ if data.get("available") is False:
49
+ console.print("[dim]Google Calendar is not configured for this deployment.[/dim]")
50
+ return
51
+
52
+ if not data.get("connected"):
53
+ console.print("[dim]No calendar connected.[/dim]")
54
+ console.print(
55
+ "[dim]Connect one from the web app: Settings → Integrations.[/dim]"
56
+ )
57
+ return
58
+
59
+ account = data.get("account_email") or "(unknown account)"
60
+ console.print(f"Connected: [green]{account}[/green]")
61
+ if data.get("last_used_at"):
62
+ console.print(f" Last read: {data['last_used_at']}")
63
+ if data.get("last_error"):
64
+ console.print(f" [yellow]Last error:[/yellow] {data['last_error']}")
65
+
66
+
67
+ @app.command("events")
68
+ def events(
69
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Day to read (YYYY-MM-DD). Defaults to today."),
70
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
71
+ ) -> None:
72
+ """List your calendar events for a day.
73
+
74
+ Fetched live from Google. Use the hours and title to create an entry:
75
+
76
+ ct calendar events
77
+ ct log 0.25 --project argo --task BE --notes "Daily standup"
78
+ """
79
+ client = CrowdTimeClient(require_auth=True, require_org=True)
80
+
81
+ params = {}
82
+ if date:
83
+ params["date"] = format_date(parse_date(date))
84
+
85
+ try:
86
+ data = client.get("/calendar/events/", params=params)
87
+ except APIError as e:
88
+ format_error(e.message)
89
+ raise typer.Exit(1)
90
+
91
+ if output_json:
92
+ print_json(data)
93
+ return
94
+
95
+ items = data.get("events", [])
96
+ if not items:
97
+ console.print(f"[dim]No events on {data.get('date', 'that day')}.[/dim]")
98
+ return
99
+
100
+ table = Table(title=f"Calendar — {data.get('date', '')}")
101
+ table.add_column("Title")
102
+ table.add_column("Hours", justify="right")
103
+ table.add_column("Start", style="dim")
104
+ for event in items:
105
+ title = event.get("title", "")
106
+ if event.get("declined"):
107
+ title = f"[dim]{title} (declined)[/dim]"
108
+ table.add_row(
109
+ title,
110
+ "—" if event.get("all_day") else f"{event.get('hours', 0)}",
111
+ "all day" if event.get("all_day") else (event.get("start", "") or "")[11:16],
112
+ )
113
+ console.print(table)
114
+
115
+
116
+ @app.command("disconnect")
117
+ def disconnect(
118
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
119
+ ) -> None:
120
+ """Disconnect your calendar and revoke CrowdTime's access.
121
+
122
+ Examples:
123
+ ct calendar disconnect
124
+ """
125
+ client = CrowdTimeClient(require_auth=True, require_org=True)
126
+
127
+ if not force and not typer.confirm("Disconnect your Google Calendar?"):
128
+ console.print("[dim]Cancelled.[/dim]")
129
+ return
130
+
131
+ try:
132
+ client.delete("/calendar/")
133
+ format_success("Calendar disconnected.")
134
+ except APIError as e:
135
+ format_error(e.message)
136
+ raise typer.Exit(1)
@@ -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)
@@ -43,7 +43,8 @@ def create(
43
43
  task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
44
44
  date: Optional[str] = typer.Option(None, "--date", "-d", help="Date in YYYY-MM-DD (default: today)."),
45
45
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
46
- help="Mark as billable/non-billable."),
46
+ help="Override billable status. Account managers only — "
47
+ "otherwise the project/task setting decides."),
47
48
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
48
49
  ) -> None:
49
50
  """Log a completed time entry.
@@ -137,7 +138,8 @@ def edit(
137
138
  task: Optional[str] = typer.Option(None, "--task", "-t", help="New task."),
138
139
  date: Optional[str] = typer.Option(None, "--date", "-d", help="New date (YYYY-MM-DD)."),
139
140
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
140
- help="Mark as billable/non-billable."),
141
+ help="Override billable status. Account managers only — "
142
+ "otherwise the project/task setting decides."),
141
143
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
142
144
  ) -> None:
143
145
  """Edit an existing time entry."""