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.
Files changed (42) hide show
  1. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/.gitignore +1 -0
  2. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/PKG-INFO +3 -2
  3. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/pyproject.toml +13 -3
  4. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/__init__.py +1 -1
  5. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/billing_cmd.py +2 -0
  6. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/invoice_cmd.py +118 -3
  7. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/projects_cmd.py +144 -4
  8. crowdtime_cli-0.15.0/src/crowdtime_cli/commands/pto_cmd.py +711 -0
  9. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/report_cmd.py +35 -5
  10. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timesheet_cmd.py +51 -1
  11. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/formatters.py +355 -4
  12. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/main.py +72 -4
  13. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/models.py +102 -0
  14. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +35 -2
  15. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +573 -7
  16. crowdtime_cli-0.15.0/src/crowdtime_cli/skills/crowdtime/references/pto.md +142 -0
  17. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +213 -1
  18. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/utils.py +59 -2
  19. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/LICENSE +0 -0
  20. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/README.md +0 -0
  21. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/auth.py +0 -0
  22. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/client.py +0 -0
  23. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  24. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  25. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  26. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  27. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  28. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  29. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  30. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  31. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  32. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  33. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  34. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  35. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  36. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  37. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  38. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  39. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/config.py +0 -0
  40. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.13.0 → crowdtime_cli-0.15.0}/src/crowdtime_cli/version_check.py +0 -0
@@ -70,3 +70,4 @@ pypi_deployment.md
70
70
  sentry-workflow.md
71
71
  linear-workflow.md
72
72
  seeds-mentores.md
73
+ _samples/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.13.0
3
+ Version: 0.15.0
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Office/Business
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
+ Requires-Dist: click>=8.0
24
25
  Requires-Dist: httpx>=0.27.0
25
26
  Requires-Dist: humanize>=4.0
26
27
  Requires-Dist: keyring>=25.0
@@ -29,7 +30,7 @@ Requires-Dist: pydantic>=2.0
29
30
  Requires-Dist: python-dateutil>=2.9
30
31
  Requires-Dist: rich>=13.0
31
32
  Requires-Dist: tomlkit>=0.12.0
32
- Requires-Dist: typer[all]>=0.12.0
33
+ Requires-Dist: typer>=0.12.0
33
34
  Description-Content-Type: text/markdown
34
35
 
35
36
  # CrowdTime CLI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.13.0"
3
+ version = "0.15.0"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -21,7 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "typer[all]>=0.12.0",
24
+ # Not typer[all]: that extra no longer exists (typer warns on every
25
+ # install), and what it used to pull in — rich, shellingham — typer
26
+ # requires outright now.
27
+ "typer>=0.12.0",
28
+ # Imported directly by commands/org_cmd.py. It used to arrive through
29
+ # typer and stopped when typer 0.27 dropped it, which broke `ct` on
30
+ # every fresh install: importing a package means declaring it.
31
+ "click>=8.0",
25
32
  "rich>=13.0",
26
33
  "httpx>=0.27.0",
27
34
  "pydantic>=2.0",
@@ -42,7 +49,10 @@ crowdtime = "crowdtime_cli.main:_original_main"
42
49
  ct = "crowdtime_cli.main:_original_main"
43
50
 
44
51
  [build-system]
45
- requires = ["hatchling"]
52
+ # Pinned below 1.28: from there hatchling stamps Metadata-Version 2.5,
53
+ # which twine refuses to upload ("not a valid metadata version"). 0.14.0
54
+ # shipped 2.4. Lift once the packaging toolchain accepts 2.5.
55
+ requires = ["hatchling<1.28"]
46
56
  build-backend = "hatchling.build"
47
57
 
48
58
  [tool.hatch.build.targets.wheel]
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.15.0"
@@ -143,6 +143,8 @@ def status(
143
143
  table.add_column("Value")
144
144
 
145
145
  table.add_row("Status", f"[{color}]{label}[/{color}]")
146
+ if data.get("billing_exempt"):
147
+ table.add_row("Exempt", "[green]Billing exempt — full access, not billed[/green]")
146
148
  table.add_row("Plan", PLAN_LABELS.get(plan, plan.capitalize()))
147
149
  table.add_row("Seats", str(seat_count))
148
150
  table.add_row("Base", f"{format_currency(base_dollars)}/mo")
@@ -89,6 +89,7 @@ def _resolve_payment_terms(value: str) -> int | None:
89
89
  """
90
90
  mapping = {
91
91
  "receipt": 0,
92
+ "net7": 7,
92
93
  "net15": 15,
93
94
  "net30": 30,
94
95
  }
@@ -580,6 +581,14 @@ def create_invoice(
580
581
  None, "--period", "-P",
581
582
  help="Preset period: last-week, last-2-weeks, last-month, this-month.",
582
583
  ),
584
+ projects: Optional[list[str]] = typer.Option(
585
+ None, "--project", "-p",
586
+ help=(
587
+ "Limit the invoice to specific project ID(s). Repeat for multiple. "
588
+ "Required to bill a fixed-fee project, which is never included "
589
+ "in a client-wide invoice."
590
+ ),
591
+ ),
583
592
  group_by: Optional[str] = typer.Option(
584
593
  None, "--group-by", "-g", help="Group line items by: project, task, user, date, none."
585
594
  ),
@@ -590,7 +599,17 @@ def create_invoice(
590
599
  terms: Optional[str] = typer.Option(None, "--terms", help="Payment terms text."),
591
600
  payment_terms: Optional[str] = typer.Option(
592
601
  None, "--payment-terms",
593
- help="Payment terms: receipt, net15, net30, or number of days.",
602
+ help=(
603
+ "Payment terms: receipt, net15, net30, or number of days. "
604
+ "Omit to use the client's configured terms, then the org default."
605
+ ),
606
+ ),
607
+ issue_date: Optional[str] = typer.Option(
608
+ None, "--issue-date", help="Invoice date. Default: today."
609
+ ),
610
+ due_date: Optional[str] = typer.Option(
611
+ None, "--due-date",
612
+ help="Explicit due date. Default: issue date + payment terms.",
594
613
  ),
595
614
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
596
615
  ) -> None:
@@ -600,11 +619,17 @@ def create_invoice(
600
619
  the specified date range. Use --period for preset ranges or
601
620
  --from/--to for custom dates.
602
621
 
622
+ The due date is derived from the invoice date, never from the end of the
623
+ billing period — invoicing a period weeks after it closed would otherwise
624
+ produce an invoice that is already overdue.
625
+
603
626
  Examples:
604
627
  ct invoice create --client <id> --from 2026-03-01 --to 2026-03-31
605
628
  ct invoice create --client <id> --period last-month --group-by project
606
629
  ct invoice create --client <id> --period last-2-weeks --payment-terms net30
607
630
  ct invoice create --client <id> --from monday --to friday --payment-terms 15
631
+ ct invoice create --client <id> --period last-month --due-date 2026-09-15
632
+ ct invoice create --client <id> --period last-month --project <fixed-project-id>
608
633
  """
609
634
  # Resolve period dates
610
635
  if period:
@@ -638,6 +663,20 @@ def create_invoice(
638
663
  )
639
664
  raise typer.Exit(1)
640
665
 
666
+ # Resolve explicit invoice dates
667
+ try:
668
+ issue_date_str = format_date(parse_date(issue_date)) if issue_date else None
669
+ due_date_str = format_date(parse_date(due_date)) if due_date else None
670
+ except ValueError as e:
671
+ format_error(str(e))
672
+ raise typer.Exit(1)
673
+
674
+ if issue_date_str and due_date_str and due_date_str < issue_date_str:
675
+ format_error(
676
+ f"--due-date {due_date_str} is before --issue-date {issue_date_str}."
677
+ )
678
+ raise typer.Exit(1)
679
+
641
680
  client = CrowdTimeClient(require_auth=True, require_org=True)
642
681
 
643
682
  payload: dict = {
@@ -645,6 +684,12 @@ def create_invoice(
645
684
  "date_from": start,
646
685
  "date_to": end,
647
686
  }
687
+ if issue_date_str:
688
+ payload["issue_date"] = issue_date_str
689
+ if due_date_str:
690
+ payload["due_date"] = due_date_str
691
+ if projects:
692
+ payload["project_ids"] = list(projects)
648
693
  if group_by:
649
694
  payload["group_by"] = group_by
650
695
  if tax_rate is not None:
@@ -796,7 +841,7 @@ def update_invoice(
796
841
  tax_rate: Optional[float] = typer.Option(None, "--tax-rate", help="Tax rate percentage (e.g. 21 for 21%)."),
797
842
  payment_terms: Optional[str] = typer.Option(
798
843
  None, "--payment-terms",
799
- help="Payment terms: receipt, net15, net30, or number of days.",
844
+ help="Payment terms: receipt, net7, net15, net30, or number of days.",
800
845
  ),
801
846
  from_name: Optional[str] = typer.Option(None, "--from-name", help="Issuer/sender name."),
802
847
  from_email: Optional[str] = typer.Option(None, "--from-email", help="Issuer/sender email."),
@@ -906,6 +951,10 @@ def send_invoice(
906
951
  None, "--cc",
907
952
  help="Contact ID(s) to CC. Repeat for multiple.",
908
953
  ),
954
+ keep_dates: bool = typer.Option(
955
+ False, "--keep-dates",
956
+ help="Send with the drafted dates as-is instead of re-stamping a stale issue date to today.",
957
+ ),
909
958
  force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
910
959
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
911
960
  ) -> None:
@@ -915,10 +964,15 @@ def send_invoice(
915
964
  Use --to to specify contact(s) or it defaults to the client's primary contact.
916
965
  This action cannot be undone (use void instead).
917
966
 
967
+ If the draft's issue date has fallen into the past, it is re-stamped to
968
+ today and the due date shifts by the same number of days, so the invoice
969
+ doesn't arrive already overdue. Pass --keep-dates to send it as drafted.
970
+
918
971
  Examples:
919
972
  ct invoice send <invoice-id>
920
973
  ct invoice send <invoice-id> --to <contact-id>
921
974
  ct invoice send <invoice-id> --to <contact-id-1> --to <contact-id-2> --cc <contact-id-3>
975
+ ct invoice send <invoice-id> --keep-dates
922
976
  ct invoice send <invoice-id> --force
923
977
  """
924
978
  if not force:
@@ -933,6 +987,8 @@ def send_invoice(
933
987
  payload["contact_ids"] = to
934
988
  if cc:
935
989
  payload["cc_contact_ids"] = cc
990
+ if keep_dates:
991
+ payload["keep_dates"] = True
936
992
 
937
993
  try:
938
994
  data = client.post(f"/invoices/{invoice_id}/send/", data=payload if payload else None)
@@ -1305,6 +1361,10 @@ def show_recurring(
1305
1361
  console.print(f" Currency: {data.get('currency', 'USD')}")
1306
1362
  console.print(f" Payment Terms: {data.get('payment_terms_days', 30)} days")
1307
1363
  console.print(f" Next Run: {data.get('next_run_date', '') or 'Not scheduled'}")
1364
+ next_start = data.get("next_period_start")
1365
+ next_end = data.get("next_period_end")
1366
+ if next_start and next_end:
1367
+ console.print(f" Next Period: {next_start} to {next_end}")
1308
1368
  console.print(f" Last Run: {data.get('last_run_date', '') or 'Never'}")
1309
1369
  console.print(f" Total Generated: {data.get('total_generated', 0)}")
1310
1370
  console.print(f" Auto-send: {'Yes' if data.get('auto_send') else 'No'}")
@@ -1482,25 +1542,80 @@ def delete_recurring(
1482
1542
  @recurring_app.command("generate-now")
1483
1543
  def generate_now(
1484
1544
  template_id: str = typer.Argument(..., help="Recurring template ID."),
1545
+ period_start: Optional[str] = typer.Option(
1546
+ None, "--period-start", help="Start of the period to bill (YYYY-MM-DD)."
1547
+ ),
1548
+ period_end: Optional[str] = typer.Option(
1549
+ None, "--period-end", help="End of the period to bill (YYYY-MM-DD)."
1550
+ ),
1551
+ issue_date: Optional[str] = typer.Option(
1552
+ None, "--issue-date", help="Invoice date (YYYY-MM-DD). Defaults to today."
1553
+ ),
1554
+ allow_overlap: bool = typer.Option(
1555
+ False, "--allow-overlap", help="Bill a period this template already invoiced."
1556
+ ),
1485
1557
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
1486
1558
  ) -> None:
1487
1559
  """Manually generate an invoice from a recurring template.
1488
1560
 
1561
+ With no dates the template bills its scheduled period, dated today —
1562
+ the same invoice the nightly job would raise, and it advances the
1563
+ schedule. Naming a period bills that one instead as a one-off and
1564
+ leaves the schedule untouched. The invoice date is separate from the
1565
+ period: an invoice raised today can bill a period that closed months
1566
+ ago (`ct invoice recurring show` prints both).
1567
+
1489
1568
  Examples:
1490
1569
  ct invoice recurring generate-now <template-id>
1570
+ ct invoice recurring generate-now <template-id> --period-start 2026-03-01 --period-end 2026-03-31
1571
+ ct invoice recurring generate-now <template-id> --period-start 2026-03-01 --period-end 2026-03-31 --issue-date 2026-08-20
1491
1572
  """
1573
+ if bool(period_start) != bool(period_end):
1574
+ format_error("Give both --period-start and --period-end, or neither.")
1575
+ raise typer.Exit(1)
1576
+
1492
1577
  client = CrowdTimeClient(require_auth=True, require_org=True)
1493
1578
 
1579
+ payload: dict = {}
1580
+ if period_start:
1581
+ payload["period_start"] = period_start
1582
+ payload["period_end"] = period_end
1583
+ if issue_date:
1584
+ payload["issue_date"] = issue_date
1585
+ if allow_overlap:
1586
+ payload["allow_overlap"] = True
1587
+
1494
1588
  try:
1495
- data = client.post(f"/invoices/recurring-templates/{template_id}/generate-now/")
1589
+ data = client.post(
1590
+ f"/invoices/recurring-templates/{template_id}/generate-now/", data=payload
1591
+ )
1496
1592
 
1497
1593
  if output_json:
1498
1594
  print_json(data)
1499
1595
  else:
1596
+ invoice = data.get("invoice") or {}
1500
1597
  format_success(data.get("detail", "Invoice generated successfully."))
1598
+ if invoice:
1599
+ console.print(f" Invoice: {invoice.get('invoice_number', '')}")
1600
+ console.print(f" ID: {invoice.get('id', '')}")
1601
+ console.print(
1602
+ f" Period: {invoice.get('period_start', '')} to {invoice.get('period_end', '')}"
1603
+ )
1604
+ console.print(f" Issued: {invoice.get('issue_date', '')}")
1501
1605
  except APIError as e:
1502
1606
  if e.status_code == 404:
1503
1607
  format_error(f"Recurring template '{template_id}' not found.")
1608
+ elif e.status_code == 409:
1609
+ # The period is already billed, or is about to be by the scheduled
1610
+ # run. Fixed lines go out in full every run, so say what clashes.
1611
+ format_error(e.message)
1612
+ body = e.detail if isinstance(e.detail, dict) else {}
1613
+ for inv in body.get("overlapping_invoices", []):
1614
+ console.print(
1615
+ f" {inv.get('invoice_number', '')} ({inv.get('status', '')}) "
1616
+ f"— {inv.get('period_start', '')} to {inv.get('period_end', '')}"
1617
+ )
1618
+ console.print("[dim]Re-run with --allow-overlap to bill it anyway.[/dim]")
1504
1619
  else:
1505
1620
  format_error(e.message)
1506
1621
  raise typer.Exit(1)
@@ -21,6 +21,44 @@ console = Console()
21
21
 
22
22
  BILLING_MODES = ["per_task", "per_person", "custom"]
23
23
 
24
+ # WHAT the client is charged. Distinct from BILLING_MODES, which only decides
25
+ # HOW an hourly rate is resolved and is irrelevant for fixed-fee work.
26
+ BILLING_TYPES = ["time_and_materials", "fixed_fee"]
27
+ # Friendly aliases so `--billing-type fixed` and `--billing-type t&m` work.
28
+ BILLING_TYPE_ALIASES = {
29
+ "tm": "time_and_materials",
30
+ "t&m": "time_and_materials",
31
+ "time": "time_and_materials",
32
+ "hourly": "time_and_materials",
33
+ "time_and_materials": "time_and_materials",
34
+ "fixed": "fixed_fee",
35
+ "fixed_fee": "fixed_fee",
36
+ "fixedfee": "fixed_fee",
37
+ }
38
+
39
+
40
+ def _resolve_billing_type(value: str) -> str | None:
41
+ """Normalise a --billing-type value, or None if unrecognised."""
42
+ return BILLING_TYPE_ALIASES.get(value.strip().lower().replace("-", "_"))
43
+
44
+
45
+ PAYMENT_TERMS_ALIASES = {"receipt": 0, "net7": 7, "net15": 15, "net30": 30}
46
+
47
+
48
+ def _resolve_payment_terms_opt(value: str) -> int | None:
49
+ """Resolve a --payment-terms value to days, or None if unrecognised.
50
+
51
+ Accepts the same presets as the invoice commands plus a raw day count.
52
+ """
53
+ lower = value.strip().lower()
54
+ if lower in PAYMENT_TERMS_ALIASES:
55
+ return PAYMENT_TERMS_ALIASES[lower]
56
+ try:
57
+ days = int(lower)
58
+ except ValueError:
59
+ return None
60
+ return days if days >= 0 else None
61
+
24
62
 
25
63
  def _resolve_or_create_client(api_client: CrowdTimeClient, client_name: str) -> str:
26
64
  """Look up a client by name; create one if it doesn't exist. Returns the client UUID."""
@@ -101,7 +139,17 @@ def show(
101
139
  console.print(f" Client: {project.client_name or project.client or '-'}")
102
140
  console.print(f" Status: {project.status}")
103
141
  console.print(f" Billable: {'Yes' if project.is_billable else 'No'}")
104
- console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
142
+ if project.billing_type == "fixed_fee":
143
+ currency = data.get("currency", "USD")
144
+ fee = (
145
+ format_currency(project.fixed_fee_amount, currency)
146
+ if project.fixed_fee_amount is not None
147
+ else "[red]not set[/red]"
148
+ )
149
+ console.print(f" Billing: Fixed Fee — {fee}")
150
+ else:
151
+ console.print(" Billing: Time & Materials")
152
+ console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
105
153
  if project.budget_type and project.budget_type != "none" and project.budget_amount:
106
154
  currency = data.get("currency", "USD")
107
155
  budget_label = f"{project.budget_amount}h" if project.budget_type == "hours" else format_currency(project.budget_amount, currency)
@@ -126,7 +174,22 @@ def create(
126
174
  help="Mark as billable."),
127
175
  billing_mode: Optional[str] = typer.Option(
128
176
  None, "--billing-mode", "-m",
129
- help="Billing mode: per_task, per_person, custom.",
177
+ help="Rate resolution for time & materials work: per_task, per_person, custom.",
178
+ ),
179
+ billing_type: Optional[str] = typer.Option(
180
+ None, "--billing-type",
181
+ help="What the client is charged: time_and_materials (default) or fixed_fee.",
182
+ ),
183
+ fixed_fee: Optional[float] = typer.Option(
184
+ None, "--fixed-fee",
185
+ help="Agreed price billed instead of logged hours. Implies --billing-type fixed_fee.",
186
+ ),
187
+ payment_terms: Optional[str] = typer.Option(
188
+ None, "--payment-terms",
189
+ help=(
190
+ "Payment terms for this project: receipt, net7, net15, net30, or a "
191
+ "number of days. Omit to inherit the client/org default."
192
+ ),
130
193
  ),
131
194
  color: Optional[str] = typer.Option(None, "--color", help="Project color hex code (e.g. #FF5733)."),
132
195
  budget: Optional[float] = typer.Option(None, "--budget", help="Budget amount (hours or money depending on --budget-type)."),
@@ -137,13 +200,39 @@ def create(
137
200
  code: Optional[str] = typer.Option(None, "--code", help="Short project code."),
138
201
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
139
202
  ) -> None:
140
- """Create a new project."""
203
+ """Create a new project.
204
+
205
+ Fixed-fee projects bill an agreed price instead of logged hours. That fee
206
+ is never billed automatically — only when the project is selected
207
+ explicitly on an invoice, or pulled in by a recurring template.
208
+ """
141
209
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
142
210
 
143
211
  if billing_mode and billing_mode not in BILLING_MODES:
144
212
  format_error(f"Invalid billing mode. Choose from: {', '.join(BILLING_MODES)}")
145
213
  raise typer.Exit(1)
146
214
 
215
+ # --fixed-fee alone is enough to mean "this is a fixed-fee project".
216
+ resolved_billing_type = None
217
+ if billing_type:
218
+ resolved_billing_type = _resolve_billing_type(billing_type)
219
+ if resolved_billing_type is None:
220
+ format_error(f"Invalid billing type. Choose from: {', '.join(BILLING_TYPES)}")
221
+ raise typer.Exit(1)
222
+ elif fixed_fee is not None:
223
+ resolved_billing_type = "fixed_fee"
224
+
225
+ if resolved_billing_type == "fixed_fee":
226
+ if fixed_fee is None:
227
+ format_error("--fixed-fee is required for fixed-fee projects.")
228
+ raise typer.Exit(1)
229
+ if fixed_fee <= 0:
230
+ format_error("--fixed-fee must be greater than zero.")
231
+ raise typer.Exit(1)
232
+ elif fixed_fee is not None:
233
+ format_error("--fixed-fee only applies to fixed-fee projects.")
234
+ raise typer.Exit(1)
235
+
147
236
  budget_types = ["hours", "money", "none"]
148
237
  if budget_type and budget_type not in budget_types:
149
238
  format_error(f"Invalid budget type. Choose from: {', '.join(budget_types)}")
@@ -158,6 +247,19 @@ def create(
158
247
  payload: dict = {"name": name, "is_billable": billable}
159
248
  if billing_mode:
160
249
  payload["billing_mode"] = billing_mode
250
+ if resolved_billing_type:
251
+ payload["billing_type"] = resolved_billing_type
252
+ if fixed_fee is not None:
253
+ payload["fixed_fee_amount"] = fixed_fee
254
+ if payment_terms is not None:
255
+ resolved_terms = _resolve_payment_terms_opt(payment_terms)
256
+ if resolved_terms is None:
257
+ format_error(
258
+ f"Invalid --payment-terms: '{payment_terms}'. "
259
+ "Use: receipt, net7, net15, net30, or a non-negative number of days."
260
+ )
261
+ raise typer.Exit(1)
262
+ payload["payment_terms_days"] = resolved_terms
161
263
  if color:
162
264
  payload["color"] = color
163
265
  if budget is not None:
@@ -229,7 +331,19 @@ def update(
229
331
  help="Mark as billable or not."),
230
332
  billing_mode: Optional[str] = typer.Option(
231
333
  None, "--billing-mode", "-m",
232
- help="Billing mode: per_task, per_person, custom.",
334
+ help="Rate resolution for time & materials work: per_task, per_person, custom.",
335
+ ),
336
+ billing_type: Optional[str] = typer.Option(
337
+ None, "--billing-type",
338
+ help="What the client is charged: time_and_materials or fixed_fee.",
339
+ ),
340
+ fixed_fee: Optional[float] = typer.Option(
341
+ None, "--fixed-fee",
342
+ help="Agreed price billed instead of logged hours.",
343
+ ),
344
+ payment_terms: Optional[str] = typer.Option(
345
+ None, "--payment-terms",
346
+ help="Payment terms: receipt, net7, net15, net30, or a number of days.",
233
347
  ),
234
348
  color: Optional[str] = typer.Option(None, "--color", help="Project color hex code (e.g. #FF5733)."),
235
349
  budget: Optional[float] = typer.Option(None, "--budget", help="Budget amount (hours or money depending on --budget-type)."),
@@ -273,6 +387,19 @@ def update(
273
387
  format_error(f"Invalid status. Choose from: {', '.join(valid_statuses)}")
274
388
  raise typer.Exit(1)
275
389
 
390
+ resolved_billing_type = None
391
+ if billing_type:
392
+ resolved_billing_type = _resolve_billing_type(billing_type)
393
+ if resolved_billing_type is None:
394
+ format_error(f"Invalid billing type. Choose from: {', '.join(BILLING_TYPES)}")
395
+ raise typer.Exit(1)
396
+ elif fixed_fee is not None:
397
+ resolved_billing_type = "fixed_fee"
398
+
399
+ if fixed_fee is not None and fixed_fee <= 0:
400
+ format_error("--fixed-fee must be greater than zero.")
401
+ raise typer.Exit(1)
402
+
276
403
  payload: dict = {}
277
404
  if name is not None:
278
405
  payload["name"] = name
@@ -280,6 +407,19 @@ def update(
280
407
  payload["is_billable"] = billable
281
408
  if billing_mode:
282
409
  payload["billing_mode"] = billing_mode
410
+ if resolved_billing_type:
411
+ payload["billing_type"] = resolved_billing_type
412
+ if fixed_fee is not None:
413
+ payload["fixed_fee_amount"] = fixed_fee
414
+ if payment_terms is not None:
415
+ resolved_terms = _resolve_payment_terms_opt(payment_terms)
416
+ if resolved_terms is None:
417
+ format_error(
418
+ f"Invalid --payment-terms: '{payment_terms}'. "
419
+ "Use: receipt, net7, net15, net30, or a non-negative number of days."
420
+ )
421
+ raise typer.Exit(1)
422
+ payload["payment_terms_days"] = resolved_terms
283
423
  if color:
284
424
  payload["color"] = color
285
425
  if budget is not None: