sovaria-cli 0.2.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.
Files changed (46) hide show
  1. apps/cli/README.md +147 -0
  2. apps/cli/__init__.py +0 -0
  3. apps/cli/src/__init__.py +3 -0
  4. apps/cli/src/commands/__init__.py +1 -0
  5. apps/cli/src/commands/admin.py +160 -0
  6. apps/cli/src/commands/api.py +48 -0
  7. apps/cli/src/commands/audit.py +181 -0
  8. apps/cli/src/commands/auth_product.py +187 -0
  9. apps/cli/src/commands/billing.py +513 -0
  10. apps/cli/src/commands/chrono.py +248 -0
  11. apps/cli/src/commands/context.py +99 -0
  12. apps/cli/src/commands/data.py +272 -0
  13. apps/cli/src/commands/db.py +528 -0
  14. apps/cli/src/commands/deploy.py +1099 -0
  15. apps/cli/src/commands/governance.py +491 -0
  16. apps/cli/src/commands/iam.py +436 -0
  17. apps/cli/src/commands/init_cmd.py +177 -0
  18. apps/cli/src/commands/keys.py +120 -0
  19. apps/cli/src/commands/login.py +842 -0
  20. apps/cli/src/commands/org.py +157 -0
  21. apps/cli/src/commands/policy.py +317 -0
  22. apps/cli/src/commands/project.py +130 -0
  23. apps/cli/src/commands/realtime.py +364 -0
  24. apps/cli/src/commands/scope.py +42 -0
  25. apps/cli/src/commands/shell.py +415 -0
  26. apps/cli/src/commands/sim.py +178 -0
  27. apps/cli/src/commands/storage.py +405 -0
  28. apps/cli/src/commands/vector.py +204 -0
  29. apps/cli/src/main.py +169 -0
  30. apps/cli/src/utils/__init__.py +1 -0
  31. apps/cli/src/utils/api_client.py +329 -0
  32. apps/cli/src/utils/auth_store.py +299 -0
  33. apps/cli/src/utils/config.py +185 -0
  34. apps/cli/src/utils/display.py +62 -0
  35. apps/cli/src/utils/migrations.py +69 -0
  36. apps/cli/src/utils/project_context.py +124 -0
  37. apps/cli/tests/test_auth_store_profiles.py +103 -0
  38. apps/cli/tests/test_cli_security.py +110 -0
  39. apps/cli/tests/test_cli_smoke_paths.py +279 -0
  40. apps/cli/tests/test_config_profiles.py +55 -0
  41. apps/cli/tests/test_main_startup.py +29 -0
  42. apps/cli/tests/test_project_and_commands.py +196 -0
  43. sovaria_cli-0.2.0.dist-info/METADATA +170 -0
  44. sovaria_cli-0.2.0.dist-info/RECORD +46 -0
  45. sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
  46. sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,187 @@
1
+ """Project-scoped product auth admin commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+ from urllib.parse import quote
7
+
8
+ import typer
9
+
10
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
11
+ from apps.cli.src.utils.config import get_scope
12
+
13
+ app = typer.Typer(help="Manage product users within an org/project scope")
14
+
15
+
16
+ class ScopeArgs(TypedDict):
17
+ org_id: str | None
18
+ project_id: str | None
19
+ env_id: str | None
20
+
21
+
22
+ def _scope_args(
23
+ org_id: str | None,
24
+ project_id: str | None,
25
+ env_id: str | None,
26
+ ) -> ScopeArgs:
27
+ return {
28
+ "org_id": org_id,
29
+ "project_id": project_id,
30
+ "env_id": env_id,
31
+ }
32
+
33
+
34
+ def _project_auth_path(profile: str | None, project_id: str | None, suffix: str) -> str:
35
+ resolved_project = (project_id or get_scope(profile).get("project_id") or "").strip()
36
+ if not resolved_project:
37
+ raise CLIRequestError(
38
+ "Missing required project scope. Set --project or run: sov scope set <org_id> <project_id>"
39
+ )
40
+ return f"/api/v1/projects/{quote(resolved_project, safe='')}/auth{suffix}"
41
+
42
+
43
+ @app.command("users")
44
+ def list_users(
45
+ profile: str | None = typer.Option(None, "--profile"),
46
+ org_id: str | None = typer.Option(None, "--org"),
47
+ project_id: str | None = typer.Option(None, "--project"),
48
+ env_id: str | None = typer.Option(None, "--env"),
49
+ ) -> None:
50
+ """List product users for the resolved org/project scope."""
51
+ try:
52
+ result = call_api(
53
+ method="GET",
54
+ path=_project_auth_path(profile, project_id, "/users"),
55
+ profile=profile,
56
+ require_scope=True,
57
+ **_scope_args(org_id, project_id, env_id),
58
+ )
59
+ except CLIRequestError as exc:
60
+ typer.echo(str(exc), err=True)
61
+ raise typer.Exit(1) from exc
62
+ typer.echo(print_json(result))
63
+
64
+
65
+ @app.command("ban")
66
+ def ban_user(
67
+ user_id: str = typer.Argument(..., help="Product user id to ban/unban"),
68
+ profile: str | None = typer.Option(None, "--profile"),
69
+ org_id: str | None = typer.Option(None, "--org"),
70
+ project_id: str | None = typer.Option(None, "--project"),
71
+ env_id: str | None = typer.Option(None, "--env"),
72
+ ) -> None:
73
+ """Toggle a product user's active state in scope."""
74
+ safe_user_id = quote(user_id, safe="")
75
+ try:
76
+ result = call_api(
77
+ method="POST",
78
+ path=_project_auth_path(profile, project_id, f"/users/{safe_user_id}/ban"),
79
+ profile=profile,
80
+ require_scope=True,
81
+ **_scope_args(org_id, project_id, env_id),
82
+ )
83
+ except CLIRequestError as exc:
84
+ typer.echo(str(exc), err=True)
85
+ raise typer.Exit(1) from exc
86
+ typer.echo(print_json(result))
87
+
88
+
89
+ @app.command("delete")
90
+ def delete_user(
91
+ user_id: str = typer.Argument(..., help="Product user id to delete"),
92
+ profile: str | None = typer.Option(None, "--profile"),
93
+ org_id: str | None = typer.Option(None, "--org"),
94
+ project_id: str | None = typer.Option(None, "--project"),
95
+ env_id: str | None = typer.Option(None, "--env"),
96
+ ) -> None:
97
+ """Delete a product user credential in scope."""
98
+ safe_user_id = quote(user_id, safe="")
99
+ try:
100
+ result = call_api(
101
+ method="DELETE",
102
+ path=_project_auth_path(profile, project_id, f"/users/{safe_user_id}"),
103
+ profile=profile,
104
+ require_scope=True,
105
+ **_scope_args(org_id, project_id, env_id),
106
+ )
107
+ except CLIRequestError as exc:
108
+ typer.echo(str(exc), err=True)
109
+ raise typer.Exit(1) from exc
110
+ typer.echo(print_json(result))
111
+
112
+
113
+ @app.command("roles")
114
+ def get_roles(
115
+ user_id: str = typer.Argument(..., help="Product user id"),
116
+ profile: str | None = typer.Option(None, "--profile"),
117
+ org_id: str | None = typer.Option(None, "--org"),
118
+ project_id: str | None = typer.Option(None, "--project"),
119
+ env_id: str | None = typer.Option(None, "--env"),
120
+ ) -> None:
121
+ """Show a product user's app roles in scope."""
122
+ safe_user_id = quote(user_id, safe="")
123
+ try:
124
+ result = call_api(
125
+ method="GET",
126
+ path=_project_auth_path(profile, project_id, f"/users/{safe_user_id}/roles"),
127
+ profile=profile,
128
+ require_scope=True,
129
+ **_scope_args(org_id, project_id, env_id),
130
+ )
131
+ except CLIRequestError as exc:
132
+ typer.echo(str(exc), err=True)
133
+ raise typer.Exit(1) from exc
134
+ typer.echo(print_json(result))
135
+
136
+
137
+ @app.command("roles-set")
138
+ def set_roles(
139
+ user_id: str = typer.Argument(..., help="Product user id"),
140
+ roles: list[str] = typer.Argument(..., help="Roles to assign"),
141
+ profile: str | None = typer.Option(None, "--profile"),
142
+ org_id: str | None = typer.Option(None, "--org"),
143
+ project_id: str | None = typer.Option(None, "--project"),
144
+ env_id: str | None = typer.Option(None, "--env"),
145
+ ) -> None:
146
+ """Replace a product user's app roles in scope."""
147
+ safe_user_id = quote(user_id, safe="")
148
+ try:
149
+ result = call_api(
150
+ method="PUT",
151
+ path=_project_auth_path(profile, project_id, f"/users/{safe_user_id}/roles"),
152
+ profile=profile,
153
+ body={"roles": roles},
154
+ require_scope=True,
155
+ **_scope_args(org_id, project_id, env_id),
156
+ )
157
+ except CLIRequestError as exc:
158
+ typer.echo(str(exc), err=True)
159
+ raise typer.Exit(1) from exc
160
+ typer.echo(print_json(result))
161
+
162
+
163
+ @app.command("create")
164
+ def create_user(
165
+ email: str = typer.Option(..., "--email"),
166
+ password: str = typer.Option(..., "--password"),
167
+ name: str | None = typer.Option(None, "--name", help="Display name (defaults to email local-part)"),
168
+ profile: str | None = typer.Option(None, "--profile"),
169
+ org_id: str | None = typer.Option(None, "--org"),
170
+ project_id: str | None = typer.Option(None, "--project"),
171
+ env_id: str | None = typer.Option(None, "--env"),
172
+ ) -> None:
173
+ """Create a product user in the current project scope."""
174
+ display_name = name or email.split("@", maxsplit=1)[0]
175
+ try:
176
+ result = call_api(
177
+ method="POST",
178
+ path=_project_auth_path(profile, project_id, "/users"),
179
+ profile=profile,
180
+ require_scope=True,
181
+ **_scope_args(org_id, project_id, env_id),
182
+ body={"name": display_name, "email": email, "password": password},
183
+ )
184
+ except CLIRequestError as exc:
185
+ typer.echo(str(exc), err=True)
186
+ raise typer.Exit(1) from exc
187
+ typer.echo(print_json(result))
@@ -0,0 +1,513 @@
1
+ """Ledger billing commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
8
+
9
+ app = typer.Typer(help="Billing and subscription commands (Ledger engine)")
10
+
11
+
12
+ @app.command("plans")
13
+ def list_plans(
14
+ profile: str | None = typer.Option(None, "--profile"),
15
+ ) -> None:
16
+ """List public billing plans."""
17
+ try:
18
+ result = call_api(
19
+ method="GET",
20
+ path="/api/v1/plans",
21
+ profile=profile,
22
+ require_auth=False,
23
+ )
24
+ except CLIRequestError as exc:
25
+ typer.echo(str(exc), err=True)
26
+ raise typer.Exit(1) from exc
27
+ typer.echo(print_json(result))
28
+
29
+
30
+ @app.command("plan")
31
+ def get_plan(
32
+ name: str = typer.Argument(..., help="Plan name"),
33
+ profile: str | None = typer.Option(None, "--profile"),
34
+ ) -> None:
35
+ """Fetch one billing plan by name."""
36
+ from urllib.parse import quote
37
+
38
+ try:
39
+ result = call_api(
40
+ method="GET",
41
+ path=f"/api/v1/plans/{quote(name, safe='')}",
42
+ profile=profile,
43
+ require_auth=False,
44
+ )
45
+ except CLIRequestError as exc:
46
+ typer.echo(str(exc), err=True)
47
+ raise typer.Exit(1) from exc
48
+ typer.echo(print_json(result))
49
+
50
+
51
+ @app.command("subscription")
52
+ def get_subscription(
53
+ org_id: str = typer.Argument(..., help="Organisation ID"),
54
+ profile: str | None = typer.Option(None, "--profile"),
55
+ ) -> None:
56
+ """Fetch the active subscription for an organisation."""
57
+ from urllib.parse import quote
58
+
59
+ try:
60
+ result = call_api(
61
+ method="GET",
62
+ path=f"/api/v1/subscriptions/{quote(org_id, safe='')}",
63
+ profile=profile,
64
+ require_scope=False,
65
+ )
66
+ except CLIRequestError as exc:
67
+ typer.echo(str(exc), err=True)
68
+ raise typer.Exit(1) from exc
69
+ typer.echo(print_json(result))
70
+
71
+
72
+ @app.command("set-subscription")
73
+ def set_subscription(
74
+ org_id: str = typer.Argument(..., help="Organisation ID"),
75
+ plan: str = typer.Option(..., "--plan", help="Plan name"),
76
+ billing_cycle: str = typer.Option("monthly", "--cycle", help="monthly or annual"),
77
+ profile: str | None = typer.Option(None, "--profile"),
78
+ ) -> None:
79
+ """Create or update an organisation subscription."""
80
+ from urllib.parse import quote
81
+
82
+ try:
83
+ result = call_api(
84
+ method="PUT",
85
+ path=f"/api/v1/subscriptions/{quote(org_id, safe='')}",
86
+ profile=profile,
87
+ require_scope=False,
88
+ body={"plan": plan, "billing_cycle": billing_cycle, "metadata": {}},
89
+ )
90
+ except CLIRequestError as exc:
91
+ typer.echo(str(exc), err=True)
92
+ raise typer.Exit(1) from exc
93
+ typer.echo(print_json(result))
94
+
95
+
96
+ @app.command("cancel-subscription")
97
+ def cancel_subscription(
98
+ org_id: str = typer.Argument(..., help="Organisation ID"),
99
+ immediate: bool = typer.Option(False, "--immediate", help="Cancel immediately"),
100
+ profile: str | None = typer.Option(None, "--profile"),
101
+ ) -> None:
102
+ """Cancel an organisation subscription."""
103
+ from urllib.parse import quote
104
+
105
+ params = {"immediate": "true"} if immediate else None
106
+ try:
107
+ result = call_api(
108
+ method="DELETE",
109
+ path=f"/api/v1/subscriptions/{quote(org_id, safe='')}",
110
+ profile=profile,
111
+ require_scope=False,
112
+ params=params,
113
+ )
114
+ except CLIRequestError as exc:
115
+ typer.echo(str(exc), err=True)
116
+ raise typer.Exit(1) from exc
117
+ typer.echo(print_json(result))
118
+
119
+
120
+ @app.command("usage")
121
+ def get_usage(
122
+ org_id: str = typer.Argument(..., help="Organisation ID"),
123
+ period: str | None = typer.Option(None, "--period", help="Usage period"),
124
+ profile: str | None = typer.Option(None, "--profile"),
125
+ ) -> None:
126
+ """Fetch usage counters for an organisation."""
127
+ from urllib.parse import quote
128
+
129
+ params = {"period": period} if period else None
130
+ try:
131
+ result = call_api(
132
+ method="GET",
133
+ path=f"/api/v1/usage/{quote(org_id, safe='')}",
134
+ profile=profile,
135
+ require_scope=False,
136
+ params=params,
137
+ )
138
+ except CLIRequestError as exc:
139
+ typer.echo(str(exc), err=True)
140
+ raise typer.Exit(1) from exc
141
+ typer.echo(print_json(result))
142
+
143
+
144
+ @app.command("usage-history")
145
+ def usage_history(
146
+ org_id: str = typer.Argument(..., help="Organisation ID"),
147
+ months: int = typer.Option(6, "--months", min=1, max=24),
148
+ profile: str | None = typer.Option(None, "--profile"),
149
+ ) -> None:
150
+ """Fetch historical usage for an organisation."""
151
+ from urllib.parse import quote
152
+
153
+ try:
154
+ result = call_api(
155
+ method="GET",
156
+ path=f"/api/v1/usage/{quote(org_id, safe='')}/history",
157
+ profile=profile,
158
+ require_scope=False,
159
+ params={"months": str(months)},
160
+ )
161
+ except CLIRequestError as exc:
162
+ typer.echo(str(exc), err=True)
163
+ raise typer.Exit(1) from exc
164
+ typer.echo(print_json(result))
165
+
166
+
167
+ @app.command("subscriptions")
168
+ def list_subscriptions(
169
+ profile: str | None = typer.Option(None, "--profile"),
170
+ ) -> None:
171
+ """List all subscriptions (platform admin)."""
172
+ try:
173
+ result = call_api(
174
+ method="GET",
175
+ path="/api/v1/subscriptions",
176
+ profile=profile,
177
+ require_scope=False,
178
+ )
179
+ except CLIRequestError as exc:
180
+ typer.echo(str(exc), err=True)
181
+ raise typer.Exit(1) from exc
182
+ typer.echo(print_json(result))
183
+
184
+
185
+ @app.command("invoices")
186
+ def list_invoices(
187
+ org_id: str = typer.Argument(..., help="Organisation ID"),
188
+ profile: str | None = typer.Option(None, "--profile"),
189
+ ) -> None:
190
+ """List invoices for an organisation."""
191
+ from urllib.parse import quote
192
+
193
+ try:
194
+ result = call_api(
195
+ method="GET",
196
+ path=f"/api/v1/invoices/{quote(org_id, safe='')}",
197
+ profile=profile,
198
+ require_scope=False,
199
+ )
200
+ except CLIRequestError as exc:
201
+ typer.echo(str(exc), err=True)
202
+ raise typer.Exit(1) from exc
203
+ typer.echo(print_json(result))
204
+
205
+
206
+ @app.command("compute")
207
+ def get_compute(
208
+ org_id: str = typer.Argument(..., help="Organisation ID"),
209
+ period: str | None = typer.Option(None, "--period", help="Billing period YYYY-MM"),
210
+ profile: str | None = typer.Option(None, "--profile"),
211
+ ) -> None:
212
+ """Fetch compute usage breakdown for an organisation."""
213
+ from urllib.parse import quote
214
+
215
+ params = {"period": period} if period else None
216
+ try:
217
+ result = call_api(
218
+ method="GET",
219
+ path=f"/api/v1/compute/{quote(org_id, safe='')}",
220
+ profile=profile,
221
+ require_scope=False,
222
+ params=params,
223
+ )
224
+ except CLIRequestError as exc:
225
+ typer.echo(str(exc), err=True)
226
+ raise typer.Exit(1) from exc
227
+ typer.echo(print_json(result))
228
+
229
+
230
+ @app.command("build-invoice")
231
+ def build_invoice(
232
+ org_id: str = typer.Argument(..., help="Organisation ID"),
233
+ period: str | None = typer.Option(None, "--period", help="Billing period YYYY-MM"),
234
+ profile: str | None = typer.Option(None, "--profile"),
235
+ ) -> None:
236
+ """Build an invoice for an organisation billing period."""
237
+ from urllib.parse import quote
238
+
239
+ params = {"period": period} if period else None
240
+ try:
241
+ result = call_api(
242
+ method="POST",
243
+ path=f"/api/v1/invoices/{quote(org_id, safe='')}/build",
244
+ profile=profile,
245
+ require_scope=False,
246
+ params=params,
247
+ body={},
248
+ )
249
+ except CLIRequestError as exc:
250
+ typer.echo(str(exc), err=True)
251
+ raise typer.Exit(1) from exc
252
+ typer.echo(print_json(result))
253
+
254
+
255
+ @app.command("checkout")
256
+ def checkout(
257
+ plan: str = typer.Option(..., "--plan", help="Plan name (pro|ultra)"),
258
+ billing_cycle: str = typer.Option("monthly", "--cycle", help="monthly or annual"),
259
+ customer_email: str | None = typer.Option(None, "--email"),
260
+ provider: str | None = typer.Option(None, "--provider", help="stripe or razorpay"),
261
+ profile: str | None = typer.Option(None, "--profile"),
262
+ org_id: str | None = typer.Option(None, "--org"),
263
+ ) -> None:
264
+ """Create a hosted billing checkout session."""
265
+ body: dict[str, object] = {"plan": plan, "billing_cycle": billing_cycle}
266
+ if customer_email:
267
+ body["customer_email"] = customer_email
268
+ if provider:
269
+ body["provider"] = provider
270
+ try:
271
+ result = call_api(
272
+ method="POST",
273
+ path="/api/v1/billing/checkout",
274
+ profile=profile,
275
+ org_id=org_id,
276
+ require_scope=False,
277
+ body=body,
278
+ )
279
+ except CLIRequestError as exc:
280
+ typer.echo(str(exc), err=True)
281
+ raise typer.Exit(1) from exc
282
+ typer.echo(print_json(result))
283
+
284
+
285
+ @app.command("provider")
286
+ def billing_provider(
287
+ profile: str | None = typer.Option(None, "--profile"),
288
+ ) -> None:
289
+ """Show the active billing payment provider."""
290
+ try:
291
+ result = call_api(
292
+ method="GET",
293
+ path="/api/v1/billing/provider",
294
+ profile=profile,
295
+ require_scope=False,
296
+ )
297
+ except CLIRequestError as exc:
298
+ typer.echo(str(exc), err=True)
299
+ raise typer.Exit(1) from exc
300
+ typer.echo(print_json(result))
301
+
302
+
303
+ @app.command("fx")
304
+ def billing_fx(
305
+ profile: str | None = typer.Option(None, "--profile"),
306
+ ) -> None:
307
+ """Fetch billing FX rates."""
308
+ try:
309
+ result = call_api(
310
+ method="GET",
311
+ path="/api/v1/billing/fx",
312
+ profile=profile,
313
+ require_scope=False,
314
+ )
315
+ except CLIRequestError as exc:
316
+ typer.echo(str(exc), err=True)
317
+ raise typer.Exit(1) from exc
318
+ typer.echo(print_json(result))
319
+
320
+
321
+ @app.command("overage")
322
+ def billing_overage(
323
+ org_id: str = typer.Argument(..., help="Organisation ID"),
324
+ profile: str | None = typer.Option(None, "--profile"),
325
+ ) -> None:
326
+ """Fetch overage billing details for an organisation."""
327
+ from urllib.parse import quote
328
+
329
+ try:
330
+ result = call_api(
331
+ method="GET",
332
+ path=f"/api/v1/billing/overage/{quote(org_id, safe='')}",
333
+ profile=profile,
334
+ require_scope=False,
335
+ )
336
+ except CLIRequestError as exc:
337
+ typer.echo(str(exc), err=True)
338
+ raise typer.Exit(1) from exc
339
+ typer.echo(print_json(result))
340
+
341
+
342
+ @app.command("open-invoice")
343
+ def open_invoice(
344
+ profile: str | None = typer.Option(None, "--profile"),
345
+ org_id: str | None = typer.Option(None, "--org"),
346
+ ) -> None:
347
+ """Fetch the caller's open invoice for the scoped organisation."""
348
+ try:
349
+ result = call_api(
350
+ method="GET",
351
+ path="/api/v1/billing/invoices/open",
352
+ profile=profile,
353
+ org_id=org_id,
354
+ require_scope=False,
355
+ )
356
+ except CLIRequestError as exc:
357
+ typer.echo(str(exc), err=True)
358
+ raise typer.Exit(1) from exc
359
+ typer.echo(print_json(result))
360
+
361
+
362
+ @app.command("pay-invoice")
363
+ def pay_invoice(
364
+ org_id: str = typer.Argument(..., help="Organisation ID"),
365
+ period: str = typer.Argument(..., help="Invoice period YYYY-MM"),
366
+ profile: str | None = typer.Option(None, "--profile"),
367
+ ) -> None:
368
+ """Pay an invoice for an organisation billing period."""
369
+ from urllib.parse import quote
370
+
371
+ try:
372
+ result = call_api(
373
+ method="POST",
374
+ path=f"/api/v1/billing/invoices/{quote(org_id, safe='')}/{quote(period, safe='')}/pay",
375
+ profile=profile,
376
+ require_scope=False,
377
+ body={},
378
+ )
379
+ except CLIRequestError as exc:
380
+ typer.echo(str(exc), err=True)
381
+ raise typer.Exit(1) from exc
382
+ typer.echo(print_json(result))
383
+
384
+
385
+ razorpay_app = typer.Typer(help="Razorpay checkout helpers")
386
+ app.add_typer(razorpay_app, name="razorpay")
387
+
388
+
389
+ @razorpay_app.command("create-order")
390
+ def razorpay_create_order(
391
+ plan: str = typer.Option(..., "--plan", help="Plan name (pro|ultra)"),
392
+ billing_cycle: str = typer.Option("monthly", "--cycle", help="monthly or annual"),
393
+ customer_email: str | None = typer.Option(None, "--email"),
394
+ account_id: str | None = typer.Option(None, "--account"),
395
+ profile: str | None = typer.Option(None, "--profile"),
396
+ org_id: str | None = typer.Option(None, "--org"),
397
+ ) -> None:
398
+ """Create a Razorpay order for self-serve checkout."""
399
+ body: dict[str, object] = {"plan": plan, "billing_cycle": billing_cycle}
400
+ if customer_email:
401
+ body["customer_email"] = customer_email
402
+ if account_id:
403
+ body["account_id"] = account_id
404
+ try:
405
+ result = call_api(
406
+ method="POST",
407
+ path="/api/v1/billing/razorpay/create-order",
408
+ profile=profile,
409
+ org_id=org_id,
410
+ require_scope=False,
411
+ body=body,
412
+ )
413
+ except CLIRequestError as exc:
414
+ typer.echo(str(exc), err=True)
415
+ raise typer.Exit(1) from exc
416
+ typer.echo(print_json(result))
417
+
418
+
419
+ @razorpay_app.command("verify")
420
+ def razorpay_verify(
421
+ order_id: str = typer.Option(..., "--order-id"),
422
+ payment_id: str = typer.Option(..., "--payment-id"),
423
+ signature: str = typer.Option(..., "--signature"),
424
+ profile: str | None = typer.Option(None, "--profile"),
425
+ org_id: str | None = typer.Option(None, "--org"),
426
+ ) -> None:
427
+ """Verify a Razorpay payment signature and activate the plan."""
428
+ try:
429
+ result = call_api(
430
+ method="POST",
431
+ path="/api/v1/billing/razorpay/verify-payment",
432
+ profile=profile,
433
+ org_id=org_id,
434
+ require_scope=False,
435
+ body={
436
+ "razorpay_order_id": order_id,
437
+ "razorpay_payment_id": payment_id,
438
+ "razorpay_signature": signature,
439
+ },
440
+ )
441
+ except CLIRequestError as exc:
442
+ typer.echo(str(exc), err=True)
443
+ raise typer.Exit(1) from exc
444
+ typer.echo(print_json(result))
445
+
446
+
447
+ account_app = typer.Typer(help="Billing account lifecycle")
448
+ app.add_typer(account_app, name="account")
449
+
450
+
451
+ @account_app.command("get")
452
+ def account_get(
453
+ account_id: str = typer.Argument(..., help="Billing account ID"),
454
+ profile: str | None = typer.Option(None, "--profile"),
455
+ ) -> None:
456
+ """Fetch a billing account."""
457
+ from urllib.parse import quote
458
+
459
+ try:
460
+ result = call_api(
461
+ method="GET",
462
+ path=f"/api/v1/accounts/{quote(account_id, safe='')}",
463
+ profile=profile,
464
+ require_scope=False,
465
+ )
466
+ except CLIRequestError as exc:
467
+ typer.echo(str(exc), err=True)
468
+ raise typer.Exit(1) from exc
469
+ typer.echo(print_json(result))
470
+
471
+
472
+ @account_app.command("cancel")
473
+ def account_cancel(
474
+ account_id: str = typer.Argument(..., help="Billing account ID"),
475
+ profile: str | None = typer.Option(None, "--profile"),
476
+ ) -> None:
477
+ """Cancel a billing account subscription."""
478
+ from urllib.parse import quote
479
+
480
+ try:
481
+ result = call_api(
482
+ method="POST",
483
+ path=f"/api/v1/accounts/{quote(account_id, safe='')}/cancel",
484
+ profile=profile,
485
+ require_scope=False,
486
+ body={},
487
+ )
488
+ except CLIRequestError as exc:
489
+ typer.echo(str(exc), err=True)
490
+ raise typer.Exit(1) from exc
491
+ typer.echo(print_json(result))
492
+
493
+
494
+ @account_app.command("resume")
495
+ def account_resume(
496
+ account_id: str = typer.Argument(..., help="Billing account ID"),
497
+ profile: str | None = typer.Option(None, "--profile"),
498
+ ) -> None:
499
+ """Resume a cancelled billing account."""
500
+ from urllib.parse import quote
501
+
502
+ try:
503
+ result = call_api(
504
+ method="POST",
505
+ path=f"/api/v1/accounts/{quote(account_id, safe='')}/resume",
506
+ profile=profile,
507
+ require_scope=False,
508
+ body={},
509
+ )
510
+ except CLIRequestError as exc:
511
+ typer.echo(str(exc), err=True)
512
+ raise typer.Exit(1) from exc
513
+ typer.echo(print_json(result))