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 +29 -0
- ledgrr/api.py +316 -0
- ledgrr/cli.py +258 -0
- ledgrr/dlq.py +181 -0
- ledgrr/event.py +132 -0
- ledgrr/fx.py +37 -0
- ledgrr/idempotency.py +62 -0
- ledgrr/instrument.py +241 -0
- ledgrr/invoice.py +359 -0
- ledgrr/irp.py +431 -0
- ledgrr/ledger.py +315 -0
- ledgrr/money.py +232 -0
- ledgrr/pricing.py +160 -0
- ledgrr/track.py +312 -0
- ledgrr-0.1.0.dist-info/METADATA +14 -0
- ledgrr-0.1.0.dist-info/RECORD +19 -0
- ledgrr-0.1.0.dist-info/WHEEL +5 -0
- ledgrr-0.1.0.dist-info/entry_points.txt +2 -0
- ledgrr-0.1.0.dist-info/top_level.txt +1 -0
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()
|