Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

BIN
backend/app/.DS_Store vendored Normal file

Binary file not shown.

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,60 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "VerifyPack"
environment: str = "development"
secret_key: str = "change-me-in-production-please-32chars-min"
access_token_expire_minutes: int = 1440
algorithm: str = "HS256"
frontend_url: str = "http://localhost:3000"
# Database
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db: str = "verifypack"
mock_db: bool = False
# Mock toggles
mock_email: bool = True
mock_storage: bool = True
# AWS
aws_region: str = "ap-south-1"
aws_access_key_id: str = ""
aws_secret_access_key: str = ""
ses_from_email: str = "no-reply@verifypack.example"
s3_bucket: str = "verifypack-uploads"
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:8000/auth/google/callback"
# Razorpay
razorpay_key_id: str = ""
razorpay_key_secret: str = ""
razorpay_webhook_secret: str = ""
gst_rate: float = 0.18
@property
def google_configured(self) -> bool:
return bool(self.google_client_id and self.google_client_secret)
@property
def razorpay_configured(self) -> bool:
return bool(self.razorpay_key_id and self.razorpay_key_secret)
@property
def ses_configured(self) -> bool:
return bool(self.aws_access_key_id and self.aws_secret_access_key)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

111
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,111 @@
"""Auth dependencies + role-based access control.
Every protected endpoint depends on `current_user`. Client endpoints derive `org_id`
from the authenticated session's OrgMember record — never trust org_id from the body.
"""
from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.security import decode_token
from app.models import User, OrgMember, Organization
from app.models.enums import ProductAdminRole, ClientRole, SubscriptionStatus
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
@dataclass
class Principal:
user: User
org: Optional[Organization]
member: Optional[OrgMember]
client_role: Optional[ClientRole]
admin_role: Optional[ProductAdminRole]
@property
def is_product_admin(self) -> bool:
return self.user.is_product_admin
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
user = await User.get(payload["sub"])
if not user or user.disabled:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled")
return user
async def current_principal(user: User = Depends(current_user)) -> Principal:
"""Resolve org context for the current user.
Picks the user's membership (preferring an active one, but accepting any
non-disabled membership) so org context is consistent with /me's has_org.
"""
members = await OrgMember.find(OrgMember.user_id == str(user.id)).to_list()
# prefer active, then anything not explicitly disabled
member = next((m for m in members if m.status == "active"), None) \
or next((m for m in members if m.status != "disabled"), None)
org = None
if member:
try:
org = await Organization.get(member.org_id)
except Exception:
org = None
return Principal(
user=user, org=org, member=member,
client_role=member.role if member else None,
admin_role=user.product_admin_role if user.is_product_admin else None,
)
# ---------- guards ----------
async def require_org(p: Principal = Depends(current_principal)) -> Principal:
"""Ensure the caller has an organization context (any client role).
Returns a clean 403 instead of letting org-scoped routes crash with a 500
when a Product Admin (who has no OrgMember) hits a client endpoint.
"""
if not p.org or not p.client_role:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"This account has no organization. Use the Super Admin Panel.",
)
return p
def require_client_roles(*roles: ClientRole):
async def _guard(p: Principal = Depends(current_principal)) -> Principal:
if not p.org or not p.client_role:
raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization context")
if roles and p.client_role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient role")
return p
return _guard
def require_admin_roles(*roles: ProductAdminRole):
async def _guard(p: Principal = Depends(current_principal)) -> Principal:
if not p.is_product_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Product admin only")
if roles and p.admin_role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient admin role")
return p
return _guard
def require_write_access(p: Principal):
"""Block writes when org is in grace/archived/deleted state."""
if not p.org:
raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization")
if p.org.subscription_status != SubscriptionStatus.active:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Organization is {p.org.subscription_status.value} — read-only",
)

View File

@@ -0,0 +1,42 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str, claims: Optional[dict] = None,
expires_minutes: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=expires_minutes or settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire, "type": "access"}
if claims:
payload.update(claims)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_token(subject: str, token_type: str, expires_minutes: int) -> str:
"""Generic signed token for email-verify / password-reset."""
expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
payload = {"sub": subject, "exp": expire, "type": token_type}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
return None

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
backend/app/db/mongo.py Normal file
View File

@@ -0,0 +1,27 @@
from beanie import init_beanie
from app.core.config import settings
from app.models import ALL_DOCUMENTS
_client = None
def _make_client():
"""Real Motor client, or in-memory mongomock when MOCK_DB=1."""
if settings.mock_db:
from mongomock_motor import AsyncMongoMockClient
return AsyncMongoMockClient()
from motor.motor_asyncio import AsyncIOMotorClient
return AsyncIOMotorClient(settings.mongodb_url)
async def init_db():
global _client
_client = _make_client()
db = _client[settings.mongodb_db]
await init_beanie(database=db, document_models=ALL_DOCUMENTS)
return db
def get_client():
return _client

160
backend/app/db/seed.py Normal file
View File

@@ -0,0 +1,160 @@
"""Seed demo data: plans, a Product Admin, a demo org with brand/product/batch/QR + scans.
Run: python -m app.db.seed
With mock DB it just verifies the pipeline; with a real Mongo it persists.
"""
import asyncio
import random
from datetime import datetime, timedelta, timezone
from app.db.mongo import init_db
from app.core.security import hash_password
from app.models import (
User, Organization, OrgMember, Brand, Product, Batch, QRCode,
ScanLog, Plan, Subscription, StorageUsage, AuditLog,
)
from app.models.documents import (
ManufacturerInfo, ProductDetails, ComplianceBadge, QRStyle,
)
from app.models.enums import (
ProductAdminRole, ClientRole, ProductStatus, QRType, SubscriptionStatus,
)
from app.services import qr as qrsvc
PLANS = [
dict(name="Free", price=0, product_limit=1, user_limit=1, storage_gb=1,
features=["1 product", "1 user", "Unlimited QRs"]),
dict(name="Starter", price=999, product_limit=100, user_limit=3, storage_gb=10,
features=["100 products", "3 users", "Unlimited QRs", "Analytics"]),
dict(name="Growth", price=2999, product_limit=1000, user_limit=15, storage_gb=50,
features=["1,000 products", "15 users", "Unlimited QRs", "Advanced analytics"]),
dict(name="Business", price=5999, product_limit=None, user_limit=50, storage_gb=200,
features=["Unlimited products", "50 users", "Priority support"]),
dict(name="Enterprise", price=0, product_limit=None, user_limit=None, storage_gb=1000,
features=["Unlimited everything", "Custom pricing", "Dedicated support"]),
]
CITIES = ["Bangalore", "Mumbai", "Delhi", "Chennai", "Hyderabad"]
DEVICES = ["Android", "iOS", "Desktop", "Other"]
async def seed():
await init_db()
# Plans
plan_map = {}
for pdata in PLANS:
existing = await Plan.find_one(Plan.name == pdata["name"])
if existing:
plan_map[pdata["name"]] = existing
continue
pl = Plan(**pdata)
await pl.insert()
plan_map[pdata["name"]] = pl
# Product Admin (super admin)
if not await User.find_one(User.email == "admin@verifypack.example"):
await User(name="VerifyPack Admin", email="admin@verifypack.example",
hashed_password=hash_password("Admin@123"), email_verified=True,
is_product_admin=True,
product_admin_role=ProductAdminRole.super_admin).insert()
# Demo org owner
owner = await User.find_one(User.email == "owner@abcpharma.example")
if not owner:
owner = User(name="Admin", email="owner@abcpharma.example",
hashed_password=hash_password("Owner@123"), email_verified=True,
last_login=datetime.now(timezone.utc))
await owner.insert()
org = await Organization.find_one(Organization.slug == "abc-pharma")
if not org:
starter = plan_map["Starter"]
org = Organization(name="ABC Pharma Pvt Ltd", slug="abc-pharma",
gstin="29ABCDE1234F1Z5", plan_id=str(starter.id),
subscription_status=SubscriptionStatus.active,
user_count=3)
await org.insert()
await OrgMember(org_id=str(org.id), user_id=str(owner.id),
role=ClientRole.admin, status="active").insert()
await StorageUsage(org_id=str(org.id), used_bytes=int(12.4 * 1024 ** 3),
total_bytes=int(50 * 1024 ** 3)).insert()
await Subscription(org_id=str(org.id), plan_id=str(starter.id),
plan_name="Starter",
renewal_date=datetime.now(timezone.utc) + timedelta(days=24)).insert()
brand = Brand(org_id=str(org.id), name="ABC Healthcare")
await brand.insert()
sample = [
("Pain Relief 500mg", "PR500", "Pharma", 320),
("Vitamin C 1000mg", "VC1000", "Supplement", 210),
("Paracetamol 650mg", "PARA650", "Pharma", 180),
("Calcium Tablet", "CAL100", "Supplement", 120),
("Omega 3 Capsules", "OMG3", "Supplement", 90),
]
for name, sku, cat, scans in sample:
product = Product(
org_id=str(org.id), brand_id=str(brand.id), brand_name=brand.name,
name=name, sku=sku, category=cat, status=ProductStatus.active,
trust_score=random.randint(90, 98), scan_count=scans,
manufacturer=ManufacturerInfo(company="ABC Pharma Pvt Ltd",
country="India", plant="Bangalore Unit-1"),
details=ProductDetails(net_weight="100g", shelf_life="24 months",
storage_condition="Store below 25°C"),
compliance_badges=[ComplianceBadge(type="FSSAI", number="10012345"),
ComplianceBadge(type="GMP")],
)
await product.insert()
org.product_count += 1
batch = Batch(org_id=str(org.id), product_id=str(product.id),
batch_number=f"{sku}{datetime.now():%y%m%d}",
mfg_date=datetime.now(timezone.utc) - timedelta(days=30),
expiry_date=datetime.now(timezone.utc) + timedelta(days=700),
quantity=1, qr_type=QRType.batch)
await batch.insert()
product.batch_count = 1
code = qrsvc.new_code()
snapshot = {
"product": {"name": product.name, "sku": product.sku,
"trust_score": product.trust_score, "recalled": False,
"details": product.details.model_dump(),
"manufacturer": product.manufacturer.model_dump(),
"compliance": [c.model_dump() for c in product.compliance_badges]},
"brand": {"name": brand.name, "logo_url": None},
"batch": {"number": batch.batch_number,
"mfg_date": str(batch.mfg_date),
"expiry_date": str(batch.expiry_date)},
}
qr = QRCode(org_id=str(org.id), product_id=str(product.id),
batch_id=str(batch.id), code=code, qr_type=QRType.batch,
style=QRStyle(), snapshot=snapshot, scan_count=scans)
await qr.insert()
product.qr_count = 1
await product.save()
# scan logs spread over the past week
for _ in range(min(scans, 60)):
await ScanLog(org_id=str(org.id), qr_id=str(qr.id), qr_code=code,
product_id=str(product.id),
city=random.choice(CITIES), country="India",
device=random.choice(DEVICES),
scanned_at=datetime.now(timezone.utc)
- timedelta(days=random.randint(0, 6),
hours=random.randint(0, 23))).insert()
await org.save()
await AuditLog(org_id=str(org.id), user_id=str(owner.id), user_name="Admin",
action="product.create", module="products",
details="Pain Relief 500mg").insert()
print(f"Seeded org ABC Pharma with {org.product_count} products.")
print(f"Demo verify URL: {qrsvc.verify_url(code)}")
print("Login: owner@abcpharma.example / Owner@123 (client admin)")
print("Admin: admin@verifypack.example / Admin@123 (super admin)")
if __name__ == "__main__":
asyncio.run(seed())

48
backend/app/main.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.db.mongo import init_db
from app.routers import (
auth, orgs, brands, products, batches, qr, verify, dashboard, admin, billing,
compliance, analytics, users,
)
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "..", "_uploads")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
os.makedirs(UPLOAD_DIR, exist_ok=True)
yield
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url, "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR, check_dir=False), name="uploads")
@app.get("/health")
async def health():
return {"status": "ok", "service": settings.app_name,
"mock_db": settings.mock_db, "env": settings.environment}
for r in (auth.router, orgs.router, brands.router, products.router, batches.router,
qr.router, verify.router, dashboard.router, admin.router, billing.router,
compliance.router, analytics.router, users.router):
app.include_router(r)

View File

@@ -0,0 +1,15 @@
from app.models.enums import (
ProductAdminRole, ClientRole, SubscriptionStatus, ProductStatus,
QRType, QRStatus, ComplianceStatus, VerificationState, PaymentStatus,
)
from app.models.documents import (
User, Organization, OrgMember, Brand, Product, Batch, QRCode,
QRTemplate, ScanLog, Compliance, Plan, Subscription, PlanHistory,
Invoice, Payment, StorageUsage, AuditLog, SupportNote, Alert, EmailLog,
)
ALL_DOCUMENTS = [
User, Organization, OrgMember, Brand, Product, Batch, QRCode,
QRTemplate, ScanLog, Compliance, Plan, Subscription, PlanHistory,
Invoice, Payment, StorageUsage, AuditLog, SupportNote, Alert, EmailLog,
]

Binary file not shown.

View File

@@ -0,0 +1,401 @@
"""Beanie document models (MongoDB collections).
Design note: QRCode embeds a denormalized snapshot of product/brand/compliance so the
public verification page (`GET /verify/{qr_code}`) resolves in a single document read.
"""
from datetime import datetime, timezone
from typing import Optional
from beanie import Document, Indexed
from pydantic import BaseModel, Field, EmailStr
from app.models.enums import (
ProductAdminRole, ClientRole, SubscriptionStatus, ProductStatus,
QRType, QRStatus, ComplianceStatus, PaymentStatus,
)
def now() -> datetime:
return datetime.now(timezone.utc)
# ---------- embedded sub-documents ----------
class ManufacturerInfo(BaseModel):
company: Optional[str] = None
plant: Optional[str] = None
country: Optional[str] = None
website: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
license_number: Optional[str] = None
location: Optional[str] = None
class ProductDetails(BaseModel):
net_weight: Optional[str] = None
pack_size: Optional[str] = None
unit_type: Optional[str] = None
form_type: Optional[str] = None
storage_condition: Optional[str] = None
shelf_life: Optional[str] = None
temperature_range: Optional[str] = None
dosage: Optional[str] = None
gtin: Optional[str] = None
class ComplianceBadge(BaseModel):
type: str # FSSAI, EPR, GMP, ISO, ...
number: Optional[str] = None
status: ComplianceStatus = ComplianceStatus.complete
class QRStyle(BaseModel):
dot_shape: str = "square" # square|rounded|dots|classy|classy-rounded|extra-rounded
corner_square_shape: str = "square" # square|dot|extra-rounded
corner_dot_shape: str = "square" # square|dot
eye_shape: str = "square" # legacy alias, kept for compatibility
fg_color: str = "#000000"
bg_color: str = "#ffffff"
transparent_bg: bool = False
gradient: bool = False
gradient_color: Optional[str] = None
logo_url: Optional[str] = None
logo_size: float = 0.4
error_correction: str = "M" # L|M|Q|H (auto H when logo set)
quiet_zone: int = 4
# ---------- top-level documents ----------
class User(Document):
email: Indexed(EmailStr, unique=True)
name: str
hashed_password: Optional[str] = None # null for pure OAuth users
google_id: Optional[str] = None
avatar_url: Optional[str] = None
email_verified: bool = False
is_product_admin: bool = False
product_admin_role: Optional[ProductAdminRole] = None
disabled: bool = False
last_login: Optional[datetime] = None
verify_code: Optional[str] = None # 6-digit email verification code
verify_code_expires: Optional[datetime] = None
created_at: datetime = Field(default_factory=now)
login_otp: Optional[str] = None
login_otp_expires: Optional[datetime] = None
class Settings:
name = "users"
class Organization(Document):
name: str
slug: Indexed(str, unique=True)
logo_url: Optional[str] = None
gstin: Optional[str] = None
pan: Optional[str] = None
address: Optional[str] = None
contact_email: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
timezone: str = "Asia/Kolkata"
language: str = "en"
plan_id: Optional[str] = None
subscription_status: SubscriptionStatus = SubscriptionStatus.active
product_count: int = 0
user_count: int = 1
storage_bytes: int = 0
country: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "organizations"
class OrgMember(Document):
org_id: Indexed(str)
user_id: Indexed(str)
role: ClientRole = ClientRole.user
department: Optional[str] = None
status: str = "active" # active|pending|disabled
invited_email: Optional[str] = None
joined_at: Optional[datetime] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "org_members"
class Brand(Document):
org_id: Indexed(str)
name: str
logo_url: Optional[str] = None
description: Optional[str] = None
product_count: int = 0
created_at: datetime = Field(default_factory=now)
class Settings:
name = "brands"
class Product(Document):
org_id: Indexed(str)
brand_id: Indexed(str)
brand_name: Optional[str] = None
name: str
sku: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
manufacturer: ManufacturerInfo = Field(default_factory=ManufacturerInfo)
details: ProductDetails = Field(default_factory=ProductDetails)
compliance_badges: list[ComplianceBadge] = []
recycling_info: Optional[str] = None
internal_notes: Optional[str] = None
qr_type: str = "dynamic" # dynamic | static
display_brand_logo: bool = True
trust_score: int = 90
status: ProductStatus = ProductStatus.draft
recalled: bool = False
batch_count: int = 0
qr_count: int = 0
scan_count: int = 0
created_at: datetime = Field(default_factory=now)
class Settings:
name = "products"
class Batch(Document):
org_id: Indexed(str)
product_id: Indexed(str)
batch_number: str
mfg_date: Optional[datetime] = None
expiry_date: Optional[datetime] = None
quantity: int = 1
qr_type: QRType = QRType.batch
notes: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "batches"
class QRCode(Document):
org_id: Indexed(str)
product_id: Indexed(str)
batch_id: Indexed(str)
code: Indexed(str, unique=True) # short slug used in /v/{code}
serial: Optional[int] = None # per-pack index for Option 2
qr_type: QRType = QRType.batch
status: QRStatus = QRStatus.active
style: QRStyle = Field(default_factory=QRStyle)
image_url: Optional[str] = None # WYSIWYG client-rendered image (S3/local)
created_by: Optional[str] = None # name of the user who generated it
remarks: Optional[str] = None
scan_count: int = 0
last_scan_at: Optional[datetime] = None
last_scan_location: Optional[str] = None
# denormalized snapshot for single-read verification
snapshot: dict = {}
created_at: datetime = Field(default_factory=now)
class Settings:
name = "qr_codes"
class QRTemplate(Document):
org_id: Indexed(str)
name: str
style: QRStyle = Field(default_factory=QRStyle)
created_at: datetime = Field(default_factory=now)
class Settings:
name = "qr_templates"
class ScanLog(Document):
org_id: Indexed(str)
qr_id: Indexed(str)
qr_code: str
product_id: str
city: Optional[str] = None
country: Optional[str] = None
device: Optional[str] = None # Android|iOS|Desktop|Other
browser: Optional[str] = None
os: Optional[str] = None
ip: Optional[str] = None
scanned_at: datetime = Field(default_factory=now)
class Settings:
name = "scan_logs"
class Compliance(Document):
org_id: Indexed(str)
product_id: Optional[str] = None
compliance_type: str
license_number: Optional[str] = None
issuing_authority: Optional[str] = None
issue_date: Optional[datetime] = None
expiry_date: Optional[datetime] = None
country: Optional[str] = None
state: Optional[str] = None
plant: Optional[str] = None
license_name: Optional[str] = None
category: Optional[str] = None
applicable_to: Optional[str] = None
remarks: Optional[str] = None
document_url: Optional[str] = None
documents: list = [] # [{name, url, uploaded_at}]
created_by: Optional[str] = None
status: ComplianceStatus = ComplianceStatus.pending
created_at: datetime = Field(default_factory=now)
updated_at: datetime = Field(default_factory=now)
class Settings:
name = "compliance"
class Plan(Document):
name: Indexed(str, unique=True)
price: int # INR / month
product_limit: Optional[int] = None # None = unlimited
user_limit: Optional[int] = None
storage_gb: int = 5
grace_days: int = 30
archive_years: int = 1
features: list[str] = []
enabled: bool = True
created_at: datetime = Field(default_factory=now)
class Settings:
name = "plans"
class Subscription(Document):
org_id: Indexed(str)
plan_id: str
plan_name: str
status: SubscriptionStatus = SubscriptionStatus.active
auto_renew: bool = True
start_date: datetime = Field(default_factory=now)
renewal_date: Optional[datetime] = None
grace_end: Optional[datetime] = None
archive_until: Optional[datetime] = None
payment_status: PaymentStatus = PaymentStatus.paid
created_at: datetime = Field(default_factory=now)
class Settings:
name = "subscriptions"
class PlanHistory(Document):
org_id: Indexed(str)
from_plan: Optional[str] = None
to_plan: str
changed_by: Optional[str] = None
reason: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "plan_history"
class Invoice(Document):
org_id: Indexed(str)
invoice_number: Indexed(str, unique=True)
plan_name: str
period: Optional[str] = None
subtotal: int = 0
gst_amount: int = 0
total: int = 0
status: PaymentStatus = PaymentStatus.pending
payment_method: Optional[str] = None
transaction_id: Optional[str] = None
gstin: Optional[str] = None
place_of_supply: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "invoices"
class Payment(Document):
org_id: Indexed(str)
invoice_id: Optional[str] = None
razorpay_order_id: Optional[str] = None
razorpay_payment_id: Optional[str] = None
amount: int = 0
status: PaymentStatus = PaymentStatus.pending
created_at: datetime = Field(default_factory=now)
class Settings:
name = "payments"
class StorageUsage(Document):
org_id: Indexed(str, unique=True)
used_bytes: int = 0
total_bytes: int = 5 * 1024 ** 3
updated_at: datetime = Field(default_factory=now)
class Settings:
name = "storage_usage"
class AuditLog(Document):
org_id: Optional[str] = None
user_id: Optional[str] = None
user_name: Optional[str] = None
user_role: Optional[str] = None
action: str
module: Optional[str] = None
details: Optional[str] = None
ip: Optional[str] = None
user_agent: Optional[str] = None
impersonator_id: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "audit_logs"
class SupportNote(Document):
org_id: Indexed(str)
author_id: Optional[str] = None
author_name: Optional[str] = None
note: str
flag: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "support_notes"
class Alert(Document):
org_id: Indexed(str)
type: str
message: str
severity: str = "info" # info|warning|critical
resolved: bool = False
created_at: datetime = Field(default_factory=now)
class Settings:
name = "alerts"
class EmailLog(Document):
to_email: str
subject: str
template: Optional[str] = None
status: str = "sent"
error: Optional[str] = None
created_at: datetime = Field(default_factory=now)
class Settings:
name = "email_logs"

View File

@@ -0,0 +1,65 @@
from enum import Enum
class ProductAdminRole(str, Enum):
"""Level 1 — VerifyPack internal staff."""
super_admin = "super_admin"
admin = "admin"
finance = "finance"
support = "support"
class ClientRole(str, Enum):
"""Level 2 — roles inside a client organization."""
admin = "admin"
finance = "finance"
user = "user"
class SubscriptionStatus(str, Enum):
active = "active"
grace = "grace" # 30 days after expiry: read-only, verify still works
archived = "archived" # verify still works, no new products/batches/QRs
deleted = "deleted" # no access, data retained
class ProductStatus(str, Enum):
active = "active"
inactive = "inactive"
draft = "draft"
archived = "archived"
class QRType(str, Enum):
batch = "batch" # Option 1 — one QR for the whole batch
per_pack = "per_pack" # Option 2 — unique serialized QR per pack
class QRStatus(str, Enum):
active = "active"
inactive = "inactive"
expired = "expired"
draft = "draft"
disabled = "disabled"
class ComplianceStatus(str, Enum):
complete = "complete"
pending = "pending"
expired = "expired"
expiring = "expiring" # within 30 days
class VerificationState(str, Enum):
genuine = "genuine"
unverified = "unverified"
not_found = "not_found"
recalled = "recalled"
class PaymentStatus(str, Enum):
paid = "paid"
pending = "pending"
failed = "failed"
refunded = "refunded"
overdue = "overdue"

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,437 @@
"""Super Admin Panel — Product Admin roles only. Org utilization, plans, revenue."""
import csv
import io
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from app.core.config import settings
from app.core.deps import require_admin_roles, Principal
from app.models import (
Organization, Plan, Product, QRCode, ScanLog, StorageUsage, Subscription,
Invoice, AuditLog, OrgMember, User, SupportNote, PlanHistory,
)
from app.models.documents import now
from app.models.enums import ProductAdminRole, SubscriptionStatus
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/config-status")
async def config_status(p: Principal = Depends(require_admin_roles())):
"""Env-only credential status surfaced on the Product Admin settings page."""
return {
"google_oauth": settings.google_configured,
"razorpay": settings.razorpay_configured,
"aws_ses": settings.ses_configured,
"mock_db": settings.mock_db,
"mock_email": settings.mock_email,
"mock_storage": settings.mock_storage,
}
async def _utilization(org: Organization) -> dict:
oid = str(org.id)
plan = await Plan.get(org.plan_id) if org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == oid)
sub = await Subscription.find_one(Subscription.org_id == oid)
qr_count = await QRCode.find(QRCode.org_id == oid).count()
scans = await ScanLog.find(ScanLog.org_id == oid).count()
return {
"org_id": oid, "name": org.name, "logo_url": org.logo_url,
"plan": plan.name if plan else None,
"plan_price": plan.price if plan else 0,
"products_used": org.product_count,
"product_limit": plan.product_limit if plan else None,
"users_used": org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
"qr_codes": qr_count,
"total_scans": scans,
"subscription_status": org.subscription_status,
"renewal_date": sub.renewal_date if sub else None,
"payment_status": sub.payment_status if sub else None,
}
@router.get("/organizations")
async def list_orgs(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
return [await _utilization(o) for o in orgs]
@router.get("/organizations/{org_id}")
async def org_detail(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
util = await _utilization(org)
members = await OrgMember.find(OrgMember.org_id == org_id).to_list()
admins = []
for m in members:
u = await User.get(m.user_id)
if u:
admins.append({"name": u.name, "email": u.email, "role": m.role,
"last_login": u.last_login})
return {**util, "admins": admins}
@router.get("/organizations/{org_id}/utilization")
async def org_utilization(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
return await _utilization(org)
@router.post("/organizations/{org_id}/impersonate")
async def impersonate(org_id: str,
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="org.impersonate", module="admin",
details=f"Impersonation started for {org.name}",
impersonator_id=str(p.user.id)).insert()
return {"message": f"Impersonating {org.name}", "org_id": org_id}
@router.get("/plans")
async def list_plans(p: Principal = Depends(require_admin_roles())):
plans = await Plan.find_all().to_list()
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features,
"enabled": pl.enabled} for pl in plans]
@router.post("/plans", status_code=201)
async def create_plan(body: dict = Body(...),
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
if await Plan.find_one(Plan.name == body.get("name")):
raise HTTPException(status.HTTP_409_CONFLICT, "Plan name already exists")
plan = Plan(
name=body.get("name", "New Plan"), price=int(body.get("price", 0)),
product_limit=body.get("product_limit"), user_limit=body.get("user_limit"),
storage_gb=int(body.get("storage_gb", 5)),
grace_days=int(body.get("grace_days", 30)),
archive_years=int(body.get("archive_years", 1)),
features=body.get("features", []), enabled=body.get("enabled", True),
)
await plan.insert()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.create", module="admin",
details=f"Created plan {plan.name}").insert()
return {"id": str(plan.id)}
@router.put("/plans/{plan_id}")
async def update_plan(plan_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
for k in ("name", "price", "product_limit", "user_limit", "storage_gb",
"grace_days", "archive_years", "features", "enabled"):
if k in body:
setattr(plan, k, body[k])
await plan.save()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.update", module="admin",
details=f"Updated plan {plan.name}").insert()
return {"message": "updated"}
@router.get("/dashboard")
async def admin_dashboard(p: Principal = Depends(require_admin_roles())):
"""Platform-wide overview KPIs for the Super Admin landing page."""
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
total_qr = await QRCode.find_all().count()
total_scans = await ScanLog.find_all().count()
def lc(s): return sum(1 for o in orgs if o.subscription_status == s)
# MRR = sum of active orgs' plan prices
mrr = 0
paying = 0
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
price = plans[o.plan_id].price
if price > 0:
mrr += price
paying += 1
paid = [i for i in invoices if i.status == "paid"]
total_revenue = sum(i.total for i in paid)
# revenue trend — last 6 months by invoice month
from collections import defaultdict
buckets = defaultdict(int)
for i in paid:
key = i.created_at.strftime("%Y-%m")
buckets[key] += i.total
months = sorted(buckets.keys())[-6:]
trend = [{"month": m, "revenue": buckets[m]} for m in months]
return {
"total_orgs": len(orgs),
"active": lc(SubscriptionStatus.active),
"grace": lc(SubscriptionStatus.grace),
"archived": lc(SubscriptionStatus.archived),
"deleted": lc(SubscriptionStatus.deleted),
"mrr": mrr, "arr": mrr * 12, "paying_orgs": paying,
"arpo": round(mrr / paying) if paying else 0,
"total_revenue": total_revenue,
"total_qr": total_qr, "total_scans": total_scans,
"revenue_trend": trend,
}
@router.get("/revenue")
async def revenue(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
paid = [i for i in invoices if i.status == "paid"]
mrr = sum(plans[o.plan_id].price for o in orgs
if o.subscription_status == SubscriptionStatus.active
and o.plan_id in plans and plans[o.plan_id].price > 0)
# plan-wise breakdown of active paying orgs
from collections import defaultdict
plan_rev = defaultdict(int)
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
pl = plans[o.plan_id]
if pl.price > 0:
plan_rev[pl.name] += pl.price
org_names = {str(o.id): o.name for o in orgs}
return {
"mrr": mrr, "arr": mrr * 12,
"total_revenue": sum(i.total for i in paid),
"invoices_count": len(invoices), "paid_count": len(paid),
"plan_breakdown": [{"plan": k, "revenue": v} for k, v in plan_rev.items()],
"invoices": [{"id": str(i.id), "invoice_number": i.invoice_number,
"org": org_names.get(i.org_id, ""), "plan": i.plan_name,
"subtotal": i.subtotal, "gst": i.gst_amount, "total": i.total,
"status": i.status, "at": i.created_at} for i in invoices[:100]],
}
@router.get("/revenue/export")
async def revenue_export(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = {str(o.id): o.name for o in await Organization.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Invoice", "Organization", "Plan", "Subtotal", "GST", "Total", "Status", "Date"])
for i in invoices:
w.writerow([i.invoice_number, orgs.get(i.org_id, ""), i.plan_name,
i.subtotal, i.gst_amount, i.total, i.status, i.created_at.isoformat()])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="revenue.csv"'})
@router.get("/audit-logs")
async def audit_logs(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin)), limit: int = 100):
logs = await AuditLog.find_all().to_list()
logs.sort(key=lambda x: x.created_at, reverse=True)
return [{"at": l.created_at, "user": l.user_name, "role": l.user_role,
"action": l.action, "module": l.module, "details": l.details,
"ip": l.ip} for l in logs[:limit]]
# ---------- org detail extras: history, payments, support notes ----------
@router.get("/organizations/{org_id}/history")
async def org_history(org_id: str, p: Principal = Depends(require_admin_roles())):
subs = await Subscription.find(Subscription.org_id == org_id).to_list()
invs = await Invoice.find(Invoice.org_id == org_id).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
plan_hist = await PlanHistory.find(PlanHistory.org_id == org_id).to_list()
plan_hist.sort(key=lambda h: h.created_at, reverse=True)
return {
"subscriptions": [{"plan": s.plan_name, "status": s.status,
"start": s.start_date, "renewal": s.renewal_date,
"payment_status": s.payment_status} for s in subs],
"invoices": [{"invoice_number": i.invoice_number, "total": i.total,
"status": i.status, "at": i.created_at} for i in invs],
"plan_history": [{"from": h.from_plan, "to": h.to_plan,
"reason": h.reason, "at": h.created_at} for h in plan_hist],
}
@router.get("/organizations/{org_id}/notes")
async def list_notes(org_id: str, p: Principal = Depends(require_admin_roles())):
notes = await SupportNote.find(SupportNote.org_id == org_id).to_list()
notes.sort(key=lambda n: n.created_at, reverse=True)
return [{"id": str(n.id), "note": n.note, "flag": n.flag,
"author": n.author_name, "at": n.created_at} for n in notes]
@router.post("/organizations/{org_id}/notes", status_code=201)
async def add_note(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles())):
note = SupportNote(org_id=org_id, author_id=str(p.user.id),
author_name=p.user.name, note=body.get("note", ""),
flag=body.get("flag"))
await note.insert()
return {"id": str(note.id)}
# ---------- subscription override / extend / reminder ----------
@router.put("/organizations/{org_id}/subscription")
async def override_subscription(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin))):
org = await Organization.get(org_id)
sub = await Subscription.find_one(Subscription.org_id == org_id)
if not org or not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org or subscription not found")
if "status" in body and body["status"] in [s.value for s in SubscriptionStatus]:
org.subscription_status = SubscriptionStatus(body["status"])
sub.status = org.subscription_status
if body.get("extend_days"):
base = sub.renewal_date or now()
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
sub.renewal_date = base + timedelta(days=int(body["extend_days"]))
if body.get("plan_id"):
plan = await Plan.get(body["plan_id"])
if plan:
org.plan_id = str(plan.id)
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
await org.save()
await sub.save()
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="subscription.override", module="admin",
details=f"Admin override on {org.name}").insert()
return {"message": "updated", "status": org.subscription_status}
@router.post("/organizations/{org_id}/send-reminder")
async def send_reminder(org_id: str, p: Principal = Depends(require_admin_roles())):
from app.services.email import send_email
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
to = org.contact_email or "admin@example.com"
await send_email(to, "VerifyPack subscription renewal reminder",
f"Hi {org.name}, your subscription is due for renewal.", "renewal_reminder")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="reminder.send", module="admin",
details=f"Renewal reminder to {to}").insert()
return {"sent": True, "to": to}
# ---------- subscription lifecycle + utilization report ----------
@router.get("/organizations/{org_id}/support-view")
async def support_view(org_id: str, p: Principal = Depends(require_admin_roles())):
"""Read-only debugging view for Support staff."""
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
products = await Product.find(Product.org_id == org_id).to_list()
qrs = await QRCode.find(QRCode.org_id == org_id).to_list()
scans = await ScanLog.find(ScanLog.org_id == org_id).to_list()
scans.sort(key=lambda s: s.scanned_at, reverse=True)
return {
"org": {"name": org.name, "slug": org.slug, "status": org.subscription_status,
"gstin": org.gstin, "contact_email": org.contact_email},
"products": [{"name": x.name, "sku": x.sku, "status": x.status,
"qr_count": x.qr_count} for x in products],
"qr_codes": [{"code": q.code, "status": q.status, "scans": q.scan_count}
for q in qrs[:50]],
"recent_scans": [{"at": s.scanned_at, "code": s.qr_code, "city": s.city,
"device": s.device} for s in scans[:25]],
}
@router.get("/subscription-lifecycle")
async def lifecycle(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
def count(s): return sum(1 for o in orgs if o.subscription_status == s)
upcoming = []
for o in orgs:
sub = await Subscription.find_one(Subscription.org_id == str(o.id))
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days = (rd - now()).days
if 0 <= days <= 30:
upcoming.append({"org": o.name, "org_id": str(o.id),
"renewal": sub.renewal_date, "days": days})
upcoming.sort(key=lambda x: x["days"])
return {
"active": count(SubscriptionStatus.active),
"grace": count(SubscriptionStatus.grace),
"archived": count(SubscriptionStatus.archived),
"deleted": count(SubscriptionStatus.deleted),
"upcoming_expiries": upcoming,
}
@router.get("/utilization-report")
async def utilization_report(format: str = "csv",
p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
rows = [await _utilization(o) for o in orgs]
if format == "pdf":
# Simple PDF table via reportlab
import io as _io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
buf = _io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
y = h - 25 * mm
c.setFont("Helvetica-Bold", 14)
c.drawString(20 * mm, y, "VerifyPack — Utilization Report")
c.setFont("Helvetica", 8)
y -= 12 * mm
c.drawString(20 * mm, y, "Org / Plan / Products / Users / QRs / Scans / Status")
y -= 6 * mm
for r in rows:
line = (f"{r['name'][:24]:24} {str(r['plan'] or '-')[:10]:10} "
f"{r['products_used']}/{r['product_limit'] or ''} "
f"{r['users_used']}/{r['user_limit'] or ''} "
f"{r['qr_codes']} {r['total_scans']} {r['subscription_status']}")
c.drawString(20 * mm, y, line)
y -= 5 * mm
if y < 20 * mm:
c.showPage(); y = h - 25 * mm
c.showPage(); c.save()
return Response(content=buf.getvalue(), media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="utilization.pdf"'})
# CSV
buf = io.StringIO()
wr = csv.writer(buf)
wr.writerow(["Organization", "Plan", "Price", "Products", "Product Limit",
"Users", "User Limit", "Storage GB", "QR Codes", "Total Scans", "Status"])
for r in rows:
wr.writerow([r["name"], r["plan"], r["plan_price"], r["products_used"],
r["product_limit"], r["users_used"], r["user_limit"],
r["storage_used_gb"], r["qr_codes"], r["total_scans"],
r["subscription_status"]])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="utilization.csv"'})

View File

@@ -0,0 +1,316 @@
"""Analytics & Insights — scan aggregates with date-range filtering + CSV export."""
import csv
import io
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Response
from app.core.deps import require_org, Principal
from app.models import ScanLog, Product
router = APIRouter(prefix="/analytics", tags=["analytics"])
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def _aware(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _range(days: int):
end = now_utc()
start = end - timedelta(days=days)
return start, end
def now_utc() -> datetime:
return datetime.now(timezone.utc)
from datetime import datetime, timedelta, timezone
from typing import Optional
from typing import Optional
async def _scans(
org_id: str,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
items = await ScanLog.find(
ScanLog.org_id == org_id
).to_list()
if from_date:
from_date = _aware(from_date)
if to_date:
to_date = _aware(to_date) + timedelta(days=1)
if from_date and to_date:
items = [
s for s in items
if from_date <= _aware(s.scanned_at) < to_date
]
elif days:
start = now_utc() - timedelta(days=days)
items = [
s for s in items
if _aware(s.scanned_at) >= start
]
if brand_id:
products = await Product.find(
Product.org_id == org_id,
Product.brand_id == brand_id,
).to_list()
product_ids = {
str(p.id)
for p in products
}
items = [
s
for s in items
if s.product_id in product_ids
]
return items
@router.get("/summary")
async def summary(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
oid = str(p.org.id)
scans = await _scans(oid, days,
from_date,
to_date,brand_id,
)
all_scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
products = await Product.find(Product.org_id == oid).to_list()
today = now_utc().date()
unique = len({(s.ip, s.product_id) for s in scans})
avg_trust = round(sum(x.trust_score for x in products) / len(products)) if products else 0
return {
"total_scans": len(scans),
"all_time_scans": len(all_scans),
"unique_consumers": unique,
"today_scans": sum(1 for s in scans if _aware(s.scanned_at).date() == today),
"active_products": sum(1 for x in products if x.status == "active"),
"avg_trust_score": avg_trust,
"countries": len({s.country for s in scans if s.country}),
}
@router.get("/trend")
async def trend(
p: Principal = Depends(require_org),
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
buckets = {}
for s in scans:
d = _aware(s.scanned_at).date()
buckets[d] = buckets.get(d, 0) + 1
return [
{"date": str(k), "scans": v}
for k, v in sorted(buckets.items())
]
@router.get("/top-products")
async def top_products(
p: Principal = Depends(require_org),
limit: int = 8,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
counter = Counter(s.product_id for s in scans if s.product_id)
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
lookup = {
str(prod.id): prod.name
for prod in products
}
return [
{
"name": lookup.get(pid, "Unknown Product"),
"scans": count,
}
for pid, count in counter.most_common(limit)
]
@router.get("/top-cities")
async def top_cities(p: Principal = Depends(require_org), limit: int = 10, days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
c = Counter(s.city for s in scans if s.city)
return [{"city": city, "scans": n} for city, n in c.most_common(limit)]
@router.get("/devices")
async def devices(
p: Principal = Depends(require_org),
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
c = Counter(s.device or "Other" for s in scans)
return [
{"device": d, "scans": n}
for d, n in c.most_common()
]
@router.get("/geography")
async def geography(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
c = Counter(s.country for s in scans if s.country)
return [{"country": k, "scans": n} for k, n in c.most_common()]
@router.get("/time-distribution")
async def time_distribution(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
"""Heatmap: day-of-week (0=Mon) x hour (0-23) scan counts."""
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
grid = defaultdict(int)
for s in scans:
dt = _aware(s.scanned_at)
grid[(dt.weekday(), dt.hour)] += 1
return [{"dow": d, "day": DOW[d], "hour": h, "count": grid.get((d, h), 0)}
for d in range(7) for h in range(24)]
@router.get("/recent-scans")
async def recent_scans(
p: Principal = Depends(require_org),
page: int = 1,
page_size: int = 20,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,
brand_id,
)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
total = len(scans)
start = (page - 1) * page_size
end = start + page_size
rows = scans[start:end]
return {
"items": [
{
"at": s.scanned_at,
"product_id": s.product_id,
"city": s.city,
"country": s.country,
"device": s.device,
"code": s.qr_code,
"ip": s.ip,
}
for s in rows
],
"page": page,
"page_size": page_size,
"total": total,
"pages": (total + page_size - 1) // page_size,
}
@router.get("/export")
async def export_csv(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Timestamp", "QR Code", "Product ID", "City", "Country", "Device", "IP"])
for s in scans:
w.writerow([_aware(s.scanned_at).isoformat(), s.qr_code, s.product_id,
s.city or "", s.country or "", s.device or "", s.ip or ""])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="scans.csv"'})
from app.models import Brand
@router.get("/brands")
async def analytics_brands(
p: Principal = Depends(require_org),
):
brands = await Brand.find(
Brand.org_id == str(p.org.id)
).to_list()
return [
{
"id": str(b.id),
"name": b.name
}
for b in brands
]

233
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,233 @@
import secrets
from datetime import timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, status, Request
from fastapi.responses import RedirectResponse
from app.core.config import settings
from app.core.security import (
hash_password, verify_password, create_access_token, create_token, decode_token,
)
from app.models import User, AuditLog
from app.models.documents import now
from app.schemas.auth import (
SignupIn, LoginIn, TokenOut, ForgotIn, ResetIn, MessageOut,
)
from app.services.email import send_verification_email, send_reset_email,send_email
router = APIRouter(prefix="/auth", tags=["auth"])
def _new_code() -> str:
return f"{secrets.randbelow(1000000):06d}"
async def _issue_verification(user: User):
"""Generate a 6-digit code + signed link token and email both."""
code = _new_code()
user.verify_code = code
user.verify_code_expires = now() + timedelta(minutes=30)
await user.save()
token = create_token(str(user.id), "verify", 60 * 24)
await send_verification_email(user.email, user.name, token, code)
@router.post("/signup", response_model=TokenOut, status_code=201)
async def signup(body: SignupIn):
if await User.find_one(User.email == body.email):
raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
user = User(name=body.name, email=body.email,
hashed_password=hash_password(body.password))
await user.insert()
await _issue_verification(user)
access = create_access_token(str(user.id), {"v": user.email_verified})
return TokenOut(access_token=access, email_verified=user.email_verified,
name=user.name, email=user.email)
@router.post("/verify-code", response_model=MessageOut)
async def verify_code(body: dict = Body(...)):
"""Verify email using the 6-digit code sent on signup."""
email = (body.get("email") or "").strip().lower()
code = (body.get("code") or "").strip()
user = await User.find_one(User.email == email)
if not user or not user.verify_code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No pending verification for this email")
exp = user.verify_code_expires
if exp and exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp and exp < now():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code expired — request a new one")
if code != user.verify_code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Incorrect code")
user.email_verified = True
user.verify_code = None
user.verify_code_expires = None
await user.save()
return MessageOut(message="Email verified")
@router.post("/resend-code", response_model=MessageOut)
async def resend_code(body: dict = Body(...)):
email = (body.get("email") or "").strip().lower()
user = await User.find_one(User.email == email)
if user and not user.email_verified:
await _issue_verification(user)
return MessageOut(message="If the email is pending verification, a new code was sent")
@router.post("/login")
async def login(body: LoginIn):
user = await User.find_one(User.email == body.email)
if not user or not user.hashed_password:
raise HTTPException(401, "Invalid credentials")
if not verify_password(body.password, user.hashed_password):
raise HTTPException(401, "Invalid credentials")
if user.disabled:
raise HTTPException(403, "Account disabled")
otp = _new_code()
user.login_otp = otp
user.login_otp_expires = now() + timedelta(minutes=10)
await user.save()
await send_email(
user.email,
"Verify Login",
f"Your login OTP is: {otp}",
"login_otp",
)
return {
"message": "OTP sent",
"email": user.email,
}
@router.post("/verify-login-otp", response_model=TokenOut)
async def verify_login_otp(body: dict = Body(...)):
email = body.get("email")
otp = body.get("otp")
user = await User.find_one(User.email == email)
if not user:
raise HTTPException(404, "User not found")
if user.login_otp != otp:
raise HTTPException(400, "Invalid OTP")
exp = user.login_otp_expires
if exp and exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp and exp < now():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "OTP expired")
user.login_otp = None
user.login_otp_expires = None
user.last_login = now()
await user.save()
token = create_access_token(
str(user.id),
{"v": user.email_verified},
)
return TokenOut(
access_token=token,
email_verified=user.email_verified,
name=user.name,
email=user.email,
)
@router.get("/verify-email", response_model=MessageOut)
async def verify_email(token: str):
payload = decode_token(token)
if not payload or payload.get("type") != "verify":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token")
user = await User.get(payload["sub"])
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
user.email_verified = True
await user.save()
return MessageOut(message="Email verified")
@router.post("/forgot-password", response_model=MessageOut)
async def forgot_password(body: ForgotIn):
user = await User.find_one(User.email == body.email)
if user:
token = create_token(str(user.id), "reset", 60)
await send_reset_email(user.email, user.name, token)
# Always return success to avoid account enumeration
return MessageOut(message="If the email exists, a reset link was sent")
@router.post("/reset-password", response_model=MessageOut)
async def reset_password(body: ResetIn):
payload = decode_token(body.token)
if not payload or payload.get("type") != "reset":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token")
user = await User.get(payload["sub"])
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
user.hashed_password = hash_password(body.password)
await user.save()
return MessageOut(message="Password reset")
# ---------- Google OAuth (env-driven; stubbed if not configured) ----------
@router.get("/google")
async def google_oauth_start():
if not settings.google_configured:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED,
"Google OAuth not configured (set GOOGLE_CLIENT_ID/SECRET)")
url = (
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.google_client_id}"
f"&redirect_uri={settings.google_redirect_uri}"
"&response_type=code&scope=openid%20email%20profile"
)
return RedirectResponse(url)
@router.get("/google/callback")
async def google_oauth_callback(code: Optional[str] = None):
if not settings.google_configured:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, "Google OAuth not configured")
import httpx
async with httpx.AsyncClient() as c:
tok = await c.post("https://oauth2.googleapis.com/token", data={
"code": code, "client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
})
if tok.status_code != 200:
print("Google Token Error:")
print(tok.status_code)
print(tok.text)
raise HTTPException(400, tok.text)
access = tok.json()["access_token"]
info = await c.get("https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access}"})
profile = info.json()
user = await User.find_one(User.email == profile["email"])
if not user:
user = User(name=profile.get("name", profile["email"]),
email=profile["email"], google_id=profile["id"],
avatar_url=profile.get("picture"), email_verified=True)
await user.insert()
user.last_login = now()
await user.save()
jwt = create_access_token(str(user.id), {"v": True})
return RedirectResponse(f"{settings.frontend_url}/auth/callback?token={jwt}")

View File

@@ -0,0 +1,61 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Batch, Product
from app.models.enums import ClientRole, QRType
from app.schemas.common import BatchCreate
router = APIRouter(prefix="/batches", tags=["batches"])
def _parse(d):
if not d:
return None
try:
return datetime.fromisoformat(d)
except ValueError:
return None
def _out(b: Batch) -> dict:
return {"id": str(b.id), "product_id": b.product_id, "batch_number": b.batch_number,
"mfg_date": b.mfg_date, "expiry_date": b.expiry_date,
"quantity": b.quantity, "qr_type": b.qr_type, "notes": b.notes,
"created_at": b.created_at}
@router.get("")
async def list_batches(p: Principal = Depends(require_org), product_id: Optional[str] = None):
q = Batch.find(Batch.org_id == str(p.org.id))
batches = await q.to_list()
if product_id:
batches = [b for b in batches if b.product_id == product_id]
return [_out(b) for b in batches]
@router.post("", status_code=201)
async def create_batch(body: BatchCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
product = await Product.get(body.product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
bn = body.batch_number or f"{(product.sku or 'B')}{datetime.utcnow():%y%m%d%H%M}"
batch = Batch(org_id=str(p.org.id), product_id=body.product_id, batch_number=bn,
mfg_date=_parse(body.mfg_date), expiry_date=_parse(body.expiry_date),
quantity=max(1, body.quantity), qr_type=body.qr_type, notes=body.notes)
await batch.insert()
product.batch_count += 1
await product.save()
return _out(batch)
@router.get("/{batch_id}")
async def get_batch(batch_id: str, p: Principal = Depends(require_org)):
batch = await Batch.get(batch_id)
if not batch or batch.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
return _out(batch)

View File

@@ -0,0 +1,250 @@
"""Billing & Subscription — Razorpay orders, GST invoices, plan changes.
GST is 18% on the plan price (INR only). Invoices store subtotal + gst + total.
Plan price changes never affect an active subscription until renewal.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from app.core.config import settings
from app.core.deps import require_org, require_client_roles, Principal
from app.models import (
Organization, Plan, Subscription, Invoice, Payment, PlanHistory,
StorageUsage, AuditLog,
)
from app.models.documents import now
from app.models.enums import ClientRole, PaymentStatus, SubscriptionStatus
from app.services import razorpay_svc
from app.services.invoice_pdf import build_invoice_pdf
router = APIRouter(prefix="/billing", tags=["billing"])
# Simple demo coupons: code -> percent off
COUPONS = {"WELCOME10": 10, "VERIFY20": 20, "LAUNCH50": 50}
def _gst(amount: int) -> int:
return round(amount * settings.gst_rate)
async def _next_invoice_number() -> str:
count = await Invoice.find_all().count()
yr = datetime.now().year
return f"VP/{yr}/{count + 1:05d}"
@router.get("/plans")
async def list_plans_for_clients(p: Principal = Depends(require_org)):
"""Plans available for upgrade/downgrade (enabled only), visible to any org member."""
plans = await Plan.find(Plan.enabled == True).to_list() # noqa: E712
plans.sort(key=lambda x: x.price)
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features} for pl in plans]
@router.get("/subscription")
async def get_subscription(p: Principal = Depends(require_org)):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == str(p.org.id))
days_remaining = None
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days_remaining = max(0, (rd - now()).days)
return {
"status": p.org.subscription_status,
"plan": {"id": str(plan.id), "name": plan.name, "price": plan.price,
"product_limit": plan.product_limit, "user_limit": plan.user_limit,
"storage_gb": plan.storage_gb} if plan else None,
"auto_renew": sub.auto_renew if sub else True,
"start_date": sub.start_date if sub else None,
"renewal_date": sub.renewal_date if sub else None,
"days_remaining": days_remaining,
"payment_status": sub.payment_status if sub else None,
"usage": {
"products": p.org.product_count,
"product_limit": plan.product_limit if plan else None,
"users": p.org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
},
}
@router.put("/subscription/auto-renewal")
async def set_auto_renewal(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
if not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No subscription")
sub.auto_renew = bool(body.get("auto_renew", True))
await sub.save()
return {"auto_renew": sub.auto_renew}
@router.post("/coupon/apply")
async def apply_coupon(body: dict = Body(...), p: Principal = Depends(require_org)):
code = (body.get("code") or "").strip().upper()
pct = COUPONS.get(code)
if not pct:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid coupon code")
return {"code": code, "percent_off": pct}
@router.post("/create-order")
async def create_order(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Create a Razorpay order for a plan upgrade/downgrade.
body = {"plan_id": "...", "coupon": "WELCOME10"?}
Returns order details + GST breakdown for the checkout modal.
"""
plan = await Plan.get(body.get("plan_id"))
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
base = plan.price
discount = 0
coupon = (body.get("coupon") or "").strip().upper()
if coupon and coupon in COUPONS:
discount = round(base * COUPONS[coupon] / 100)
subtotal = max(0, base - discount)
gst = _gst(subtotal)
total = subtotal + gst
if total == 0:
# Free plan or fully discounted — activate immediately, no Razorpay.
await _activate_plan(p.org, plan, subtotal, gst, total, method="free")
return {"free": True, "plan": plan.name}
receipt = f"rcpt_{str(p.org.id)[-6:]}_{int(now().timestamp())}"
order = razorpay_svc.create_order(total * 100, receipt,
{"org_id": str(p.org.id), "plan_id": str(plan.id)})
await Payment(org_id=str(p.org.id), razorpay_order_id=order["id"],
amount=total, status=PaymentStatus.pending).insert()
return {
"order_id": order["id"],
"amount": total,
"currency": "INR",
"key_id": settings.razorpay_key_id,
"mock": order.get("mock", False),
"breakdown": {"base": base, "discount": discount, "subtotal": subtotal,
"gst": gst, "total": total, "coupon": coupon or None},
"plan_id": str(plan.id),
"plan_name": plan.name,
}
@router.post("/verify")
async def verify_payment(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Verify Razorpay payment signature and activate the plan."""
order_id = body.get("razorpay_order_id")
payment_id = body.get("razorpay_payment_id")
signature = body.get("razorpay_signature", "")
plan_id = body.get("plan_id")
if not razorpay_svc.verify_payment_signature(order_id, payment_id, signature):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid payment signature")
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
pay = await Payment.find_one(Payment.razorpay_order_id == order_id)
subtotal = plan.price
if pay:
subtotal = pay.amount - _gst(pay.amount) # back out gst-inclusive stored total
gst = _gst(plan.price)
total = plan.price + gst
if pay:
pay.razorpay_payment_id = payment_id
pay.status = PaymentStatus.paid
await pay.save()
invoice = await _activate_plan(p.org, plan, plan.price, gst, plan.price + gst,
method="razorpay", txn=payment_id)
return {"success": True, "plan": plan.name, "invoice_number": invoice}
@router.post("/webhook")
async def razorpay_webhook(request: Request):
"""Razorpay webhook handler (payment.captured / payment.failed)."""
payload = await request.body()
sig = request.headers.get("x-razorpay-signature", "")
if not razorpay_svc.verify_webhook_signature(payload, sig):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook signature")
# In production: parse event, reconcile Payment/Invoice by order id.
return {"ok": True}
async def _activate_plan(org: Organization, plan: Plan, subtotal: int, gst: int,
total: int, method: str, txn: Optional[str] = None) -> str:
"""Switch org to a plan, write subscription + invoice + plan_history."""
old_plan = await Plan.get(org.plan_id) if org.plan_id else None
org.plan_id = str(plan.id)
org.subscription_status = SubscriptionStatus.active
await org.save()
storage = await StorageUsage.find_one(StorageUsage.org_id == str(org.id))
if storage:
storage.total_bytes = plan.storage_gb * 1024 ** 3
await storage.save()
sub = await Subscription.find_one(Subscription.org_id == str(org.id))
renewal = now() + timedelta(days=30)
if sub:
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
sub.status = SubscriptionStatus.active
sub.start_date = now()
sub.renewal_date = renewal
sub.payment_status = PaymentStatus.paid
await sub.save()
else:
await Subscription(org_id=str(org.id), plan_id=str(plan.id), plan_name=plan.name,
renewal_date=renewal).insert()
inv_no = await _next_invoice_number()
if total > 0:
await Invoice(org_id=str(org.id), invoice_number=inv_no, plan_name=plan.name,
period=f"{now():%b %Y} - {renewal:%b %Y}",
subtotal=subtotal, gst_amount=gst, total=total,
status=PaymentStatus.paid, payment_method=method,
transaction_id=txn, gstin=org.gstin,
place_of_supply="Karnataka").insert()
await PlanHistory(org_id=str(org.id),
from_plan=old_plan.name if old_plan else None,
to_plan=plan.name, reason=f"{method} payment").insert()
await AuditLog(org_id=str(org.id), action="subscription.change", module="billing",
details=f"Plan changed to {plan.name}").insert()
return inv_no
@router.get("/invoices")
async def list_invoices(p: Principal = Depends(require_org)):
invs = await Invoice.find(Invoice.org_id == str(p.org.id)).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
return [{"id": str(i.id), "invoice_number": i.invoice_number, "plan_name": i.plan_name,
"period": i.period, "subtotal": i.subtotal, "gst_amount": i.gst_amount,
"total": i.total, "status": i.status, "payment_method": i.payment_method,
"transaction_id": i.transaction_id, "created_at": i.created_at} for i in invs]
@router.get("/invoices/{invoice_id}/pdf")
async def invoice_pdf(invoice_id: str, p: Principal = Depends(require_org)):
inv = await Invoice.get(invoice_id)
if not inv or inv.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Invoice not found")
pdf = build_invoice_pdf(inv.model_dump(), p.org.model_dump())
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{inv.invoice_number.replace("/", "-")}.pdf"'})

View File

@@ -0,0 +1,119 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Brand, Product,AuditLog
from app.models.enums import ClientRole
from app.schemas.common import BrandCreate
router = APIRouter(prefix="/brands", tags=["brands"])
def _out(b: Brand) -> dict:
return {"id": str(b.id), "name": b.name, "logo_url": b.logo_url,
"description": b.description, "product_count": b.product_count,
"created_at": b.created_at}
from fastapi import APIRouter, UploadFile, File
import os
import uuid
import os
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
UPLOAD_DIR = os.path.join(BASE_DIR, "_uploads", "brands")
os.makedirs(UPLOAD_DIR, exist_ok=True)
@router.post("/upload-logo")
async def upload_logo(file: UploadFile = File(...)):
ext = file.filename.split(".")[-1]
filename = f"{uuid.uuid4()}.{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as buffer:
buffer.write(await file.read())
return {
"url": f"http://localhost:8000/uploads/brands/{filename}"
}
@router.get("")
async def list_brands(p: Principal = Depends(require_org)):
brands = await Brand.find(Brand.org_id == str(p.org.id)).to_list()
return [_out(b) for b in brands]
@router.post("", status_code=201)
async def create_brand(body: BrandCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
brand = Brand(org_id=str(p.org.id), name=body.name,
description=body.description, logo_url=body.logo_url)
await brand.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.create",
module="brands",
details=f"Created brand {brand.name}",
).insert()
return _out(brand)
@router.get("/{brand_id}")
async def get_brand(brand_id: str, p: Principal = Depends(require_org)):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
products = await Product.find(Product.brand_id == brand_id).to_list()
return {**_out(brand),
"products": [{"id": str(x.id), "name": x.name, "sku": x.sku,
"status": x.status,"image_url": x.image_url,
"qr_count": x.qr_count,
"batch_count": x.batch_count,} for x in products]}
@router.put("/{brand_id}")
async def update_brand(brand_id: str, body: dict,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
for k in ("name", "logo_url", "description"):
if k in body:
setattr(brand, k, body[k])
await brand.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.update",
module="brands",
details=f"Updated brand {brand.name}",
).insert()
return _out(brand)
@router.delete("/{brand_id}", status_code=204)
async def delete_brand(brand_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
await brand.delete()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.delete",
module="brands",
details=f"Deleted brand {brand.name}",
).insert()

View File

@@ -0,0 +1,365 @@
"""Compliance Center — licenses & certificates with expiry tracking."""
import base64
import binascii
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, Principal
from app.models import Compliance, Product,AuditLog
from app.models.documents import now
from app.models.enums import ClientRole, ComplianceStatus
from app.services.storage import upload_file
router = APIRouter(prefix="/compliance", tags=["compliance"])
def _parse(d):
if not d:
return None
try:
return datetime.fromisoformat(str(d).replace("Z", "+00:00"))
except ValueError:
return None
def _status_for(expiry: Optional[datetime], has_number: bool) -> ComplianceStatus:
today = now()
if expiry:
exp = expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc)
if exp < today:
return ComplianceStatus.expired
if exp <= today + timedelta(days=30):
return ComplianceStatus.expiring
if not has_number:
return ComplianceStatus.pending
return ComplianceStatus.complete
def _days_left(expiry: Optional[datetime]):
if not expiry:
return None
exp = expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc)
return (exp - now()).days
def _out(c: Compliance, prod=None) -> dict:
return {
"id": str(c.id), "product_id": c.product_id,
"product_name": prod.name if prod else None,
"sku": prod.sku if prod else None,
"product_image": prod.image_url if prod else None,
"compliance_type": c.compliance_type,
"license_number": c.license_number, "license_name": c.license_name,
"issuing_authority": c.issuing_authority,
"issue_date": c.issue_date, "expiry_date": c.expiry_date,
"days_left": _days_left(c.expiry_date),
"country": c.country, "state": c.state, "plant": c.plant,
"category": c.category, "applicable_to": c.applicable_to,
"remarks": c.remarks, "document_url": c.document_url,
"documents": c.documents, "created_by": c.created_by,
"status": c.status, "created_at": c.created_at, "updated_at": c.updated_at,
}
@router.get("")
async def list_compliance(p: Principal = Depends(require_org),
status_filter: Optional[str] = None,
compliance_type: Optional[str] = None,
country: Optional[str] = None,
category: Optional[str] = None,
search: Optional[str] = None):
items = await Compliance.find(Compliance.org_id == str(p.org.id)).to_list()
products = {str(x.id): x for x in await Product.find(Product.org_id == str(p.org.id)).to_list()}
for c in items:
c.status = _status_for(c.expiry_date, bool(c.license_number))
rows = []
for c in items:
if status_filter and c.status != status_filter:
continue
if compliance_type and c.compliance_type != compliance_type:
continue
if country and c.country != country:
continue
if category and c.category != category:
continue
prod = products.get(c.product_id or "")
if search:
s = search.lower()
hay = f"{c.compliance_type} {c.license_number or ''} {c.issuing_authority or ''} {prod.name if prod else ''}".lower()
if s not in hay:
continue
rows.append(_out(c, prod))
rows.sort(key=lambda r: r["updated_at"], reverse=True)
return rows
@router.get("/summary")
async def summary(p: Principal = Depends(require_org)):
items = await Compliance.find(Compliance.org_id == str(p.org.id)).to_list()
for c in items:
c.status = _status_for(c.expiry_date, bool(c.license_number))
def n(s): return sum(1 for c in items if c.status == s)
# Match the dashboard's Products KPI: use the org's live product_count
# (excludes archived), not a raw collection count.
total_products = p.org.product_count
total = len(items) or 1
return {
"total": len(items),
"total_products": total_products,
"complete": n(ComplianceStatus.complete),
"pending": n(ComplianceStatus.pending),
"expired": n(ComplianceStatus.expired),
"expiring": n(ComplianceStatus.expiring),
"complete_pct": round(n(ComplianceStatus.complete) / total * 100, 1),
"pending_pct": round(n(ComplianceStatus.pending) / total * 100, 1),
"expired_pct": round(n(ComplianceStatus.expired) / total * 100, 1),
"alerts": [
{"type": c.compliance_type, "number": c.license_number,
"status": c.status, "expiry": c.expiry_date}
for c in items if c.status in (ComplianceStatus.expired, ComplianceStatus.expiring)
],
}
@router.post("", status_code=201)
async def create_compliance(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
expiry = _parse(body.get("expiry_date"))
c = Compliance(
org_id=str(p.org.id), product_id=body.get("product_id"),
compliance_type=body.get("compliance_type", "Certificate"),
license_number=body.get("license_number"),
license_name=body.get("license_name"),
issuing_authority=body.get("issuing_authority"),
issue_date=_parse(body.get("issue_date")), expiry_date=expiry,
country=body.get("country"), state=body.get("state"), plant=body.get("plant"),
category=body.get("category"), applicable_to=body.get("applicable_to"),
remarks=body.get("remarks"), created_by=p.user.name,
status=_status_for(expiry, bool(body.get("license_number"))),
)
await c.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.create",
module="compliance",
details=f"Created {c.compliance_type} for product {c.product_id}",
).insert()
return _out(c, await _safe_prod(c.product_id))
async def _safe_prod(pid):
if not pid:
return None
try:
return await Product.get(pid)
except Exception:
return None
async def _owned(cid: str, p: Principal) -> Compliance:
try:
c = await Compliance.get(cid)
except Exception:
c = None
if not c or c.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Compliance record not found")
return c
@router.get("/{cid}")
async def get_compliance(cid: str, p: Principal = Depends(require_org)):
c = await _owned(cid, p)
return _out(c, await _safe_prod(c.product_id))
@router.get("/{cid}/detail")
async def compliance_detail(cid: str, p: Principal = Depends(require_org)):
"""Full detail for the Compliance Details modal (overview + documents + history)."""
c = await _owned(cid, p)
return {
**_out(c, await _safe_prod(c.product_id)),
"history": [
{"action": "Created", "by": c.created_by, "at": c.created_at},
{"action": "Last updated", "by": c.created_by, "at": c.updated_at},
],
}
@router.put("/{cid}")
async def update_compliance(cid: str, body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
c = await _owned(cid, p)
for k in ("compliance_type", "license_number", "license_name", "issuing_authority",
"country", "state", "plant", "category", "applicable_to", "remarks"):
if k in body:
setattr(c, k, body[k])
if "issue_date" in body:
c.issue_date = _parse(body["issue_date"])
if "expiry_date" in body:
c.expiry_date = _parse(body["expiry_date"])
c.status = _status_for(c.expiry_date, bool(c.license_number))
c.updated_at = now()
await c.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.update",
module="compliance",
details=f"Updated {c.compliance_type}",
).insert()
return _out(c, await _safe_prod(c.product_id))
@router.delete("/{cid}", status_code=204)
async def delete_compliance(cid: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
c = await _owned(cid, p)
await c.delete()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.delete",
module="compliance",
details=f"Deleted {c.compliance_type}",
).insert()
@router.post("/{cid}/document")
async def upload_document(cid: str, body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""body = {data_url, filename}"""
c = await _owned(cid, p)
data_url = body.get("data_url", "")
if "," not in data_url:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid data URL")
try:
content = base64.b64decode(data_url.split(",", 1)[1])
except (binascii.Error, ValueError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bad base64")
fname = body.get("filename", f"{cid}.pdf")
url = await upload_file(content, fname, "application/pdf")
c.document_url = url
c.documents = (c.documents or []) + [{"name": fname, "url": url,
"uploaded_at": now().isoformat()}]
c.updated_at = now()
await c.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.upload_document",
module="compliance",
details=f"Uploaded document {fname}",
).insert()
return {"document_url": url, "documents": c.documents}
import csv
import io
from fastapi import UploadFile, File
@router.post("/import")
async def import_compliance(
file: UploadFile = File(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user)),
):
if not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=400,
detail="Only CSV files are supported."
)
content = await file.read()
try:
text = content.decode("utf-8-sig")
except Exception:
raise HTTPException(
status_code=400,
detail="Invalid CSV file."
)
reader = csv.DictReader(io.StringIO(text))
inserted = 0
skipped = 0
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
product_map = {
x.name.strip().lower(): str(x.id)
for x in products
}
for row in reader:
product_name = (row.get("Product") or "").strip()
product_id = product_map.get(product_name.lower())
if not product_id:
skipped += 1
continue
expiry = _parse(row.get("Expiry Date"))
issue = _parse(row.get("Issue Date"))
compliance = Compliance(
org_id=str(p.org.id),
product_id=product_id,
compliance_type=row.get("Compliance Type"),
license_number=row.get("License Number"),
license_name=row.get("License Name"),
issuing_authority=row.get("Issuing Authority"),
issue_date=issue,
expiry_date=expiry,
country=row.get("Country"),
state=row.get("State"),
category=row.get("Category"),
applicable_to=row.get("Applicable To"),
plant=row.get("Manufacturing Plant"),
remarks=row.get("Remarks"),
created_by=p.user.name,
status=_status_for(
expiry,
bool(row.get("License Number"))
),
)
await compliance.insert()
inserted += 1
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.import",
module="compliance",
details=f"Imported {inserted} compliance records",
).insert()
return {
"message": "Import completed.",
"inserted": inserted,
"skipped": skipped,
}

View File

@@ -0,0 +1,106 @@
"""Dashboard + analytics aggregates that feed the client dashboard UI."""
from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from app.core.deps import require_org, Principal
from app.models import Product, Batch, QRCode, ScanLog, Plan, StorageUsage
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary")
async def summary(p: Principal = Depends(require_org)):
oid = str(p.org.id)
products = await Product.find(Product.org_id == oid).to_list()
batches = await Batch.find(Batch.org_id == oid).to_list()
qrs = await QRCode.find(QRCode.org_id == oid).to_list()
scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == oid)
today = datetime.now(timezone.utc).date()
scans_today = sum(1 for s in scans if s.scanned_at.date() == today)
active_qr = sum(1 for q in qrs if q.status == "active")
avg_trust = round(sum(x.trust_score for x in products) / len(products)) if products else 0
return {
"products": len(products),
"product_limit": plan.product_limit if plan else None,
"qr_codes": len(qrs),
"active_qr": active_qr,
"batches": len(batches),
"scans_today": scans_today,
"total_scans": len(scans),
"trust_score_avg": avg_trust,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
"plan_name": plan.name if plan else "Free",
"user_count": p.org.user_count,
"user_limit": plan.user_limit if plan else None,
}
@router.get("/scan-trend")
async def scan_trend(p: Principal = Depends(require_org), days: int = 7):
oid = str(p.org.id)
scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
start = datetime.now(timezone.utc).date() - timedelta(days=days - 1)
buckets = {start + timedelta(days=i): 0 for i in range(days)}
for s in scans:
d = s.scanned_at.date()
if d in buckets:
buckets[d] += 1
return [{"date": str(d), "scans": c} for d, c in sorted(buckets.items())]
@router.get("/top-products")
async def top_products(
p: Principal = Depends(require_org),
limit: int = 8,
):
scans = await ScanLog.find(
ScanLog.org_id == str(p.org.id)
).to_list()
counter = Counter(s.product_id for s in scans if s.product_id)
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
lookup = {str(prod.id): prod.name for prod in products}
result = []
for product_id, count in counter.most_common(limit):
result.append({
"name": lookup.get(product_id, "Unknown Product"),
"scans": count,
})
return result
@router.get("/top-locations")
async def top_locations(p: Principal = Depends(require_org), limit: int = 5):
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
counter = Counter(s.city for s in scans if s.city)
return [{"city": city, "scans": n} for city, n in counter.most_common(limit)]
@router.get("/recent-activity")
async def recent_activity(p: Principal = Depends(require_org), limit: int = 8):
from app.models import AuditLog
logs = await AuditLog.find(AuditLog.org_id == str(p.org.id)).to_list()
logs.sort(key=lambda x: x.created_at, reverse=True)
return [{"action": l.action, "details": l.details, "user": l.user_name,
"module": l.module, "at": l.created_at} for l in logs[:limit]]
@router.get("/devices")
async def devices(p: Principal = Depends(require_org)):
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
counter = Counter(s.device or "Other" for s in scans)
return [{"device": d, "scans": n} for d, n in counter.most_common()]

159
backend/app/routers/orgs.py Normal file
View File

@@ -0,0 +1,159 @@
import re
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import current_principal, current_user, require_client_roles, Principal
from app.models import Organization, OrgMember, Plan, Subscription, StorageUsage, AuditLog
from app.models.enums import ClientRole
from app.schemas.common import OrgCreate
router = APIRouter(tags=["organization"])
@router.get("/me")
async def me(p: Principal = Depends(current_principal)):
"""Who am I — used by the frontend to route admins vs clients."""
return {
"id": str(p.user.id),
"name": p.user.name,
"email": p.user.email,
"email_verified": p.user.email_verified,
"is_product_admin": p.user.is_product_admin,
"product_admin_role": p.admin_role,
"client_role": p.client_role,
"has_org": p.org is not None,
"org_name": p.org.name if p.org else None,
}
def _slug(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return base or "org"
@router.post("/organization", status_code=201)
async def create_org(body: OrgCreate, user=Depends(current_user)):
existing = await OrgMember.find_one(OrgMember.user_id == str(user.id))
if existing:
raise HTTPException(status.HTTP_409_CONFLICT, "User already belongs to an organization")
free = await Plan.find_one(Plan.name == "Free")
slug = _slug(body.name)
if await Organization.find_one(Organization.slug == slug):
slug = f"{slug}-{str(user.id)[-4:]}"
org = Organization(name=body.name, slug=slug, gstin=body.gstin, pan=body.pan,
contact_email=body.contact_email or user.email,address=body.address,
phone=body.phone, website=body.website,
plan_id=str(free.id) if free else None)
await org.insert()
await OrgMember(org_id=str(org.id), user_id=str(user.id),
role=ClientRole.admin, status="active").insert()
await StorageUsage(org_id=str(org.id),
total_bytes=(free.storage_gb if free else 5) * 1024 ** 3).insert()
if free:
await Subscription(org_id=str(org.id), plan_id=str(free.id),
plan_name=free.name).insert()
await AuditLog(org_id=str(org.id), user_id=str(user.id), user_name=user.name,
action="org.create", module="organization",
details=f"Created org {org.name}").insert()
return {"id": str(org.id), "name": org.name, "slug": org.slug}
@router.get("/organization")
async def get_org(p: Principal = Depends(current_principal)):
if not p.org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No organization")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"id": str(p.org.id), "name": p.org.name, "slug": p.org.slug,
"logo_url": p.org.logo_url, "gstin": p.org.gstin, "pan": p.org.pan,
"timezone": p.org.timezone, "language": p.org.language,
"subscription_status": p.org.subscription_status,"address": p.org.address,
"phone": p.org.phone,
"website": p.org.website,
"contact_email": p.org.contact_email,
"address":p.org.address,"country": p.org.country,
"product_count": p.org.product_count, "user_count": p.org.user_count,
"plan": {"name": plan.name, "product_limit": plan.product_limit,
"user_limit": plan.user_limit, "price": plan.price} if plan else None,
"your_role": p.client_role,
}
@router.put("/organization")
async def update_org(body: dict, p: Principal = Depends(require_client_roles(ClientRole.admin))):
allowed = {"name", "logo_url", "gstin", "pan", "address", "contact_email","country",
"phone", "website", "timezone", "language"}
for k, v in body.items():
if k in allowed:
setattr(p.org, k, v)
await p.org.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="organization.update",
module="organization",
details=f"Updated organization {p.org.name}",
).insert()
return {"message": "updated"}
from datetime import datetime
from typing import Optional
@router.get("/audit-logs")
async def org_audit_logs(
p: Principal = Depends(require_client_roles(ClientRole.admin)),
limit: int = 50,
module: Optional[str] = None,
user: Optional[str] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
):
query = AuditLog.find(AuditLog.org_id == str(p.org.id))
if module:
query = query.find(AuditLog.module == module)
if user:
query = query.find(AuditLog.user_name == user)
if from_date:
query = query.find(
AuditLog.created_at >= datetime.fromisoformat(from_date)
)
if to_date:
query = query.find(
AuditLog.created_at <= datetime.fromisoformat(to_date + "T23:59:59")
)
logs = await query.sort(-AuditLog.created_at).limit(limit).to_list()
return [
{
"at": l.created_at,
"user": l.user_name,
"role": l.user_role,
"action": l.action,
"module": l.module,
"details": l.details,
"ip": l.ip,
}
for l in logs
]
@router.get("/roles")
async def list_roles():
return [
{"role": "admin", "label": "Admin",
"description": "Full access within the organization: products, QRs, batches, compliance, users, billing."},
{"role": "finance", "label": "Finance",
"description": "Billing, invoices, payment methods, subscription within the org only."},
{"role": "user", "label": "User",
"description": "Create products, generate QRs, manage batches, view analytics. No billing/invites."},
]

View File

@@ -0,0 +1,324 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Brand, Product, Plan, AuditLog
from app.models.documents import ManufacturerInfo, ProductDetails
from app.models.enums import ClientRole, ProductStatus
from app.schemas.common import ProductCreate
router = APIRouter(prefix="/products", tags=["products"])
def _out(p: Product, full: bool = False) -> dict:
base = {
"id": str(p.id), "name": p.name, "sku": p.sku, "category": p.category,
"brand_id": p.brand_id, "brand_name": p.brand_name, "image_url": p.image_url,
"status": p.status, "trust_score": p.trust_score, "recalled": p.recalled,
"batch_count": p.batch_count, "qr_count": p.qr_count, "scan_count": p.scan_count,
"created_at": p.created_at,"qr_status": "Generated" if p.qr_count > 0 else "Not Generated",
}
if full:
base.update({
"description": p.description,
"manufacturer": p.manufacturer.model_dump(),
"details": p.details.model_dump(),
"compliance_badges": [c.model_dump() for c in p.compliance_badges],
"recycling_info": p.recycling_info,
"internal_notes": p.internal_notes,
"qr_type": p.qr_type,
"display_brand_logo": p.display_brand_logo,
})
return base
from datetime import datetime
from typing import Optional
from fastapi import Depends
@router.get("")
async def list_products(
p: Principal = Depends(require_org),
# Search
search: Optional[str] = None,
# Filters
brand_id: Optional[str] = None,
category: Optional[str] = None,
status_filter: Optional[str] = None,
qr_status: Optional[str] = None,
trust_from: Optional[int] = None,
trust_to: Optional[int] = None,
created_from: Optional[datetime] = None,
created_to: Optional[datetime] = None,
# Pagination
page: int = 1,
page_size: int = 20,
):
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
# Hide archived by default
if status_filter:
products = [p for p in products if p.status == status_filter]
else:
products = [
p for p in products
if p.status != ProductStatus.archived
]
# Search
if search:
s = search.lower()
products = [
p for p in products
if (
s in (p.name or "").lower()
or s in (p.sku or "").lower()
or s in (p.brand_name or "").lower()
or s in str(p.trust_score)
)
]
# Brand
if brand_id:
products = [
p for p in products
if p.brand_id == brand_id
]
# Category
if category:
products = [
p for p in products
if p.category == category
]
# QR Status
if qr_status:
if qr_status == "generated":
products = [
p for p in products
if p.qr_count > 0
]
elif qr_status == "not_generated":
products = [
p for p in products
if p.qr_count == 0
]
# Trust Score
if trust_from is not None:
products = [
p for p in products
if p.trust_score >= trust_from
]
if trust_to is not None:
products = [
p for p in products
if p.trust_score <= trust_to
]
# Created Date
if created_from:
products = [
p for p in products
if p.created_at >= created_from
]
if created_to:
products = [
p for p in products
if p.created_at <= created_to
]
products.sort(
key=lambda p: p.created_at,
reverse=True, # newest first
)
total = len(products)
start = (page - 1) * page_size
end = start + page_size
products = products[start:end]
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"items": [_out(x) for x in products],
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
"count": p.org.product_count,
"limit": plan.product_limit if plan else None,
"limit_reached": bool(
plan
and plan.product_limit is not None
and p.org.product_count >= plan.product_limit
),
}
@router.post("", status_code=201)
async def create_product(body: ProductCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
# ----- plan product-limit enforcement (the only enforced limit) -----
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.",
"limit": plan.product_limit, "plan": plan.name, "upgrade": True},
)
if not body.brand_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
detail={"message": "Please select a brand before saving."})
brand = await Brand.get(body.brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
product = Product(
org_id=str(p.org.id), brand_id=body.brand_id, brand_name=brand.name,
name=body.name, sku=body.sku, category=body.category,
description=body.description, image_url=body.image_url,
manufacturer=body.manufacturer or ManufacturerInfo(),
details=body.details or ProductDetails(),
compliance_badges=body.compliance_badges, recycling_info=body.recycling_info,
internal_notes=body.internal_notes, qr_type=body.qr_type,
display_brand_logo=body.display_brand_logo, status=body.status,
)
await product.insert()
p.org.product_count += 1
await p.org.save()
brand.product_count += 1
await brand.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
user_role=p.client_role, action="product.create", module="products",
details=f"Created product {product.name}").insert()
return _out(product)
@router.get("/{product_id}")
async def get_product(product_id: str, p: Principal = Depends(require_org)):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
return _out(product, full=True)
@router.put("/{product_id}")
async def update_product(product_id: str, body: dict,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
for k in ("name", "sku", "category", "description", "image_url",
"recycling_info", "internal_notes", "qr_type", "display_brand_logo",
"status", "trust_score", "recalled"):
if k in body:
setattr(product, k, body[k])
if "brand_id" in body and body["brand_id"]:
brand = await Brand.get(body["brand_id"])
if brand and brand.org_id == str(p.org.id):
product.brand_id = body["brand_id"]
product.brand_name = brand.name
if "manufacturer" in body and body["manufacturer"]:
product.manufacturer = ManufacturerInfo(**body["manufacturer"])
if "details" in body and body["details"]:
product.details = ProductDetails(**body["details"])
await product.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="product.update",
module="products",
details=f"Updated product {product.name}",
).insert()
return _out(product, full=True)
@router.post("/{product_id}/duplicate", status_code=201)
async def duplicate_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Clone a product as a new draft (counts against the plan limit)."""
require_write_access(p)
src = await Product.get(product_id)
if not src or src.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.",
"limit": plan.product_limit, "plan": plan.name, "upgrade": True})
data = src.model_dump(exclude={"id", "revision_id"})
data.update({"name": f"{src.name} (Copy)", "sku": (src.sku or "") + "-COPY",
"status": ProductStatus.draft, "batch_count": 0, "qr_count": 0,
"scan_count": 0})
dup = Product(**data)
await dup.insert()
p.org.product_count += 1
await p.org.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
action="product.duplicate", module="products",
details=f"Duplicated {src.name}").insert()
return _out(dup, full=True)
@router.put("/{product_id}/archive")
async def archive_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Soft delete — status=archived. Existing QRs keep working; hidden from default list."""
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
product.status = ProductStatus.archived
await product.save()
p.org.product_count = max(0, p.org.product_count - 1)
await p.org.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
action="product.archive", module="products",
details=f"Archived {product.name}").insert()
return {"status": "archived"}
@router.put("/{product_id}/restore")
async def restore_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": "Restoring exceeds your plan limit. Upgrade first.",
"upgrade": True})
product.status = ProductStatus.active
await product.save()
p.org.product_count += 1
await p.org.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="product.restore",
module="products",
details=f"Restored product {product.name}",
).insert()
return {"status": "active"}

474
backend/app/routers/qr.py Normal file
View File

@@ -0,0 +1,474 @@
import base64
import binascii
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status, Response
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Batch, Product, Brand, QRCode, Compliance, ScanLog,AuditLog
from app.models.documents import QRStyle
from app.models.enums import ClientRole, QRType, QRStatus
from app.schemas.common import QRGenerateIn
from app.services import qr as qrsvc
from app.services.storage import upload_file
router = APIRouter(prefix="/qr", tags=["qr"])
async def _safe_get(model, oid):
"""Beanie .get() that returns None for empty/invalid ObjectIds instead of raising."""
if not oid:
return None
try:
return await model.get(oid)
except Exception:
return None
async def _build_snapshot(product: Product, brand: Brand, batch: Batch) -> dict:
return {
"product": {
"name": product.name, "sku": product.sku, "category": product.category,
"description": product.description, "image_url": product.image_url,
"trust_score": product.trust_score, "recalled": product.recalled,
"details": product.details.model_dump(),
"manufacturer": product.manufacturer.model_dump(),
"compliance": [c.model_dump() for c in product.compliance_badges],
"recycling_info": product.recycling_info,
},
"brand": {"name": brand.name, "logo_url": brand.logo_url} if brand else None,
"batch": {"number": batch.batch_number, "mfg_date": str(batch.mfg_date),
"expiry_date": str(batch.expiry_date)},
}
@router.post("/generate", status_code=201)
async def generate(body: QRGenerateIn,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Generate QR(s) for a batch. No credit deduction — free and unlimited."""
require_write_access(p)
batch = await Batch.get(body.batch_id)
if not batch or batch.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
product = await Product.get(batch.product_id)
brand = await Brand.get(product.brand_id) if product else None
style = body.style or QRStyle()
snapshot = await _build_snapshot(product, brand, batch)
created: list[QRCode] = []
if batch.qr_type == QRType.batch:
# Option 1 — one QR for the whole batch
code = qrsvc.new_code()
qr = QRCode(org_id=str(p.org.id), product_id=str(product.id), batch_id=str(batch.id),
code=code, qr_type=QRType.batch, style=style, snapshot=snapshot,
created_by=p.user.name)
await qr.insert()
created.append(qr)
else:
# Option 2 — serialized unique QR per pack
for i in range(1, batch.quantity + 1):
code = qrsvc.new_code()
qr = QRCode(org_id=str(p.org.id), product_id=str(product.id), batch_id=str(batch.id),
code=code, serial=i, qr_type=QRType.per_pack, style=style, snapshot=snapshot,
created_by=p.user.name)
await qr.insert()
created.append(qr)
product.qr_count += len(created)
await product.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.generate",
module="qr",
details=f"Generated {len(created)} QR(s) for batch {batch.batch_number}",
).insert()
return {
"batch_id": str(batch.id), "qr_type": batch.qr_type, "generated": len(created),
"codes": [{"id": str(q.id), "code": q.code, "serial": q.serial,
"url": qrsvc.verify_url(q.code)} for q in created],
"download_zip": batch.qr_type == QRType.per_pack,
}
@router.get("/summary")
async def qr_summary(p: Principal = Depends(require_org)):
from datetime import datetime, timezone
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
total = len(qrs)
active = sum(1 for q in qrs if q.status == QRStatus.active)
inactive = total - active
today = datetime.now(timezone.utc).date()
today_scans = sum(1 for s in scans
if (s.scanned_at if s.scanned_at.tzinfo else s.scanned_at.replace(tzinfo=timezone.utc)).date() == today)
return {
"total": total, "active": active, "inactive": inactive,
"total_scans": sum(q.scan_count for q in qrs),
"today_scans": today_scans,
"active_pct": round(active / total * 100) if total else 0,
"inactive_pct": round(inactive / total * 100) if total else 0,
}
from datetime import datetime, timedelta
@router.get("")
async def list_qr(p: Principal = Depends(require_org), product_id: Optional[str] = None,
batch_id: Optional[str] = None, status_filter: Optional[str] = None,
brand_id: Optional[str] = None, qr_type: Optional[str] = None,
search: Optional[str] = None,start_date: Optional[str] = None,
end_date: Optional[str] = None):
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
# join product + batch info for display
products = {str(x.id): x for x in await Product.find(Product.org_id == str(p.org.id)).to_list()}
batches = {str(b.id): b for b in await Batch.find(Batch.org_id == str(p.org.id)).to_list()}
rows = []
for q in qrs:
prod = products.get(q.product_id)
batch = batches.get(q.batch_id)
brand = await _safe_get(Brand, prod.brand_id) if prod else None
if product_id and q.product_id != product_id:
continue
if batch_id and q.batch_id != batch_id:
continue
if status_filter and q.status != status_filter:
continue
if qr_type and q.qr_type != qr_type:
continue
if start_date:
start = datetime.fromisoformat(start_date)
if q.created_at.replace(tzinfo=None) < start:
continue
if end_date:
end = datetime.fromisoformat(end_date) + timedelta(days=1)
if q.created_at.replace(tzinfo=None) >= end:
continue
if brand_id and (not prod or prod.brand_id != brand_id):
continue
if search:
s = search.lower()
hay = f"{q.code} {prod.name if prod else ''} {prod.sku if prod else ''} {batch.batch_number if batch else ''}".lower()
if s not in hay:
continue
rows.append({
"id": str(q.id), "code": q.code, "serial": q.serial, "qr_type": q.qr_type,
"status": q.status, "scan_count": q.scan_count, "last_scan_at": q.last_scan_at,
"last_scan_location": q.last_scan_location,
"product_id": q.product_id, "product_name": prod.name if prod else None,
"sku": prod.sku if prod else None, "brand_id": prod.brand_id if prod else None,"brand_name": brand.name if brand else None,
"batch_id": q.batch_id, "batch_number": batch.batch_number if batch else None,
"image_url": q.image_url, "url": qrsvc.verify_url(q.code), "created_at": q.created_at,"created_by": q.created_by,
})
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows
@router.post("/bulk")
async def bulk_action(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""body = {ids: [...], action: 'disable'|'enable'|'archive'|'delete'}"""
ids = body.get("ids", [])
action = body.get("action")
n = 0
for qid in ids:
qr = await QRCode.get(qid)
if not qr or qr.org_id != str(p.org.id):
continue
if action == "disable":
qr.status = QRStatus.disabled
elif action == "enable":
qr.status = QRStatus.active
elif action == "archive":
qr.status = QRStatus.inactive
elif action == "delete":
await qr.delete(); n += 1; continue
else:
continue
await qr.save(); n += 1
return {"affected": n, "action": action}
async def _get_owned(qr_id: str, p: Principal) -> QRCode:
qr = await QRCode.get(qr_id)
if not qr or qr.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "QR not found")
return qr
@router.get("/{qr_id}")
async def get_qr(qr_id: str, p: Principal = Depends(require_org)):
qr = await _get_owned(qr_id, p)
return {"id": str(qr.id), "code": qr.code, "serial": qr.serial, "qr_type": qr.qr_type,
"status": qr.status, "scan_count": qr.scan_count, "style": qr.style.model_dump(),
"url": qrsvc.verify_url(qr.code), "snapshot": qr.snapshot}
@router.get("/{qr_id}/detail")
async def qr_detail(qr_id: str, p: Principal = Depends(require_org)):
"""Rich detail for the QR Details drawer: overview + scan stats + history."""
from datetime import datetime, timezone, timedelta
qr = await _get_owned(qr_id, p)
prod = await _safe_get(Product, qr.product_id)
batch = await _safe_get(Batch, qr.batch_id)
scans = await ScanLog.find(ScanLog.qr_id == str(qr.id)).to_list()
def aware(dt):
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
now_ = datetime.now(timezone.utc)
today = now_.date()
week_ago = now_ - timedelta(days=7)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
serial_suffix = f"-{qr.serial:04d}" if qr.serial else "-0001"
qr_id_label = f"QR-{batch.batch_number}{serial_suffix}" if batch else f"QR-{qr.code}"
return {
"id": str(qr.id), "code": qr.code, "qr_id_label": qr_id_label,
"status": qr.status, "qr_type": qr.qr_type,
"short_url": qrsvc.verify_url(qr.code),
"image_url": qr.image_url,
"overview": {
"product": prod.name if prod else None,
"sku": prod.sku if prod else None,
"batch_number": batch.batch_number if batch else None,
"created_by": qr.created_by,
"created_at": qr.created_at,
"redirect_url": qrsvc.verify_url(qr.code),
"verification_enabled": qr.status == QRStatus.active,
"expiry": "No Expiry",
"remarks": qr.remarks,
},
"stats": {
"total": qr.scan_count,
"today": sum(1 for s in scans if aware(s.scanned_at).date() == today),
"week": sum(1 for s in scans if aware(s.scanned_at) >= week_ago),
},
"history": [{"at": s.scanned_at, "city": s.city, "country": s.country,
"device": s.device} for s in scans[:25]],
}
@router.post("/{qr_id}/image")
async def upload_qr_image(qr_id: str,
payload: dict = Body(...),
p: Principal = Depends(require_org)):
"""Store the client-rendered WYSIWYG image (data URL) for a QR code.
payload = {"data_url": "data:image/png;base64,....", "format": "png"|"svg"}
"""
qr = await _get_owned(qr_id, p)
data_url = payload.get("data_url", "")
fmt = payload.get("format", "png")
if "," not in data_url:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid data URL")
b64 = data_url.split(",", 1)[1]
try:
content = base64.b64decode(b64)
except (binascii.Error, ValueError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bad base64")
mime = "image/svg+xml" if fmt == "svg" else "image/png"
url = await upload_file(content, f"{qr.code}.{fmt}", mime)
qr.image_url = url
await qr.save()
return {"image_url": url}
@router.get("/{qr_id}/download")
async def download_qr(qr_id: str, format: str = "png"):
# Public: the rendered QR only encodes the public verify URL — no private data.
# This lets <a>/<img> links (which can't send the Bearer header) work directly.
qr = await QRCode.get(qr_id)
if not qr:
raise HTTPException(status.HTTP_404_NOT_FOUND, "QR not found")
data, mime = qrsvc.render(qr.code, qr.style, format)
ext = {"image/svg+xml": "svg", "application/pdf": "pdf"}.get(mime, "png")
return Response(content=data, media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{qr.code}.{ext}"'})
@router.get("/batch/{batch_id}/download-zip")
async def download_zip(batch_id: str, format: str = "png"):
batch = await Batch.get(batch_id)
if not batch:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
qrs = await QRCode.find(QRCode.batch_id == batch_id).to_list()
if not qrs:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No QR codes for this batch")
style = qrs[0].style
data = qrsvc.zip_codes([q.code for q in qrs], style, format)
return Response(content=data, media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="batch_{batch.batch_number}.zip"'})
@router.put("/{qr_id}/disable")
async def disable_qr(qr_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
qr = await _get_owned(qr_id, p)
qr.status = QRStatus.disabled
await qr.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.disable",
module="qr",
details=f"Disabled QR {qr.code}",
).insert()
return {"message": "disabled"}
@router.post("/{qr_id}/regenerate")
async def regenerate_qr(qr_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Regenerate the code (free, no cost). Keeps style; old code stops resolving."""
qr = await _get_owned(qr_id, p)
qr.code = qrsvc.new_code()
qr.status = QRStatus.active
await qr.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.regenerate",
module="qr",
details=f"Regenerated QR {qr.code}",
).insert()
return {"id": str(qr.id), "code": qr.code, "url": qrsvc.verify_url(qr.code)}
from fastapi.responses import StreamingResponse
from io import BytesIO
from openpyxl import Workbook
from datetime import datetime, timedelta
@router.get("/export")
async def export_qr(
p: Principal = Depends(require_org),
product_id: Optional[str] = None,
batch_id: Optional[str] = None,
brand_id: Optional[str] = None,
qr_type: Optional[str] = None,
status_filter: Optional[str] = None,
search: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
products = {
str(x.id): x
for x in await Product.find(Product.org_id == str(p.org.id)).to_list()
}
brands = {
str(x.id): x
for x in await Brand.find(Brand.org_id == str(p.org.id)).to_list()
}
batches = {
str(x.id): x
for x in await Batch.find(Batch.org_id == str(p.org.id)).to_list()
}
wb = Workbook()
ws = wb.active
ws.title = "QR Codes"
ws.append([
"QR Code",
"Status",
"QR Type",
"Serial",
"Product",
"SKU",
"Brand",
"Category",
"Batch No",
"Quantity",
"Manufacture Date",
"Expiry Date",
"Scan Count",
"Last Scan",
"Last Scan Location",
"Created By",
"Created At",
"Verification URL",
])
for q in qrs:
product = products.get(q.product_id)
batch = batches.get(q.batch_id)
brand = brands.get(product.brand_id) if product else None
if product_id and q.product_id != product_id:
continue
if batch_id and q.batch_id != batch_id:
continue
if status_filter and q.status != status_filter:
continue
if qr_type and q.qr_type != qr_type:
continue
if brand_id and (not product or product.brand_id != brand_id):
continue
if search:
s = search.lower()
text = f"{q.code} {product.name if product else ''} {product.sku if product else ''} {batch.batch_number if batch else ''}".lower()
if s not in text:
continue
if start_date:
start = datetime.fromisoformat(start_date)
if q.created_at.replace(tzinfo=None) < start:
continue
if end_date:
end = datetime.fromisoformat(end_date) + timedelta(days=1)
if q.created_at.replace(tzinfo=None) >= end:
continue
ws.append([
q.code,
q.status,
q.qr_type,
q.serial,
product.name if product else "",
product.sku if product else "",
brand.name if brand else "",
product.category if product else "",
batch.batch_number if batch else "",
batch.quantity if batch else "",
str(batch.mfg_date) if batch else "",
str(batch.expiry_date) if batch else "",
q.scan_count,
str(q.last_scan_at) if q.last_scan_at else "",
q.last_scan_location,
q.created_by,
str(q.created_at),
qrsvc.verify_url(q.code),
])
stream = BytesIO()
wb.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": "attachment; filename=QR_Report.xlsx"
},
)

View File

@@ -0,0 +1,248 @@
"""Users & Organization — team management within an org, role-scoped."""
import secrets
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status
from app.core.config import settings
from app.core.deps import require_org, require_client_roles, Principal
from app.core.security import create_token
from app.models import User, OrgMember, Plan, AuditLog
from app.models.documents import now
from app.models.enums import ClientRole
from app.services.email import send_invite_email
import csv
from io import StringIO
from fastapi.responses import StreamingResponse
from app.core.security import hash_password
router = APIRouter(prefix="/users", tags=["users"])
async def _member_out(m: OrgMember) -> dict:
user = await User.get(m.user_id) if m.user_id else None
return {
"member_id": str(m.id),
"user_id": m.user_id,
"name": user.name if user else (m.invited_email or "Invited user"),
"email": user.email if user else m.invited_email,
"avatar_url": user.avatar_url if user else None,
"role": m.role,
"department": m.department,
"status": m.status,
"last_login": user.last_login if user else None,
"joined_at": m.joined_at or m.created_at,
}
from collections import Counter
@router.get("")
async def list_users(p: Principal = Depends(require_org)):
members = await OrgMember.find(OrgMember.org_id == str(p.org.id)).to_list()
rows = [await _member_out(m) for m in members]
role_summary = Counter()
for r in rows:
role_summary[r["role"]] += 1
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"items": rows,
"role_summary": dict(role_summary),
"kpis": {
"total": len(rows),
"active": sum(1 for r in rows if r["status"] == "active"),
"pending": sum(1 for r in rows if r["status"] == "pending"),
"disabled": sum(1 for r in rows if r["status"] == "disabled"),
"user_limit": plan.user_limit if plan else None,
},
}
@router.get("/export")
async def export_users(
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
members = await OrgMember.find(
OrgMember.org_id == str(p.org.id)
).to_list()
rows = [await _member_out(m) for m in members]
output = StringIO()
writer = csv.writer(output)
writer.writerow([
"Name",
"Email",
"Role",
"Department",
"Status",
"Last Login",
"Joined On",
])
for r in rows:
writer.writerow([
r["name"],
r["email"],
r["role"],
r["department"] or "",
r["status"],
r["last_login"] or "",
r["joined_at"] or "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition":
"attachment; filename=users.csv"
},
)
@router.post("/invite", status_code=201)
async def invite_user(
body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
email = body["email"].strip().lower()
if await User.find_one(User.email == email):
raise HTTPException(
status.HTTP_409_CONFLICT,
"Email already exists",
)
user = User(
name=body["name"],
email=email,
hashed_password=hash_password(body["password"]),
email_verified=True,
)
await user.insert()
member = OrgMember(
org_id=str(p.org.id),
user_id=str(user.id),
invited_email=email,
role=ClientRole(body["role"]),
department=body.get("department"),
status="active",
joined_at=now(),
)
await member.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
action="user.create",
module="users",
details=f"Created user {email}",
).insert()
return await _member_out(member)
@router.get("/{member_id}/activity")
async def user_activity(member_id: str,
p: Principal = Depends(require_org)):
logs = await (
AuditLog.find(
AuditLog.org_id == str(p.org.id),
AuditLog.target_member_id == member_id,
)
.sort(-AuditLog.created_at)
.to_list()
)
return {
"items": [
{
"id": str(log.id),
"action": log.action,
"module": log.module,
"details": log.details,
"created_at": log.created_at,
}
for log in logs
]
}
@router.put("/{member_id}")
async def update_member(
member_id: str,
body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
# Find member
m = await OrgMember.get(member_id)
if not m or m.org_id != str(p.org.id):
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Member not found"
)
# Find linked user
user = await User.get(m.user_id)
if user:
# Update name
if body.get("name"):
user.name = body["name"]
# Update email (optional)
if body.get("email"):
user.email = body["email"].strip().lower()
# Update password (optional)
if body.get("password"):
user.hashed_password = hash_password(body["password"])
await user.save()
# Update OrgMember fields
if "role" in body:
m.role = ClientRole(body["role"])
if "department" in body:
m.department = body["department"]
if (
"status" in body
and body["status"] in ("active", "disabled", "pending")
):
m.status = body["status"]
await m.save()
# Audit log
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
action="user.update",
module="users",
details=f"Updated member {member_id}",
).insert()
return await _member_out(m)
@router.delete("/{member_id}", status_code=204)
async def remove_member(member_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
m = await OrgMember.get(member_id)
if not m or m.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Member not found")
if m.user_id == str(p.user.id):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "You cannot remove yourself")
await m.delete()
p.org.user_count = max(1, p.org.user_count - 1)
await p.org.save()

View File

@@ -0,0 +1,85 @@
"""Public consumer verification — no auth. Single-read via the QR snapshot.
GET /verify/{code} is READ-ONLY (no scan logging) so renders, prefetches, bots
and React Strict Mode double-mounts never inflate the scan count.
The frontend logs a scan explicitly via POST /scans/log after the page mounts.
Must return valid data in Active, Grace, and Archived subscription states;
never 404 for a valid QR. Resolves the four states:
genuine | unverified | not_found | recalled.
"""
from fastapi import APIRouter, Request
from app.models import QRCode, Organization, ScanLog
from app.models.documents import now
from app.models.enums import QRStatus, VerificationState, SubscriptionStatus
router = APIRouter(tags=["verify"])
def _device_from_ua(ua: str) -> str:
ua = (ua or "").lower()
if "android" in ua:
return "Android"
if "iphone" in ua or "ipad" in ua or "ios" in ua:
return "iOS"
if "mobile" in ua:
return "Other"
return "Desktop"
def _resolve_state(qr: QRCode, product: dict) -> VerificationState:
if product.get("recalled"):
return VerificationState.recalled
if qr.status in (QRStatus.disabled, QRStatus.inactive, QRStatus.expired):
return VerificationState.unverified
return VerificationState.genuine
@router.get("/verify/{qr_code}")
async def verify(qr_code: str):
"""Read-only. Does NOT log a scan."""
qr = await QRCode.find_one(QRCode.code == qr_code)
if not qr:
return {"state": VerificationState.not_found,
"message": "This QR code is not registered with VerifyPack."}
org = await Organization.get(qr.org_id)
if org and org.subscription_status == SubscriptionStatus.deleted:
return {"state": VerificationState.not_found,
"message": "This product record is no longer available."}
snap = qr.snapshot or {}
product = snap.get("product", {})
state = _resolve_state(qr, product)
return {
"state": state,
"qr_code": qr.code,
"serial": qr.serial,
"brand": snap.get("brand"),
"product": product,
"batch": snap.get("batch"),
"trust_score": product.get("trust_score", 90),
"subscription_state": org.subscription_status if org else None,
"powered_by": "VerifyPack",
}
@router.post("/scans/log")
async def log_scan(qr_code: str, request: Request):
"""Record a single scan. Called once by the consumer page after it mounts."""
qr = await QRCode.find_one(QRCode.code == qr_code)
if not qr:
return {"logged": False}
ua = request.headers.get("user-agent", "")
try:
await ScanLog(org_id=qr.org_id, qr_id=str(qr.id), qr_code=qr.code,
product_id=qr.product_id, device=_device_from_ua(ua),
ip=request.client.host if request.client else None).insert()
qr.scan_count += 1
qr.last_scan_at = now()
await qr.save()
except Exception:
return {"logged": False}
return {"logged": True, "scan_count": qr.scan_count}

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel, EmailStr
class SignupIn(BaseModel):
name: str
email: EmailStr
password: str
class LoginIn(BaseModel):
email: EmailStr
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
email_verified: bool
name: str
email: EmailStr
class ForgotIn(BaseModel):
email: EmailStr
class ResetIn(BaseModel):
token: str
password: str
class MessageOut(BaseModel):
message: str

View File

@@ -0,0 +1,55 @@
from typing import Optional
from pydantic import BaseModel
from app.models.documents import (
ManufacturerInfo, ProductDetails, ComplianceBadge, QRStyle,
)
from app.models.enums import QRType, ProductStatus
class OrgCreate(BaseModel):
name: str
gstin: Optional[str] = None
pan: Optional[str] = None
contact_email: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
address: Optional[str] = None
class BrandCreate(BaseModel):
name: str
description: Optional[str] = None
logo_url: Optional[str] = None
class ProductCreate(BaseModel):
brand_id: str
name: str
sku: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
manufacturer: Optional[ManufacturerInfo] = None
details: Optional[ProductDetails] = None
compliance_badges: list[ComplianceBadge] = []
recycling_info: Optional[str] = None
internal_notes: Optional[str] = None
qr_type: str = "dynamic"
display_brand_logo: bool = True
status: ProductStatus = ProductStatus.draft
class BatchCreate(BaseModel):
product_id: str
batch_number: Optional[str] = None
mfg_date: Optional[str] = None
expiry_date: Optional[str] = None
quantity: int = 1
qr_type: QRType = QRType.batch
notes: Optional[str] = None
class QRGenerateIn(BaseModel):
batch_id: str
style: Optional[QRStyle] = None

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,59 @@
"""Transactional email via AWS SES, with a console mock fallback."""
from typing import Optional
from app.core.config import settings
from app.models import EmailLog
async def _log(to_email: str, subject: str, template: str, status: str = "sent", error: Optional[str] = None):
try:
await EmailLog(to_email=to_email, subject=subject, template=template,
status=status, error=error).insert()
except Exception:
pass
async def send_email(to_email: str, subject: str, body: str, template: str = "generic"):
if settings.mock_email or not settings.ses_configured:
print(f"\n[MOCK EMAIL] to={to_email} subject={subject}\n{body}\n")
await _log(to_email, subject, template, status="mocked")
return True
try:
import boto3
client = boto3.client(
"ses", region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
client.send_email(
Source=settings.ses_from_email,
Destination={"ToAddresses": [to_email]},
Message={"Subject": {"Data": subject},
"Body": {"Html": {"Data": body}}},
)
await _log(to_email, subject, template)
return True
except Exception as e: # noqa
await _log(to_email, subject, template, status="failed", error=str(e))
return False
async def send_verification_email(to_email: str, name: str, token: str, code: str = ""):
link = f"{settings.frontend_url}/verify-email?token={token}"
body = (f"Hi {name},\n\n"
f"Your VerifyPack verification code is: {code}\n\n"
f"Or click this link to verify: {link}\n\n"
f"This code expires in 30 minutes.")
await send_email(to_email, "Verify your VerifyPack email", body, "verify_email")
async def send_reset_email(to_email: str, name: str, token: str):
link = f"{settings.frontend_url}/reset-password?token={token}"
await send_email(to_email, "Reset your VerifyPack password",
f"Hi {name}, reset your password: {link}", "reset_password")
async def send_invite_email(to_email: str, org_name: str, token: str):
link = f"{settings.frontend_url}/signup?invite={token}"
await send_email(to_email, f"You're invited to {org_name} on VerifyPack",
f"Join {org_name}: {link}", "invite")

View File

@@ -0,0 +1,102 @@
"""Server-side GST invoice PDF (reportlab). INR, 18% GST."""
import io
from datetime import datetime
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
def _inr(paise_or_rupees: int) -> str:
return f"Rs. {paise_or_rupees:,.2f}"
def build_invoice_pdf(invoice: dict, org: dict) -> bytes:
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
y = h - 25 * mm
# Header
c.setFillColor(colors.HexColor("#0b1f4d"))
c.setFont("Helvetica-Bold", 20)
c.drawString(20 * mm, y, "VerifyPack")
c.setFont("Helvetica", 9)
c.setFillColor(colors.grey)
c.drawString(20 * mm, y - 6 * mm, "Tax Invoice")
c.setFont("Helvetica-Bold", 12)
c.setFillColor(colors.black)
c.drawRightString(w - 20 * mm, y, f"Invoice {invoice.get('invoice_number', '')}")
c.setFont("Helvetica", 9)
c.setFillColor(colors.grey)
date = invoice.get("created_at")
date_str = date.strftime("%d %b %Y") if isinstance(date, datetime) else str(date or "")
c.drawRightString(w - 20 * mm, y - 6 * mm, f"Date: {date_str}")
y -= 22 * mm
c.setStrokeColor(colors.HexColor("#e2e8f0"))
c.line(20 * mm, y, w - 20 * mm, y)
# Billed to
y -= 10 * mm
c.setFillColor(colors.black)
c.setFont("Helvetica-Bold", 10)
c.drawString(20 * mm, y, "Billed To")
c.setFont("Helvetica", 9)
c.drawString(20 * mm, y - 5 * mm, org.get("name", ""))
if org.get("gstin"):
c.drawString(20 * mm, y - 10 * mm, f"GSTIN: {org['gstin']}")
if org.get("address"):
c.drawString(20 * mm, y - 15 * mm, org["address"][:60])
c.setFont("Helvetica-Bold", 10)
c.drawRightString(w - 20 * mm, y, "From")
c.setFont("Helvetica", 9)
c.drawRightString(w - 20 * mm, y - 5 * mm, "VerifyPack Technologies")
c.drawRightString(w - 20 * mm, y - 10 * mm, "Bengaluru, Karnataka, India")
# Line items table
y -= 30 * mm
c.setFillColor(colors.HexColor("#f1f5f9"))
c.rect(20 * mm, y, w - 40 * mm, 8 * mm, fill=1, stroke=0)
c.setFillColor(colors.HexColor("#334155"))
c.setFont("Helvetica-Bold", 9)
c.drawString(23 * mm, y + 2.5 * mm, "Description")
c.drawRightString(w - 23 * mm, y + 2.5 * mm, "Amount")
subtotal = invoice.get("subtotal", 0)
gst = invoice.get("gst_amount", 0)
total = invoice.get("total", 0)
y -= 10 * mm
c.setFillColor(colors.black)
c.setFont("Helvetica", 9)
period = invoice.get("period", "")
c.drawString(23 * mm, y, f"{invoice.get('plan_name', 'Plan')} subscription {period}".strip())
c.drawRightString(w - 23 * mm, y, _inr(subtotal))
# Totals
y -= 14 * mm
c.line(w - 80 * mm, y + 6 * mm, w - 20 * mm, y + 6 * mm)
c.setFont("Helvetica", 9)
c.drawRightString(w - 55 * mm, y, "Subtotal")
c.drawRightString(w - 23 * mm, y, _inr(subtotal))
y -= 6 * mm
c.drawRightString(w - 55 * mm, y, "GST (18%)")
c.drawRightString(w - 23 * mm, y, _inr(gst))
y -= 8 * mm
c.setFont("Helvetica-Bold", 11)
c.drawRightString(w - 55 * mm, y, "Total")
c.drawRightString(w - 23 * mm, y, _inr(total))
# Footer
c.setFont("Helvetica", 8)
c.setFillColor(colors.grey)
c.drawString(20 * mm, 20 * mm, f"Place of supply: {invoice.get('place_of_supply', 'Karnataka')}")
c.drawString(20 * mm, 15 * mm, "This is a computer-generated invoice and does not require a signature.")
c.drawRightString(w - 20 * mm, 15 * mm, f"Status: {str(invoice.get('status', '')).upper()}")
c.showPage()
c.save()
return buf.getvalue()

View File

@@ -0,0 +1,70 @@
"""QR generation engine. No credit deduction — generation is free and unlimited."""
import io
import secrets
import zipfile
import segno
from app.core.config import settings
from app.models.documents import QRStyle
_EC_MAP = {"L": "l", "M": "m", "Q": "q", "H": "h"}
def new_code(prefix: str = "vp") -> str:
return f"{prefix}{secrets.token_urlsafe(8).replace('-', '').replace('_', '')[:10]}".lower()
def verify_url(code: str) -> str:
return f"{settings.frontend_url}/v/{code}"
def _make(code: str, style: QRStyle) -> segno.QRCode:
ec = _EC_MAP.get(style.error_correction.upper(), "m")
if style.logo_url:
ec = "h" # force high error correction when a logo is overlaid
return segno.make(verify_url(code), error=ec)
def render_png(code: str, style: QRStyle, scale: int = 10) -> bytes:
buf = io.BytesIO()
_make(code, style).save(buf, kind="png", scale=scale,
dark=style.fg_color, light=style.bg_color,
border=style.quiet_zone)
return buf.getvalue()
def render_svg(code: str, style: QRStyle, scale: int = 10) -> bytes:
buf = io.BytesIO()
_make(code, style).save(buf, kind="svg", scale=scale,
dark=style.fg_color, light=style.bg_color,
border=style.quiet_zone)
return buf.getvalue()
def render_pdf(code: str, style: QRStyle, scale: int = 10) -> bytes:
buf = io.BytesIO()
_make(code, style).save(buf, kind="pdf", scale=scale,
dark=style.fg_color, light=style.bg_color,
border=style.quiet_zone)
return buf.getvalue()
def render(code: str, style: QRStyle, fmt: str = "png") -> tuple[bytes, str]:
fmt = fmt.lower()
if fmt == "svg":
return render_svg(code, style), "image/svg+xml"
if fmt == "pdf":
return render_pdf(code, style), "application/pdf"
return render_png(code, style), "image/png"
def zip_codes(codes: list[str], style: QRStyle, fmt: str = "png") -> bytes:
"""Bundle per-pack QRs (Option 2) into a single downloadable ZIP."""
buf = io.BytesIO()
ext = {"svg": "svg", "pdf": "pdf"}.get(fmt.lower(), "png")
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for i, code in enumerate(codes, start=1):
data, _ = render(code, style, fmt)
zf.writestr(f"qr_{i:05d}_{code}.{ext}", data)
return buf.getvalue()

View File

@@ -0,0 +1,55 @@
"""Razorpay integration with a mock fallback for local dev.
When RAZORPAY_KEY_ID/SECRET are unset (or MOCK), create_order returns a fake
order and verify_signature always succeeds, so the whole billing flow can be
exercised end-to-end without real keys.
"""
import hashlib
import hmac
import secrets
from typing import Optional
from app.core.config import settings
def is_live() -> bool:
return settings.razorpay_configured
def create_order(amount_paise: int, receipt: str, notes: Optional[dict] = None) -> dict:
if not is_live():
return {
"id": f"order_mock_{secrets.token_hex(8)}",
"amount": amount_paise,
"currency": "INR",
"receipt": receipt,
"status": "created",
"mock": True,
}
import razorpay
client = razorpay.Client(auth=(settings.razorpay_key_id, settings.razorpay_key_secret))
return client.order.create({
"amount": amount_paise,
"currency": "INR",
"receipt": receipt,
"notes": notes or {},
})
def verify_payment_signature(order_id: str, payment_id: str, signature: str) -> bool:
if not is_live():
return True # mock: accept any payment
body = f"{order_id}|{payment_id}"
expected = hmac.new(
settings.razorpay_key_secret.encode(), body.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
if not settings.razorpay_webhook_secret:
return not is_live() # accept in mock mode only
expected = hmac.new(
settings.razorpay_webhook_secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)

View File

@@ -0,0 +1,46 @@
"""S3 uploads with presigned URLs, plus a local-filesystem mock."""
import os
import uuid
from app.core.config import settings
# Must match the StaticFiles mount in main.py (app/../_uploads = backend/app/_uploads)
LOCAL_DIR = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"..",
"..",
"_uploads",
)
)
async def upload_file(content: bytes, filename: str, content_type: str = "application/octet-stream") -> str:
key = f"{uuid.uuid4().hex}_{filename}"
if settings.mock_storage or not (settings.aws_access_key_id and settings.aws_secret_access_key):
os.makedirs(LOCAL_DIR, exist_ok=True)
path = os.path.join(LOCAL_DIR, key)
with open(path, "wb") as f:
f.write(content)
# served by FastAPI static mount at /uploads
return f"/uploads/{key}"
import boto3
client = boto3.client(
"s3", region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
client.put_object(Bucket=settings.s3_bucket, Key=key, Body=content, ContentType=content_type)
return presigned_url(key)
def presigned_url(key: str, expires: int = 3600) -> str:
import boto3
client = boto3.client(
"s3", region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
return client.generate_presigned_url(
"get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=expires
)