crowdtime-cli 0.13.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.13.0 → crowdtime_cli-0.15.0}/.gitignore +1 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/PKG-INFO +3 -2
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/pyproject.toml +13 -3
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/projects_cmd.py +144 -4
- crowdtime_cli-0.15.0/src/crowdtime_cli/commands/pto_cmd.py +711 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/report_cmd.py +35 -5
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/formatters.py +355 -4
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/main.py +72 -4
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/models.py +102 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +35 -2
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +573 -7
- crowdtime_cli-0.15.0/src/crowdtime_cli/skills/crowdtime/references/pto.md +142 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +213 -1
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/utils.py +59 -2
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/LICENSE +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/README.md +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.13.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:
|