diff --git a/.env.example b/.env.example index 030809e..1d941b5 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,6 @@ APP_PORT=8000 APP_BASE_URL=http://localhost:8000 # x402 Payment Settings -PAY_TO_ADDRESS=0xYourWalletAddress NETWORK=base-sepolia FACILITATOR_URL=https://x402f1.secondstate.io MAX_TIMEOUT_SECONDS=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e8785..fb7f400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,18 +44,20 @@ jobs: - name: "Test 1: Health Check" run: | RESPONSE=$(curl -s http://localhost:8080/) - echo "$RESPONSE" - echo "$RESPONSE" | grep -q '"status":"running"' + echo "$RESPONSE" | head -5 + echo "$RESPONSE" | grep -q 'Payment Link Service' - name: "Test 2: Create Payment Link" run: | - RESPONSE=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05") + TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678" + RESPONSE=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER") echo "$RESPONSE" PAYMENT_ID=$(echo "$RESPONSE" | jq -r '.payment_id') echo "PAYMENT_ID=$PAYMENT_ID" >> $GITHUB_ENV echo "$RESPONSE" | jq -e '.payment_id' > /dev/null echo "$RESPONSE" | jq -e '.payment_url' > /dev/null echo "$RESPONSE" | jq -e '.amount' > /dev/null + echo "$RESPONSE" | jq -e '.receiver' > /dev/null - name: "Test 2b: Verify Database Record" run: | @@ -64,15 +66,17 @@ jobs: conn = sqlite3.connect('payments.db') conn.row_factory = sqlite3.Row cur = conn.cursor() - cur.execute('SELECT payment_id, amount, status, tx_hash FROM payments WHERE payment_id=?', ('${{ env.PAYMENT_ID }}',)) + cur.execute('SELECT payment_id, amount, receiver, status, tx_hash FROM payments WHERE payment_id=?', ('${{ env.PAYMENT_ID }}',)) row = cur.fetchone() assert row is not None, 'Record not found in database' assert row['amount'] == 0.05, f'Amount mismatch: {row[\"amount\"]}' + assert row['receiver'] == '0x1234567890abcdef1234567890abcdef12345678', f'Receiver mismatch: {row[\"receiver\"]}' assert row['status'] == 'pending', f'Status mismatch: {row[\"status\"]}' assert row['tx_hash'] is None, f'tx_hash should be None: {row[\"tx_hash\"]}' print('Database record verified successfully') print(f' payment_id: {row[\"payment_id\"]}') print(f' amount: {row[\"amount\"]}') + print(f' receiver: {row[\"receiver\"]}') print(f' status: {row[\"status\"]}') print(f' tx_hash: {row[\"tx_hash\"]}') conn.close() diff --git a/Dockerfile b/Dockerfile index 7da66bf..1bd93d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN uv sync --frozen --no-dev --no-install-project # Copy application code COPY main.py config.py database.py ./ +COPY static/ ./static/ # Expose port EXPOSE 8000 diff --git a/README.md b/README.md index a205306..50cd2cd 100644 --- a/README.md +++ b/README.md @@ -43,28 +43,21 @@ Configure the service using environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `PAY_TO_ADDRESS` | `0xYourWalletAddress` | Wallet address to receive payments | | `NETWORK` | `base-sepolia` | Blockchain network (`base-sepolia` for testnet, `base` for mainnet) | | `APP_BASE_URL` | `http://localhost:8000` | Public base URL for generated payment links | | `APP_NAME` | `Payment Link Service` | Service name displayed in payment UI | | `APP_LOGO` | `/static/logo.png` | Logo URL for payment UI | | `FACILITATOR_URL` | `https://x402f1.secondstate.io` | x402 facilitator service endpoint | | `MAX_TIMEOUT_SECONDS` | `60` | Payment timeout in seconds | -| `DATABASE_PATH` | `/data/payments.db` | SQLite database file path | +| `DATABASE_PATH` | `payments.db` | SQLite database file path | ## API Endpoints ### GET / -Health check endpoint. +Serves the web UI for creating and paying payment links. -**Response:** -```json -{ - "service": "Payment Link Service", - "status": "running" -} -``` +Open in a browser to access the interactive payment interface. --- @@ -77,10 +70,11 @@ Creates a new payment link with a unique ID. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `amount` | float | Yes | Payment amount in USD (must be > 0) | +| `receiver` | string | Yes | Blockchain address to receive the payment | **Example Request:** ```bash -curl "http://localhost:8000/create-payment-link?amount=0.01" +curl "http://localhost:8000/create-payment-link?amount=0.01&receiver=0x1234567890abcdef1234567890abcdef12345678" ``` **Response:** @@ -88,7 +82,8 @@ curl "http://localhost:8000/create-payment-link?amount=0.01" { "payment_id": "550e8400-e29b-41d4-a716-446655440000", "payment_url": "http://localhost:8000/pay/550e8400-e29b-41d4-a716-446655440000", - "amount": "0.01" + "amount": "0.01", + "receiver": "0x1234567890abcdef1234567890abcdef12345678" } ``` diff --git a/TEST_PLAN.md b/TEST_PLAN.md index 4333610..fdcdc6c 100644 --- a/TEST_PLAN.md +++ b/TEST_PLAN.md @@ -70,7 +70,8 @@ curl -s http://localhost:8080/ | jq **Request:** ```bash -curl -s "http://localhost:8080/create-payment-link?amount=0.01" | jq +TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678" +curl -s "http://localhost:8080/create-payment-link?amount=0.01&receiver=$TEST_RECEIVER" | jq ``` **Expected Response:** @@ -78,18 +79,20 @@ curl -s "http://localhost:8080/create-payment-link?amount=0.01" | jq { "payment_id": "", "payment_url": "http://localhost:8080/pay/", - "amount": "0.01" + "amount": "0.01", + "receiver": "0x1234567890abcdef1234567890abcdef12345678" } ``` **Pass Criteria:** - Status code 200 -- Response contains `payment_id`, `payment_url`, and `amount` +- Response contains `payment_id`, `payment_url`, `amount`, and `receiver` - `payment_url` matches the format `http://localhost:8080/pay/` **Save the payment_id for subsequent tests:** ```bash -PAYMENT_ID=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05" | jq -r '.payment_id') +TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678" +PAYMENT_ID=$(curl -s "http://localhost:8080/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER" | jq -r '.payment_id') echo "Payment ID: $PAYMENT_ID" ``` @@ -101,29 +104,30 @@ Directly query the SQLite database to confirm the payment was stored correctly. **Request:** ```bash -sqlite3 payments.db "SELECT payment_id, amount, status, tx_hash FROM payments WHERE payment_id='$PAYMENT_ID';" +sqlite3 payments.db "SELECT payment_id, amount, receiver, status, tx_hash FROM payments WHERE payment_id='$PAYMENT_ID';" ``` **Expected Output:** ``` -|0.05|pending| +|0.05|0x1234567890abcdef1234567890abcdef12345678|pending| ``` **Alternative (formatted output):** ```bash -sqlite3 -header -column payments.db "SELECT payment_id, amount, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';" +sqlite3 -header -column payments.db "SELECT payment_id, amount, receiver, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';" ``` **Expected Output:** ``` -payment_id amount status tx_hash created_at ------------------------------------- ---------- ---------- ---------- ------------------- -xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 0.05 pending 2024-01-01 12:00:00 +payment_id amount receiver status tx_hash created_at +------------------------------------ ---------- ------------------------------------------ ---------- ---------- ------------------- +xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 0.05 0x1234567890abcdef1234567890abcdef12345678 pending 2024-01-01 12:00:00 ``` **Pass Criteria:** - Record exists in the database - `amount` matches the requested amount (0.05) +- `receiver` matches the requested receiver address - `status` is `pending` - `tx_hash` is empty/null @@ -287,28 +291,29 @@ Save this as `test_endpoints.sh` and run with `bash test_endpoints.sh`: #!/bin/bash BASE_URL="http://localhost:8080" +TEST_RECEIVER="0x1234567890abcdef1234567890abcdef12345678" echo "=== Test 1: Health Check ===" -curl -s "$BASE_URL/" | jq +curl -s "$BASE_URL/" | head -5 echo echo "=== Test 2: Create Payment Link ===" -RESPONSE=$(curl -s "$BASE_URL/create-payment-link?amount=0.05") +RESPONSE=$(curl -s "$BASE_URL/create-payment-link?amount=0.05&receiver=$TEST_RECEIVER") echo "$RESPONSE" | jq PAYMENT_ID=$(echo "$RESPONSE" | jq -r '.payment_id') echo "Saved Payment ID: $PAYMENT_ID" echo echo "=== Test 2b: Verify Database Record ===" -sqlite3 -header -column payments.db "SELECT payment_id, amount, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';" +sqlite3 -header -column payments.db "SELECT payment_id, amount, receiver, status, tx_hash, created_at FROM payments WHERE payment_id='$PAYMENT_ID';" echo echo "=== Test 3: Invalid Amount ===" -curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?amount=-5" | head -1 +curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?amount=-5&receiver=$TEST_RECEIVER" | head -1 echo echo "=== Test 4: Missing Amount ===" -curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link" | head -1 +curl -s -w "HTTP Status: %{http_code}\n" "$BASE_URL/create-payment-link?receiver=$TEST_RECEIVER" | head -1 echo echo "=== Test 5: Check Payment Status (Pending) ===" diff --git a/config.py b/config.py index 67736c2..c097904 100644 --- a/config.py +++ b/config.py @@ -20,7 +20,6 @@ def __init__(self) -> None: self.app_base_url: str = os.getenv("APP_BASE_URL", "http://localhost:8000") # x402 Payment settings - self.pay_to_address: str = os.getenv("PAY_TO_ADDRESS", "0xYourWalletAddress") self.network: str = os.getenv("NETWORK", "base-sepolia") self.facilitator_url: str = os.getenv( "FACILITATOR_URL", "https://x402f1.secondstate.io" diff --git a/database.py b/database.py index 834ae5c..9b5e4df 100644 --- a/database.py +++ b/database.py @@ -12,6 +12,7 @@ async def init_db() -> None: CREATE TABLE IF NOT EXISTS payments ( payment_id TEXT PRIMARY KEY, amount REAL NOT NULL, + receiver TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', tx_hash TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -21,17 +22,19 @@ async def init_db() -> None: await db.commit() -async def create_payment(payment_id: str, amount: float) -> None: +async def create_payment(payment_id: str, amount: float, receiver: str) -> None: """Create a new payment record. Args: payment_id: Unique identifier for the payment. amount: Payment amount in USD. + receiver: Blockchain address to receive the payment. """ async with aiosqlite.connect(settings.database_path) as db: await db.execute( - "INSERT INTO payments (payment_id, amount, status) VALUES (?, ?, ?)", - (payment_id, amount, "pending"), + "INSERT INTO payments (payment_id, amount, receiver, status) " + "VALUES (?, ?, ?, ?)", + (payment_id, amount, receiver, "pending"), ) await db.commit() diff --git a/main.py b/main.py index c0d59cd..f4b94af 100644 --- a/main.py +++ b/main.py @@ -2,14 +2,19 @@ import uuid from contextlib import asynccontextmanager +from pathlib import Path from typing import TYPE_CHECKING, AsyncGenerator from fastapi import FastAPI, Query, Request, Response -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles from config import settings from database import create_payment, get_payment, init_db, update_payment_status +# Static files directory +STATIC_DIR = Path(__file__).parent / "static" + if TYPE_CHECKING: from x402_payment_service import PaymentService as PaymentServiceType @@ -47,35 +52,48 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp ) +# Mount static files if directory exists +if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/") -async def root() -> dict[str, str]: - """Root endpoint with service info.""" - return { - "service": settings.app_name, - "status": "running", - } +async def root() -> Response: + """Serve the index.html page.""" + index_path = STATIC_DIR / "index.html" + if index_path.exists(): + return FileResponse(index_path) + return JSONResponse( + { + "service": settings.app_name, + "status": "running", + } + ) @app.get("/create-payment-link") async def create_payment_link( amount: float = Query(..., gt=0, description="Payment amount in USD"), + receiver: str = Query(..., description="Blockchain address to receive payment"), ) -> dict[str, str]: """Create a new payment link with a unique ID. Args: amount: Payment amount in USD (must be greater than 0). + receiver: Blockchain address to receive the payment. Returns: JSON with the payment link URL. """ payment_id = str(uuid.uuid4()) - await create_payment(payment_id, amount) + await create_payment(payment_id, amount, receiver) payment_url = f"{settings.app_base_url}/pay/{payment_id}" return { "payment_id": payment_id, "payment_url": payment_url, "amount": str(amount), + "receiver": receiver, } @@ -153,7 +171,7 @@ async def pay(payment_id: str, request: Request) -> Response: price=payment_record["amount"], description=f"Payment for order {payment_id}", network=settings.network, - pay_to_address=settings.pay_to_address, + pay_to_address=payment_record["receiver"], facilitator_url=settings.facilitator_url, max_timeout_seconds=settings.max_timeout_seconds, ) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..2ea28a7 --- /dev/null +++ b/static/index.html @@ -0,0 +1,524 @@ + + + + + + Payment Link Service + + + + +

Payment Link Service

+ + +
+

Create Payment Link

+ + + + + + +
+ + +
+

Pay

+
Wallet: Not connected
+ +

+ + + + +
+ + +
+

Check Payment Status

+ + + + +
+ + + + diff --git a/tests/test_main.py b/tests/test_main.py index 0708a6a..f03308e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,33 +28,43 @@ def client() -> Generator[TestClient, None, None]: def test_root_endpoint(client: TestClient) -> None: - """Test the root endpoint returns service info.""" + """Test the root endpoint returns the index.html page.""" response = client.get("/") assert response.status_code == 200 - data = response.json() - assert "service" in data - assert data["status"] == "running" + # Should return HTML content + assert "" in response.text + assert "Payment Link Service" in response.text + + +TEST_RECEIVER = "0x1234567890abcdef1234567890abcdef12345678" def test_create_payment_link(client: TestClient) -> None: """Test creating a payment link.""" - response = client.get("/create-payment-link?amount=10.50") + response = client.get(f"/create-payment-link?amount=10.50&receiver={TEST_RECEIVER}") assert response.status_code == 200 data = response.json() assert "payment_id" in data assert "payment_url" in data assert float(data["amount"]) == 10.50 + assert data["receiver"] == TEST_RECEIVER def test_create_payment_link_invalid_amount(client: TestClient) -> None: """Test creating a payment link with invalid amount.""" - response = client.get("/create-payment-link?amount=-5") + response = client.get(f"/create-payment-link?amount=-5&receiver={TEST_RECEIVER}") assert response.status_code == 422 # Validation error def test_create_payment_link_missing_amount(client: TestClient) -> None: """Test creating a payment link without amount.""" - response = client.get("/create-payment-link") + response = client.get(f"/create-payment-link?receiver={TEST_RECEIVER}") + assert response.status_code == 422 # Missing required parameter + + +def test_create_payment_link_missing_receiver(client: TestClient) -> None: + """Test creating a payment link without receiver.""" + response = client.get("/create-payment-link?amount=10.50") assert response.status_code == 422 # Missing required parameter @@ -75,7 +85,9 @@ def test_status_nonexistent_payment(client: TestClient) -> None: def test_payment_flow_without_x402_header(client: TestClient) -> None: """Test the payment flow without x402 header returns 402.""" # Create a payment link - create_response = client.get("/create-payment-link?amount=0.01") + create_response = client.get( + f"/create-payment-link?amount=0.01&receiver={TEST_RECEIVER}" + ) assert create_response.status_code == 200 payment_id = create_response.json()["payment_id"] @@ -88,7 +100,9 @@ def test_payment_flow_without_x402_header(client: TestClient) -> None: def test_status_after_create(client: TestClient) -> None: """Test checking status after creating a payment.""" # Create a payment link - create_response = client.get("/create-payment-link?amount=5.00") + create_response = client.get( + f"/create-payment-link?amount=5.00&receiver={TEST_RECEIVER}" + ) assert create_response.status_code == 200 payment_id = create_response.json()["payment_id"]