Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 426 additions & 14 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ boto3 = "^1.37.16"
botocore = "^1.37.16"
httpx = "^0.28.1"
dotenv = "^0.9.9"
xrpl-py = "^4.1.0"
pymongo = "^4.11.3"
motor = "^3.7.0"

[build-system]
requires = ["poetry-core"]
Expand Down
9 changes: 9 additions & 0 deletions src/main/config/XrplConfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import os
from dotenv import load_dotenv
import xrpl

load_dotenv()

XRPL_RPC_URL = os.getenv("XRPL_RPC_URL")

client = xrpl.clients.JsonRpcClient(XRPL_RPC_URL)
10 changes: 10 additions & 0 deletions src/main/config/mongodb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import os
from dotenv import load_dotenv
from pymongo import MongoClient

load_dotenv()

MONGODB_URL = os.getenv("MONGODB_URL")

def get_mongo_client() -> MongoClient:
return MongoClient(MONGODB_URL + "?retryWrites=true")
4 changes: 4 additions & 0 deletions src/main/payments/dto/PaymentsRequestDto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pydantic import BaseModel

class PaymentsRequestDto(BaseModel):
file_id: str
Empty file.
56 changes: 56 additions & 0 deletions src/main/payments/repository/PaymentsRepository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pymongo import MongoClient
from src.main.config.mongodb import get_mongo_client

class PaymentsRepository:
def __init__(self):
client: MongoClient = get_mongo_client()
self.db = client["xrpedia-data"]
self.wallets_collection = self.db["wallets"]
self.document_collection = self.db["document_collection"]
self.transactions_collection = self.db["transactions"]

# MongoDB에서 user_id 기반으로 XRPL 지갑 정보 조회
def get_user_wallet(self, user_id: str):
return self.wallets_collection.find_one({"user_id": user_id}, {"_id": 0, "address": 1, "seed": 1})

# MongoDB에서 파일 정보 전체 조회 (price + owner_id 포함)
def get_file_info(self, file_id: str):
return self.document_collection.find_one(
{"file_id": file_id},
{"_id": 0, "price": 1, "uploader_id": 1}
)

# MongoDB에서 파일 가격 조회
def get_file_price(self, file_id: str):
file_info = self.document_collection.find_one(
{"file_id": file_id},
{"_id": 0, "price": 1}
)
return file_info["price"] if file_info else None

def get_user_profile(self, user_id: str):
return self.wallets_collection.find_one(
{"user_id": user_id},
{"_id": 0, "profile": 1}
)

def update_user_rlusd(self, user_id: str, new_rlusd: int):
self.wallets_collection.update_one(
{"user_id": user_id},
{"$set": {"rlusd": new_rlusd}}
)

def is_transaction_exist(self, user_id: str, file_id: str):
print(f"Checking if transaction exists for user_id: {user_id}, file_id: {file_id}")
transaction = self.transactions_collection.find_one(
{"user_id": user_id, "file_id": file_id}
)
if transaction:
print(f"Transaction found: {transaction}")
else:
print("Transaction not found")
return transaction is not None

def save_transaction(self, tx_data: dict):
print(f"Saving transaction: {tx_data}")
self.transactions_collection.insert_one(tx_data)
Empty file.
21 changes: 21 additions & 0 deletions src/main/payments/router/PaymentsAPIRouter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from fastapi.concurrency import run_in_threadpool
from src.main.payments.dto.PaymentsRequestDto import PaymentsRequestDto
from src.main.payments.service.PaymentsService import PaymentsService
from src.main.auth.dependencies import get_current_user
import uuid

router = APIRouter(
prefix="/payments",
tags=["payments"],
)

@router.post("/_request")
async def request_payment(
request: PaymentsRequestDto,
user_id: uuid.UUID = Depends(get_current_user),
payments_service: PaymentsService = Depends()
):
return await run_in_threadpool(
payments_service.request_payment, str(user_id), request.file_id
)
Empty file.
162 changes: 162 additions & 0 deletions src/main/payments/service/PaymentsService.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import asyncio
from xrpl.wallet import Wallet
from xrpl.utils import xrp_to_drops
from xrpl.asyncio.clients import AsyncJsonRpcClient
from src.main.payments.repository.PaymentsRepository import PaymentsRepository
from src.main.config.XrplConfig import client
from fastapi import HTTPException
from datetime import datetime, UTC
from src.main.config.XrplConfig import client as sync_client
from xrpl.clients import JsonRpcClient
from xrpl.transaction import (
autofill_and_sign,
submit_and_wait,
XRPLReliableSubmissionException,
)
from xrpl.models.transactions import Transaction, Payment

client = JsonRpcClient("https://s.altnet.rippletest.net:51234/")

NET_DISCOUNT_RATE = {
"bronze": 0,
"silver": 0.05,
"gold": 0.10,
"platinum": 0.20
}

def submit_transaction(
client: JsonRpcClient,
wallet: Wallet,
transaction: Transaction,
check_fee: bool = True,
) -> dict:
signed_tx = autofill_and_sign(
transaction=transaction,
client=client,
wallet=wallet,
check_fee=check_fee,
)

signed_tx.validate()

response = submit_and_wait(transaction=signed_tx, client=client, wallet=wallet)

if not response.is_successful():
raise XRPLReliableSubmissionException(response.result)

return response.result


class PaymentsService:
def __init__(self):
self.payments_repository = PaymentsRepository()

# XRPL 결제 요청 처리
def request_payment(self, user_id: str, file_id: str):
if self.payments_repository.is_transaction_exist(user_id, file_id):
raise HTTPException(status_code=409, detail="이미 결제한 항목입니다.")

# 사용자 XRPL 지갑 주소 가져오기
wallet_data = self.payments_repository.get_user_wallet(user_id)
if not wallet_data or "seed" not in wallet_data or "address" not in wallet_data:
raise HTTPException(status_code=404, detail="사용자의 XRPL 지갑 정보가가 존재하지 않습니다.")

sender_secret = wallet_data["seed"]
sender_wallet_address = wallet_data["address"]

print(f"[INFO] Sender wallet address: {sender_wallet_address}")

# 파일 정보 조회 (가격 + 판매자 ID)
file_info = self.payments_repository.get_file_info(file_id)
if not file_info:
return HTTPException(status_code=404, detail="파일 정보 없음")

file_price = file_info["price"]
seller_id = file_info["uploader_id"]

# 판매자의 지갑 주소 가져오기
receiver_wallet_address = self.payments_repository.get_user_wallet(seller_id)
if not receiver_wallet_address:
return HTTPException(status_cod=404, detail="판매자의 XRPL 지갑 없음")

receiver_wallet_address = receiver_wallet_address["address"]

user_profile = self.payments_repository.get_user_profile(user_id)
profile = user_profile.get("profile", {})
user_rlusd = profile.get("rlusd", 0)
nft_rank = profile.get("nft_rank", "bronze").lower()
max_discount_rate = NET_DISCOUNT_RATE.get(nft_rank, 0)

max_rlusd_usable = int(file_price * max_discount_rate)
use_rlusd = min(user_rlusd, max_rlusd_usable)
discounted_price = file_price - use_rlusd

sender_wallet = Wallet.from_seed(sender_secret)

from xrpl.models.requests import AccountInfo
try:
print(f"[CHECK] XRPL에 등록된 지갑인지 확인 중... {sender_wallet.classic_address}")
info = client.request(AccountInfo(account=sender_wallet.classic_address))
print(f"[SUCCESS] Account exists! info: {info.result}")
except Exception as e:
print(f"[FAIL] XRPL에 계정 없음 (actNotFound 가능성): {e}")
raise HTTPException(status_code=500, detail=f"XRPL 지갑이 네트워크에 존재하지 않음: {e}")


# 결제 트랜잭션 생성 및 전송
payment_tx = Payment(
account=sender_wallet.classic_address,
amount=xrp_to_drops(discounted_price),
destination=str(receiver_wallet_address)
)

# 동기적으로 autofill_and_sign 호출 (스레드에서 실행)
try:
print("[ACTION] 서명 및 전송 시작")

result = submit_transaction(
client=client,
wallet=sender_wallet,
transaction=payment_tx,
check_fee=True
)
except XRPLReliableSubmissionException as e:

print(f"[ERROR] XRPLReliableSubmissionException: {e}")

raise HTTPException(status_code=400, detail=f"트랜잭션 실패: {str(e)}")
except Exception as e:

print(f"[ERROR] 기타 XRPL 오류: {e}")

raise HTTPException(status_code=500, detail=f"XRPL 처리 중 오류: {e}")

tx_hash = result.get("tx_json", {}).get("hash", "Unknown")

self.payments_repository.update_user_rlusd(user_id, user_rlusd - use_rlusd)
self.payments_repository.save_transaction({
"user_id": user_id,
"file_id": file_id,
"transaction_hash": tx_hash,
"price" : file_price,
"amount": discounted_price,
"discounted_amount": use_rlusd,
"nft_rank": nft_rank,
"timestamp": datetime.now(UTC)
})

return {
"status": "success",
"message": "결제 완료",
"original_price": file_price,
"discounted_price": discounted_price,
"used_rlusd": use_rlusd,
"nft_rank": nft_rank,
"transaction_hash": tx_hash
}

def confirm_payment(self, file_id: str, payment_intent_id: str):
return {
"status": "success",
"message": "결제 확인 완료"
}
Empty file.
4 changes: 3 additions & 1 deletion src/router.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from fastapi import APIRouter

from src.main.health.router import HealthAPIRouter
from src.main.payments.router import PaymentsAPIRouter


router = APIRouter(
prefix="",
)

router.include_router(HealthAPIRouter.router)
router.include_router(HealthAPIRouter.router)
router.include_router(PaymentsAPIRouter.router)
Empty file added src/tests/payments/__init__.py
Empty file.
64 changes: 64 additions & 0 deletions src/tests/payments/test_payments_api_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# import pytest
# from unittest.mock import MagicMock, patch
# from src.main.payments.service.PaymentsService import PaymentsService

# # 테스트용 고정 값 -- 바꿔야 합니다.
# TEST_USER_ID = "11111111-2222-3333-6666-555555555555"
# TEST_FILE_ID = "67de75eca76bd4020aab2da5"
# TEST_SEED = "snYP7oArxKepd3GPDcrjMsJYiJeJB" # 테스트넷 용 시드
# TEST_SELLER_ID = "11111111-2222-3333-4444-555555555555"
# TEST_SELLER_WALLET = "rKrLduwLhgCtgXaoqCQJJE1RRzRXPQ4UPR"

# @pytest.fixture
# def mock_repository():
# repo = MagicMock()
# # 거래 이력 없음
# repo.is_transaction_exist.return_value = False

# # 구매자 지갑 주소
# repo.get_user_wallet.side_effect = lambda user_id: (
# "rL2NkVRopQ7c3VsB6LfCrMF4ZBLzDRdGXg" if user_id == TEST_USER_ID else TEST_SELLER_WALLET
# )

# # 파일 정보
# repo.get_file_info.return_value = {
# "price": 10,
# "uploader_id": TEST_SELLER_ID
# }

# # 유저 프로필
# repo.get_user_profile.return_value = {
# "profile": {
# "rlusd": 5,
# "nft_rank": "gold"
# }
# }

# return repo

# @patch("src.main.payments.service.PaymentsService.reliable_submission")
# @patch("src.main.payments.service.PaymentsService.autofill_and_sign")
# def test_request_payment_success(mock_sign_tx, mock_submit_tx, mock_repository):
# # XRPL 트랜잭션 결과 mocking
# mock_submit_result = MagicMock()
# mock_submit_result.result = {"engine_result": "tesSUCCESS", "tx_json": {"hash": "ABC123HASH"}}
# mock_submit_tx.return_value = mock_submit_result

# # 서명된 트랜잭션도 더미로 넘김
# mock_sign_tx.return_value = MagicMock()

# service = PaymentsService()
# service.payments_repository = mock_repository

# response = service.request_payment(TEST_USER_ID, TEST_FILE_ID, TEST_SEED)

# assert response["status"] == "success"
# assert response["original_price"] == 10
# assert response["used_rlusd"] == 1 # 10% of 10
# assert response["discounted_price"] == 9
# assert response["nft_rank"] == "gold"
# assert response["transaction_hash"] == "ABC123HASH"

# # DB에 저장했는지 확인
# mock_repository.update_user_rlusd.assert_called_once_with(TEST_USER_ID, 4)
# mock_repository.save_transaction.assert_called_once()