diff --git a/django-backend/soroscan/ingest/migrations/0016_organizationusage.py b/django-backend/soroscan/ingest/migrations/0016_organizationusage.py new file mode 100644 index 000000000..a194963f2 --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0016_organizationusage.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("ingest", "0015_merge_notification_and_teams"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationUsage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("period_start", models.DateField(db_index=True, help_text="First day of the billing period (YYYY-MM-01)")), + ("period_end", models.DateField(help_text="Last day of the billing period (inclusive)")), + ("request_count", models.PositiveBigIntegerField(default=0, help_text="Total API requests made by this org in the period")), + ("storage_bytes", models.PositiveBigIntegerField(default=0, help_text="Total bytes of event payload data stored for this org")), + ("egress_bytes", models.PositiveBigIntegerField(default=0, help_text="Total bytes served to this org via API responses")), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "team", + models.ForeignKey( + help_text="Organization this usage record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="usage_records", + to="ingest.team", + ), + ), + ], + options={ + "ordering": ["-period_start"], + }, + ), + migrations.AddIndex( + model_name="organizationusage", + index=models.Index(fields=["team", "period_start"], name="ingest_orgusage_team_period_idx"), + ), + migrations.AlterUniqueTogether( + name="organizationusage", + unique_together={("team", "period_start")}, + ), + ] diff --git a/django-backend/soroscan/ingest/services/billing.py b/django-backend/soroscan/ingest/services/billing.py new file mode 100644 index 000000000..bcd0c2a48 --- /dev/null +++ b/django-backend/soroscan/ingest/services/billing.py @@ -0,0 +1,68 @@ +""" +Billing service: compute cost attribution per organization (issue #538). + +Pricing constants (USD per unit) — override via Django settings if needed. +""" +import json +from datetime import date +from decimal import Decimal + +from django.conf import settings +from django.db.models import Sum + +from ..models import ContractEvent, OrganizationUsage, Team + +# --- Pricing rates (USD) --- +PRICE_PER_REQUEST = Decimal(getattr(settings, "BILLING_PRICE_PER_REQUEST", "0.000010")) # $0.01 / 1000 requests +PRICE_PER_GB_STORAGE = Decimal(getattr(settings, "BILLING_PRICE_PER_GB_STORAGE", "0.023")) # $0.023 / GB-month +PRICE_PER_GB_EGRESS = Decimal(getattr(settings, "BILLING_PRICE_PER_GB_EGRESS", "0.090")) # $0.09 / GB + +_GB = 1_073_741_824 # bytes in 1 GiB + + +def compute_costs(usage: OrganizationUsage) -> dict: + """Return a dict of itemised and total costs (USD) for a usage record.""" + request_cost = PRICE_PER_REQUEST * usage.request_count + storage_cost = PRICE_PER_GB_STORAGE * Decimal(usage.storage_bytes) / _GB + egress_cost = PRICE_PER_GB_EGRESS * Decimal(usage.egress_bytes) / _GB + total = request_cost + storage_cost + egress_cost + return { + "request_cost_usd": float(request_cost.quantize(Decimal("0.000001"))), + "storage_cost_usd": float(storage_cost.quantize(Decimal("0.000001"))), + "egress_cost_usd": float(egress_cost.quantize(Decimal("0.000001"))), + "total_cost_usd": float(total.quantize(Decimal("0.000001"))), + } + + +def snapshot_usage_for_team(team: Team, period_start: date, period_end: date) -> OrganizationUsage: + """ + Compute and upsert an OrganizationUsage record for the given team and period. + + - request_count: number of ContractEvents in the period (proxy for API-driven ingestion) + - storage_bytes: sum of JSON-serialised payload sizes for events in the period + - egress_bytes: same as storage_bytes (conservative estimate; real egress tracked via middleware) + """ + qs = ContractEvent.objects.filter( + contract__team=team, + timestamp__date__gte=period_start, + timestamp__date__lte=period_end, + ) + + request_count = qs.count() + + # Estimate storage as sum of serialised payload lengths + storage_bytes = sum( + len(json.dumps(e.payload).encode()) for e in qs.only("payload").iterator(chunk_size=500) + ) + + usage, _ = OrganizationUsage.objects.update_or_create( + team=team, + period_start=period_start, + defaults={ + "period_end": period_end, + "request_count": request_count, + "storage_bytes": storage_bytes, + "egress_bytes": storage_bytes, # conservative default + }, + ) + return usage diff --git a/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css b/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css new file mode 100644 index 000000000..081b485cb --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css @@ -0,0 +1,55 @@ +.statsGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +.statCard { + border: 1px solid rgba(0, 212, 255, 0.2); + background: linear-gradient(180deg, rgba(13, 21, 34, 0.95), rgba(7, 12, 20, 0.95)); + box-shadow: 0 0 0 1px rgba(0, 255, 156, 0.05), 0 4px 12px rgba(0, 0, 0, 0.25); + padding: 1rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + border-left: 3px solid var(--accent, #00d4ff); + transition: box-shadow 0.18s ease; +} + +.statCard:hover { + box-shadow: 0 0 0 1px rgba(0, 255, 156, 0.12), 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.statIcon { + font-size: 1.1rem; + color: var(--accent, #00d4ff); + line-height: 1; +} + +.statValue { + font-size: 1.5rem; + font-weight: 700; + color: #d6f7ff; + font-family: "JetBrains Mono", "Fira Code", monospace; + line-height: 1.1; +} + +.statLabel { + font-size: 0.72rem; + color: #7ba8b5; + text-transform: uppercase; + letter-spacing: 0.06rem; +} + +@media (max-width: 700px) { + .statsGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 400px) { + .statsGrid { + grid-template-columns: 1fr; + } +} diff --git a/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx b/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx new file mode 100644 index 000000000..aaadce234 --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useMemo } from "react"; +import type { EventRecord } from "@/components/ingest/types"; +import styles from "./ContractStatsCards.module.css"; + +interface Props { + events: EventRecord[]; + totalCount: number; + loading: boolean; +} + +export function ContractStatsCards({ events, totalCount, loading }: Props) { + const stats = useMemo(() => { + if (!events.length) { + return { eventsPerHour: 0, latestEventTime: null, eventTypesCount: 0 }; + } + + // Events per hour: count events in the last 60 minutes + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const eventsPerHour = events.filter( + (e) => new Date(e.timestamp).getTime() >= oneHourAgo + ).length; + + // Latest event time + const sorted = [...events].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + const latestEventTime = sorted[0]?.timestamp ?? null; + + // Unique event types + const eventTypesCount = new Set(events.map((e) => e.eventType)).size; + + return { eventsPerHour, latestEventTime, eventTypesCount }; + }, [events]); + + const cards = [ + { + label: "Total Events", + value: loading ? "—" : totalCount.toLocaleString(), + icon: "◈", + accent: "#00ff9c", + }, + { + label: "Events / Hour", + value: loading ? "—" : stats.eventsPerHour.toLocaleString(), + icon: "⏱", + accent: "#00d4ff", + }, + { + label: "Latest Event", + value: loading + ? "—" + : stats.latestEventTime + ? new Date(stats.latestEventTime).toLocaleTimeString() + : "N/A", + icon: "◉", + accent: "#a78bfa", + }, + { + label: "Event Types", + value: loading ? "—" : stats.eventTypesCount.toLocaleString(), + icon: "⬡", + accent: "#f59e0b", + }, + ]; + + return ( +