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()]