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.
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/invoice_cmd.py +74 -10
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/projects_cmd.py +153 -4
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/models.py +5 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +11 -4
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +28 -9
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +24 -3
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/.gitignore +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/LICENSE +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/README.md +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/utils.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
593
|
-
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
|
|
694
|
-
|
|
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=
|
|
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
|
-
|
|
1432
|
-
payload
|
|
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=
|
|
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
|
-
|
|
1490
|
-
payload
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
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 #
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
> **
|
|
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
|
-
| `--
|
|
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
|
|
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
|
|
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 |
|
|
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 |
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crowdtime_cli-0.21.1 → crowdtime_cli-0.23.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|