crowdtime-cli 0.21.1__tar.gz → 0.22.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.22.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/invoice_cmd.py +14 -0
  5. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/projects_cmd.py +153 -4
  6. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/models.py +5 -0
  7. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -2
  8. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +19 -4
  9. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +23 -2
  10. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/.gitignore +0 -0
  11. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/LICENSE +0 -0
  12. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/README.md +0 -0
  13. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/auth.py +0 -0
  14. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/client.py +0 -0
  15. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  16. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
  17. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  18. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  19. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
  20. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  21. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  22. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  23. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  24. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  25. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
  26. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  27. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  28. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
  29. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  30. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  31. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  32. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  33. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  34. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  35. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  36. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/config.py +0 -0
  37. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/formatters.py +0 -0
  38. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/main.py +0 -0
  39. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/oauth.py +0 -0
  40. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/resolvers.py +0 -0
  41. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
  42. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.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.22.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.22.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.22.0"
@@ -589,6 +589,14 @@ def create_invoice(
589
589
  "in a client-wide invoice."
590
590
  ),
591
591
  ),
592
+ payments: Optional[list[str]] = typer.Option(
593
+ None, "--payment",
594
+ help=(
595
+ "One-off fixed-fee payment ID(s) to bill (see `ct projects show`). "
596
+ "Repeat for multiple. Without it a selected one-off project bills "
597
+ "its next unbilled payment; each payment is billed only once."
598
+ ),
599
+ ),
592
600
  group_by: Optional[str] = typer.Option(
593
601
  None, "--group-by", "-g", help="Group line items by: project, task, user, date, none."
594
602
  ),
@@ -630,6 +638,7 @@ def create_invoice(
630
638
  ct invoice create --client <id> --from monday --to friday --payment-terms 15
631
639
  ct invoice create --client <id> --period last-month --due-date 2026-09-15
632
640
  ct invoice create --client <id> --period last-month --project <fixed-project-id>
641
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
633
642
  """
634
643
  # Resolve period dates
635
644
  if period:
@@ -690,6 +699,11 @@ def create_invoice(
690
699
  payload["due_date"] = due_date_str
691
700
  if projects:
692
701
  payload["project_ids"] = list(projects)
702
+ if payments:
703
+ if not projects:
704
+ format_error("--payment needs the fixed-fee project selected with --project.")
705
+ raise typer.Exit(1)
706
+ payload["milestone_ids"] = list(payments)
693
707
  if group_by:
694
708
  payload["group_by"] = group_by
695
709
  if tax_rate is not None:
@@ -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
@@ -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
 
@@ -2028,6 +2041,7 @@ 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 |
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 |
2031
2045
  | `--group-by` | string | Group line items by: `project`, `task`, `user`, `date`, `none` |
2032
2046
  | `--tax-rate` | float | Tax rate percentage (e.g. 21 for 21%) |
2033
2047
  | `--payment-terms` | string | Payment terms: `receipt` (0 days), `net7`, `net15`, `net30`, or number of days. **Omit** to resolve from the waterfall below |
@@ -2051,7 +2065,8 @@ ct invoice create --client <id> --period last-2-weeks --group-by project
2051
2065
  ct invoice create --client <id> --period last-month --group-by user # One line item per team member
2052
2066
  ct invoice create --client <id> --period last-month # Uses the client's own terms
2053
2067
  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
2068
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> # Bills the next payment / one period
2069
+ ct invoice create --client <id> --period last-month --project <fixed-project-id> --payment <payment-id>
2055
2070
  ```
2056
2071
 
2057
2072
  Endpoint: `POST /invoices/from-time-entries/`
@@ -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
@@ -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