224 lines
10 KiB
Python
224 lines
10 KiB
Python
# /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": "finance"}
|
|
|
|
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="finance"),
|
|
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="finance"),
|
|
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": "finance"}
|
|
|
|
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="finance"),
|
|
nullable=False
|
|
)
|
|
|
|
# Státusz
|
|
status: Mapped[WithdrawalRequestStatus] = mapped_column(
|
|
PG_ENUM(WithdrawalRequestStatus, name="withdrawal_request_status", schema="finance"),
|
|
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 |