Epic 3: Economy & Billing Engine (Pénzügyi Motor)

This commit is contained in:
Roo
2026-03-08 23:15:52 +00:00
parent 8d25f44ec6
commit 4e40af8a08
69 changed files with 3758 additions and 72 deletions

View File

@@ -19,6 +19,7 @@ from .asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetFinancials,
# 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
from .payment import PaymentIntent, PaymentIntentStatus
# 7. Szolgáltatások és staging
from .service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter
@@ -56,10 +57,12 @@ __all__ = [
"Document", "Translation", "PendingAction",
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
"PaymentIntent", "PaymentIntentStatus",
"AuditLog", "VehicleOwnership", "LogSeverity",
"SecurityAuditLog", "ProcessLog", "FinancialLedger",
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter",
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType"
]
]
from app.models.payment import PaymentIntent, WithdrawalRequest

View File

@@ -1,9 +1,12 @@
# /opt/docker/dev/service_finder/backend/app/models/audit.py
import enum
import uuid
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import String, DateTime, JSON, ForeignKey, text, Numeric, Boolean, BigInteger, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
from app.database import Base
class SecurityAuditLog(Base):
@@ -48,6 +51,19 @@ class ProcessLog(Base):
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class LedgerEntryType(str, enum.Enum):
DEBIT = "DEBIT"
CREDIT = "CREDIT"
class WalletType(str, enum.Enum):
EARNED = "EARNED"
PURCHASED = "PURCHASED"
SERVICE_COINS = "SERVICE_COINS"
VOUCHER = "VOUCHER"
class FinancialLedger(Base):
""" Minden pénz- és kreditmozgás központi naplója. Billing Engine alapja. """
__tablename__ = "financial_ledger"
@@ -56,8 +72,21 @@ class FinancialLedger(Base):
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
currency: Mapped[Optional[str]] = mapped_column(String(10))
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
currency: Mapped[Optional[str]] = mapped_column(String(10))
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
related_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Új mezők doubleentry és okos levonáshoz
entry_type: Mapped[LedgerEntryType] = mapped_column(
PG_ENUM(LedgerEntryType, name="ledger_entry_type", schema="audit"),
nullable=False
)
balance_after: Mapped[Optional[float]] = mapped_column(Numeric(18, 4))
wallet_type: Mapped[Optional[WalletType]] = mapped_column(
PG_ENUM(WalletType, name="wallet_type", schema="audit")
)
transaction_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), default=uuid.uuid4, nullable=False, index=True
)

View File

@@ -124,6 +124,19 @@ class User(Base):
owned_organizations: Mapped[List["Organization"]] = relationship("Organization", back_populates="owner")
stats: Mapped[Optional["UserStats"]] = relationship("UserStats", back_populates="user", uselist=False, cascade="all, delete-orphan")
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="user")
# PaymentIntent kapcsolatok
payment_intents_as_payer: Mapped[List["PaymentIntent"]] = relationship(
"PaymentIntent",
foreign_keys="[PaymentIntent.payer_id]",
back_populates="payer"
)
withdrawal_requests: Mapped[List["WithdrawalRequest"]] = relationship("WithdrawalRequest", foreign_keys="[WithdrawalRequest.user_id]", back_populates="user", cascade="all, delete-orphan")
payment_intents_as_beneficiary: Mapped[List["PaymentIntent"]] = relationship(
"PaymentIntent",
foreign_keys="[PaymentIntent.beneficiary_id]",
back_populates="beneficiary"
)
@property
def tier_name(self) -> str:
@@ -143,6 +156,7 @@ class Wallet(Base):
currency: Mapped[str] = mapped_column(String(3), default="HUF")
user: Mapped["User"] = relationship("User", back_populates="wallet")
active_vouchers: Mapped[List["ActiveVoucher"]] = relationship("ActiveVoucher", back_populates="wallet", cascade="all, delete-orphan")
class VerificationToken(Base):
__tablename__ = "verification_tokens"
@@ -171,4 +185,20 @@ class SocialAccount(Base):
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User", back_populates="social_accounts")
user: Mapped["User"] = relationship("User", back_populates="social_accounts")
class ActiveVoucher(Base):
"""Aktív, le nem járt voucher-ek tárolása FIFO elv szerint."""
__tablename__ = "active_vouchers"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
wallet_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.wallets.id", ondelete="CASCADE"), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
original_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
wallet: Mapped["Wallet"] = relationship("Wallet", back_populates="active_vouchers")

View File

@@ -0,0 +1,224 @@
# /opt/docker/dev/service_finder/backend/app/models/payment.py
"""
Payment Intent modell a Stripe integrációhoz és belső fizetésekhez.
Kettős Lakat (Double Lock) biztonságot valósít meg.
"""
import enum
import uuid
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import String, DateTime, JSON, ForeignKey, Numeric, Boolean, Integer, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
from sqlalchemy.sql import func
from app.database import Base
from app.models.audit import WalletType
class PaymentIntentStatus(str, enum.Enum):
"""PaymentIntent státuszok."""
PENDING = "PENDING" # Létrehozva, vár Stripe fizetésre
PROCESSING = "PROCESSING" # Fizetés folyamatban (belső ajándékozás)
COMPLETED = "COMPLETED" # Sikeresen teljesítve
FAILED = "FAILED" # Sikertelen (pl. Stripe hiba)
CANCELLED = "CANCELLED" # Felhasználó által törölve
EXPIRED = "EXPIRED" # Lejárt (pl. Stripe session timeout)
class PaymentIntent(Base):
"""
Fizetési szándék (Prior Intent) a Kettős Lakat biztonsághoz.
Minden külső (Stripe) vagy belső fizetés előtt létre kell hozni egy PENDING
státuszú PaymentIntent-et. A Stripe metadata tartalmazza az intent_token-t,
így a webhook validáció során vissza lehet keresni.
Fontos mezők:
- net_amount: A kedvezményezett által kapott összeg (pénztárcába kerül)
- handling_fee: Kényelmi díj (rendszer bevétele)
- gross_amount: net_amount + handling_fee (Stripe-nak küldött összeg)
"""
__tablename__ = "payment_intents"
__table_args__ = {"schema": "audit"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Egyedi token a Stripe metadata számára
intent_token: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False, index=True
)
# Fizető felhasználó
payer_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
payer: Mapped["User"] = relationship("User", foreign_keys=[payer_id], back_populates="payment_intents_as_payer")
# Kedvezményezett felhasználó (opcionális, ha None, akkor a rendszernek fizet)
beneficiary_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
beneficiary: Mapped[Optional["User"]] = relationship("User", foreign_keys=[beneficiary_id], back_populates="payment_intents_as_beneficiary")
# Cél pénztárca típusa
target_wallet_type: Mapped[WalletType] = mapped_column(
PG_ENUM(WalletType, name="wallet_type", schema="audit"),
nullable=False
)
# Összeg mezők (javított a kényelmi díj kezelésére)
net_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False, comment="Kedvezményezett által kapott összeg")
handling_fee: Mapped[float] = mapped_column(Numeric(18, 4), default=0.0, comment="Kényelmi díj")
gross_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False, comment="Fizetendő összeg (net + fee)")
currency: Mapped[str] = mapped_column(String(10), default="EUR", nullable=False)
# Státusz
status: Mapped[PaymentIntentStatus] = mapped_column(
PG_ENUM(PaymentIntentStatus, name="payment_intent_status", schema="audit"),
default=PaymentIntentStatus.PENDING,
nullable=False,
index=True
)
# Stripe információk (külső fizetés esetén)
stripe_session_id: Mapped[Optional[str]] = mapped_column(String(255), unique=True, index=True)
stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column(String(255), index=True)
stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255))
# Metaadatok (metadata foglalt név SQLAlchemy-ban, ezért meta_data)
meta_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"), name="metadata")
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="Stripe session lejárati ideje")
# Tranzakció kapcsolat
transaction_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), comment="Kapcsolódó FinancialLedger transaction_id")
# Soft delete
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
def __repr__(self) -> str:
return f"<PaymentIntent {self.id}: {self.status} {self.gross_amount}{self.currency}>"
def mark_completed(self, transaction_id: Optional[uuid.UUID] = None) -> None:
"""PaymentIntent befejezése sikeres fizetés után."""
self.status = PaymentIntentStatus.COMPLETED
self.completed_at = datetime.utcnow()
if transaction_id:
self.transaction_id = transaction_id
def mark_failed(self, reason: Optional[str] = None) -> None:
"""PaymentIntent sikertelen státuszba helyezése."""
self.status = PaymentIntentStatus.FAILED
if reason and self.meta_data:
self.meta_data = {**self.meta_data, "failure_reason": reason}
def is_valid_for_webhook(self) -> bool:
"""Ellenőrzi, hogy a PaymentIntent érvényes-e webhook feldolgozásra."""
return (
self.status == PaymentIntentStatus.PENDING
and not self.is_deleted
and (self.expires_at is None or self.expires_at > datetime.utcnow())
)
# Import User modell a relationship-ekhez (circular import elkerülésére)
from app.models.identity import User
class WithdrawalPayoutMethod(str, enum.Enum):
"""Kifizetési módok."""
FIAT_BANK = "FIAT_BANK" # Banki átutalás (SEPA)
CRYPTO_USDT = "CRYPTO_USDT" # USDT (ERC20/TRC20)
class WithdrawalRequestStatus(str, enum.Enum):
"""Kifizetési kérelem státuszai."""
PENDING = "PENDING" # Beküldve, admin ellenőrzésre vár
APPROVED = "APPROVED" # Jóváhagyva, kifizetés folyamatban
REJECTED = "REJECTED" # Elutasítva (pl. hiányzó bizonylat)
COMPLETED = "COMPLETED" # Kifizetés teljesítve
CANCELLED = "CANCELLED" # Felhasználó által visszavonva
class WithdrawalRequest(Base):
"""
Kifizetési kérelem (Withdrawal Request) a felhasználók Earned zsebéből való pénzkivételhez.
A felhasználó beküld egy kérést, amely admin jóváhagyást igényel.
Ha 14 napon belül nem kerül jóváhagyásra, automatikusan REJECTED lesz és a pénz visszakerül a Earned zsebbe.
"""
__tablename__ = "withdrawal_requests"
__table_args__ = {"schema": "audit"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Felhasználó aki a kérést benyújtotta
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
user: Mapped["User"] = relationship("User", back_populates="withdrawal_requests", foreign_keys=[user_id])
# Összeg és pénznem
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
currency: Mapped[str] = mapped_column(String(10), default="EUR", nullable=False)
# Kifizetési mód
payout_method: Mapped[WithdrawalPayoutMethod] = mapped_column(
PG_ENUM(WithdrawalPayoutMethod, name="withdrawal_payout_method", schema="audit"),
nullable=False
)
# Státusz
status: Mapped[WithdrawalRequestStatus] = mapped_column(
PG_ENUM(WithdrawalRequestStatus, name="withdrawal_request_status", schema="audit"),
default=WithdrawalRequestStatus.PENDING,
nullable=False,
index=True
)
# Okozata (pl. admin megjegyzés vagy automatikus elutasítás oka)
reason: Mapped[Optional[str]] = mapped_column(String(500))
# Admin információk
approved_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
approved_by: Mapped[Optional["User"]] = relationship("User", foreign_keys=[approved_by_id])
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Tranzakció kapcsolat (ha a pénz visszakerül a zsebbe)
refund_transaction_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True))
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Soft delete
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
def __repr__(self) -> str:
return f"<WithdrawalRequest {self.id}: {self.status} {self.amount}{self.currency}>"
def approve(self, admin_user_id: int) -> None:
"""Admin jóváhagyás."""
self.status = WithdrawalRequestStatus.APPROVED
self.approved_by_id = admin_user_id
self.approved_at = datetime.utcnow()
self.reason = None
def reject(self, reason: str) -> None:
"""Admin elutasítás."""
self.status = WithdrawalRequestStatus.REJECTED
self.reason = reason
def cancel(self) -> None:
"""Felhasználó visszavonja a kérést."""
self.status = WithdrawalRequestStatus.CANCELLED
self.reason = "User cancelled"
def is_expired(self, days: int = 14) -> bool:
"""Ellenőrzi, hogy a kérelem lejárt-e (14 nap)."""
from datetime import timedelta
expiry_date = self.created_at + timedelta(days=days)
return datetime.utcnow() > expiry_date