test
This commit is contained in:
54
apps/coordinator-api/migrations/004_payments.sql
Normal file
54
apps/coordinator-api/migrations/004_payments.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- Migration: Add payment support
|
||||
-- Date: 2026-01-26
|
||||
|
||||
-- Add payment tracking to jobs table
|
||||
ALTER TABLE job
|
||||
ADD COLUMN payment_id VARCHAR(255) REFERENCES job_payments(id),
|
||||
ADD COLUMN payment_status VARCHAR(20);
|
||||
|
||||
-- Create job_payments table
|
||||
CREATE TABLE IF NOT EXISTS job_payments (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
job_id VARCHAR(255) NOT NULL,
|
||||
amount DECIMAL(20, 8) NOT NULL,
|
||||
currency VARCHAR(10) DEFAULT 'AITBC',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
payment_method VARCHAR(20) DEFAULT 'aitbc_token',
|
||||
escrow_address VARCHAR(100),
|
||||
refund_address VARCHAR(100),
|
||||
transaction_hash VARCHAR(100),
|
||||
refund_transaction_hash VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
escrowed_at TIMESTAMP,
|
||||
released_at TIMESTAMP,
|
||||
refunded_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
metadata JSON
|
||||
);
|
||||
|
||||
-- Create payment_escrows table
|
||||
CREATE TABLE IF NOT EXISTS payment_escrows (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
payment_id VARCHAR(255) NOT NULL,
|
||||
amount DECIMAL(20, 8) NOT NULL,
|
||||
currency VARCHAR(10) DEFAULT 'AITBC',
|
||||
address VARCHAR(100) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_released BOOLEAN DEFAULT FALSE,
|
||||
is_refunded BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
released_at TIMESTAMP,
|
||||
refunded_at TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_job_payments_job_id ON job_payments(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_payments_status ON job_payments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_job_payments_created_at ON job_payments(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_escrows_payment_id ON payment_escrows(payment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_escrows_address ON payment_escrows(address);
|
||||
|
||||
-- Add index for job payment_id
|
||||
CREATE INDEX IF NOT EXISTS idx_job_payment_id ON job(payment_id);
|
||||
@@ -4,8 +4,8 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, JSON
|
||||
from sqlmodel import Field, SQLModel
|
||||
from sqlalchemy import Column, JSON, String
|
||||
from sqlmodel import Field, SQLModel, Relationship
|
||||
|
||||
from ..types import JobState
|
||||
|
||||
@@ -28,3 +28,10 @@ class Job(SQLModel, table=True):
|
||||
receipt: Optional[dict] = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
receipt_id: Optional[str] = Field(default=None, index=True)
|
||||
error: Optional[str] = None
|
||||
|
||||
# Payment tracking
|
||||
payment_id: Optional[str] = Field(default=None, foreign_key="job_payments.id", index=True)
|
||||
payment_status: Optional[str] = Field(default=None, max_length=20) # pending, escrowed, released, refunded
|
||||
|
||||
# Relationships
|
||||
payment: Optional["JobPayment"] = Relationship(back_populates="jobs")
|
||||
|
||||
74
apps/coordinator-api/src/app/domain/payment.py
Normal file
74
apps/coordinator-api/src/app/domain/payment.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Payment domain model"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Numeric, ForeignKey
|
||||
from sqlmodel import Field, SQLModel, Relationship
|
||||
|
||||
from ..schemas.payments import PaymentStatus, PaymentMethod
|
||||
|
||||
|
||||
class JobPayment(SQLModel, table=True):
|
||||
"""Payment record for a job"""
|
||||
|
||||
__tablename__ = "job_payments"
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True)
|
||||
job_id: str = Field(index=True)
|
||||
|
||||
# Payment details
|
||||
amount: float = Field(sa_column=Column(Numeric(20, 8), nullable=False))
|
||||
currency: str = Field(default="AITBC", max_length=10)
|
||||
status: PaymentStatus = Field(default=PaymentStatus.PENDING)
|
||||
payment_method: PaymentMethod = Field(default=PaymentMethod.AITBC_TOKEN)
|
||||
|
||||
# Addresses
|
||||
escrow_address: Optional[str] = Field(default=None, max_length=100)
|
||||
refund_address: Optional[str] = Field(default=None, max_length=100)
|
||||
|
||||
# Transaction hashes
|
||||
transaction_hash: Optional[str] = Field(default=None, max_length=100)
|
||||
refund_transaction_hash: Optional[str] = Field(default=None, max_length=100)
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
escrowed_at: Optional[datetime] = None
|
||||
released_at: Optional[datetime] = None
|
||||
refunded_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
# Additional metadata
|
||||
metadata: Optional[dict] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
jobs: List["Job"] = Relationship(back_populates="payment")
|
||||
|
||||
|
||||
class PaymentEscrow(SQLModel, table=True):
|
||||
"""Escrow record for holding payments"""
|
||||
|
||||
__tablename__ = "payment_escrows"
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex, primary_key=True, index=True)
|
||||
payment_id: str = Field(index=True)
|
||||
|
||||
# Escrow details
|
||||
amount: float = Field(sa_column=Column(Numeric(20, 8), nullable=False))
|
||||
currency: str = Field(default="AITBC", max_length=10)
|
||||
address: str = Field(max_length=100)
|
||||
|
||||
# Status
|
||||
is_active: bool = Field(default=True)
|
||||
is_released: bool = Field(default=False)
|
||||
is_refunded: bool = Field(default=False)
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
released_at: Optional[datetime] = None
|
||||
refunded_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
@@ -16,6 +16,7 @@ from .routers import (
|
||||
marketplace_offers,
|
||||
zk_applications,
|
||||
explorer,
|
||||
payments,
|
||||
)
|
||||
from .routers import zk_applications
|
||||
from .routers.governance import router as governance
|
||||
@@ -48,6 +49,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(exchange, prefix="/v1")
|
||||
app.include_router(users, prefix="/v1/users")
|
||||
app.include_router(services, prefix="/v1")
|
||||
app.include_router(payments, prefix="/v1")
|
||||
app.include_router(marketplace_offers, prefix="/v1")
|
||||
app.include_router(zk_applications.router, prefix="/v1")
|
||||
app.include_router(governance, prefix="/v1")
|
||||
|
||||
@@ -9,6 +9,7 @@ from .services import router as services
|
||||
from .users import router as users
|
||||
from .exchange import router as exchange
|
||||
from .marketplace_offers import router as marketplace_offers
|
||||
from .payments import router as payments
|
||||
# from .registry import router as registry
|
||||
|
||||
__all__ = ["client", "miner", "admin", "marketplace", "explorer", "services", "users", "exchange", "marketplace_offers", "registry"]
|
||||
__all__ = ["client", "miner", "admin", "marketplace", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "registry"]
|
||||
|
||||
@@ -2,12 +2,15 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..deps import require_client_key
|
||||
from ..schemas import JobCreate, JobView, JobResult
|
||||
from ..schemas.payments import JobPaymentCreate, PaymentMethod
|
||||
from ..types import JobState
|
||||
from ..services import JobService
|
||||
from ..services.payments import PaymentService
|
||||
from ..storage import SessionDep
|
||||
|
||||
router = APIRouter(tags=["client"])
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=JobView, status_code=status.HTTP_201_CREATED, summary="Submit a job")
|
||||
async def submit_job(
|
||||
req: JobCreate,
|
||||
@@ -16,6 +19,22 @@ async def submit_job(
|
||||
) -> JobView: # type: ignore[arg-type]
|
||||
service = JobService(session)
|
||||
job = service.create_job(client_id, req)
|
||||
|
||||
# Create payment if amount is specified
|
||||
if req.payment_amount and req.payment_amount > 0:
|
||||
payment_service = PaymentService(session)
|
||||
payment_create = JobPaymentCreate(
|
||||
job_id=job.id,
|
||||
amount=req.payment_amount,
|
||||
currency=req.payment_currency,
|
||||
payment_method=PaymentMethod.AITBC_TOKEN # Jobs use AITBC tokens
|
||||
)
|
||||
payment = await payment_service.create_payment(job.id, payment_create)
|
||||
job.payment_id = payment.id
|
||||
job.payment_status = payment.status.value
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
return service.to_view(job)
|
||||
|
||||
|
||||
|
||||
171
apps/coordinator-api/src/app/routers/payments.py
Normal file
171
apps/coordinator-api/src/app/routers/payments.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Payment router for job payments"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
|
||||
from ..deps import require_client_key
|
||||
from ..schemas.payments import (
|
||||
JobPaymentCreate,
|
||||
JobPaymentView,
|
||||
PaymentRequest,
|
||||
PaymentReceipt,
|
||||
EscrowRelease,
|
||||
RefundRequest
|
||||
)
|
||||
from ..services.payments import PaymentService
|
||||
from ..storage import SessionDep
|
||||
|
||||
router = APIRouter(tags=["payments"])
|
||||
|
||||
|
||||
@router.post("/payments", response_model=JobPaymentView, status_code=status.HTTP_201_CREATED, summary="Create payment for a job")
|
||||
async def create_payment(
|
||||
payment_data: JobPaymentCreate,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> JobPaymentView:
|
||||
"""Create a payment for a job"""
|
||||
|
||||
service = PaymentService(session)
|
||||
payment = await service.create_payment(payment_data.job_id, payment_data)
|
||||
|
||||
return service.to_view(payment)
|
||||
|
||||
|
||||
@router.get("/payments/{payment_id}", response_model=JobPaymentView, summary="Get payment details")
|
||||
async def get_payment(
|
||||
payment_id: str,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> JobPaymentView:
|
||||
"""Get payment details by ID"""
|
||||
|
||||
service = PaymentService(session)
|
||||
payment = service.get_payment(payment_id)
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found"
|
||||
)
|
||||
|
||||
return service.to_view(payment)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/payment", response_model=JobPaymentView, summary="Get payment for a job")
|
||||
async def get_job_payment(
|
||||
job_id: str,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> JobPaymentView:
|
||||
"""Get payment information for a specific job"""
|
||||
|
||||
service = PaymentService(session)
|
||||
payment = service.get_job_payment(job_id)
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found for this job"
|
||||
)
|
||||
|
||||
return service.to_view(payment)
|
||||
|
||||
|
||||
@router.post("/payments/{payment_id}/release", response_model=dict, summary="Release payment from escrow")
|
||||
async def release_payment(
|
||||
payment_id: str,
|
||||
release_data: EscrowRelease,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> dict:
|
||||
"""Release payment from escrow (for completed jobs)"""
|
||||
|
||||
service = PaymentService(session)
|
||||
|
||||
# Verify the payment belongs to the client's job
|
||||
payment = service.get_payment(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found"
|
||||
)
|
||||
|
||||
success = await service.release_payment(
|
||||
release_data.job_id,
|
||||
payment_id,
|
||||
release_data.reason
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to release payment"
|
||||
)
|
||||
|
||||
return {"status": "released", "payment_id": payment_id}
|
||||
|
||||
|
||||
@router.post("/payments/{payment_id}/refund", response_model=dict, summary="Refund payment")
|
||||
async def refund_payment(
|
||||
payment_id: str,
|
||||
refund_data: RefundRequest,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> dict:
|
||||
"""Refund payment (for failed or cancelled jobs)"""
|
||||
|
||||
service = PaymentService(session)
|
||||
|
||||
# Verify the payment belongs to the client's job
|
||||
payment = service.get_payment(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found"
|
||||
)
|
||||
|
||||
success = await service.refund_payment(
|
||||
refund_data.job_id,
|
||||
payment_id,
|
||||
refund_data.reason
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to refund payment"
|
||||
)
|
||||
|
||||
return {"status": "refunded", "payment_id": payment_id}
|
||||
|
||||
|
||||
@router.get("/payments/{payment_id}/receipt", response_model=PaymentReceipt, summary="Get payment receipt")
|
||||
async def get_payment_receipt(
|
||||
payment_id: str,
|
||||
session: SessionDep,
|
||||
client_id: str = Depends(require_client_key()),
|
||||
) -> PaymentReceipt:
|
||||
"""Get payment receipt with verification status"""
|
||||
|
||||
service = PaymentService(session)
|
||||
payment = service.get_payment(payment_id)
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found"
|
||||
)
|
||||
|
||||
receipt = PaymentReceipt(
|
||||
payment_id=payment.id,
|
||||
job_id=payment.job_id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
status=payment.status,
|
||||
transaction_hash=payment.transaction_hash,
|
||||
created_at=payment.created_at,
|
||||
verified_at=payment.released_at or payment.refunded_at
|
||||
)
|
||||
|
||||
return receipt
|
||||
@@ -66,6 +66,8 @@ class JobCreate(BaseModel):
|
||||
payload: Dict[str, Any]
|
||||
constraints: Constraints = Field(default_factory=Constraints)
|
||||
ttl_seconds: int = 900
|
||||
payment_amount: Optional[float] = None # Amount to pay for the job
|
||||
payment_currency: str = "AITBC" # Jobs paid with AITBC tokens
|
||||
|
||||
|
||||
class JobView(BaseModel):
|
||||
@@ -75,6 +77,8 @@ class JobView(BaseModel):
|
||||
requested_at: datetime
|
||||
expires_at: datetime
|
||||
error: Optional[str] = None
|
||||
payment_id: Optional[str] = None
|
||||
payment_status: Optional[str] = None
|
||||
|
||||
|
||||
class JobResult(BaseModel):
|
||||
|
||||
85
apps/coordinator-api/src/app/schemas/payments.py
Normal file
85
apps/coordinator-api/src/app/schemas/payments.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Payment-related schemas for job payments"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PaymentStatus(str, Enum):
|
||||
"""Payment status values"""
|
||||
PENDING = "pending"
|
||||
ESCROWED = "escrowed"
|
||||
RELEASED = "released"
|
||||
REFUNDED = "refunded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class PaymentMethod(str, Enum):
|
||||
"""Payment methods"""
|
||||
AITBC_TOKEN = "aitbc_token" # Primary method for job payments
|
||||
BITCOIN = "bitcoin" # Only for exchange purchases
|
||||
|
||||
|
||||
class JobPaymentCreate(BaseModel):
|
||||
"""Request to create a payment for a job"""
|
||||
job_id: str
|
||||
amount: float
|
||||
currency: str = "AITBC" # Jobs paid with AITBC tokens
|
||||
payment_method: PaymentMethod = PaymentMethod.AITBC_TOKEN
|
||||
escrow_timeout_seconds: int = 3600 # 1 hour default
|
||||
|
||||
|
||||
class JobPaymentView(BaseModel):
|
||||
"""Payment information for a job"""
|
||||
job_id: str
|
||||
payment_id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: PaymentStatus
|
||||
payment_method: PaymentMethod
|
||||
escrow_address: Optional[str] = None
|
||||
refund_address: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
released_at: Optional[datetime] = None
|
||||
refunded_at: Optional[datetime] = None
|
||||
transaction_hash: Optional[str] = None
|
||||
refund_transaction_hash: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentRequest(BaseModel):
|
||||
"""Request to pay for a job"""
|
||||
job_id: str
|
||||
amount: float
|
||||
currency: str = "BTC"
|
||||
refund_address: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentReceipt(BaseModel):
|
||||
"""Receipt for a payment"""
|
||||
payment_id: str
|
||||
job_id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: PaymentStatus
|
||||
transaction_hash: Optional[str] = None
|
||||
created_at: datetime
|
||||
verified_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class EscrowRelease(BaseModel):
|
||||
"""Request to release escrow payment"""
|
||||
job_id: str
|
||||
payment_id: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class RefundRequest(BaseModel):
|
||||
"""Request to refund a payment"""
|
||||
job_id: str
|
||||
payment_id: str
|
||||
reason: str
|
||||
@@ -7,11 +7,13 @@ from sqlmodel import Session, select
|
||||
|
||||
from ..domain import Job, Miner, JobReceipt
|
||||
from ..schemas import AssignedJob, Constraints, JobCreate, JobResult, JobState, JobView
|
||||
from .payments import PaymentService
|
||||
|
||||
|
||||
class JobService:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
self.payment_service = PaymentService(session)
|
||||
|
||||
def create_job(self, client_id: str, req: JobCreate) -> Job:
|
||||
ttl = max(req.ttl_seconds, 1)
|
||||
@@ -27,6 +29,19 @@ class JobService:
|
||||
self.session.add(job)
|
||||
self.session.commit()
|
||||
self.session.refresh(job)
|
||||
|
||||
# Create payment if amount is specified
|
||||
if req.payment_amount and req.payment_amount > 0:
|
||||
from ..schemas.payments import JobPaymentCreate, PaymentMethod
|
||||
payment_create = JobPaymentCreate(
|
||||
job_id=job.id,
|
||||
amount=req.payment_amount,
|
||||
currency=req.payment_currency,
|
||||
payment_method=PaymentMethod.BITCOIN
|
||||
)
|
||||
# Note: This is async, so we'll handle it in the router
|
||||
job.payment_pending = True
|
||||
|
||||
return job
|
||||
|
||||
def get_job(self, job_id: str, client_id: Optional[str] = None) -> Job:
|
||||
|
||||
270
apps/coordinator-api/src/app/services/payments.py
Normal file
270
apps/coordinator-api/src/app/services/payments.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Payment service for job payments"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
from ..domain.payment import JobPayment, PaymentEscrow
|
||||
from ..schemas.payments import (
|
||||
JobPaymentCreate,
|
||||
JobPaymentView,
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
EscrowRelease,
|
||||
RefundRequest
|
||||
)
|
||||
from ..storage import SessionDep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""Service for handling job payments"""
|
||||
|
||||
def __init__(self, session: SessionDep):
|
||||
self.session = session
|
||||
self.wallet_base_url = "http://127.0.0.1:20000" # Wallet daemon URL
|
||||
self.exchange_base_url = "http://127.0.0.1:23000" # Exchange API URL
|
||||
|
||||
async def create_payment(self, job_id: str, payment_data: JobPaymentCreate) -> JobPayment:
|
||||
"""Create a new payment for a job"""
|
||||
|
||||
# Create payment record
|
||||
payment = JobPayment(
|
||||
job_id=job_id,
|
||||
amount=payment_data.amount,
|
||||
currency=payment_data.currency,
|
||||
payment_method=payment_data.payment_method,
|
||||
expires_at=datetime.utcnow() + timedelta(seconds=payment_data.escrow_timeout_seconds)
|
||||
)
|
||||
|
||||
self.session.add(payment)
|
||||
self.session.commit()
|
||||
self.session.refresh(payment)
|
||||
|
||||
# For AITBC token payments, use token escrow
|
||||
if payment_data.payment_method == PaymentMethod.AITBC_TOKEN:
|
||||
await self._create_token_escrow(payment)
|
||||
# Bitcoin payments only for exchange purchases
|
||||
elif payment_data.payment_method == PaymentMethod.BITCOIN:
|
||||
await self._create_bitcoin_escrow(payment)
|
||||
|
||||
return payment
|
||||
|
||||
async def _create_token_escrow(self, payment: JobPayment) -> None:
|
||||
"""Create an escrow for AITBC token payments"""
|
||||
try:
|
||||
# For AITBC tokens, we use the token contract escrow
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call exchange API to create token escrow
|
||||
response = await client.post(
|
||||
f"{self.exchange_base_url}/api/v1/token/escrow/create",
|
||||
json={
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"job_id": payment.job_id,
|
||||
"timeout_seconds": 3600 # 1 hour
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
escrow_data = response.json()
|
||||
payment.escrow_address = escrow_data.get("escrow_id")
|
||||
payment.status = PaymentStatus.ESCROWED
|
||||
payment.escrowed_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
# Create escrow record
|
||||
escrow = PaymentEscrow(
|
||||
payment_id=payment.id,
|
||||
amount=payment.amount,
|
||||
currency=payment.currency,
|
||||
address=escrow_data.get("escrow_id"),
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1)
|
||||
)
|
||||
self.session.add(escrow)
|
||||
|
||||
self.session.commit()
|
||||
logger.info(f"Created AITBC token escrow for payment {payment.id}")
|
||||
else:
|
||||
logger.error(f"Failed to create token escrow: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating token escrow: {e}")
|
||||
payment.status = PaymentStatus.FAILED
|
||||
payment.updated_at = datetime.utcnow()
|
||||
self.session.commit()
|
||||
|
||||
async def _create_bitcoin_escrow(self, payment: JobPayment) -> None:
|
||||
"""Create an escrow for Bitcoin payments (exchange only)"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call wallet daemon to create escrow
|
||||
response = await client.post(
|
||||
f"{self.wallet_base_url}/api/v1/escrow/create",
|
||||
json={
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"timeout_seconds": 3600 # 1 hour
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
escrow_data = response.json()
|
||||
payment.escrow_address = escrow_data["address"]
|
||||
payment.status = PaymentStatus.ESCROWED
|
||||
payment.escrowed_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
# Create escrow record
|
||||
escrow = PaymentEscrow(
|
||||
payment_id=payment.id,
|
||||
amount=payment.amount,
|
||||
currency=payment.currency,
|
||||
address=escrow_data["address"],
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1)
|
||||
)
|
||||
self.session.add(escrow)
|
||||
|
||||
self.session.commit()
|
||||
logger.info(f"Created Bitcoin escrow for payment {payment.id}")
|
||||
else:
|
||||
logger.error(f"Failed to create Bitcoin escrow: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Bitcoin escrow: {e}")
|
||||
payment.status = PaymentStatus.FAILED
|
||||
payment.updated_at = datetime.utcnow()
|
||||
self.session.commit()
|
||||
|
||||
async def release_payment(self, job_id: str, payment_id: str, reason: Optional[str] = None) -> bool:
|
||||
"""Release payment from escrow to miner"""
|
||||
|
||||
payment = self.session.get(JobPayment, payment_id)
|
||||
if not payment or payment.job_id != job_id:
|
||||
return False
|
||||
|
||||
if payment.status != PaymentStatus.ESCROWED:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call wallet daemon to release escrow
|
||||
response = await client.post(
|
||||
f"{self.wallet_base_url}/api/v1/escrow/release",
|
||||
json={
|
||||
"address": payment.escrow_address,
|
||||
"reason": reason or "Job completed successfully"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
release_data = response.json()
|
||||
payment.status = PaymentStatus.RELEASED
|
||||
payment.released_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.transaction_hash = release_data.get("transaction_hash")
|
||||
|
||||
# Update escrow record
|
||||
escrow = self.session.exec(
|
||||
self.session.query(PaymentEscrow).where(
|
||||
PaymentEscrow.payment_id == payment_id
|
||||
)
|
||||
).first()
|
||||
|
||||
if escrow:
|
||||
escrow.is_released = True
|
||||
escrow.released_at = datetime.utcnow()
|
||||
|
||||
self.session.commit()
|
||||
logger.info(f"Released payment {payment_id} for job {job_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to release payment: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing payment: {e}")
|
||||
return False
|
||||
|
||||
async def refund_payment(self, job_id: str, payment_id: str, reason: str) -> bool:
|
||||
"""Refund payment to client"""
|
||||
|
||||
payment = self.session.get(JobPayment, payment_id)
|
||||
if not payment or payment.job_id != job_id:
|
||||
return False
|
||||
|
||||
if payment.status not in [PaymentStatus.ESCROWED, PaymentStatus.PENDING]:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Call wallet daemon to refund
|
||||
response = await client.post(
|
||||
f"{self.wallet_base_url}/api/v1/refund",
|
||||
json={
|
||||
"payment_id": payment_id,
|
||||
"address": payment.refund_address,
|
||||
"amount": payment.amount,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
refund_data = response.json()
|
||||
payment.status = PaymentStatus.REFUNDED
|
||||
payment.refunded_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.refund_transaction_hash = refund_data.get("transaction_hash")
|
||||
|
||||
# Update escrow record
|
||||
escrow = self.session.exec(
|
||||
self.session.query(PaymentEscrow).where(
|
||||
PaymentEscrow.payment_id == payment_id
|
||||
)
|
||||
).first()
|
||||
|
||||
if escrow:
|
||||
escrow.is_refunded = True
|
||||
escrow.refunded_at = datetime.utcnow()
|
||||
|
||||
self.session.commit()
|
||||
logger.info(f"Refunded payment {payment_id} for job {job_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to refund payment: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refunding payment: {e}")
|
||||
return False
|
||||
|
||||
def get_payment(self, payment_id: str) -> Optional[JobPayment]:
|
||||
"""Get payment by ID"""
|
||||
return self.session.get(JobPayment, payment_id)
|
||||
|
||||
def get_job_payment(self, job_id: str) -> Optional[JobPayment]:
|
||||
"""Get payment for a specific job"""
|
||||
return self.session.exec(
|
||||
self.session.query(JobPayment).where(JobPayment.job_id == job_id)
|
||||
).first()
|
||||
|
||||
def to_view(self, payment: JobPayment) -> JobPaymentView:
|
||||
"""Convert payment to view model"""
|
||||
return JobPaymentView(
|
||||
job_id=payment.job_id,
|
||||
payment_id=payment.id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
status=payment.status,
|
||||
payment_method=payment.payment_method,
|
||||
escrow_address=payment.escrow_address,
|
||||
refund_address=payment.refund_address,
|
||||
created_at=payment.created_at,
|
||||
updated_at=payment.updated_at,
|
||||
released_at=payment.released_at,
|
||||
refunded_at=payment.refunded_at,
|
||||
transaction_hash=payment.transaction_hash,
|
||||
refund_transaction_hash=payment.refund_transaction_hash
|
||||
)
|
||||
Reference in New Issue
Block a user