Epic 3: Economy & Billing Engine (Pénzügyi Motor)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 double‑entry é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
|
||||
)
|
||||
@@ -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")
|
||||
224
backend/app/models/payment.py
Normal file
224
backend/app/models/payment.py
Normal 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
|
||||
Reference in New Issue
Block a user