ledgrr 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.
ledgrr/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Ledgrr — Financial observability for AI systems.
3
+ ledgrr.io
4
+ """
5
+
6
+ from .event import BillingEvent, FinancialStatus, GSTType, OperationalStatus
7
+ from .instrument import Ledgrr
8
+ from .ledger import DuplicateEventError, Ledger
9
+ from .dlq import DeadLetterQueue
10
+ from .pricing import compute_usd_cost
11
+ from .track import track_llm, configure
12
+
13
+ __version__ = "0.2.0"
14
+ __all__ = [
15
+ # Primary interface
16
+ "track_llm",
17
+ "configure",
18
+ # Class-based interface
19
+ "Ledgrr",
20
+ "Ledger",
21
+ "DeadLetterQueue",
22
+ # Types
23
+ "BillingEvent",
24
+ "FinancialStatus",
25
+ "OperationalStatus",
26
+ "GSTType",
27
+ "DuplicateEventError",
28
+ "compute_usd_cost",
29
+ ]
ledgrr/api.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ api.py — FastAPI server for the Ledgrr hosted control plane.
3
+
4
+ This wraps the SDK into a real HTTP API so customers can use
5
+ Ledgrr from any language — not just Python.
6
+
7
+ Endpoints:
8
+ POST /events — Ingest a billing event
9
+ GET /events/{customer_id}/{period} — Get events for a customer
10
+ GET /totals/{customer_id}/{period} — Get cost totals
11
+ GET /breakdown/users/{customer_id}/{period} — Per-user breakdown
12
+ GET /breakdown/models/{customer_id}/{period} — Per-model breakdown
13
+ POST /invoices/generate — Generate a GST invoice
14
+ POST /invoices/submit-irp — Submit invoice to IRP
15
+ GET /invoices/{invoice_number}/irn — Get IRN status
16
+ GET /health — Health check
17
+ """
18
+
19
+ from datetime import datetime, timezone
20
+ from typing import Optional
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+
23
+ from fastapi import FastAPI, HTTPException, Header
24
+ from pydantic import BaseModel
25
+
26
+ from .event import BillingEvent, FinancialStatus, GSTType, OperationalStatus
27
+ from .idempotency import generate_event_id
28
+ from .invoice import InvoiceGenerator
29
+ from .irp import IRPClient
30
+ from .ledger import DuplicateEventError, Ledger
31
+
32
+
33
+ # ─── App ─────────────────────────────────────────────────────────────────────
34
+
35
+ app = FastAPI(
36
+ title="Ledgrr API",
37
+ description="Financial infrastructure for Indian AI companies",
38
+ version="0.1.0",
39
+ )
40
+ app.add_middleware(
41
+ CORSMiddleware,
42
+ allow_origins=["*"],
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ # Shared ledger instance — in production this connects to PostgreSQL
48
+ ledger = Ledger()
49
+
50
+ # IRP client — in production this uses real GSP credentials
51
+ irp_client = IRPClient(
52
+ gstin="29AABCL1234A1Z5",
53
+ simulate=True,
54
+ )
55
+
56
+ # In-memory invoice store — in production this uses DB
57
+ _invoices: dict = {}
58
+
59
+
60
+ # ─── Request / Response Models ────────────────────────────────────────────────
61
+
62
+ class IngestEventRequest(BaseModel):
63
+ """Ingest a billing event from the SDK."""
64
+ event_id: str
65
+ idempotency_key: str
66
+ event_type: str
67
+ provider_request_id: str
68
+ provider: str
69
+ model: str
70
+ customer_id: str
71
+ user_id: str
72
+ billing_period: str
73
+ input_tokens: int
74
+ output_tokens: int
75
+ cached_input_tokens: int = 0
76
+ usd_cost: float
77
+ fx_rate_snapshot: float
78
+ fx_snapshot_date: str
79
+ inr_cost: float
80
+ gst_type: str = "IGST"
81
+ gst_rate: float = 0.18
82
+ gst_component: float = 0.0
83
+ gstin_customer: Optional[str] = None
84
+ operational_status: str = "completed"
85
+ financial_status: str = "pending_confirmation"
86
+ billable: bool = True
87
+ margin_inr: Optional[float] = None
88
+ parent_event_id: Optional[str] = None
89
+ created_at: str
90
+
91
+
92
+ class GenerateInvoiceRequest(BaseModel):
93
+ customer_id: str
94
+ customer_name: str
95
+ billing_period: str
96
+ customer_gstin: Optional[str] = None
97
+ customer_state_code: Optional[str] = None
98
+ invoice_number: Optional[str] = None
99
+ seller_name: str = "Ledgrr Technologies Pvt Ltd"
100
+ seller_gstin: str = "29AABCL1234A1Z5"
101
+ seller_address: str = "Bangalore, Karnataka - 560001"
102
+ seller_state_code: str = "29"
103
+
104
+
105
+ class SubmitIRPRequest(BaseModel):
106
+ invoice_number: str
107
+ force_retry: bool = False
108
+
109
+
110
+ # ─── Health ──────────────────────────────────────────────────────────────────
111
+
112
+ @app.get("/health")
113
+ def health():
114
+ return {
115
+ "status": "ok",
116
+ "version": "0.1.0",
117
+ "timestamp": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+
120
+
121
+ # ─── Events ──────────────────────────────────────────────────────────────────
122
+
123
+ @app.post("/events", status_code=201)
124
+ def ingest_event(req: IngestEventRequest):
125
+ """
126
+ Ingest a billing event emitted by the Ledgrr SDK.
127
+
128
+ Idempotent — submitting the same event twice is safe.
129
+ Returns 200 if deduplicated, 201 if newly stored.
130
+ """
131
+ event = BillingEvent(
132
+ event_id=req.event_id,
133
+ idempotency_key=req.idempotency_key,
134
+ event_type=req.event_type,
135
+ provider_request_id=req.provider_request_id,
136
+ provider=req.provider,
137
+ model=req.model,
138
+ customer_id=req.customer_id,
139
+ user_id=req.user_id,
140
+ billing_period=req.billing_period,
141
+ input_tokens=req.input_tokens,
142
+ output_tokens=req.output_tokens,
143
+ cached_input_tokens=req.cached_input_tokens,
144
+ usd_cost=req.usd_cost,
145
+ fx_rate_snapshot=req.fx_rate_snapshot,
146
+ fx_snapshot_date=req.fx_snapshot_date,
147
+ inr_cost=req.inr_cost,
148
+ gst_type=GSTType(req.gst_type),
149
+ gst_rate=req.gst_rate,
150
+ gst_component=req.gst_component,
151
+ gstin_customer=req.gstin_customer,
152
+ operational_status=OperationalStatus(req.operational_status),
153
+ financial_status=FinancialStatus(req.financial_status),
154
+ billable=req.billable,
155
+ margin_inr=req.margin_inr,
156
+ parent_event_id=req.parent_event_id,
157
+ created_at=datetime.fromisoformat(req.created_at),
158
+ )
159
+
160
+ try:
161
+ ledger.append(event)
162
+ return {"status": "stored", "event_id": event.event_id}
163
+ except DuplicateEventError:
164
+ return {"status": "deduplicated", "event_id": event.event_id}
165
+
166
+
167
+ @app.get("/events/{customer_id}/{period}")
168
+ def get_events(customer_id: str, period: str, billable_only: bool = True):
169
+ """Get all events for a customer in a billing period."""
170
+ events = ledger.get_events_for_customer(
171
+ customer_id=customer_id,
172
+ billing_period=period,
173
+ billable_only=billable_only,
174
+ )
175
+ return {
176
+ "customer_id": customer_id,
177
+ "billing_period": period,
178
+ "count": len(events),
179
+ "events": [e.to_dict() for e in events],
180
+ }
181
+
182
+
183
+ # ─── Totals & Breakdowns ──────────────────────────────────────────────────────
184
+
185
+ @app.get("/totals/{customer_id}/{period}")
186
+ def get_totals(customer_id: str, period: str):
187
+ """
188
+ Get aggregated cost totals for a customer in a billing period.
189
+
190
+ Returns total INR cost, GST, and total with GST.
191
+ This is the primary endpoint for the margin dashboard.
192
+ """
193
+ return ledger.get_total_cost_inr(
194
+ customer_id=customer_id,
195
+ billing_period=period,
196
+ )
197
+
198
+
199
+ @app.get("/breakdown/users/{customer_id}/{period}")
200
+ def get_user_breakdown(customer_id: str, period: str):
201
+ """Per-user cost breakdown — shows which users drive the most AI spend."""
202
+ breakdown = ledger.get_cost_by_user(
203
+ customer_id=customer_id,
204
+ billing_period=period,
205
+ )
206
+ return {
207
+ "customer_id": customer_id,
208
+ "billing_period": period,
209
+ "users": breakdown,
210
+ }
211
+
212
+
213
+ @app.get("/breakdown/models/{customer_id}/{period}")
214
+ def get_model_breakdown(customer_id: str, period: str):
215
+ """Per-model cost breakdown — use for model routing decisions."""
216
+ breakdown = ledger.get_cost_by_model(
217
+ customer_id=customer_id,
218
+ billing_period=period,
219
+ )
220
+ return {
221
+ "customer_id": customer_id,
222
+ "billing_period": period,
223
+ "models": breakdown,
224
+ }
225
+
226
+
227
+ # ─── Invoices ────────────────────────────────────────────────────────────────
228
+
229
+ @app.post("/invoices/generate", status_code=201)
230
+ def generate_invoice(req: GenerateInvoiceRequest):
231
+ """
232
+ Generate a GST invoice for a customer's billing period.
233
+
234
+ Reads from ledger — reproducible from same ledger state.
235
+ GST type (IGST vs CGST+SGST) determined automatically from state codes.
236
+ """
237
+ generator = InvoiceGenerator(
238
+ ledger=ledger,
239
+ seller_name=req.seller_name,
240
+ seller_gstin=req.seller_gstin,
241
+ seller_address=req.seller_address,
242
+ seller_state_code=req.seller_state_code,
243
+ )
244
+
245
+ try:
246
+ invoice = generator.generate(
247
+ customer_id=req.customer_id,
248
+ customer_name=req.customer_name,
249
+ billing_period=req.billing_period,
250
+ customer_gstin=req.customer_gstin,
251
+ customer_state_code=req.customer_state_code,
252
+ invoice_number=req.invoice_number,
253
+ )
254
+ except ValueError as e:
255
+ raise HTTPException(status_code=404, detail=str(e))
256
+
257
+ # Store invoice in memory for IRP submission
258
+ _invoices[invoice.invoice_number] = invoice
259
+
260
+ return invoice.to_dict()
261
+
262
+
263
+ @app.post("/invoices/submit-irp")
264
+ def submit_to_irp(req: SubmitIRPRequest):
265
+ """
266
+ Submit an invoice to the Indian IRP for IRN generation.
267
+
268
+ Handles:
269
+ - Idempotent resubmission (same invoice_number = cached result)
270
+ - Automatic retry on IRP timeouts
271
+ - Permanent failure detection (invalid GSTIN etc.)
272
+ """
273
+ invoice = _invoices.get(req.invoice_number)
274
+ if not invoice:
275
+ raise HTTPException(
276
+ status_code=404,
277
+ detail=f"Invoice {req.invoice_number} not found. Generate it first via POST /invoices/generate",
278
+ )
279
+
280
+ result = irp_client.submit(invoice, force_retry=req.force_retry)
281
+
282
+ return {
283
+ "success": result.success,
284
+ "invoice_number": result.invoice_number,
285
+ "irn": result.irn,
286
+ "ack_number": result.ack_number,
287
+ "ack_date": result.ack_date,
288
+ "qr_code": result.qr_code,
289
+ "error_code": result.error_code,
290
+ "error_message": result.error_message,
291
+ "retryable": result.retryable,
292
+ "attempts_made": result.attempts_made,
293
+ }
294
+
295
+
296
+ @app.get("/invoices/{invoice_number}/irn")
297
+ def get_irn_status(invoice_number: str):
298
+ """Get the IRN submission status for an invoice."""
299
+ history = irp_client.get_submission_history(invoice_number)
300
+
301
+ if not history:
302
+ raise HTTPException(
303
+ status_code=404,
304
+ detail=f"No IRP submission history found for invoice {invoice_number}",
305
+ )
306
+
307
+ latest = history[-1]
308
+ return {
309
+ "invoice_number": invoice_number,
310
+ "status": latest.status.value,
311
+ "irn": latest.irn,
312
+ "ack_number": latest.ack_number,
313
+ "ack_date": latest.ack_date,
314
+ "attempts": len(history),
315
+ "latest_error": latest.error_message,
316
+ }
ledgrr/cli.py ADDED
@@ -0,0 +1,258 @@
1
+ """
2
+ cli.py — Ledgrr command-line tools.
3
+
4
+ Usage:
5
+ python -m ledgrr.cli replay --from 2026-05-01
6
+ python -m ledgrr.cli diff invoice_123
7
+ python -m ledgrr.cli dlq
8
+ python -m ledgrr.cli totals --workspace org_abc --period 2026-05
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from datetime import datetime, timezone
15
+ from decimal import Decimal
16
+
17
+ from .ledger import Ledger
18
+ from .dlq import DeadLetterQueue
19
+ from .invoice import InvoiceGenerator
20
+ from .money import assert_invoice_matches_ledger
21
+
22
+
23
+ def _get_ledger(args) -> Ledger:
24
+ db_path = getattr(args, "db", None)
25
+ return Ledger(db_path)
26
+
27
+
28
+ def cmd_totals(args):
29
+ """Show cost totals for a workspace and period."""
30
+ ledger = _get_ledger(args)
31
+ totals = ledger.get_total_cost_inr(args.workspace, args.period)
32
+
33
+ print(f"\n{'─' * 40}")
34
+ print(f" Workspace : {args.workspace}")
35
+ print(f" Period : {args.period}")
36
+ print(f"{'─' * 40}")
37
+ print(f" INR Cost : ₹{totals['total_inr_cost']}")
38
+ print(f" GST : ₹{totals['total_gst']}")
39
+ print(f" Total : ₹{totals['total_with_gst']}")
40
+ print(f" Calls : {totals['event_count']}")
41
+ print(f"{'─' * 40}\n")
42
+
43
+
44
+ def cmd_users(args):
45
+ """Show per-user cost breakdown."""
46
+ ledger = _get_ledger(args)
47
+ users = ledger.get_cost_by_user(args.workspace, args.period)
48
+
49
+ if not users:
50
+ print(f"No billable events for {args.workspace} in {args.period}")
51
+ return
52
+
53
+ print(f"\n{'─' * 60}")
54
+ print(f" Per-user breakdown: {args.workspace} / {args.period}")
55
+ print(f"{'─' * 60}")
56
+ print(f" {'USER':<30} {'CALLS':>6} {'INR COST':>12}")
57
+ print(f" {'─'*30} {'─'*6} {'─'*12}")
58
+ for u in users:
59
+ print(f" {u['user_id']:<30} {u['call_count']:>6} ₹{u['total_inr_cost']:>10.4f}")
60
+ print(f"{'─' * 60}\n")
61
+
62
+
63
+ def cmd_replay(args):
64
+ """
65
+ Replay all events from a date and verify ledger integrity.
66
+
67
+ This doesn't modify the ledger — it reads all events from
68
+ the given date and reports the deterministic totals.
69
+ Use this to verify your ledger hasn't drifted.
70
+ """
71
+ ledger = _get_ledger(args)
72
+ from_date = args.from_date
73
+ workspace = args.workspace
74
+
75
+ # Get all periods from the date
76
+ start = datetime.fromisoformat(from_date)
77
+ current = datetime.now(timezone.utc)
78
+
79
+ periods = []
80
+ year, month = start.year, start.month
81
+ while (year, month) <= (current.year, current.month):
82
+ periods.append(f"{year}-{month:02d}")
83
+ month += 1
84
+ if month > 12:
85
+ month = 1
86
+ year += 1
87
+
88
+ print(f"\n Replaying events from {from_date}")
89
+ print(f" Workspace: {workspace}")
90
+ print(f"{'─' * 50}")
91
+
92
+ grand_total_cost = Decimal("0")
93
+ grand_total_gst = Decimal("0")
94
+ grand_total_calls = 0
95
+
96
+ for period in periods:
97
+ totals = ledger.get_total_cost_inr(workspace, period)
98
+ if totals["event_count"] > 0:
99
+ print(f" {period}: {totals['event_count']:>5} calls "
100
+ f"₹{totals['total_inr_cost']:>10.4f} + "
101
+ f"₹{totals['total_gst']:>8.4f} GST")
102
+ grand_total_cost += Decimal(str(totals["total_inr_cost"]))
103
+ grand_total_gst += Decimal(str(totals["total_gst"]))
104
+ grand_total_calls += totals["event_count"]
105
+
106
+ print(f"{'─' * 50}")
107
+ print(f" TOTAL : {grand_total_calls:>5} calls "
108
+ f"₹{grand_total_cost:>10.4f} + "
109
+ f"₹{grand_total_gst:>8.4f} GST")
110
+ print(f" PAYABLE : ₹{grand_total_cost + grand_total_gst:.4f}\n")
111
+
112
+
113
+ def cmd_diff(args):
114
+ """
115
+ Diff an invoice against current ledger state.
116
+
117
+ Shows whether the invoice total still matches the ledger.
118
+ Use this to detect billing drift after amendments or corrections.
119
+ """
120
+ ledger = _get_ledger(args)
121
+ invoice_number = args.invoice_number
122
+
123
+ print(f"\n Ledger diff for invoice: {invoice_number}")
124
+ print(f"{'─' * 50}")
125
+ print(f" This command compares stored invoice total")
126
+ print(f" against current ledger state for the same period.")
127
+ print(f"")
128
+ print(f" To use: generate the invoice, note the total,")
129
+ print(f" then run this after any ledger amendments.")
130
+ print(f"{'─' * 50}")
131
+ print(f" Invoice number: {invoice_number}")
132
+ print(f" Status: requires hosted control plane for full diff")
133
+ print(f" Run: ledgrr totals --workspace <id> --period <YYYY-MM>")
134
+ print(f" to manually verify ledger totals match your invoice.\n")
135
+
136
+
137
+ def cmd_dlq(args):
138
+ """Inspect the dead-letter queue."""
139
+ db_path = getattr(args, "db", None)
140
+ dlq = DeadLetterQueue(db_path)
141
+ workspace = getattr(args, "workspace", None)
142
+
143
+ failures = dlq.get_unresolved(workspace)
144
+ summary = dlq.get_error_summary()
145
+
146
+ print(f"\n Dead-Letter Queue")
147
+ print(f"{'─' * 60}")
148
+
149
+ if not failures:
150
+ print(f" ✓ No unresolved failures")
151
+ else:
152
+ print(f" {len(failures)} unresolved failure(s)\n")
153
+ print(f" Error Summary:")
154
+ for s in summary:
155
+ print(f" {s['count']}x {s['error_prefix'][:50]}")
156
+ print(f" Last seen: {s['last_seen']}")
157
+
158
+ if getattr(args, "verbose", False):
159
+ print(f"\n All failures:")
160
+ for f in failures[:10]:
161
+ print(f" [{f['id']}] {f['created_at']}")
162
+ print(f" {f['error'][:80]}")
163
+ print(f" workspace={f['workspace_id']} user={f['user_id']}")
164
+
165
+ print(f"{'─' * 60}\n")
166
+
167
+
168
+ def cmd_health(args):
169
+ """Check ledger health and invariants."""
170
+ ledger = _get_ledger(args)
171
+ workspace = args.workspace
172
+ period = args.period
173
+
174
+ print(f"\n Ledger Health Check")
175
+ print(f" Workspace: {workspace} / {period}")
176
+ print(f"{'─' * 50}")
177
+
178
+ totals = ledger.get_total_cost_inr(workspace, period)
179
+ events = ledger.get_events_for_customer(workspace, period)
180
+
181
+ # Verify invariant: sum of events == reported total
182
+ from decimal import Decimal
183
+ computed_inr = sum(Decimal(str(e.inr_cost)) for e in events)
184
+ computed_gst = sum(Decimal(str(e.gst_component)) for e in events)
185
+ reported_inr = Decimal(str(totals["total_inr_cost"]))
186
+ reported_gst = Decimal(str(totals["total_gst"]))
187
+
188
+ inr_ok = abs(computed_inr - reported_inr) <= Decimal("0.01")
189
+ gst_ok = abs(computed_gst - reported_gst) <= Decimal("0.01")
190
+
191
+ print(f" Events: {totals['event_count']}")
192
+ print(f" INR cost: ₹{totals['total_inr_cost']} {'✓' if inr_ok else '✗ DRIFT DETECTED'}")
193
+ print(f" GST: ₹{totals['total_gst']} {'✓' if gst_ok else '✗ DRIFT DETECTED'}")
194
+ print(f" Invariant: {'✓ PASS' if inr_ok and gst_ok else '✗ FAIL — ledger drift detected'}")
195
+ print(f"{'─' * 50}\n")
196
+
197
+ if not (inr_ok and gst_ok):
198
+ sys.exit(1)
199
+
200
+
201
+ def main():
202
+ parser = argparse.ArgumentParser(
203
+ prog="ledgrr",
204
+ description="Ledgrr CLI — financial observability for AI systems",
205
+ )
206
+ parser.add_argument("--db", help="Path to ledger SQLite file", default=None)
207
+
208
+ subparsers = parser.add_subparsers(dest="command")
209
+
210
+ # totals
211
+ p = subparsers.add_parser("totals", help="Show cost totals")
212
+ p.add_argument("--workspace", required=True)
213
+ p.add_argument("--period", required=True, help="YYYY-MM")
214
+
215
+ # users
216
+ p = subparsers.add_parser("users", help="Per-user cost breakdown")
217
+ p.add_argument("--workspace", required=True)
218
+ p.add_argument("--period", required=True)
219
+
220
+ # replay
221
+ p = subparsers.add_parser("replay", help="Replay events from a date")
222
+ p.add_argument("--from", dest="from_date", required=True, help="YYYY-MM-DD")
223
+ p.add_argument("--workspace", required=True)
224
+
225
+ # diff
226
+ p = subparsers.add_parser("diff", help="Diff invoice against ledger")
227
+ p.add_argument("invoice_number")
228
+
229
+ # dlq
230
+ p = subparsers.add_parser("dlq", help="Inspect dead-letter queue")
231
+ p.add_argument("--workspace", default=None)
232
+ p.add_argument("--verbose", action="store_true")
233
+
234
+ # health
235
+ p = subparsers.add_parser("health", help="Check ledger health and invariants")
236
+ p.add_argument("--workspace", required=True)
237
+ p.add_argument("--period", required=True)
238
+
239
+ args = parser.parse_args()
240
+
241
+ commands = {
242
+ "totals": cmd_totals,
243
+ "users": cmd_users,
244
+ "replay": cmd_replay,
245
+ "diff": cmd_diff,
246
+ "dlq": cmd_dlq,
247
+ "health": cmd_health,
248
+ }
249
+
250
+ if args.command not in commands:
251
+ parser.print_help()
252
+ sys.exit(1)
253
+
254
+ commands[args.command](args)
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()