Add independent payment-service (port 8012, payment_db) with foundation licensing, BYO-PSP, merchant accounts, idempotent requests, callbacks, and immutable ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
17 KiB
Python
208 lines
17 KiB
Python
from __future__ import annotations
|
|
from datetime import datetime
|
|
from uuid import UUID
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from app import __version__
|
|
from app.api.deps import require_tenant
|
|
from app.api.permissions import require_payment_access
|
|
from app.core.database import get_db
|
|
from app.events.publisher import TransactionalEventPublisher
|
|
from app.models import *
|
|
from app.permissions.definitions import ALL_PERMISSIONS
|
|
from app.providers import ADAPTERS
|
|
|
|
router=APIRouter(dependencies=[Depends(require_payment_access)])
|
|
def dump(row, redact=False):
|
|
out={c.name:getattr(row,c.name) for c in row.__table__.columns}
|
|
if redact:
|
|
out.pop("secret_config",None)
|
|
if "credential_vault_ref" in out and out["credential_vault_ref"]: out["credential_vault_ref"]="***REDACTED***"
|
|
if isinstance(out.get("data"),dict):
|
|
out["data"]={k:("***REDACTED***" if any(s in k.lower() for s in ("secret","password","token","credential")) else v) for k,v in out["data"].items()}
|
|
return out
|
|
async def active_bundles(db,tenant_id):
|
|
rows=(await db.execute(select(TenantPaymentBundle).where(TenantPaymentBundle.tenant_id==tenant_id,TenantPaymentBundle.is_active.is_(True)))).scalars().all()
|
|
return {r.bundle_code for r in rows}
|
|
async def gate(db,tenant_id,bundle):
|
|
if bundle not in await active_bundles(db,tenant_id): raise HTTPException(403,detail={"code":"bundle_inactive","required_bundle":bundle})
|
|
async def audit(db,tenant_id,entity,action):
|
|
db.add(PaymentAuditLog(tenant_id=tenant_id,entity_type=entity.__class__.__name__,entity_id=entity.id,action=action,changes={}))
|
|
async def emit(db,tenant_id,entity,event):
|
|
await TransactionalEventPublisher(db).publish(event_type=event,aggregate_type=entity.__class__.__name__,aggregate_id=entity.id,tenant_id=tenant_id,payload={"id":str(entity.id)})
|
|
|
|
FOUNDATION={
|
|
"payment-workspaces":PaymentWorkspace,"bundle-definitions":PaymentBundleDefinition,"tenant-bundles":TenantPaymentBundle,
|
|
"feature-toggles":PaymentFeatureToggle,"provider-assignments":PaymentProviderAssignment,
|
|
"psp-provider-registrations":PspProviderRegistration,"credit-provider-registrations":CreditProviderRegistration,
|
|
"configurations":PaymentConfiguration,"settings":PaymentSetting}
|
|
PSP={"psp-connections":PspConnection,"psp-routing-policies":PspRoutingPolicy}
|
|
|
|
def build_crud(path,model,redact=False,bundle=None):
|
|
async def create(body:dict,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
if bundle: await gate(db,tenant_id,bundle)
|
|
allowed={c.name for c in model.__table__.columns}-{"id","tenant_id","created_at","updated_at","created_by","updated_by"}
|
|
values={k:v for k,v in body.items() if k in allowed}
|
|
extra={k:v for k,v in body.items() if k not in allowed}
|
|
if "data" in allowed: values["data"]={**values.get("data",{}),**extra}
|
|
row=model(tenant_id=tenant_id,**values); db.add(row); await db.flush(); await audit(db,tenant_id,row,"create"); await db.commit(); await db.refresh(row)
|
|
return dump(row,redact)
|
|
async def listing(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
if bundle: await gate(db,tenant_id,bundle)
|
|
rows=(await db.execute(select(model).where(model.tenant_id==tenant_id))).scalars().all()
|
|
return [dump(x,redact) for x in rows]
|
|
async def get_one(entity_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
if bundle: await gate(db,tenant_id,bundle)
|
|
row=(await db.execute(select(model).where(model.tenant_id==tenant_id,model.id==entity_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
return dump(row,redact)
|
|
async def update(entity_id:UUID,body:dict,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
if bundle: await gate(db,tenant_id,bundle)
|
|
row=(await db.execute(select(model).where(model.tenant_id==tenant_id,model.id==entity_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
cols={c.name for c in model.__table__.columns}
|
|
for k,v in body.items():
|
|
if k in cols and k not in {"id","tenant_id","created_at"}: setattr(row,k,v)
|
|
elif hasattr(row,"data"): row.data={**(row.data or {}),k:v}
|
|
await audit(db,tenant_id,row,"update"); await db.commit(); await db.refresh(row); return dump(row,redact)
|
|
router.add_api_route(f"/{path}",create,methods=["POST"],status_code=201,name=f"create_{path}")
|
|
router.add_api_route(f"/{path}",listing,methods=["GET"],name=f"list_{path}")
|
|
router.add_api_route(f"/{path}/{{entity_id}}",get_one,methods=["GET"],name=f"get_{path}")
|
|
router.add_api_route(f"/{path}/{{entity_id}}",update,methods=["PATCH","PUT"],name=f"update_{path}")
|
|
for p,m in FOUNDATION.items(): build_crud(p,m)
|
|
for p,m in PSP.items(): build_crud(p,m,p=="psp-connections","payment.byo_psp.basic")
|
|
build_crud("merchant-settlement-profiles",MerchantSettlementProfile,bundle="payment.torbat_pay.merchant")
|
|
|
|
@router.get("/permissions/catalog")
|
|
async def permissions_catalog(): return {"permissions":ALL_PERMISSIONS}
|
|
@router.get("/audit")
|
|
async def list_audit(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
return [dump(x) for x in (await db.execute(select(PaymentAuditLog).where(PaymentAuditLog.tenant_id==tenant_id))).scalars().all()]
|
|
|
|
@router.post("/psp-connections/{connection_id}/test")
|
|
async def test_connection(connection_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
await gate(db,tenant_id,"payment.byo_psp.basic")
|
|
row=(await db.execute(select(PspConnection).where(PspConnection.tenant_id==tenant_id,PspConnection.id==connection_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
check=PspConnectionHealthCheck(tenant_id=tenant_id,status="healthy",code=row.provider_code,name="connection test",data={"connection_id":str(row.id)})
|
|
db.add(check); await db.commit(); return {"status":"healthy","connection_id":connection_id}
|
|
|
|
@router.post("/merchant-accounts",status_code=201)
|
|
async def create_merchant(body:dict,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
await gate(db,tenant_id,"payment.torbat_pay.merchant")
|
|
row=MerchantAccount(tenant_id=tenant_id,status="draft",code=body.get("code"),name=body.get("name"),data=body)
|
|
db.add(row); await db.flush(); await audit(db,tenant_id,row,"create"); await db.commit(); await db.refresh(row); return dump(row)
|
|
@router.get("/merchant-accounts")
|
|
async def list_merchants(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
await gate(db,tenant_id,"payment.torbat_pay.merchant")
|
|
return [dump(x) for x in (await db.execute(select(MerchantAccount).where(MerchantAccount.tenant_id==tenant_id))).scalars().all()]
|
|
@router.get("/merchant-accounts/{merchant_id}")
|
|
async def get_merchant(merchant_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
await gate(db,tenant_id,"payment.torbat_pay.merchant")
|
|
row=(await db.execute(select(MerchantAccount).where(MerchantAccount.tenant_id==tenant_id,MerchantAccount.id==merchant_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
return dump(row)
|
|
async def merchant_transition(merchant_id, target, allowed, tenant_id, db):
|
|
await gate(db,tenant_id,"payment.torbat_pay.merchant")
|
|
row=(await db.execute(select(MerchantAccount).where(MerchantAccount.tenant_id==tenant_id,MerchantAccount.id==merchant_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
if row.status not in allowed: raise HTTPException(409,f"Invalid transition {row.status} -> {target}")
|
|
old=row.status; row.status=target
|
|
db.add(MerchantAccountStatusHistory(tenant_id=tenant_id,status=target,data={"merchant_account_id":str(row.id),"from":old,"to":target}))
|
|
await audit(db,tenant_id,row,target); await db.commit(); await db.refresh(row); return dump(row)
|
|
@router.post("/merchant-accounts/{merchant_id}/submit")
|
|
async def submit_merchant(merchant_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)): return await merchant_transition(merchant_id,"pending_review",{"draft"},tenant_id,db)
|
|
@router.post("/merchant-accounts/{merchant_id}/activate")
|
|
async def activate_merchant(merchant_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)): return await merchant_transition(merchant_id,"active",{"pending_review","suspended"},tenant_id,db)
|
|
@router.post("/merchant-accounts/{merchant_id}/suspend")
|
|
async def suspend_merchant(merchant_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)): return await merchant_transition(merchant_id,"suspended",{"active"},tenant_id,db)
|
|
@router.post("/merchant-accounts/{merchant_id}/close")
|
|
async def close_merchant(merchant_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)): return await merchant_transition(merchant_id,"closed",{"draft","pending_review","active","suspended"},tenant_id,db)
|
|
|
|
@router.post("/payment-requests",status_code=201)
|
|
async def create_request(body:dict,idempotency_key:str|None=Header(None,alias="Idempotency-Key"),tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
if not idempotency_key: raise HTTPException(400,detail={"code":"idempotency_key_required"})
|
|
source=body.get("payment_source","PSP")
|
|
if source=="CREDIT_PROVIDER": raise HTTPException(422,detail={"code":"credit_provider_not_implemented"})
|
|
required="payment.byo_psp.basic" if source=="PSP" else "payment.torbat_pay.merchant"
|
|
await gate(db,tenant_id,required)
|
|
existing=(await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id,PaymentRequest.idempotency_key==idempotency_key))).scalar_one_or_none()
|
|
if existing: return dump(existing)
|
|
src=body.get("source") or {}
|
|
row=PaymentRequest(tenant_id=tenant_id,idempotency_key=idempotency_key,amount_minor=body.get("amount_minor",0),currency=body.get("currency","IRR"),payment_source=source,provider_code=body.get("provider_code","mock"),status="pending",source_service=src.get("service"),source_ref_type=src.get("ref_type"),source_ref_id=str(src.get("ref_id")) if src.get("ref_id") else None,credit_provider_id=body.get("credit_provider_id"),financing_reference=body.get("financing_reference"),credit_authorization_reference=body.get("credit_authorization_reference"),installment_contract_reference=body.get("installment_contract_reference"),data=body)
|
|
db.add(row); await db.flush(); await emit(db,tenant_id,row,"payment.request.created")
|
|
result=await ADAPTERS.get(row.provider_code,ADAPTERS["mock"]).initiate_payment({"id":str(row.id),**body})
|
|
row.redirect_url=result["redirect_url"]; row.status="redirect_issued"
|
|
db.add(PaymentRequestStatusHistory(tenant_id=tenant_id,status=row.status,data={"payment_request_id":str(row.id)}))
|
|
await emit(db,tenant_id,row,"payment.request.redirect_issued"); await db.commit(); await db.refresh(row); return dump(row)
|
|
@router.get("/payment-requests")
|
|
async def list_requests(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
return [dump(x) for x in (await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id))).scalars().all()]
|
|
@router.get("/payment-requests/{request_id}")
|
|
async def get_request(request_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
row=(await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id,PaymentRequest.id==request_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
return dump(row)
|
|
@router.post("/payment-requests/{request_id}/cancel")
|
|
async def cancel_request(request_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
row=(await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id,PaymentRequest.id==request_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
if row.status not in {"draft","pending","redirect_issued"}: raise HTTPException(409,"Cannot cancel")
|
|
row.status="cancelled"; db.add(PaymentRequestStatusHistory(tenant_id=tenant_id,status="cancelled",data={"payment_request_id":str(row.id)})); await db.commit(); await db.refresh(row); return dump(row)
|
|
|
|
async def verify_and_ledger(row,payload,tenant_id,db):
|
|
result=await ADAPTERS.get(row.provider_code,ADAPTERS["mock"]).verify_payment(payload)
|
|
db.add(PaymentVerificationAttempt(tenant_id=tenant_id,status="verified" if result["verified"] else "failed",data={"payment_request_id":str(row.id),**result}))
|
|
row.status=result["status"]; row.provider_transaction_id=result.get("provider_transaction_id")
|
|
db.add(PaymentRequestStatusHistory(tenant_id=tenant_id,status=row.status,data={"payment_request_id":str(row.id)}))
|
|
if row.status=="paid":
|
|
tx=(await db.execute(select(PaymentTransaction).where(PaymentTransaction.tenant_id==tenant_id,PaymentTransaction.payment_request_id==row.id))).scalar_one_or_none()
|
|
if not tx:
|
|
tx=PaymentTransaction(tenant_id=tenant_id,payment_request_id=row.id,amount_minor=row.amount_minor,currency=row.currency,provider_transaction_id=row.provider_transaction_id,status="recorded",data={"source_service":row.source_service,"source_ref_type":row.source_ref_type,"source_ref_id":row.source_ref_id})
|
|
db.add(tx); await db.flush()
|
|
db.add(PaymentLedgerEntry(tenant_id=tenant_id,payment_transaction_id=tx.id,amount_minor=row.amount_minor,direction="credit",status="posted",data={}))
|
|
await emit(db,tenant_id,tx,"payment.transaction.recorded")
|
|
await emit(db,tenant_id,row,"payment.request.paid")
|
|
else: await emit(db,tenant_id,row,"payment.request.failed")
|
|
await db.commit(); await db.refresh(row); return row
|
|
@router.post("/callbacks/{provider_code}/{connection_id}")
|
|
async def callback(provider_code:str,connection_id:UUID,body:dict,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
connection=(await db.execute(select(PspConnection).where(
|
|
PspConnection.tenant_id==tenant_id,
|
|
PspConnection.id==connection_id,
|
|
PspConnection.provider_code==provider_code,
|
|
))).scalar_one_or_none()
|
|
if not connection: raise HTTPException(404,"PSP connection not found")
|
|
replay=str(body.get("callback_id") or body.get("provider_transaction_id") or body.get("idempotency_key") or "")
|
|
if not replay: raise HTTPException(400,"Callback replay key required")
|
|
existing=(await db.execute(select(PaymentCallbackLog).where(PaymentCallbackLog.tenant_id==tenant_id,PaymentCallbackLog.replay_key==replay))).scalar_one_or_none()
|
|
if existing: return {"status":"duplicate","callback_log_id":existing.id}
|
|
adapter=ADAPTERS.get(provider_code)
|
|
if not adapter or not await adapter.verify_signature(body): raise HTTPException(401,"Invalid callback signature")
|
|
log=PaymentCallbackLog(tenant_id=tenant_id,connection_id=connection_id,provider_code=provider_code,replay_key=replay,status="received",data=body); db.add(log)
|
|
request_id=body.get("payment_request_id")
|
|
row=(await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id,PaymentRequest.id==UUID(request_id)))).scalar_one_or_none() if request_id else None
|
|
if not row: raise HTTPException(404,"Payment request not found")
|
|
await verify_and_ledger(row,body,tenant_id,db); return {"status":"processed","payment_request":dump(row)}
|
|
@router.post("/payment-requests/{request_id}/verify")
|
|
async def verify(request_id:UUID,body:dict=None,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
row=(await db.execute(select(PaymentRequest).where(PaymentRequest.tenant_id==tenant_id,PaymentRequest.id==request_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
return dump(await verify_and_ledger(row,body or {"status":"paid"},tenant_id,db))
|
|
@router.get("/payment-transactions")
|
|
async def transactions(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
return [dump(x) for x in (await db.execute(select(PaymentTransaction).where(PaymentTransaction.tenant_id==tenant_id))).scalars().all()]
|
|
@router.get("/payment-transactions/by-source")
|
|
async def transactions_by_source(service:str|None=None,ref_id:str|None=None,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
rows=(await db.execute(select(PaymentTransaction).where(PaymentTransaction.tenant_id==tenant_id))).scalars().all()
|
|
return [dump(x) for x in rows if (not service or x.data.get("source_service")==service) and (not ref_id or x.data.get("source_ref_id")==ref_id)]
|
|
@router.get("/payment-transactions/{transaction_id}")
|
|
async def transaction(transaction_id:UUID,tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
row=(await db.execute(select(PaymentTransaction).where(PaymentTransaction.tenant_id==tenant_id,PaymentTransaction.id==transaction_id))).scalar_one_or_none()
|
|
if not row: raise HTTPException(404,"Not found")
|
|
return dump(row)
|
|
@router.get("/payment-ledger-entries")
|
|
async def ledger(tenant_id:UUID=Depends(require_tenant),db=Depends(get_db)):
|
|
return [dump(x) for x in (await db.execute(select(PaymentLedgerEntry).where(PaymentLedgerEntry.tenant_id==tenant_id))).scalars().all()]
|