crowdtime-cli 0.21.1__tar.gz → 0.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/invoice_cmd.py +74 -10
  5. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/projects_cmd.py +153 -4
  6. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/models.py +5 -0
  7. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +11 -4
  8. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +28 -9
  9. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +24 -3
  10. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/.gitignore +0 -0
  11. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/LICENSE +0 -0
  12. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/README.md +0 -0
  13. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/auth.py +0 -0
  14. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/client.py +0 -0
  15. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  16. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  17. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  18. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  19. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  20. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  21. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  22. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  23. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  24. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  25. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  26. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  27. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  28. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
  29. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  30. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  31. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  32. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  33. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  34. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  35. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  36. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/config.py +0 -0
  37. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/formatters.py +0 -0
  38. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/main.py +0 -0
  39. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/oauth.py +0 -0
  40. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/resolvers.py +0 -0
  41. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
  42. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.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.21.1
3
+ Version: 0.23.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.21.1"
3
+ version = "0.23.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"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.21.1"
3
+ __version__ = "0.23.0"
@@ -572,6 +572,41 @@ def show_invoice(
572
572
  raise typer.Exit(1)
573
573
 
574
574
 
575
+ # How hours are shown on an invoice (CRO-94). "person" is accepted for "user".
576
+ HOURS_DISPLAYS = {
577
+ "project": "project", "task": "task", "user": "user", "person": "user",
578
+ "date": "date", "none": "none", "detailed": "detailed",
579
+ }
580
+ LINE_DETAILS = ["date", "person", "project", "task", "notes"]
581
+ GROUP_BY_HELP = (
582
+ "How to display hours: project, task, person (one line each, per project), "
583
+ "detailed (one line per time entry), date, or none (everything on one line)."
584
+ )
585
+ DETAILS_HELP = (
586
+ "What each detailed line shows (implies --group-by detailed), comma-separated "
587
+ "from date, person, project, task, notes. Default: person,date,notes."
588
+ )
589
+
590
+
591
+ def _display_payload(group_by: Optional[str], details: Optional[str]) -> dict:
592
+ """Validate --group-by / --details into API fields, or raise ValueError."""
593
+ payload: dict = {}
594
+ if group_by is not None:
595
+ resolved = HOURS_DISPLAYS.get(group_by.strip().lower())
596
+ if resolved is None:
597
+ raise ValueError(f"Invalid --group-by. Choose from: {', '.join(HOURS_DISPLAYS)}.")
598
+ payload["group_by"] = resolved
599
+ if details is not None:
600
+ items = [d.strip().lower() for d in details.split(",") if d.strip()]
601
+ bad = [d for d in items if d not in LINE_DETAILS]
602
+ if bad or not items:
603
+ raise ValueError(f"Invalid --details. Choose from: {', '.join(LINE_DETAILS)}.")
604
+ if payload.setdefault("group_by", "detailed") != "detailed":
605
+ raise ValueError("--details only applies to --group-by detailed.")
606
+ payload["line_details"] = items
607
+ return payload
608
+
609
+
575
610
  @app.command("create")
576
611
  def create_invoice(
577
612
  client_id: str = typer.Option(..., "--client", "-c", help="Client ID."),
@@ -589,9 +624,16 @@ def create_invoice(
589
624
  "in a client-wide invoice."
590
625
  ),
591
626
  ),
592
- group_by: Optional[str] = typer.Option(
593
- None, "--group-by", "-g", help="Group line items by: project, task, user, date, none."
627
+ payments: Optional[list[str]] = typer.Option(
628
+ None, "--payment",
629
+ help=(
630
+ "One-off fixed-fee payment ID(s) to bill (see `ct projects show`). "
631
+ "Repeat for multiple. Without it a selected one-off project bills "
632
+ "its next unbilled payment; each payment is billed only once."
633
+ ),
594
634
  ),
635
+ group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help=GROUP_BY_HELP),
636
+ details: Optional[str] = typer.Option(None, "--details", help=DETAILS_HELP),
595
637
  tax_rate: Optional[float] = typer.Option(
596
638
  None, "--tax-rate", help="Tax rate percentage (e.g. 21 for 21%)."
597
639
  ),
@@ -626,10 +668,13 @@ def create_invoice(
626
668
  Examples:
627
669
  ct invoice create --client <id> --from 2026-03-01 --to 2026-03-31
628
670
  ct invoice create --client <id> --period last-month --group-by project
671
+ ct invoice create --client <id> --period last-month --group-by person
672
+ ct invoice create --client <id> --period last-month --group-by detailed --details date,person,notes
629
673
  ct invoice create --client <id> --period last-2-weeks --payment-terms net30
630
674
  ct invoice create --client <id> --from monday --to friday --payment-terms 15
631
675
  ct invoice create --client <id> --period last-month --due-date 2026-09-15
632
676
  ct invoice create --client <id> --period last-month --project <fixed-project-id>
677
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
633
678
  """
634
679
  # Resolve period dates
635
680
  if period:
@@ -690,8 +735,16 @@ def create_invoice(
690
735
  payload["due_date"] = due_date_str
691
736
  if projects:
692
737
  payload["project_ids"] = list(projects)
693
- if group_by:
694
- payload["group_by"] = group_by
738
+ if payments:
739
+ if not projects:
740
+ format_error("--payment needs the fixed-fee project selected with --project.")
741
+ raise typer.Exit(1)
742
+ payload["milestone_ids"] = list(payments)
743
+ try:
744
+ payload.update(_display_payload(group_by, details))
745
+ except ValueError as e:
746
+ format_error(str(e))
747
+ raise typer.Exit(1)
695
748
  if tax_rate is not None:
696
749
  payload["tax_rate"] = str(tax_rate)
697
750
  if notes:
@@ -1370,6 +1423,9 @@ def show_recurring(
1370
1423
  console.print(f" Auto-send: {'Yes' if data.get('auto_send') else 'No'}")
1371
1424
  console.print(f" Pull Entries: {'Yes' if data.get('pull_time_entries') else 'No'}")
1372
1425
  console.print(f" Group By: {data.get('group_by', 'project')}")
1426
+ if data.get("group_by") == "detailed":
1427
+ shown = ", ".join(data.get("line_details") or ["person", "date", "notes"])
1428
+ console.print(f" Line Details: {shown}")
1373
1429
 
1374
1430
  line_items = data.get("line_items", [])
1375
1431
  if line_items:
@@ -1402,7 +1458,8 @@ def create_recurring(
1402
1458
  payment_terms: Optional[int] = typer.Option(None, "--payment-terms", help="Payment terms in days."),
1403
1459
  auto_send: bool = typer.Option(False, "--auto-send", help="Automatically send generated invoices."),
1404
1460
  pull_entries: bool = typer.Option(True, "--pull-entries/--no-pull-entries", help="Pull time entries into invoice."),
1405
- group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help="Group by: project, task, user, date, none."),
1461
+ group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help=GROUP_BY_HELP),
1462
+ details: Optional[str] = typer.Option(None, "--details", help=DETAILS_HELP),
1406
1463
  notes: Optional[str] = typer.Option(None, "--notes", help="Invoice notes."),
1407
1464
  terms: Optional[str] = typer.Option(None, "--terms", help="Payment terms text."),
1408
1465
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
@@ -1428,8 +1485,11 @@ def create_recurring(
1428
1485
  payload["payment_terms_days"] = payment_terms
1429
1486
  if auto_send:
1430
1487
  payload["auto_send"] = True
1431
- if group_by:
1432
- payload["group_by"] = group_by
1488
+ try:
1489
+ payload.update(_display_payload(group_by, details))
1490
+ except ValueError as e:
1491
+ format_error(str(e))
1492
+ raise typer.Exit(1)
1433
1493
  if notes:
1434
1494
  payload["notes"] = notes
1435
1495
  if terms:
@@ -1460,7 +1520,8 @@ def update_recurring(
1460
1520
  active: Optional[bool] = typer.Option(None, "--active/--inactive", help="Activate or deactivate."),
1461
1521
  auto_send: Optional[bool] = typer.Option(None, "--auto-send/--no-auto-send", help="Auto-send invoices."),
1462
1522
  payment_terms: Optional[int] = typer.Option(None, "--payment-terms", help="Payment terms in days."),
1463
- group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help="Group by: project, task, user, date, none."),
1523
+ group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help=GROUP_BY_HELP),
1524
+ details: Optional[str] = typer.Option(None, "--details", help=DETAILS_HELP),
1464
1525
  notes: Optional[str] = typer.Option(None, "--notes", help="Invoice notes."),
1465
1526
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
1466
1527
  ) -> None:
@@ -1486,8 +1547,11 @@ def update_recurring(
1486
1547
  payload["auto_send"] = auto_send
1487
1548
  if payment_terms is not None:
1488
1549
  payload["payment_terms_days"] = payment_terms
1489
- if group_by is not None:
1490
- payload["group_by"] = group_by
1550
+ try:
1551
+ payload.update(_display_payload(group_by, details))
1552
+ except ValueError as e:
1553
+ format_error(str(e))
1554
+ raise typer.Exit(1)
1491
1555
  if notes is not None:
1492
1556
  payload["notes"] = notes
1493
1557
 
@@ -42,6 +42,69 @@ def _resolve_billing_type(value: str) -> str | None:
42
42
  return BILLING_TYPE_ALIASES.get(value.strip().lower().replace("-", "_"))
43
43
 
44
44
 
45
+ FEE_SCHEDULES = {"one-off": "one_off", "one_off": "one_off", "oneoff": "one_off",
46
+ "recurring": "recurring"}
47
+ FEE_FREQUENCIES = ["weekly", "biweekly", "monthly", "quarterly"]
48
+
49
+
50
+ def _parse_payment(value: str) -> dict:
51
+ """Parse a --payment value: NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD."""
52
+ if "=" not in value:
53
+ raise ValueError(f"Invalid --payment '{value}'. Use NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD.")
54
+ name, rest = value.rsplit("=", 1)
55
+ amount, _, due = rest.partition("@")
56
+ try:
57
+ amount_dec = Decimal(amount.strip())
58
+ except Exception:
59
+ raise ValueError(f"Invalid amount in --payment '{value}'.")
60
+ if not name.strip() or amount_dec <= 0:
61
+ raise ValueError(f"Invalid --payment '{value}': needs a name and an amount above zero.")
62
+ return {"name": name.strip(), "amount": str(amount_dec), "due_date": due.strip() or None}
63
+
64
+
65
+ def _fee_plan_payload(
66
+ fee_schedule: Optional[str],
67
+ fee_frequency: Optional[str],
68
+ payments: Optional[List[str]],
69
+ first_invoice: Optional[str],
70
+ existing: Optional[list[dict]] = None,
71
+ ) -> dict:
72
+ """Build the fixed-fee plan fields of a project payload (CRO-61).
73
+
74
+ On update, ``existing`` milestones keep their id when a --payment names
75
+ them, and payments already invoiced are always kept — the API refuses a
76
+ plan that drops or changes them.
77
+ """
78
+ payload: dict = {}
79
+ if fee_schedule is not None:
80
+ resolved = FEE_SCHEDULES.get(fee_schedule.strip().lower())
81
+ if resolved is None:
82
+ raise ValueError("Invalid --fee-schedule. Choose from: one-off, recurring.")
83
+ payload["fixed_fee_schedule"] = resolved
84
+ if fee_frequency is not None:
85
+ if fee_frequency not in FEE_FREQUENCIES:
86
+ raise ValueError(f"Invalid --fee-frequency. Choose from: {', '.join(FEE_FREQUENCIES)}.")
87
+ payload["fixed_fee_frequency"] = fee_frequency
88
+ payload.setdefault("fixed_fee_schedule", "recurring")
89
+ if first_invoice is not None:
90
+ payload["fixed_fee_first_invoice_date"] = first_invoice
91
+ if payments:
92
+ items = [_parse_payment(p) for p in payments]
93
+ by_name = {m["name"]: m for m in (existing or [])}
94
+ for item in items:
95
+ if item["name"] in by_name:
96
+ item["id"] = by_name[item["name"]]["id"]
97
+ named = {i["name"] for i in items}
98
+ billed = [
99
+ {"id": m["id"], "name": m["name"], "amount": m["amount"], "due_date": m.get("due_date")}
100
+ for m in (existing or [])
101
+ if m.get("invoice") and m["name"] not in named
102
+ ]
103
+ payload["fixed_fee_milestones"] = billed + items
104
+ payload.setdefault("fixed_fee_schedule", "one_off")
105
+ return payload
106
+
107
+
45
108
  PAYMENT_TERMS_ALIASES = {"receipt": 0, "net7": 7, "net15": 15, "net30": 30}
46
109
 
47
110
 
@@ -146,7 +209,26 @@ def show(
146
209
  if project.fixed_fee_amount is not None
147
210
  else "[red]not set[/red]"
148
211
  )
149
- console.print(f" Billing: Fixed Fee — {fee}")
212
+ if project.fixed_fee_schedule == "recurring":
213
+ freq = project.fixed_fee_frequency or "?"
214
+ console.print(f" Billing: Recurring fixed fee — {fee} {freq}")
215
+ tpl = project.fixed_fee_template
216
+ if tpl:
217
+ state = (f"next invoice {tpl['next_run_date']}" if tpl.get("is_active")
218
+ else "paused")
219
+ console.print(f" Invoiced by: {tpl['name']} ({tpl['id']}) — {state}")
220
+ else:
221
+ console.print(" Invoiced by: [yellow]no active recurring invoice[/yellow]")
222
+ else:
223
+ console.print(f" Billing: Fixed Fee (one-off) — {fee}")
224
+ for m in project.fixed_fee_milestones or []:
225
+ inv = m.get("invoice")
226
+ status = f"billed on {inv['invoice_number']}" if inv else "not billed"
227
+ due = f" due {m['due_date']}" if m.get("due_date") else ""
228
+ console.print(
229
+ f" • {m['name']}: {format_currency(Decimal(m['amount']), currency)}"
230
+ f"{due} — {status} [dim]{m['id']}[/dim]"
231
+ )
150
232
  else:
151
233
  console.print(" Billing: Time & Materials")
152
234
  console.print(f" Billing Mode: {billing_labels.get(project.billing_mode, project.billing_mode)}")
@@ -184,6 +266,22 @@ def create(
184
266
  None, "--fixed-fee",
185
267
  help="Agreed price billed instead of logged hours. Implies --billing-type fixed_fee.",
186
268
  ),
269
+ fee_schedule: Optional[str] = typer.Option(
270
+ None, "--fee-schedule",
271
+ help="one-off (the price is paid in payments, default) or recurring (the price every period).",
272
+ ),
273
+ fee_frequency: Optional[str] = typer.Option(
274
+ None, "--fee-frequency",
275
+ help="How often a recurring fee is billed: weekly, biweekly, monthly, quarterly.",
276
+ ),
277
+ payments: Optional[List[str]] = typer.Option(
278
+ None, "--payment",
279
+ help="One-off payment as NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD (due date). Repeat; must add up to --fixed-fee.",
280
+ ),
281
+ first_invoice: Optional[str] = typer.Option(
282
+ None, "--first-invoice",
283
+ help="Recurring fee: date of the first invoice (YYYY-MM-DD). Default: one period after start.",
284
+ ),
187
285
  payment_terms: Optional[str] = typer.Option(
188
286
  None, "--payment-terms",
189
287
  help=(
@@ -202,9 +300,16 @@ def create(
202
300
  ) -> None:
203
301
  """Create a new project.
204
302
 
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.
303
+ Fixed-fee projects bill an agreed price instead of logged hours, one of two
304
+ ways. One-off: --fixed-fee is the whole price, paid in --payment
305
+ milestones (one full payment if none are given), each billed once.
306
+ Recurring: --fixed-fee is billed every --fee-frequency period by a
307
+ recurring invoice the project sets up for itself.
308
+
309
+ Examples:
310
+ ct projects create "Website" -c Acme --fixed-fee 5000 \
311
+ --payment Kickoff=1500@2026-06-01 --payment Midpoint=1500 --payment Delivery=2000
312
+ ct projects create "Support" -c Acme --fixed-fee 4000 --fee-frequency monthly
208
313
  """
209
314
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
210
315
 
@@ -251,6 +356,17 @@ def create(
251
356
  payload["billing_type"] = resolved_billing_type
252
357
  if fixed_fee is not None:
253
358
  payload["fixed_fee_amount"] = fixed_fee
359
+ if resolved_billing_type != "fixed_fee" and (fee_schedule or fee_frequency or payments or first_invoice):
360
+ format_error("--fee-schedule, --fee-frequency, --payment and --first-invoice only apply to fixed-fee projects.")
361
+ raise typer.Exit(1)
362
+ try:
363
+ payload.update(_fee_plan_payload(fee_schedule, fee_frequency, payments, first_invoice))
364
+ except ValueError as e:
365
+ format_error(str(e))
366
+ raise typer.Exit(1)
367
+ if payload.get("fixed_fee_schedule") == "recurring" and not payload.get("fixed_fee_frequency"):
368
+ format_error("A recurring fee needs --fee-frequency.")
369
+ raise typer.Exit(1)
254
370
  if payment_terms is not None:
255
371
  resolved_terms = _resolve_payment_terms_opt(payment_terms)
256
372
  if resolved_terms is None:
@@ -341,6 +457,22 @@ def update(
341
457
  None, "--fixed-fee",
342
458
  help="Agreed price billed instead of logged hours.",
343
459
  ),
460
+ fee_schedule: Optional[str] = typer.Option(
461
+ None, "--fee-schedule",
462
+ help="one-off (the price is paid in payments, default) or recurring (the price every period).",
463
+ ),
464
+ fee_frequency: Optional[str] = typer.Option(
465
+ None, "--fee-frequency",
466
+ help="How often a recurring fee is billed: weekly, biweekly, monthly, quarterly.",
467
+ ),
468
+ payments: Optional[List[str]] = typer.Option(
469
+ None, "--payment",
470
+ help="One-off payment as NAME=AMOUNT or NAME=AMOUNT@YYYY-MM-DD (due date). Repeat; replaces the plan; invoiced payments are kept.",
471
+ ),
472
+ first_invoice: Optional[str] = typer.Option(
473
+ None, "--first-invoice",
474
+ help="Recurring fee: date of the first invoice (YYYY-MM-DD). Moves the next invoice.",
475
+ ),
344
476
  payment_terms: Optional[str] = typer.Option(
345
477
  None, "--payment-terms",
346
478
  help="Payment terms: receipt, net7, net15, net30, or a number of days.",
@@ -364,6 +496,8 @@ def update(
364
496
  ct projects update my-project --name "New Name"
365
497
  ct projects update my-project --budget 200 --budget-type hours
366
498
  ct projects update my-project --billing-mode per_person --status paused
499
+ ct projects update my-project --fixed-fee 6000 --payment Kickoff=1500 --payment Rest=4500
500
+ ct projects update my-project --fee-frequency quarterly
367
501
  """
368
502
  api_client = CrowdTimeClient(require_auth=True, require_org=True)
369
503
 
@@ -411,6 +545,21 @@ def update(
411
545
  payload["billing_type"] = resolved_billing_type
412
546
  if fixed_fee is not None:
413
547
  payload["fixed_fee_amount"] = fixed_fee
548
+ existing_milestones = None
549
+ if payments:
550
+ try:
551
+ current = api_client.get(f"/projects/{resolve_project(api_client, project_id)}/")
552
+ existing_milestones = current.get("fixed_fee_milestones") or []
553
+ except APIError as e:
554
+ format_error(e.message)
555
+ raise typer.Exit(1)
556
+ try:
557
+ payload.update(_fee_plan_payload(
558
+ fee_schedule, fee_frequency, payments, first_invoice, existing_milestones,
559
+ ))
560
+ except ValueError as e:
561
+ format_error(str(e))
562
+ raise typer.Exit(1)
414
563
  if payment_terms is not None:
415
564
  resolved_terms = _resolve_payment_terms_opt(payment_terms)
416
565
  if resolved_terms is None:
@@ -40,6 +40,11 @@ class Project(BaseModel):
40
40
  billing_mode: str = "custom"
41
41
  billing_type: str = "time_and_materials"
42
42
  fixed_fee_amount: Decimal | None = None
43
+ # one_off (price paid in milestones) or recurring (amount per period).
44
+ fixed_fee_schedule: str = "one_off"
45
+ fixed_fee_frequency: str = ""
46
+ fixed_fee_milestones: list[dict] | None = None
47
+ fixed_fee_template: dict | None = None
43
48
  payment_terms_days: int | None = None
44
49
  status: str = "active"
45
50
  is_billable: bool = True
@@ -161,7 +161,9 @@ ct projects list # List all projects
161
161
  ct projects show <id> # Project details
162
162
  ct projects create "New Project" --client "Acme" --budget 100 --budget-type hours -b
163
163
  ct projects create "New Project" --client "Acme" --billing-mode per_person # Set billing mode
164
- ct projects create "Redesign" --client "Acme" --fixed-fee 10000 # Fixed price instead of hours
164
+ ct projects create "Redesign" --client "Acme" --fixed-fee 10000 # One-off price, one payment
165
+ ct projects create "Web" --client "Acme" --fixed-fee 5000 --payment Kickoff=1500@2026-06-01 --payment Delivery=3500
166
+ ct projects create "Support" --client "Acme" --fixed-fee 4000 --fee-frequency monthly # Recurring fee
165
167
  ct projects archive <id> # Archive a project
166
168
  ct projects switch <slug> # Set default project
167
169
 
@@ -202,9 +204,13 @@ ct projects add-tasks <project-id> --task <tid1> --task <tid2> # Bulk assign ta
202
204
 
203
205
  **Billing types** (*what* is charged): `time_and_materials` (default) or `fixed_fee` (an agreed price via `--fixed-fee`, billed instead of logged hours).
204
206
 
207
+ **Fixed fees come in two schedules** — ask which one when the user doesn't say:
208
+ - **One-off** (default): `--fixed-fee` is the whole project price, split into `--payment NAME=AMOUNT[@DUE]` milestones that must add up to it (one "Full payment" if none are given). Each payment is invoiced exactly once.
209
+ - **Recurring**: `--fixed-fee` is charged every `--fee-frequency` (`weekly`, `biweekly`, `monthly`, `quarterly`). The project sets up its own recurring invoice automatically — don't create a template for it by hand.
210
+
205
211
  **Billing modes** (*how* an hourly rate resolves, T&M only): `custom` (default), `per_task` (rate per task), `per_person` (rate per person).
206
212
 
207
- 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.
213
+ A fixed fee is never swept into a client-wide invoice. A one-off price is billed by naming the project (`ct invoice create --project <id>`, which bills its next unbilled payment; add `--payment <payment-id>` to pick one — IDs are in `ct projects show`). A recurring fee is billed by its own recurring invoice.
208
214
 
209
215
  **Rate waterfall** (order depends on billing mode):
210
216
  - `custom`: Entry → ProjectMember → ProjectTask → Project → Task → Membership → User
@@ -337,7 +343,8 @@ ct invoice aging # Overdue aging report: buckets 1-30d, 3
337
343
  ct invoice aging --client <id> # Aging for a specific client
338
344
  ct invoice show <id> # Invoice details with line items
339
345
  ct invoice create --client <id> --period last-month --payment-terms net30 # From time entries
340
- ct invoice create --client <id> --period last-month --group-by user # Group by team member
346
+ ct invoice create --client <id> --period last-month --group-by person # One line per person on each project
347
+ ct invoice create --client <id> --period last-month --group-by detailed --details date,person,notes # One line per entry
341
348
  ct invoice create-blank --client <id> --issue-date today --payment-terms net30 # Empty draft
342
349
  ct invoice update <id> --terms "Wire to IBAN XX" --notes "March" # Edit draft fields
343
350
  ct invoice update <id> --subject "April Dev" --currency EUR --tax-rate <id> # More fields
@@ -595,7 +602,7 @@ Read `references/commands.md` for the full reference of every command, subcomman
595
602
  12. **Output formats** — `ct report` supports `table`, `json`, `csv`, and `markdown` via `--format`; suggest `csv` for spreadsheet export, `markdown` for docs
596
603
  13. **Ask for details when vague** — if the user's request is ambiguous (e.g. "log 2 hours" with no description or project), ask them for the missing context rather than guessing. It's better to ask one clarifying question than to log something wrong that needs to be edited or deleted
597
604
  14. **Expenses** — when the user mentions expenses, receipts, or purchases, use the `ct expense` commands. Always suggest a category and ask if the expense is billable. If recording a billable expense for a client project, always set `--project` and `--billable` so it gets included automatically when the client is invoiced
598
- 15. **Invoices include expenses** — when creating invoices with `ct invoice create --period`, billable expenses from the same period are automatically included as line items. Mention this to users so they know their expenses will appear on the invoice. Invoices can be grouped by `project`, `task`, `user`, `date`, or `none` via `--group-by`. Line items support discounts (`--discount-type fixed|percent|hours --discount-value N`)
605
+ 15. **Invoices include expenses** — when creating invoices with `ct invoice create --period`, billable expenses from the same period are automatically included as line items. Mention this to users so they know their expenses will appear on the invoice. Hours are shown per `--group-by`: `project`, `task` or `person` (one line each, per project, labelled with the period), `detailed` (one line per time entry; `--details` picks what each line shows from date, person, project, task, notes), `date`, or `none` (all on one line). Recurring templates take the same options. Line items support discounts (`--discount-type fixed|percent|hours --discount-value N`)
599
606
  16. **Client contacts** — use `ct clients contacts` and `ct clients add-contact` to manage multiple contacts per client. Set `--primary` for the main point of contact. Use `--receives-invoices` to flag contacts who should receive invoice emails
600
607
  17. **Permission errors are answers, not glitches** — the CLI now prints the server's reason on `403` (not your entry, already invoiced, no approval authority, read-only token, role too low) and on `400` validation errors (not assigned to the project, task not on the project). Relay the reason and the suggested alternative; do not retry the same command or look for another route to the same data. What each role can see is summarised under Team → Visibility rule and Organizations → Role hierarchy
601
608
  18. **Rate confidentiality** — rates, budgets, amounts, invoices and retainers are manager+ only (see Projects). If a member, viewer or project manager asks about rates or billed amounts, tell them it's restricted to account managers instead of trying workarounds
@@ -958,7 +958,11 @@ ct projects create NAME [options]
958
958
  | `--billable`, `-b` | flag | Billable (default: true) |
959
959
  | `--no-billable`, `-B` | flag | Non-billable |
960
960
  | `--billing-type` | string | What the client is charged: `time_and_materials` (default) or `fixed_fee`. Accepts `tm` / `fixed` as aliases |
961
- | `--fixed-fee` | float | Agreed price billed instead of logged hours. Implies `--billing-type fixed_fee` |
961
+ | `--fixed-fee` | float | Agreed price billed instead of logged hours. Implies `--billing-type fixed_fee`. The whole price for a one-off fee; the amount per period for a recurring one |
962
+ | `--fee-schedule` | string | `one-off` (default) or `recurring` |
963
+ | `--fee-frequency` | string | Recurring fee cadence: `weekly`, `biweekly`, `monthly`, `quarterly`. Implies `--fee-schedule recurring` |
964
+ | `--payment` | string (repeatable) | One-off payment `NAME=AMOUNT` or `NAME=AMOUNT@YYYY-MM-DD`. Must add up to `--fixed-fee`. Omitted → one "Full payment" |
965
+ | `--first-invoice` | string | Recurring fee: date of the first invoice. Default: one period after the project starts |
962
966
  | `--billing-mode` | string | Rate resolution for T&M work: `per_task`, `per_person`, `custom` (default). Ignored for fixed-fee projects |
963
967
  | `--payment-terms` | string | Terms for this project: `receipt`, `net7`, `net15`, `net30`, or a number of days. Omit to inherit the client, then org, default |
964
968
  | `--color` | string | Hex color code |
@@ -974,9 +978,13 @@ ct projects create NAME [options]
974
978
  | `time_and_materials` | Default. Billed from logged hours at the resolved rate |
975
979
  | `fixed_fee` | Billed at `--fixed-fee`, an agreed price. Logged hours are tracked but never charged by the hour |
976
980
 
977
- > **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:
981
+ > **Fixed-fee schedules.**
982
+ > - **One-off** — the price is paid in payments (milestones) that add up to it. Each payment is billed **once**: naming the project on an invoice bills its next unbilled payment (or the ones picked with `--payment <id>`), and a fully billed project bills nothing. Voiding or deleting the invoice frees the payment again. A recurring template that lists the project bills the payments due by the end of the period it covers; payments without a due date are only billed by naming the project on an invoice. Duplicating an invoice leaves out its payment lines. Invoiced payments cannot be changed or removed.
983
+ > - **Recurring** — the amount is billed every period. Creating the project (or switching it to recurring) sets up its own recurring invoice template with that cadence; changing the frequency updates it, and switching away, making it non-billable or archiving pauses it. If a hand-made template already bills the project, no second one is created.
984
+ >
985
+ > **How a fixed fee gets invoiced.** 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:
978
986
  > 1. the project is named explicitly — `ct invoice create --client <id> --project <fixed-project-id>`, or
979
- > 2. a recurring template lists the project.
987
+ > 2. a recurring template lists the project (including the one a recurring fee creates for itself).
980
988
  >
981
989
  > 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.
982
990
 
@@ -992,7 +1000,12 @@ ct projects create NAME [options]
992
1000
  **Fixed-fee examples:**
993
1001
  ```bash
994
1002
  ct projects create "Rediseño Web" --client "Acme" --fixed-fee 10000
1003
+ ct projects create "Web" --client "Acme" --fixed-fee 5000 \
1004
+ --payment Kickoff=1500@2026-06-01 --payment Midpoint=1500@2026-07-01 --payment Delivery=2000@2026-08-01
1005
+ ct projects create "Soporte" --client "Acme" --fixed-fee 4000 --fee-frequency monthly --first-invoice 2026-12-01
995
1006
  ct projects update redesign --billing-type fixed --fixed-fee 12000
1007
+ ct projects update web --fixed-fee 6000 --payment Kickoff=1500 --payment Rest=4500 # Invoiced payments are kept
1008
+ ct projects update soporte --fee-frequency quarterly
996
1009
  ct projects update redesign --billing-type tm --billing-mode per_person
997
1010
  ```
998
1011
 
@@ -2016,7 +2029,7 @@ Shows invoice details including line items, payments, and totals. Endpoint: `GET
2016
2029
  ### ct invoice create
2017
2030
 
2018
2031
  ```
2019
- 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]
2032
+ ct invoice create --client CLIENT --from DATE --to DATE [--period PERIOD] [--group-by GROUP] [--details LIST] [--tax-rate RATE] [--payment-terms TERMS] [--issue-date DATE] [--due-date DATE] [--notes NOTES] [--terms TEXT] [--json]
2020
2033
  ```
2021
2034
 
2022
2035
  Creates an invoice from tracked time entries. Requires either `--from`/`--to` or `--period`.
@@ -2028,7 +2041,9 @@ Creates an invoice from tracked time entries. Requires either `--from`/`--to` or
2028
2041
  | `--to` | string | Period end date |
2029
2042
  | `--period` | string | Preset: `last-week`, `last-2-weeks`, `last-month`, `this-month` |
2030
2043
  | `--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 |
2031
- | `--group-by` | string | Group line items by: `project`, `task`, `user`, `date`, `none` |
2044
+ | `--payment` | string (repeatable) | One-off fixed-fee payment ID(s) to bill (from `ct projects show`). Without it, each selected one-off project bills its next unbilled payment |
2045
+ | `--group-by` | string | How to display hours: `project`, `task`, `person` (alias `user`) — one line each, per project, e.g. "Website: Ana Pérez (Oct 1 – Oct 31, 2026)"; `detailed` — one line per time entry; `date`; `none` — all on one line. Omitted: by project, or the project's billing mode when one project is billed |
2046
+ | `--details` | string | With `detailed`: comma-separated `date`, `person`, `project`, `task`, `notes` (default `person,date,notes`), e.g. "10/23/2026 – Ana Pérez: Signup form". Implies `--group-by detailed` |
2032
2047
  | `--tax-rate` | float | Tax rate percentage (e.g. 21 for 21%) |
2033
2048
  | `--payment-terms` | string | Payment terms: `receipt` (0 days), `net7`, `net15`, `net30`, or number of days. **Omit** to resolve from the waterfall below |
2034
2049
  | `--issue-date` | string | Invoice date. Default: today |
@@ -2048,10 +2063,12 @@ Billable time entries and expenses from the period are automatically imported as
2048
2063
  ct invoice create --client <id> --period last-month --payment-terms net30
2049
2064
  ct invoice create --client <id> --from 2026-03-01 --to 2026-03-31
2050
2065
  ct invoice create --client <id> --period last-2-weeks --group-by project
2051
- ct invoice create --client <id> --period last-month --group-by user # One line item per team member
2066
+ ct invoice create --client <id> --period last-month --group-by person # One line per person on each project
2067
+ ct invoice create --client <id> --period last-month --group-by detailed --details date,task,notes # One line per entry
2052
2068
  ct invoice create --client <id> --period last-month # Uses the client's own terms
2053
2069
  ct invoice create --client <id> --period last-month --due-date 2026-09-15
2054
- ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the fixed fee
2070
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the next payment / one period
2071
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
2055
2072
  ```
2056
2073
 
2057
2074
  Endpoint: `POST /invoices/from-time-entries/`
@@ -2188,7 +2205,8 @@ ct invoice recurring create --name NAME --client CLIENT_ID --schedule SCHEDULE -
2188
2205
  | `--payment-terms` | integer | Payment terms in days |
2189
2206
  | `--auto-send` | flag | Auto-send generated invoices |
2190
2207
  | `--pull-entries/--no-pull-entries` | flag | Pull time entries (default: yes) |
2191
- | `--group-by`, `-g` | string | Group by: `project`, `task`, `user`, `date`, `none` |
2208
+ | `--group-by`, `-g` | string | How to display hours: `project`, `task`, `person`, `detailed`, `date`, `none` (see `ct invoice create`) |
2209
+ | `--details` | string | With `detailed`: what each line shows (`date,person,project,task,notes`) |
2192
2210
  | `--notes` | string | Invoice notes |
2193
2211
  | `--terms` | string | Payment terms text |
2194
2212
  | `--json` | flag | JSON output |
@@ -2209,7 +2227,8 @@ ct invoice recurring update TEMPLATE_ID [options]
2209
2227
  | `--active/--inactive` | flag | Activate or deactivate |
2210
2228
  | `--auto-send/--no-auto-send` | flag | Auto-send setting |
2211
2229
  | `--payment-terms` | integer | Payment terms in days |
2212
- | `--group-by`, `-g` | string | Group by setting |
2230
+ | `--group-by`, `-g` | string | How to display hours (see `ct invoice create`) |
2231
+ | `--details` | string | With `detailed`: what each line shows |
2213
2232
  | `--notes` | string | Invoice notes |
2214
2233
  | `--json` | flag | JSON output |
2215
2234
 
@@ -278,6 +278,26 @@ ct projects update-member consulting-engagement senior@co.com --rate 200
278
278
  ct projects update-member consulting-engagement junior@co.com --rate 100
279
279
  ```
280
280
 
281
+ ### Set Up a Fixed-Fee Project
282
+
283
+ First ask which kind of fixed fee it is — the two are billed very differently:
284
+
285
+ ```bash
286
+ # One project price, paid in milestones (each invoiced exactly once)
287
+ ct projects create "Website" --client "Acme" --fixed-fee 5000 \
288
+ --payment Kickoff=1500@2026-06-01 --payment Midpoint=1500@2026-07-01 --payment Delivery=2000@2026-08-01
289
+
290
+ # Bill the next payment when it's time (see `ct projects show` for payment IDs)
291
+ ct invoice create --client <client-id> --period this-month --project <project-id>
292
+
293
+ # The same amount every month — a recurring invoice is created for it automatically
294
+ ct projects create "Support" --client "Acme" --fixed-fee 4000 --fee-frequency monthly
295
+ ct projects show support # "Invoiced by:" names the template and its next run
296
+ ```
297
+
298
+ Payments must add up to the price. Invoiced payments can't be changed or removed;
299
+ void the invoice first if one was billed by mistake.
300
+
281
301
  ### Apply Task Presets to an Existing Project
282
302
 
283
303
  ```bash
@@ -310,7 +330,7 @@ When a project uses per-person billing, grouping the invoice by user gives clien
310
330
 
311
331
  ```bash
312
332
  # Create invoice grouped by team member
313
- ct invoice create --client "Acme Corp" --period last-month --group-by user --payment-terms net30
333
+ ct invoice create --client "Acme Corp" --period last-month --group-by person --payment-terms net30
314
334
 
315
335
  # Add a line item with a discount (e.g. 2 hours courtesy)
316
336
  ct invoice add-item <invoice-id> --desc "Extra support" --qty 10 --rate 150 --discount-type hours --discount-value 2
@@ -1142,8 +1162,9 @@ ct invoice recurring generate-now <template-id> \
1142
1162
  ```
1143
1163
 
1144
1164
  Logged hours cannot be double-billed — they are settled on the invoice that
1145
- pulled them. Fixed lines and fixed-fee projects can: they are billed in full on
1146
- every run whatever the period, which is what the guard exists for.
1165
+ pulled them. Fixed lines and recurring fixed fees can: they are billed in full
1166
+ on every run whatever the period, which is what the guard exists for. One-off
1167
+ fixed-fee payments are safe — each is billed only once.
1147
1168
 
1148
1169
  ---
1149
1170
 
File without changes
File without changes