feat: stabilize KYC, international assets and multi-currency schema

- Split mother's name in KYC (last/first)
- Added mileage_unit and fuel_type to Assets
- Expanded AssetCost for international VAT and original currency
- Fixed SQLAlchemy IndexError in asset catalog lookup
- Added exchange_rate and ratings tables to models
This commit is contained in:
2026-02-08 23:41:07 +00:00
parent 451900ae1a
commit 24d35fe0c1
34 changed files with 709 additions and 347 deletions

View File

@@ -4,28 +4,32 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
# SQLAlchemy importok
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, cast, String, func
from sqlalchemy import select, and_
from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
# Modell és Schema importok - EZ HIÁNYZOTT!
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.organization import Organization
from app.schemas.auth import UserLiteRegister, UserKYCComplete # <--- Ez javítja a hibát
from app.models.gamification import UserStats # <--- Innen importáljuk mostantól!
from app.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password
from app.services.email_manager import email_manager
from app.core.config import settings
from app.services.config_service import config # A dinamikus beállításokhoz
from app.services.config_service import config
from app.services.geo_service import GeoService
logger = logging.getLogger(__name__)
class AuthService:
@staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""Step 1: Alapszintű regisztráció..."""
"""
Step 1: Lite Regisztráció (Master Book 1.1)
Új User és ideiglenes Person rekord létrehozása.
"""
try:
# 1. Person alap létrehozása
# Ideiglenes Person rekord a KYC-ig
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
@@ -34,36 +38,29 @@ class AuthService:
db.add(new_person)
await db.flush()
# 2. User fiók
new_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
person_id=new_person.id,
role=UserRole.user,
is_active=False,
is_deleted=False,
region_code=user_in.region_code
)
db.add(new_user)
await db.flush()
# --- DINAMIKUS TOKEN LEJÁRAT ---
reg_hours = await config.get_setting(
"auth_registration_hours",
region_code=user_in.region_code,
default=48
)
# Regisztrációs token generálása
reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48)
token_val = uuid.uuid4()
new_token = VerificationToken(
db.add(VerificationToken(
token=token_val,
user_id=new_user.id,
token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
)
db.add(new_token)
await db.flush()
# 4. Email küldés
))
# Email küldés (Master Book 3.2: Nincs manuális subject)
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
recipient=user_in.email,
@@ -80,32 +77,139 @@ class AuthService:
raise e
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
"""Jelszó-visszaállítás indítása dinamikus lejárattal."""
stmt = select(User).where(User.email == email, User.is_deleted == False)
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if user:
now = datetime.now(timezone.utc)
# --- DINAMIKUS JELSZÓ RESET LEJÁRAT ---
reset_hours = await config.get_setting(
"auth_password_reset_hours",
region_code=user.region_code,
default=2
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""
1.3. Fázis: Atomi Tranzakció & Shadow Identity
Felismeri a visszatérő Person-t, de új User-ként, izolált flottával indít.
"""
try:
# 1. Aktuális technikai User lekérése
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if not user: return None
# 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján)
# Globális keresés, régiótól függetlenül
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
Person.birth_place == kyc_in.birth_place,
Person.birth_date == kyc_in.birth_date
))
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
if existing_person:
# Visszatérő identitás: A User-t a régi Person-hoz kötjük
user.person_id = existing_person.id
active_person = existing_person
logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
else:
active_person = user.person
# 3. Címkezelés
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=kyc_in.address_zip,
city=kyc_in.address_city,
street_name=kyc_in.address_street_name,
street_type=kyc_in.address_street_type,
house_number=kyc_in.address_house_number,
parcel_id=kyc_in.address_hrsz
)
# ... (Rate limit ellenőrzés marad változatlan) ...
# 4. Person adatok frissítése (mindig a legfrissebbet tároljuk)
active_person.mothers_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place
active_person.birth_date = kyc_in.birth_date
active_person.phone = kyc_in.phone_number
active_person.address_id = addr_id
active_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3)
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
org_type=OrgType.individual,
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified"
)
db.add(new_org)
await db.flush()
# 6. Tagság és Jogosultságok
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
role="owner",
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# 7. Wallet & Stats (Friss kezdés 0 ponttal)
db.add(Wallet(user_id=user.id, coin_balance=0, credit_balance=0))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás
user.is_active = True
await db.commit()
await db.refresh(user)
return user
except Exception as e:
await db.rollback()
logger.error(f"KYC Atomi Tranzakció Hiba: {str(e)}")
raise e
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(
and_(
VerificationToken.token == token_uuid,
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
res = await db.execute(stmt)
token = res.scalar_one_or_none()
if not token: return False
token.is_used = True
await db.commit()
return True
except:
return False
@staticmethod
async def authenticate(db: AsyncSession, email: str, password: str):
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if user and verify_password(password, user.hashed_password):
return user
return None
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
# Csak aktív (nem törölt) felhasználónak engedünk jelszót resetelni
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
reset_hours = await config.get_setting("auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4()
new_token = VerificationToken(
db.add(VerificationToken(
token=token_val,
user_id=user.id,
token_type="password_reset",
expires_at=now + timedelta(hours=int(reset_hours))
)
db.add(new_token)
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
))
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email(
@@ -115,7 +219,30 @@ class AuthService:
)
await db.commit()
return "success"
return "not_found"
# ... (többi metódus: verify_email, complete_kyc, authenticate, reset_password maradnak) ...
@staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).join(User).where(
and_(
User.email == email,
VerificationToken.token == token_uuid,
VerificationToken.token_type == "password_reset",
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
token_rec = (await db.execute(stmt)).scalar_one_or_none()
if not token_rec: return False
user_stmt = select(User).where(User.id == token_rec.user_id)
user = (await db.execute(user_stmt)).scalar_one()
user.hashed_password = get_password_hash(new_password)
token_rec.is_used = True
await db.commit()
return True
except:
return False