From 191fe34913edeeb84f0a2413810a3522e544f49b Mon Sep 17 00:00:00 2001 From: kalanakanishkaa Date: Sat, 24 Oct 2026 23:12:40 +0530 Subject: [PATCH] docs: add end-to-end FastAPI callback and idempotency example --- examples/fastapi/main.py | 101 +++++++++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/examples/fastapi/main.py b/examples/fastapi/main.py index 0d3caf6..3d1f249 100644 --- a/examples/fastapi/main.py +++ b/examples/fastapi/main.py @@ -3,25 +3,106 @@ from __future__ import annotations import os +import sqlite3 +from contextlib import asynccontextmanager +from typing import AsyncGenerator -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI, Request, Response, HTTPException +from fastapi.responses import RedirectResponse from tbc_payments import AsyncTBCClient from tbc_payments.integrations.fastapi import payment_id_from_request -app = FastAPI() + +def init_db(): + """Initialize a simple SQLite database for orders.""" + with sqlite3.connect("orders.db") as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tbc_payment_id TEXT, + paid_at TIMESTAMP + ) + """ + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator: + # Initialize the local database + init_db() + + # Share one AsyncTBCClient through lifespan instead of opening one per request + tbc = AsyncTBCClient( + os.environ.get("TBC_API_KEY", "dummy_key"), + os.environ.get("TBC_CLIENT_ID", "dummy_client_id"), + os.environ.get("TBC_CLIENT_SECRET", "dummy_secret"), + ) + app.state.tbc = tbc + yield + await tbc.close() + + +app = FastAPI(lifespan=lifespan) + + +@app.post("/checkout") +async def checkout(request: Request): + """Creates a payment and safely redirects the user.""" + tbc: AsyncTBCClient = request.app.state.tbc + + # 1. Create a pending order in our local database + with sqlite3.connect("orders.db") as conn: + cursor = conn.cursor() + cursor.execute("INSERT INTO orders (tbc_payment_id) VALUES (NULL)") + order_id = cursor.lastrowid + + # 2. Create the payment in TBC + payment = await tbc.create_payment( + amount={"currency": "GEL", "total": 10}, + return_url=f"http://localhost:8000/orders/{order_id}", + ) + + # 3. Update the order with the actual TBC payment ID + with sqlite3.connect("orders.db") as conn: + conn.execute( + "UPDATE orders SET tbc_payment_id = ? WHERE id = ?", + (payment.pay_id, order_id) + ) + + # 4. Redirect the user to the checkout page + return RedirectResponse(url=payment.links.redirect, status_code=303) @app.post("/webhooks/tbc") async def tbc_callback(request: Request) -> Response: + """Handles the callback securely by verifying the status server-side.""" payment_id = await payment_id_from_request(request) - async with AsyncTBCClient( - os.environ["TBC_API_KEY"], - os.environ["TBC_CLIENT_ID"], - os.environ["TBC_CLIENT_SECRET"], - ) as tbc: - payment = await tbc.get_payment(payment_id) + tbc: AsyncTBCClient = request.app.state.tbc + + # Verify payment status server-side rather than trusting callback data + payment = await tbc.get_payment(payment_id) + if payment.status == "Succeeded": - # Store payment.pay_id under a unique database constraint before fulfilment. - pass + with sqlite3.connect("orders.db") as conn: + # Idempotency boundary: mark the order paid only if paid_at is NULL + conn.execute( + "UPDATE orders SET paid_at = CURRENT_TIMESTAMP WHERE tbc_payment_id = ? AND paid_at IS NULL", + (payment.pay_id,) + ) + return Response(status_code=200) + + +@app.get("/orders/{order_id}") +async def get_order(order_id: int): + """The return page to check the order status.""" + with sqlite3.connect("orders.db") as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Order not found") + + return dict(row) \ No newline at end of file