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.
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/PKG-INFO +3 -2
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/pyproject.toml +13 -3
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/ai_cmd.py +17 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
- crowdtime_cli-0.17.0/src/crowdtime_cli/commands/calendar_cmd.py +136 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/log_cmd.py +4 -2
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/projects_cmd.py +190 -4
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/pto_cmd.py +42 -13
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/formatters.py +29 -9
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/main.py +26 -12
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/models.py +25 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +76 -10
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +212 -24
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +42 -4
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +71 -3
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/.gitignore +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/LICENSE +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/README.md +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/utils.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
@@ -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
|
-
|
|
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=
|
|
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(
|
|
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="
|
|
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="
|
|
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."""
|