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.
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/invoice_cmd.py +14 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/projects_cmd.py +153 -4
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/models.py +5 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +8 -2
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +19 -4
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +23 -2
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/.gitignore +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/LICENSE +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/README.md +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -0
- {crowdtime_cli-0.21.1 → crowdtime_cli-0.22.0}/src/crowdtime_cli/utils.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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.22.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|