freelancer-payment-protection-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """freelancer-payment-protection-cli — command-line client for the
2
+ freelancer-payment-protection API (invoice escalation, legal document
3
+ generation, and client risk scoring for freelancers).
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ """
2
+ Thin HTTP client for the freelancer-payment-protection FastAPI backend.
3
+
4
+ Every route this module calls is a real endpoint read directly from
5
+ apps/api/app/routers/*.py in the target repo:
6
+ - clients.py: GET/POST /api/v1/clients, GET/PUT/DELETE /api/v1/clients/{id}
7
+ - invoices.py: GET/POST /api/v1/invoices, GET /api/v1/invoices/{id},
8
+ PATCH /api/v1/invoices/{id}/status
9
+ - escalations.py: GET /api/v1/escalations, POST /api/v1/escalations/{id}/draft,
10
+ GET /api/v1/escalations/{id}/history
11
+ - risk_scoring.py: POST /api/v1/risk/score
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from . import auth, config
20
+
21
+
22
+ class ApiError(RuntimeError):
23
+ def __init__(self, status_code: int, detail: str):
24
+ super().__init__(f"{status_code}: {detail}")
25
+ self.status_code = status_code
26
+ self.detail = detail
27
+
28
+
29
+ class ApiClient:
30
+ def __init__(self, base_url: str | None = None, http: httpx.Client | None = None):
31
+ self.base_url = (base_url or config.api_url()).rstrip("/")
32
+ self._http = http or httpx.Client(timeout=30.0)
33
+ self._owns_http = http is None
34
+
35
+ def close(self) -> None:
36
+ if self._owns_http:
37
+ self._http.close()
38
+
39
+ def __enter__(self) -> "ApiClient":
40
+ return self
41
+
42
+ def __exit__(self, *exc: Any) -> None:
43
+ self.close()
44
+
45
+ def _request(self, method: str, path: str, retried: bool = False, **kwargs: Any) -> httpx.Response:
46
+ token = auth.get_valid_access_token(client=self._http)
47
+ headers = kwargs.pop("headers", {}) or {}
48
+ headers["Authorization"] = f"Bearer {token}"
49
+ url = f"{self.base_url}{path}"
50
+ response = self._http.request(method, url, headers=headers, **kwargs)
51
+
52
+ if response.status_code == 401 and not retried:
53
+ # Access token was rejected server-side (e.g. expired sooner than
54
+ # our local clock expected). Force a refresh and retry once.
55
+ creds = auth.load_credentials()
56
+ if creds is not None:
57
+ refreshed = auth.refresh(creds.refresh_token, client=self._http)
58
+ auth.save_credentials(refreshed)
59
+ return self._request(method, path, retried=True, **kwargs)
60
+
61
+ if response.status_code >= 400:
62
+ raise ApiError(response.status_code, _error_detail(response))
63
+
64
+ return response
65
+
66
+ def get(self, path: str, params: dict | None = None) -> Any:
67
+ return self._request("GET", path, params=params).json()
68
+
69
+ def post(self, path: str, json_body: dict | None = None) -> Any:
70
+ return self._request("POST", path, json=json_body).json()
71
+
72
+ def patch(self, path: str, json_body: dict | None = None) -> Any:
73
+ return self._request("PATCH", path, json=json_body).json()
74
+
75
+ def put(self, path: str, json_body: dict | None = None) -> Any:
76
+ return self._request("PUT", path, json=json_body).json()
77
+
78
+ def delete(self, path: str) -> None:
79
+ self._request("DELETE", path)
80
+
81
+
82
+ def _error_detail(response: httpx.Response) -> str:
83
+ try:
84
+ data = response.json()
85
+ if isinstance(data, dict) and "detail" in data:
86
+ return str(data["detail"])
87
+ return str(data)
88
+ except ValueError:
89
+ return response.text
@@ -0,0 +1,223 @@
1
+ """
2
+ Authentication against Supabase's own REST auth API.
3
+
4
+ The backend (apps/api/app/middleware/auth.py) validates Supabase-issued JWTs
5
+ directly — it never mints tokens itself. So this CLI authenticates the same
6
+ way the web app's Supabase client does: it calls Supabase's documented
7
+ password-grant token endpoint directly, using the project's public anon key
8
+ as the `apikey` header. The anon key is safe to ship in a client (that is
9
+ its documented purpose in Supabase's own docs); it is not a secret.
10
+
11
+ Reference: https://supabase.com/docs/reference/auth/token-password-grant
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import stat
18
+ import time
19
+ from dataclasses import asdict, dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from . import config
26
+
27
+
28
+ class AuthError(RuntimeError):
29
+ """Raised when login, token storage, or token refresh fails."""
30
+
31
+
32
+ @dataclass
33
+ class Credentials:
34
+ access_token: str
35
+ refresh_token: str
36
+ expires_at: float # unix timestamp
37
+ token_type: str = "bearer"
38
+
39
+ def is_expired(self, skew_seconds: int = 30) -> bool:
40
+ return time.time() >= (self.expires_at - skew_seconds)
41
+
42
+ def to_dict(self) -> dict:
43
+ return asdict(self)
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: dict) -> "Credentials":
47
+ return cls(
48
+ access_token=data["access_token"],
49
+ refresh_token=data["refresh_token"],
50
+ expires_at=float(data["expires_at"]),
51
+ token_type=data.get("token_type", "bearer"),
52
+ )
53
+
54
+
55
+ def _supabase_headers() -> dict:
56
+ anon_key = config.supabase_anon_key()
57
+ if not anon_key:
58
+ raise AuthError(
59
+ "FPP_SUPABASE_ANON_KEY (or SUPABASE_ANON_KEY) is not set. "
60
+ "The CLI needs your Supabase project's anon key to authenticate, "
61
+ "the same public key the web app uses — see README.md 'Login and "
62
+ "authentication'."
63
+ )
64
+ return {"apikey": anon_key, "Content-Type": "application/json"}
65
+
66
+
67
+ def _require_supabase_url() -> str:
68
+ url = config.supabase_url()
69
+ if not url:
70
+ raise AuthError(
71
+ "FPP_SUPABASE_URL (or SUPABASE_URL) is not set. Point it at your "
72
+ "Supabase project URL, e.g. https://your-project.supabase.co."
73
+ )
74
+ return url.rstrip("/")
75
+
76
+
77
+ def login(email: str, password: str, client: httpx.Client | None = None) -> Credentials:
78
+ """
79
+ Calls Supabase's password-grant token endpoint directly:
80
+ POST {SUPABASE_URL}/auth/v1/token?grant_type=password
81
+ Body: {"email": ..., "password": ...}
82
+ Header: apikey: <anon key>
83
+ """
84
+ base = _require_supabase_url()
85
+ headers = _supabase_headers()
86
+ owns_client = client is None
87
+ http = client or httpx.Client(timeout=15.0)
88
+ try:
89
+ response = http.post(
90
+ f"{base}/auth/v1/token",
91
+ params={"grant_type": "password"},
92
+ headers=headers,
93
+ json={"email": email, "password": password},
94
+ )
95
+ except httpx.HTTPError as exc:
96
+ raise AuthError(f"Could not reach Supabase auth endpoint: {exc}") from exc
97
+ finally:
98
+ if owns_client:
99
+ http.close()
100
+
101
+ if response.status_code >= 400:
102
+ detail = _extract_error(response)
103
+ raise AuthError(f"Login failed ({response.status_code}): {detail}")
104
+
105
+ body = response.json()
106
+ return _credentials_from_token_response(body)
107
+
108
+
109
+ def refresh(refresh_token: str, client: httpx.Client | None = None) -> Credentials:
110
+ """
111
+ Calls Supabase's refresh-token grant:
112
+ POST {SUPABASE_URL}/auth/v1/token?grant_type=refresh_token
113
+ Body: {"refresh_token": ...}
114
+ """
115
+ base = _require_supabase_url()
116
+ headers = _supabase_headers()
117
+ owns_client = client is None
118
+ http = client or httpx.Client(timeout=15.0)
119
+ try:
120
+ response = http.post(
121
+ f"{base}/auth/v1/token",
122
+ params={"grant_type": "refresh_token"},
123
+ headers=headers,
124
+ json={"refresh_token": refresh_token},
125
+ )
126
+ except httpx.HTTPError as exc:
127
+ raise AuthError(f"Could not reach Supabase auth endpoint: {exc}") from exc
128
+ finally:
129
+ if owns_client:
130
+ http.close()
131
+
132
+ if response.status_code >= 400:
133
+ detail = _extract_error(response)
134
+ raise AuthError(f"Token refresh failed ({response.status_code}): {detail}")
135
+
136
+ body = response.json()
137
+ return _credentials_from_token_response(body)
138
+
139
+
140
+ def _credentials_from_token_response(body: dict) -> Credentials:
141
+ access_token = body.get("access_token")
142
+ refresh_token = body.get("refresh_token")
143
+ expires_in = body.get("expires_in", 3600)
144
+ if not access_token or not refresh_token:
145
+ raise AuthError(f"Unexpected response from Supabase auth endpoint: {body}")
146
+ return Credentials(
147
+ access_token=access_token,
148
+ refresh_token=refresh_token,
149
+ expires_at=time.time() + float(expires_in),
150
+ token_type=body.get("token_type", "bearer"),
151
+ )
152
+
153
+
154
+ def _extract_error(response: httpx.Response) -> str:
155
+ try:
156
+ data = response.json()
157
+ return data.get("error_description") or data.get("msg") or data.get("error") or response.text
158
+ except (json.JSONDecodeError, ValueError):
159
+ return response.text
160
+
161
+
162
+ def save_credentials(creds: Credentials) -> Path:
163
+ path = config.credentials_path()
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ path.write_text(json.dumps(creds.to_dict(), indent=2))
166
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # chmod 600
167
+ return path
168
+
169
+
170
+ def load_credentials() -> Credentials | None:
171
+ path = config.credentials_path()
172
+ if not path.exists():
173
+ return None
174
+ try:
175
+ data = json.loads(path.read_text())
176
+ return Credentials.from_dict(data)
177
+ except (json.JSONDecodeError, KeyError, ValueError):
178
+ return None
179
+
180
+
181
+ def clear_credentials() -> bool:
182
+ path = config.credentials_path()
183
+ if path.exists():
184
+ path.unlink()
185
+ return True
186
+ return False
187
+
188
+
189
+ def get_valid_access_token(client: httpx.Client | None = None) -> str:
190
+ """
191
+ Returns a valid access token, transparently refreshing (and persisting
192
+ the refreshed tokens) if the cached one has expired.
193
+ """
194
+ creds = load_credentials()
195
+ if creds is None:
196
+ raise AuthError("Not logged in. Run `fpp login` first.")
197
+
198
+ if creds.is_expired():
199
+ creds = refresh(creds.refresh_token, client=client)
200
+ save_credentials(creds)
201
+
202
+ return creds.access_token
203
+
204
+
205
+ def decode_claims_unverified(token: str) -> dict[str, Any]:
206
+ """
207
+ Best-effort decode of the JWT payload for display purposes only
208
+ (`whoami`). Does not verify the signature — verification is the
209
+ backend's job (apps/api/app/middleware/auth.py); the CLI only reads
210
+ the claims to show the user which workspace/session is cached.
211
+ """
212
+ import base64
213
+
214
+ parts = token.split(".")
215
+ if len(parts) != 3:
216
+ return {}
217
+ payload = parts[1]
218
+ padding = "=" * (-len(payload) % 4)
219
+ try:
220
+ decoded = base64.urlsafe_b64decode(payload + padding)
221
+ return json.loads(decoded)
222
+ except (ValueError, json.JSONDecodeError):
223
+ return {}
File without changes
@@ -0,0 +1,84 @@
1
+ """login / logout / whoami — Supabase password-grant auth, cached locally."""
2
+ from __future__ import annotations
3
+
4
+ import click
5
+
6
+ from .. import auth
7
+ from ..output import emit, emit_error, error_boundary, kv_table
8
+
9
+
10
+ @click.command()
11
+ def login() -> None:
12
+ """
13
+ Log in with your email and password.
14
+
15
+ Calls Supabase's own REST auth endpoint directly
16
+ (POST {SUPABASE_URL}/auth/v1/token?grant_type=password) using your
17
+ project's anon key — the same call the web app's Supabase client
18
+ makes. Requires FPP_SUPABASE_URL (or SUPABASE_URL) and
19
+ FPP_SUPABASE_ANON_KEY (or SUPABASE_ANON_KEY) to be set in your
20
+ environment.
21
+
22
+ On success, caches the access_token and refresh_token to
23
+ ~/.config/freelancer-payment-protection-cli/credentials.json (mode
24
+ 600). Later commands silently refresh the access token using the
25
+ cached refresh_token once it expires.
26
+ """
27
+ email = click.prompt("Email")
28
+ password = click.prompt("Password", hide_input=True)
29
+
30
+ with error_boundary(as_json=False):
31
+ creds = auth.login(email, password)
32
+ path = auth.save_credentials(creds)
33
+ click.echo(f"Logged in as {email}.")
34
+ click.echo(f"Credentials cached at {path}")
35
+
36
+
37
+ @click.command()
38
+ def logout() -> None:
39
+ """Delete the locally cached credentials."""
40
+ removed = auth.clear_credentials()
41
+ if removed:
42
+ click.echo("Logged out. Cached credentials removed.")
43
+ else:
44
+ click.echo("Not logged in — nothing to remove.")
45
+
46
+
47
+ @click.command()
48
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
49
+ def whoami(as_json: bool) -> None:
50
+ """Show the workspace and session info from the cached access token."""
51
+ creds = auth.load_credentials()
52
+ if creds is None:
53
+ emit_error("Not logged in. Run `fpp login` first.", as_json)
54
+ raise SystemExit(1)
55
+
56
+ claims = auth.decode_claims_unverified(creds.access_token)
57
+ workspace_id = claims.get("workspace_id")
58
+ data = {
59
+ "workspace_id": workspace_id,
60
+ "email": claims.get("email"),
61
+ "expires_at": creds.expires_at,
62
+ "expired": creds.is_expired(),
63
+ }
64
+
65
+ if not workspace_id:
66
+ note = (
67
+ "Warning: cached token has no workspace_id claim. The backend's "
68
+ "get_current_workspace() dependency requires one — API calls "
69
+ "will fail with 401 until you log in with an account that has "
70
+ "a workspace_id configured on its Supabase JWT."
71
+ )
72
+ if not as_json:
73
+ click.echo(note, err=True)
74
+ else:
75
+ data["warning"] = note
76
+
77
+ human = kv_table(
78
+ [
79
+ ("Workspace ID", workspace_id or "(none)"),
80
+ ("Email", claims.get("email", "(unknown)")),
81
+ ("Token expired", data["expired"]),
82
+ ]
83
+ )
84
+ emit(data, as_json, human=human)
@@ -0,0 +1,111 @@
1
+ """
2
+ client list / show / risk
3
+
4
+ Wraps apps/api/app/routers/clients.py and risk_scoring.py. ClientResponse
5
+ fields (camelCase over the wire, same alias-generator pattern as
6
+ InvoiceResponse): id, workspaceId, name, email, company, industry,
7
+ country, riskScore, riskLevel, totalInvoiced, totalOutstanding,
8
+ paymentTermsDays, averagePaymentDelay, contractUrl, notes, createdAt,
9
+ updatedAt.
10
+
11
+ risk_scoring.py's RiskScoreRequest is a plain (non-aliased) BaseModel with
12
+ a single `client_id` field — unlike the other schemas in this repo, it
13
+ does NOT accept a camelCase `clientId` key, so the CLI sends snake_case
14
+ here specifically.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import click
19
+
20
+ from ..api import ApiClient
21
+ from ..output import emit, error_boundary, kv_table, simple_table
22
+
23
+ RISK_LEVELS = {"low", "medium", "high", "critical"}
24
+
25
+
26
+ @click.group()
27
+ def client() -> None:
28
+ """Manage clients and client risk scoring."""
29
+
30
+
31
+ @client.command("list")
32
+ @click.option("--risk-level", type=click.Choice(sorted(RISK_LEVELS)), help="Filter by risk level.")
33
+ @click.option("--search", help="Search by name or company.")
34
+ @click.option("--page", default=1, show_default=True, type=int)
35
+ @click.option("--page-size", default=20, show_default=True, type=int, help="Max 100 (server-enforced).")
36
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
37
+ def list_clients(risk_level: str | None, search: str | None, page: int, page_size: int, as_json: bool) -> None:
38
+ """List clients for the current workspace. GET /api/v1/clients"""
39
+ params = {"page": page, "page_size": page_size}
40
+ if risk_level:
41
+ params["risk_level"] = risk_level
42
+ if search:
43
+ params["search"] = search
44
+
45
+ with error_boundary(as_json):
46
+ with ApiClient() as api:
47
+ clients = api.get("/api/v1/clients", params=params)
48
+
49
+ rows = [
50
+ [c["name"], c.get("company") or "-", c["riskLevel"], f"{c['riskScore']:.0f}", f"{c['totalOutstanding']:.2f}", c["id"]]
51
+ for c in clients
52
+ ]
53
+ human = simple_table(["NAME", "COMPANY", "RISK LEVEL", "RISK SCORE", "OUTSTANDING", "ID"], rows)
54
+ emit(clients, as_json, human=human)
55
+
56
+
57
+ @client.command("show")
58
+ @click.argument("client_id")
59
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
60
+ def show_client(client_id: str, as_json: bool) -> None:
61
+ """Show a single client. GET /api/v1/clients/{client_id}"""
62
+ with error_boundary(as_json):
63
+ with ApiClient() as api:
64
+ c = api.get(f"/api/v1/clients/{client_id}")
65
+
66
+ human = kv_table(
67
+ [
68
+ ("Name", c["name"]),
69
+ ("Company", c.get("company") or "-"),
70
+ ("Email", c["email"]),
71
+ ("Risk level", c["riskLevel"]),
72
+ ("Risk score", c["riskScore"]),
73
+ ("Total invoiced", c["totalInvoiced"]),
74
+ ("Total outstanding", c["totalOutstanding"]),
75
+ ("Payment terms (days)", c["paymentTermsDays"]),
76
+ ("Average payment delay (days)", c["averagePaymentDelay"]),
77
+ ("Client ID", c["id"]),
78
+ ]
79
+ )
80
+ emit(c, as_json, human=human)
81
+
82
+
83
+ @client.command("risk")
84
+ @click.argument("client_id")
85
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
86
+ def client_risk(client_id: str, as_json: bool) -> None:
87
+ """
88
+ Compute (or re-compute) a client's AI risk score.
89
+ POST /api/v1/risk/score body: {"client_id": ...}
90
+
91
+ Score is 0-100. Thresholds (apps/api/app/services/risk_service.py's
92
+ _compute_heuristic_risk): 0-25 low, 26-50 medium, 51-75 high, 76-100 critical.
93
+ Rate-limited server-side to 30 requests/minute per the @limiter.limit
94
+ decorator on this route.
95
+ """
96
+ with error_boundary(as_json):
97
+ with ApiClient() as api:
98
+ result = api.post("/api/v1/risk/score", json_body={"client_id": client_id})
99
+
100
+ factors = result.get("factors") or []
101
+ factor_lines = "\n".join(f" - {f.get('name', '?')}: {f.get('description', '')}" for f in factors)
102
+ human = kv_table(
103
+ [
104
+ ("Score", result.get("score")),
105
+ ("Level", result.get("level")),
106
+ ("Reasoning", result.get("reasoning") or "-"),
107
+ ]
108
+ )
109
+ if factor_lines:
110
+ human += f"\nFactors:\n{factor_lines}"
111
+ emit(result, as_json, human=human)
@@ -0,0 +1,142 @@
1
+ """
2
+ escalation list / status / advance
3
+
4
+ Wraps apps/api/app/routers/escalations.py, which exposes exactly three
5
+ routes: GET "" (all active escalations grouped by stage), POST
6
+ "/{invoice_id}/draft" (AI-drafts the next stage's email), and GET
7
+ "/{invoice_id}/history" (past escalation events for one invoice). There is
8
+ no endpoint in this API that persists a stage change — draft_escalation_email
9
+ in apps/api/app/services/escalation_service.py only computes and returns
10
+ what the next stage's email would say; it never writes escalation_stage
11
+ back to the database. `advance` below reflects that honestly: it drafts
12
+ the next-stage email and says so, it does not claim to move the invoice
13
+ to that stage.
14
+
15
+ `status` is not a single endpoint either — the closest real equivalent is
16
+ the invoice's own escalationStage/daysPastDue/nextEscalationDate fields
17
+ (GET /api/v1/invoices/{id}) plus its escalation history
18
+ (GET /api/v1/escalations/{id}/history), so this command calls both.
19
+
20
+ Stage order (apps/api/app/services/escalation_service.py STAGE_ORDER):
21
+ polite_reminder -> firm_notice -> final_warning -> legal_demand -> legal_action
22
+ This product's documented policy calls for 7d -> 7d -> 5d -> 7d minimum
23
+ waits between stages, but the /draft endpoint itself does not enforce
24
+ those waits — it only checks stage order (get_next_stage), so a draft can
25
+ be requested at any time regardless of how recently the previous stage
26
+ was sent.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import click
31
+
32
+ from ..api import ApiClient
33
+ from ..output import emit, error_boundary, kv_table, simple_table
34
+
35
+ STAGE_ORDER = ["polite_reminder", "firm_notice", "final_warning", "legal_demand", "legal_action"]
36
+
37
+
38
+ @click.group()
39
+ def escalation() -> None:
40
+ """Manage invoice escalations."""
41
+
42
+
43
+ @escalation.command("list")
44
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
45
+ def list_escalations(as_json: bool) -> None:
46
+ """List every active escalation, grouped by stage. GET /api/v1/escalations"""
47
+ with error_boundary(as_json):
48
+ with ApiClient() as api:
49
+ grouped = api.get("/api/v1/escalations")
50
+
51
+ rows = []
52
+ for stage in STAGE_ORDER:
53
+ for item in grouped.get(stage, []):
54
+ rows.append(
55
+ [
56
+ stage,
57
+ item["invoiceNumber"],
58
+ f"{item['amount']:.2f} {item['currency']}",
59
+ item["daysPastDue"],
60
+ item["clientName"],
61
+ item["invoiceId"],
62
+ ]
63
+ )
64
+ human = simple_table(["STAGE", "INVOICE #", "AMOUNT", "DAYS PAST DUE", "CLIENT", "INVOICE ID"], rows)
65
+ emit(grouped, as_json, human=human)
66
+
67
+
68
+ @escalation.command("status")
69
+ @click.argument("invoice_id")
70
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
71
+ def escalation_status(invoice_id: str, as_json: bool) -> None:
72
+ """
73
+ Show an invoice's current escalation stage and its history.
74
+ Combines GET /api/v1/invoices/{invoice_id} with
75
+ GET /api/v1/escalations/{invoice_id}/history.
76
+ """
77
+ with error_boundary(as_json):
78
+ with ApiClient() as api:
79
+ inv = api.get(f"/api/v1/invoices/{invoice_id}")
80
+ history = api.get(f"/api/v1/escalations/{invoice_id}/history")
81
+
82
+ combined = {"invoice": inv, "history": history}
83
+
84
+ lines = [
85
+ kv_table(
86
+ [
87
+ ("Invoice #", inv["invoiceNumber"]),
88
+ ("Status", inv["status"]),
89
+ ("Current stage", inv.get("escalationStage") or "(none)"),
90
+ ("Days past due", inv["daysPastDue"]),
91
+ ("Next escalation date", inv.get("nextEscalationDate") or "-"),
92
+ ("Last escalated at", inv.get("lastEscalatedAt") or "-"),
93
+ ]
94
+ )
95
+ ]
96
+ if history:
97
+ rows = [
98
+ [h["stage"], h.get("sentAt") or "-", f"{h.get('aiConfidenceScore', 0):.0%}" if h.get("aiConfidenceScore") is not None else "-", h.get("outcome") or "-"]
99
+ for h in history
100
+ ]
101
+ lines.append("\nHistory:")
102
+ lines.append(simple_table(["STAGE", "SENT AT", "AI CONFIDENCE", "OUTCOME"], rows))
103
+ else:
104
+ lines.append("\nNo escalation history yet.")
105
+
106
+ emit(combined, as_json, human="\n".join(lines))
107
+
108
+
109
+ @escalation.command("advance")
110
+ @click.argument("invoice_id")
111
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
112
+ def escalation_advance(invoice_id: str, as_json: bool) -> None:
113
+ """
114
+ AI-draft the next escalation stage's email for an invoice.
115
+ POST /api/v1/escalations/{invoice_id}/draft
116
+
117
+ This previews what the next stage's email would say. It does not
118
+ change the invoice's stored escalation stage: the backend has no
119
+ endpoint that persists that change today, only this preview endpoint.
120
+ Send/record the actual escalation through the product's own workflow.
121
+ """
122
+ with error_boundary(as_json):
123
+ with ApiClient() as api:
124
+ draft = api.post(f"/api/v1/escalations/{invoice_id}/draft")
125
+
126
+ if "error" in draft:
127
+ emit(draft, as_json, human=f"{draft['error']}\n{draft.get('message', '')}")
128
+ return
129
+
130
+ human = kv_table(
131
+ [
132
+ ("Next stage", f"{draft.get('stage')} ({draft.get('stage_label', '')})"),
133
+ ("Subject", draft.get("subject")),
134
+ ("Confidence", draft.get("confidence_score")),
135
+ ]
136
+ )
137
+ human += f"\n\n{draft.get('body', '')}"
138
+ human += (
139
+ "\n\n(Preview only — no stage change was persisted. This API does not "
140
+ "expose an endpoint that writes the stage back to the invoice.)"
141
+ )
142
+ emit(draft, as_json, human=human)
@@ -0,0 +1,145 @@
1
+ """
2
+ invoice list / create / show / set-status
3
+
4
+ Wraps apps/api/app/routers/invoices.py. Every field name below matches
5
+ apps.api.app.schemas.invoice.{InvoiceCreate,InvoiceStatusUpdate,InvoiceResponse}
6
+ exactly. Those schemas use a camelCase alias generator with
7
+ populate_by_name=True and FastAPI's default response_model_by_alias=True,
8
+ so requests accept either casing but responses are camelCase over the wire
9
+ (clientId, invoiceNumber, dueDate, daysPastDue, escalationStage,
10
+ sourceSystem, externalId, lineItems, lastEscalatedAt, nextEscalationDate,
11
+ evidenceCount, createdAt, updatedAt).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import click
16
+
17
+ from ..api import ApiClient
18
+ from ..output import emit, error_boundary, simple_table
19
+
20
+ VALID_STATUSES = {"paid", "pending", "overdue", "disputed", "written_off"}
21
+
22
+
23
+ @click.group()
24
+ def invoice() -> None:
25
+ """Manage invoices (apps/api/app/routers/invoices.py)."""
26
+
27
+
28
+ @invoice.command("list")
29
+ @click.option("--status", "status_filter", type=click.Choice(sorted(VALID_STATUSES)), help="Filter by invoice status.")
30
+ @click.option("--client-id", help="Filter by client ID.")
31
+ @click.option("--page", default=1, show_default=True, type=int)
32
+ @click.option("--page-size", default=20, show_default=True, type=int, help="Max 100 (server-enforced).")
33
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
34
+ def list_invoices(status_filter: str | None, client_id: str | None, page: int, page_size: int, as_json: bool) -> None:
35
+ """List invoices for the current workspace. GET /api/v1/invoices"""
36
+ params = {"page": page, "page_size": page_size}
37
+ if status_filter:
38
+ params["status"] = status_filter
39
+ if client_id:
40
+ params["client_id"] = client_id
41
+
42
+ with error_boundary(as_json):
43
+ with ApiClient() as api:
44
+ invoices = api.get("/api/v1/invoices", params=params)
45
+
46
+ rows = [
47
+ [
48
+ inv["invoiceNumber"],
49
+ inv["status"],
50
+ f"{inv['amount']:.2f} {inv['currency']}",
51
+ inv["daysPastDue"],
52
+ inv.get("escalationStage") or "-",
53
+ inv["id"],
54
+ ]
55
+ for inv in invoices
56
+ ]
57
+ human = simple_table(["INVOICE #", "STATUS", "AMOUNT", "DAYS PAST DUE", "STAGE", "ID"], rows)
58
+ emit(invoices, as_json, human=human)
59
+
60
+
61
+ @invoice.command("create")
62
+ @click.option("--client-id", required=True)
63
+ @click.option("--invoice-number", required=True)
64
+ @click.option("--amount", required=True, type=float)
65
+ @click.option("--currency", default="USD", show_default=True)
66
+ @click.option("--due-date", required=True, help="ISO 8601 datetime, e.g. 2026-08-15T00:00:00Z")
67
+ @click.option("--source-system", default=None)
68
+ @click.option("--external-id", default=None)
69
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
70
+ def create_invoice(
71
+ client_id: str,
72
+ invoice_number: str,
73
+ amount: float,
74
+ currency: str,
75
+ due_date: str,
76
+ source_system: str | None,
77
+ external_id: str | None,
78
+ as_json: bool,
79
+ ) -> None:
80
+ """Create an invoice. POST /api/v1/invoices"""
81
+ body = {
82
+ "clientId": client_id,
83
+ "invoiceNumber": invoice_number,
84
+ "amount": amount,
85
+ "currency": currency,
86
+ "dueDate": due_date,
87
+ "sourceSystem": source_system,
88
+ "externalId": external_id,
89
+ "lineItems": [],
90
+ }
91
+
92
+ with error_boundary(as_json):
93
+ with ApiClient() as api:
94
+ created = api.post("/api/v1/invoices", json_body=body)
95
+
96
+ human = f"Created invoice {created['invoiceNumber']} ({created['id']}) — {created['amount']:.2f} {created['currency']}"
97
+ emit(created, as_json, human=human)
98
+
99
+
100
+ @invoice.command("show")
101
+ @click.argument("invoice_id")
102
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
103
+ def show_invoice(invoice_id: str, as_json: bool) -> None:
104
+ """Show a single invoice. GET /api/v1/invoices/{invoice_id}"""
105
+ with error_boundary(as_json):
106
+ with ApiClient() as api:
107
+ inv = api.get(f"/api/v1/invoices/{invoice_id}")
108
+
109
+ from ..output import kv_table
110
+
111
+ human = kv_table(
112
+ [
113
+ ("Invoice #", inv["invoiceNumber"]),
114
+ ("Status", inv["status"]),
115
+ ("Amount", f"{inv['amount']:.2f} {inv['currency']}"),
116
+ ("Due date", inv["dueDate"]),
117
+ ("Days past due", inv["daysPastDue"]),
118
+ ("Escalation stage", inv.get("escalationStage") or "-"),
119
+ ("Next escalation date", inv.get("nextEscalationDate") or "-"),
120
+ ("Last escalated at", inv.get("lastEscalatedAt") or "-"),
121
+ ("Evidence count", inv["evidenceCount"]),
122
+ ("Client ID", inv["clientId"]),
123
+ ("Invoice ID", inv["id"]),
124
+ ]
125
+ )
126
+ emit(inv, as_json, human=human)
127
+
128
+
129
+ @invoice.command("set-status")
130
+ @click.argument("invoice_id")
131
+ @click.argument("new_status", type=click.Choice(sorted(VALID_STATUSES)))
132
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
133
+ def set_invoice_status(invoice_id: str, new_status: str, as_json: bool) -> None:
134
+ """
135
+ Update an invoice's status. PATCH /api/v1/invoices/{invoice_id}/status
136
+
137
+ Setting status to "paid" also clears escalationStage and
138
+ nextEscalationDate server-side (see invoices.py update_invoice_status).
139
+ """
140
+ with error_boundary(as_json):
141
+ with ApiClient() as api:
142
+ updated = api.patch(f"/api/v1/invoices/{invoice_id}/status", json_body={"status": new_status})
143
+
144
+ human = f"Invoice {updated['invoiceNumber']} status set to {updated['status']}."
145
+ emit(updated, as_json, human=human)
@@ -0,0 +1,45 @@
1
+ """
2
+ Configuration and credential-storage paths.
3
+
4
+ The API base URL and Supabase project settings are read from environment
5
+ variables so the same CLI build works against any deployment (local dev
6
+ server, self-hosted, or a hosted instance) without hardcoding a URL that
7
+ does not exist for every installer of this package.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ CLI_NAME = "freelancer-payment-protection-cli"
15
+
16
+ # Backend API base URL. Defaults to the FastAPI dev server's default port
17
+ # (apps/api/README instructions: `uvicorn app.main:app --reload` serves on
18
+ # http://localhost:8000, with routers mounted under /api/v1).
19
+ DEFAULT_API_URL = "http://localhost:8000"
20
+
21
+
22
+ def api_url() -> str:
23
+ return os.environ.get("FPP_API_URL", DEFAULT_API_URL).rstrip("/")
24
+
25
+
26
+ def supabase_url() -> str | None:
27
+ # FPP_SUPABASE_URL takes precedence; falls back to the same SUPABASE_URL
28
+ # name the backend's own .env.example uses, since the anon-key auth
29
+ # endpoint lives on the same Supabase project as the backend.
30
+ return os.environ.get("FPP_SUPABASE_URL") or os.environ.get("SUPABASE_URL")
31
+
32
+
33
+ def supabase_anon_key() -> str | None:
34
+ return os.environ.get("FPP_SUPABASE_ANON_KEY") or os.environ.get("SUPABASE_ANON_KEY")
35
+
36
+
37
+ def config_dir() -> Path:
38
+ override = os.environ.get("FPP_CONFIG_DIR")
39
+ if override:
40
+ return Path(override)
41
+ return Path.home() / ".config" / CLI_NAME
42
+
43
+
44
+ def credentials_path() -> Path:
45
+ return config_dir() / "credentials.json"
@@ -0,0 +1,43 @@
1
+ """CLI entry point. Registered as console scripts `fpp` and
2
+ `freelancer-payment-protection` (see pyproject.toml [project.scripts])."""
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from . import __version__
8
+ from .commands.auth_cmds import login, logout, whoami
9
+ from .commands.client_cmds import client
10
+ from .commands.escalation_cmds import escalation
11
+ from .commands.invoice_cmds import invoice
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(version=__version__, prog_name="freelancer-payment-protection-cli")
16
+ def cli() -> None:
17
+ """
18
+ Command-line client for freelancer-payment-protection: automated
19
+ invoice escalation, legal document generation, and client risk
20
+ scoring for freelancers.
21
+
22
+ Every command talks to a real freelancer-payment-protection API
23
+ instance (default http://localhost:8000, override with FPP_API_URL).
24
+ Log in first with `fpp login`.
25
+
26
+ Every data-returning command supports --json for scripting/agent use.
27
+ """
28
+
29
+
30
+ cli.add_command(login)
31
+ cli.add_command(logout)
32
+ cli.add_command(whoami)
33
+ cli.add_command(invoice)
34
+ cli.add_command(escalation)
35
+ cli.add_command(client)
36
+
37
+
38
+ def main() -> None:
39
+ cli()
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,72 @@
1
+ """Shared output helpers: --json passthrough vs. a plain human-readable table."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from contextlib import contextmanager
6
+ from typing import Any, Iterator
7
+
8
+ import click
9
+
10
+ from .api import ApiError
11
+ from .auth import AuthError
12
+
13
+
14
+ @contextmanager
15
+ def error_boundary(as_json: bool) -> Iterator[None]:
16
+ """
17
+ Wraps a command body: turns ApiError/AuthError into a clean message
18
+ (JSON-shaped when --json is set) and a non-zero exit, instead of a
19
+ Python traceback.
20
+ """
21
+ try:
22
+ yield
23
+ except ApiError as exc:
24
+ emit_error(exc.detail, as_json, status_code=exc.status_code)
25
+ raise SystemExit(1)
26
+ except AuthError as exc:
27
+ emit_error(str(exc), as_json)
28
+ raise SystemExit(1)
29
+
30
+
31
+ def emit(data: Any, as_json: bool, human: str | None = None) -> None:
32
+ """
33
+ Prints `data` as JSON when --json is set, otherwise prints `human` if
34
+ given, else falls back to a generic key/value dump of `data`.
35
+ """
36
+ if as_json:
37
+ click.echo(json.dumps(data, indent=2, default=str))
38
+ return
39
+ if human is not None:
40
+ click.echo(human)
41
+ return
42
+ click.echo(json.dumps(data, indent=2, default=str))
43
+
44
+
45
+ def emit_error(message: str, as_json: bool, status_code: int | None = None) -> None:
46
+ if as_json:
47
+ payload: dict[str, Any] = {"error": message}
48
+ if status_code is not None:
49
+ payload["status_code"] = status_code
50
+ click.echo(json.dumps(payload, indent=2), err=False)
51
+ else:
52
+ click.echo(f"Error: {message}", err=True)
53
+
54
+
55
+ def kv_table(rows: list[tuple[str, Any]]) -> str:
56
+ width = max((len(k) for k, _ in rows), default=0)
57
+ return "\n".join(f"{k.ljust(width)} : {v}" for k, v in rows)
58
+
59
+
60
+ def simple_table(headers: list[str], rows: list[list[Any]]) -> str:
61
+ if not rows:
62
+ return "(no results)"
63
+ widths = [len(h) for h in headers]
64
+ str_rows = [[str(cell) for cell in row] for row in rows]
65
+ for row in str_rows:
66
+ for i, cell in enumerate(row):
67
+ widths[i] = max(widths[i], len(cell))
68
+ lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers))]
69
+ lines.append(" ".join("-" * w for w in widths))
70
+ for row in str_rows:
71
+ lines.append(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
72
+ return "\n".join(lines)
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: freelancer-payment-protection-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for freelancer-payment-protection: automated invoice escalation, legal document generation, and client risk scoring for freelancers.
5
+ Project-URL: Homepage, https://github.com/RudrenduPaul/freelancer-payment-protection
6
+ Project-URL: Repository, https://github.com/RudrenduPaul/freelancer-payment-protection
7
+ Project-URL: Bug Tracker, https://github.com/RudrenduPaul/freelancer-payment-protection/issues
8
+ Author: Rudrendu Paul, Sourav Nandy
9
+ License: Proprietary
10
+ License-File: LICENSE
11
+ Keywords: agent-native,cli,fastapi,freelance,invoice-escalation,invoicing,risk-scoring,supabase
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: click<9,>=8.1
25
+ Requires-Dist: httpx<1,>=0.27
26
+ Provides-Extra: dev
27
+ Requires-Dist: build<2,>=1.0; extra == 'dev'
28
+ Requires-Dist: pytest-cov<7,>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest<9,>=8.0; extra == 'dev'
30
+ Requires-Dist: respx<1,>=0.21; extra == 'dev'
31
+ Requires-Dist: twine<7,>=5.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # freelancer-payment-protection-cli
35
+
36
+ Command-line client for [freelancer-payment-protection](https://github.com/RudrenduPaul/freelancer-payment-protection):
37
+ automated invoice escalation, legal document generation, and client risk
38
+ scoring for freelancers. This package wraps the project's FastAPI backend
39
+ (`apps/api/app/routers/*.py`) so the same clients, invoices, escalations,
40
+ and risk-scoring data you'd otherwise reach through the dashboard or the
41
+ raw HTTP API is scriptable from a terminal or an agent.
42
+
43
+ ![Login and first command](../../docs/demo.gif)
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install freelancer-payment-protection-cli
49
+ ```
50
+
51
+ Or with [uv](https://docs.astral.sh/uv/) / [pipx](https://pipx.pypa.io/):
52
+
53
+ ```bash
54
+ uvx freelancer-payment-protection-cli --help
55
+ pipx install freelancer-payment-protection-cli
56
+ ```
57
+
58
+ Requires Python 3.10+. The console command is installed as both `fpp`
59
+ (short form) and `freelancer-payment-protection` (full form) — they are the
60
+ same entry point.
61
+
62
+ ## Configuration
63
+
64
+ The CLI needs to know two things: which backend API to talk to, and which
65
+ Supabase project issues your login tokens.
66
+
67
+ | Variable | Purpose | Default |
68
+ |---|---|---|
69
+ | `FPP_API_URL` | Backend API base URL | `http://localhost:8000` |
70
+ | `FPP_SUPABASE_URL` (or `SUPABASE_URL`) | Your Supabase project URL | none, required for `login` |
71
+ | `FPP_SUPABASE_ANON_KEY` (or `SUPABASE_ANON_KEY`) | Your Supabase project's anon key | none, required for `login` |
72
+
73
+ The anon key is the same public key the web app's Supabase client uses —
74
+ it is not a secret, and it's the key already in `apps/web/.env.example` as
75
+ `NEXT_PUBLIC_SUPABASE_ANON_KEY`.
76
+
77
+ ## Login and authentication
78
+
79
+ ```bash
80
+ export FPP_SUPABASE_URL="https://your-project.supabase.co"
81
+ export FPP_SUPABASE_ANON_KEY="your-anon-key"
82
+ fpp login
83
+ ```
84
+
85
+ `fpp login` prompts for your email and password, then calls Supabase's own
86
+ REST auth endpoint directly:
87
+
88
+ ```
89
+ POST {SUPABASE_URL}/auth/v1/token?grant_type=password
90
+ Header: apikey: <anon key>
91
+ Body: {"email": ..., "password": ...}
92
+ ```
93
+
94
+ This is the same call the web dashboard's Supabase client makes — the CLI
95
+ never talks to a login endpoint of its own, because the backend
96
+ (`apps/api/app/middleware/auth.py`) doesn't mint tokens; it only validates
97
+ Supabase-issued JWTs. On success, the returned `access_token` and
98
+ `refresh_token` are cached to `~/.config/freelancer-payment-protection-cli/credentials.json`
99
+ (mode 600). Every later command reads the JWT's `workspace_id` claim the
100
+ same way `get_current_workspace()` does server-side, and transparently
101
+ refreshes the access token with the cached refresh token once it expires.
102
+
103
+ Run `fpp logout` to delete the cached credentials, or `fpp whoami` to see
104
+ which workspace and email the cached token resolves to.
105
+
106
+ ## Commands
107
+
108
+ Every data-returning command supports `--json` for scripting and agent use.
109
+
110
+ ```
111
+ fpp login Log in (prompts for email/password)
112
+ fpp logout Remove cached credentials
113
+ fpp whoami [--json] Show cached workspace/session info
114
+
115
+ fpp invoice list [--status] [--client-id] [--page] [--page-size] [--json]
116
+ fpp invoice create --client-id --invoice-number --amount --due-date [...] [--json]
117
+ fpp invoice show <invoice-id> [--json]
118
+ fpp invoice set-status <invoice-id> <status> [--json]
119
+
120
+ fpp escalation list [--json] Active escalations, grouped by stage
121
+ fpp escalation status <invoice-id> [--json] Current stage + full history
122
+ fpp escalation advance <invoice-id> [--json] AI-draft the next stage's email
123
+
124
+ fpp client list [--risk-level] [--search] [--page] [--page-size] [--json]
125
+ fpp client show <client-id> [--json]
126
+ fpp client risk <client-id> [--json] Compute/refresh the AI risk score
127
+ ```
128
+
129
+ Run `fpp --help` or `fpp <command> --help` for full flag references.
130
+
131
+ ![Filtering invoices, scoring a client, and checking escalation status](../../docs/usage.gif)
132
+
133
+ ### A note on `escalation advance`
134
+
135
+ The backend's escalation router (`apps/api/app/routers/escalations.py`)
136
+ exposes exactly three routes: list active escalations, draft the next
137
+ stage's email, and read history. There is no route that persists a stage
138
+ change — `draft_escalation_email` in `escalation_service.py` computes and
139
+ returns what the next stage's email would say, but never writes
140
+ `escalation_stage` back to the database. `fpp escalation advance` calls
141
+ that draft endpoint and shows you the preview; it does not claim to move
142
+ the invoice to the next stage, because the API it wraps doesn't do that
143
+ either.
144
+
145
+ ### JSON output for agents/scripts
146
+
147
+ ```bash
148
+ fpp invoice list --status overdue --json | jq '.[] | {id, invoiceNumber, daysPastDue}'
149
+ fpp client risk "$CLIENT_ID" --json | jq '.level'
150
+ ```
151
+
152
+ Field names in `--json` output match the backend's actual Pydantic response
153
+ models exactly (camelCase, e.g. `invoiceNumber`, `daysPastDue`,
154
+ `escalationStage`) since the CLI passes the API's response straight
155
+ through rather than remapping it.
156
+
157
+ ## FAQ
158
+
159
+ **What is this, and how is it different from just calling the API with curl?**
160
+ It's a typed command-line wrapper around the same backend the web
161
+ dashboard uses — invoices, escalations, and client risk scoring — with
162
+ persistent login (so you're not re-attaching a bearer token to every
163
+ request), human-readable tables by default, and a `--json` flag on every
164
+ data command for piping into `jq`, scripts, or an agent's tool-calling
165
+ loop. The differentiator versus curl is session handling (login once,
166
+ silent token refresh after) and consistent, documented output shapes
167
+ instead of hand-rolled request construction.
168
+
169
+ **What platforms and Python versions does it support?**
170
+ Python 3.10 through 3.13, on any OS `pip`/`uvx`/`pipx` runs on (Linux,
171
+ macOS, Windows). It has no compiled dependencies — the only runtime
172
+ dependencies are `click` and `httpx`, both pure-Python-installable wheels.
173
+
174
+ **How do I log in, and where are my credentials stored?**
175
+ Run `fpp login`, enter your email and password when prompted. The CLI
176
+ calls Supabase's password-grant token endpoint directly (see "Login and
177
+ authentication" above) and caches the resulting access and refresh tokens
178
+ to `~/.config/freelancer-payment-protection-cli/credentials.json` with
179
+ file mode 600 (owner read/write only). Nothing is sent anywhere except
180
+ Supabase's own auth endpoint and the backend API you configure via
181
+ `FPP_API_URL`.
182
+
183
+ **I ran a command and got `Error: Internal Server Error` — is that a CLI bug?**
184
+ Usually not. The CLI's error handling surfaces whatever the backend
185
+ returned; a 500 means the backend itself failed. Two real, backend-side
186
+ causes to check first: (1) `fpp client risk` and `fpp escalation advance`
187
+ both trigger AI calls through `packages/legal_ai/client.py` — if
188
+ `ANTHROPIC_API_KEY` isn't a real key on the server, `client risk` falls
189
+ back to a heuristic score automatically, but `escalation advance` has no
190
+ such fallback and will 500. (2) Confirm you're running a backend build
191
+ that includes the `packages/legal_ai` module-name fix and the
192
+ `risk_scoring.py` request-parameter fix — both were real bugs in this
193
+ repo (a hyphenated `packages/legal-ai` directory that the code imports as
194
+ `packages.legal_ai`, and a rate-limiter/parameter-name collision on
195
+ `/api/v1/risk/score`) that this CLI's own end-to-end testing surfaced and
196
+ this same change fixed. A plain 401 means your cached token expired and
197
+ couldn't refresh — run `fpp login` again.
198
+
199
+ **Does `fpp escalation advance` actually send the escalation email or move the invoice to the next stage?**
200
+ No. It calls the backend's `/api/v1/escalations/{id}/draft` endpoint,
201
+ which only returns an AI-drafted preview of what the next stage's email
202
+ would say. The backend has no endpoint today that persists a stage change
203
+ or sends the email — see "A note on `escalation advance`" above.
204
+
205
+ **Can I point this at a self-hosted or non-default backend?**
206
+ Yes. Set `FPP_API_URL` to your backend's base URL (default is
207
+ `http://localhost:8000`, matching the FastAPI dev server's default port).
208
+ Every command reads that variable at call time, so switching environments
209
+ is just re-exporting it.
210
+
211
+ **What's the licensing situation — can I use or modify this commercially?**
212
+ This CLI ships from the same repository as, and under the same license
213
+ as, freelancer-payment-protection itself: a proprietary license, copyright
214
+ Rudrendu Paul and Sourav Nandy, all rights reserved. Personal, academic,
215
+ commercial, or scheduled use requires explicit written permission from
216
+ both owners — see the `LICENSE` file. This is not an MIT/Apache-style
217
+ open-source license; publishing it to PyPI makes it installable, not
218
+ freely reusable.
219
+
220
+ **Why isn't there a hosted/default API URL I can just start using?**
221
+ freelancer-payment-protection is self-hosted software, not a hosted
222
+ multi-tenant SaaS with a public API endpoint today. You (or whoever
223
+ operates your workspace) runs the backend; the CLI just needs its URL.
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ cd packages/cli
229
+ python -m venv .venv && source .venv/bin/activate
230
+ pip install -e ".[dev]"
231
+ pytest
232
+ ```
233
+
234
+ All HTTP calls (to Supabase and to the backend API) are mocked in tests
235
+ with `respx` — the test suite makes no live network calls.
236
+
237
+ ## Contributing
238
+
239
+ This package lives inside the freelancer-payment-protection monorepo. See
240
+ the repository root for contribution guidelines.
241
+
242
+ ## License
243
+
244
+ Proprietary — see [`LICENSE`](./LICENSE). Contact the owners (see
245
+ `LICENSE`) before any use beyond installing and running the package as
246
+ published.
@@ -0,0 +1,16 @@
1
+ freelancer_payment_protection_cli/__init__.py,sha256=AamRexv0y8UFRRerE5RRy9efDpNVEq90dQNyMo5TD9k,220
2
+ freelancer_payment_protection_cli/api.py,sha256=FTx01e3xmeeXMtG317qXKOqpX7k_RrXaYDb8XxZTL6Q,3304
3
+ freelancer_payment_protection_cli/auth.py,sha256=qemALBy0Hviwo91apFbETzYRCv80HF4L1pUETq22QE4,7114
4
+ freelancer_payment_protection_cli/config.py,sha256=n_nupRlHbLM6hwatshgV0gV8Q0A-GhXJ6-OkW0iSCKM,1500
5
+ freelancer_payment_protection_cli/main.py,sha256=iiENxnwmfmKMIJz7snlT3r-twB-qPfMUvU0cbKNMJ9I,1179
6
+ freelancer_payment_protection_cli/output.py,sha256=uz0EfFx_HQOyaZKM2n-157pD2flKRbwyb9sdhZPf8xo,2337
7
+ freelancer_payment_protection_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ freelancer_payment_protection_cli/commands/auth_cmds.py,sha256=2cpWSRSJGC4ok6X2dCxXlCAK0s7CBSGOhx4SIUNgEb4,2825
9
+ freelancer_payment_protection_cli/commands/client_cmds.py,sha256=OeV9eOgmQG6MSOrR6ZtTcutTHIsQpwUkxUjIy5dV6mw,4239
10
+ freelancer_payment_protection_cli/commands/escalation_cmds.py,sha256=z2lq8lTsu8fvzYmCaGyIlmx-bgsL94o4tAJZ1M938rk,5615
11
+ freelancer_payment_protection_cli/commands/invoice_cmds.py,sha256=ngjQHJ9lKav41cR3zpO87d1iTE55unE7zWZYSznwmXQ,5528
12
+ freelancer_payment_protection_cli-0.1.0.dist-info/METADATA,sha256=YYdPV1vyINyR3nARVtyasFDGK_tC5qQk5NIkSi5g1f4,11029
13
+ freelancer_payment_protection_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ freelancer_payment_protection_cli-0.1.0.dist-info/entry_points.txt,sha256=8pJ-LhzXWMeMbCde3Asj-PiMPiXOMv8ArOQs432yGdg,144
15
+ freelancer_payment_protection_cli-0.1.0.dist-info/licenses/LICENSE,sha256=eQNth9Qd5zQvzMfAcHNSHxZOEVMRIDM_sqj0MU89wyc,1400
16
+ freelancer_payment_protection_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fpp = freelancer_payment_protection_cli.main:main
3
+ freelancer-payment-protection = freelancer_payment_protection_cli.main:main
@@ -0,0 +1,35 @@
1
+ Copyright (c) 2026 Rudrendu Paul and Sourav Nandy. All rights reserved.
2
+
3
+ PROPRIETARY LICENSE
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ exclusive intellectual property of Rudrendu Paul and Sourav Nandy
7
+ ("the Owners").
8
+
9
+ RESTRICTIONS
10
+
11
+ 1. No use, copying, modification, merging, publishing, distribution,
12
+ sublicensing, or selling of this Software — in whole or in part — is
13
+ permitted without prior written approval from both Owners.
14
+
15
+ 2. Any personal, academic, commercial, or scheduled use of this Software
16
+ requires explicit written permission obtained directly from:
17
+
18
+ Rudrendu Paul — https://github.com/RudrenduPaul
19
+ Sourav Nandy — (contact via Rudrendu Paul)
20
+
21
+ 3. Unauthorized use of this Software for any purpose, including but not
22
+ limited to building competing products, white-labeling, or redistribution,
23
+ is strictly prohibited and may result in legal action.
24
+
25
+ TO REQUEST PERMISSION
26
+
27
+ Submit a written request describing your intended use to the Owners. Approval
28
+ is granted solely at the discretion of both Owners and may be subject to
29
+ additional terms or a commercial licensing agreement.
30
+
31
+ DISCLAIMER
32
+
33
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34
+ IMPLIED. IN NO EVENT SHALL THE OWNERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR
35
+ OTHER LIABILITY ARISING FROM THE USE OF OR INABILITY TO USE THE SOFTWARE.