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.
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/PKG-INFO +3 -2
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/pyproject.toml +13 -3
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/projects_cmd.py +144 -4
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/pto_cmd.py +19 -2
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/formatters.py +20 -8
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/models.py +5 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -2
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +114 -16
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +69 -1
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/.gitignore +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/LICENSE +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/README.md +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
- {crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/utils.py +0 -0
- {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.
|
|
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
|
|
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.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
|
-
|
|
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]
|
|
@@ -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=
|
|
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)
|
|
@@ -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
|
-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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] #
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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):**
|
|
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,
|
|
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
|
-
#
|
|
2926
|
-
ct pto calendar --from 2026-
|
|
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
|
-
#
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crowdtime_cli-0.14.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|