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.
- freelancer_payment_protection_cli/__init__.py +6 -0
- freelancer_payment_protection_cli/api.py +89 -0
- freelancer_payment_protection_cli/auth.py +223 -0
- freelancer_payment_protection_cli/commands/__init__.py +0 -0
- freelancer_payment_protection_cli/commands/auth_cmds.py +84 -0
- freelancer_payment_protection_cli/commands/client_cmds.py +111 -0
- freelancer_payment_protection_cli/commands/escalation_cmds.py +142 -0
- freelancer_payment_protection_cli/commands/invoice_cmds.py +145 -0
- freelancer_payment_protection_cli/config.py +45 -0
- freelancer_payment_protection_cli/main.py +43 -0
- freelancer_payment_protection_cli/output.py +72 -0
- freelancer_payment_protection_cli-0.1.0.dist-info/METADATA +246 -0
- freelancer_payment_protection_cli-0.1.0.dist-info/RECORD +16 -0
- freelancer_payment_protection_cli-0.1.0.dist-info/WHEEL +4 -0
- freelancer_payment_protection_cli-0.1.0.dist-info/entry_points.txt +3 -0
- freelancer_payment_protection_cli-0.1.0.dist-info/licenses/LICENSE +35 -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
|
+

|
|
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
|
+

|
|
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,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.
|