Buka terminal, bikin folder baru:

mkdir payment-gateway-multi-tenant && cd payment-gateway-multi-tenant

Gue asumsikan lu udah punya Rust (1.75+), Node.js (18+), Docker, dan akun Xendit. Kalo belum, install dulu.

payment-gateway-multi-tenant/
โ”œโ”€โ”€ backend/
โ”‚   โ”œโ”€โ”€ Cargo.toml
โ”‚   โ”œโ”€โ”€ src/
โ”‚   โ”‚   โ”œโ”€โ”€ main.rs
โ”‚   โ”‚   โ”œโ”€โ”€ graphql/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ schema.rs
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ mutation.rs
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ query.rs
โ”‚   โ”‚   โ”œโ”€โ”€ models/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ tenant.rs
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ payment.rs
โ”‚   โ”‚   โ”œโ”€โ”€ db/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ pool.rs
โ”‚   โ”‚   โ””โ”€โ”€ xendit/
โ”‚   โ”‚       โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚       โ””โ”€โ”€ client.rs
โ”‚   โ””โ”€โ”€ Dockerfile
โ”œโ”€โ”€ frontend/
โ”‚   โ”œโ”€โ”€ package.json
โ”‚   โ”œโ”€โ”€ next.config.ts
โ”‚   โ”œโ”€โ”€ app/
โ”‚   โ”‚   โ”œโ”€โ”€ layout.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ page.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ api/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ graphql/
โ”‚   โ”‚   โ”‚       โ””โ”€โ”€ route.ts
โ”‚   โ”‚   โ””โ”€โ”€ payments/
โ”‚   โ”‚       โ””โ”€โ”€ [tenantId]/
โ”‚   โ”‚           โ””โ”€โ”€ page.tsx
โ”‚   โ””โ”€โ”€ Dockerfile
โ”œโ”€โ”€ docker-compose.yml
โ”œโ”€โ”€ .github/
โ”‚   โ””โ”€โ”€ workflows/
โ”‚       โ””โ”€โ”€ deploy.yml
โ””โ”€โ”€ README.md

### Architecture Decisions
- **Rust (async-graphql) instead of Node.js** โ€” Karena butuh performance tinggi buat handle banyak tenant concurrent. `async-graphql` mature dan punya fitur `@parameter` directive.
- **PostgreSQL with schema-per-tenant** โ€” Gue pilih schema isolation (bukan row-level) karena lebih aman buat payment data. Masing-masing tenant punya schema sendiri `tenant_<id>`.
- **Xendit** โ€” Satu-satunya payment gateway yang support Indonesia dengan API sederhana. Mereka punya `xendit-node` tapi kita bikin wrapper Rust sendiri.
- **Docker Compose** โ€” Simulasi production di lokal. Nanti pas CI/CD kita ganti ke orchestrator kaya Kubernetes.

Client (Next.js) โ†’ API Route (Next.js) โ†’ Rust GraphQL (async-graphql) โ†’ PostgreSQL (schema-per-tenant)
                                                                    โ†’ Xendit API (create invoice)

Buka terminal, inisialisasi project Rust:

cd backend cargo init --name payment-gateway-backend

Edit Cargo.toml:

[package]
name = "payment-gateway-backend"
version = "0.1.0"
edition = "2021"

[dependencies]
async-graphql = { version = "7.0", features = ["dataloader", "uuid"] }
async-graphql-axum = "7.0"
axum = { version = "0.7", features = ["macros"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"

Bikin file `.env` di `backend/`:

DATABASEURL=postgres://postgres:password@localhost:5432/paymentgateway XENDITSECRETKEY=xnddevelopment... XENDITCALLBACKURL=https://your-ngrok-url.ngrok.io/api/xendit/callback

Sekarang, bikin database connection pool. Ini krusial karena kita harus dynamic pilih schema tenant.

// backend/src/db/pool.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::sync::Arc;

pub struct DbPool {
    pub pool: PgPool,
}

impl DbPool {
    pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
        let pool = PgPoolOptions::new()
            .max_connections(20)
            .connect(database_url)
            .await?;
        Ok(Self { pool })
    }

    pub async fn set_tenant_schema(&self, tenant_id: &str) -> Result<(), sqlx::Error> {
        let schema = format!("tenant_{}", tenant_id);
        sqlx::query(&format!("SET search_path TO '{}'", schema))
            .execute(&self.pool)
            .await?;
        Ok(())
    }
}

Kenapa pake `SET search_path`? Karena kita pake schema-per-tenant. Setiap query bakal otomatis ngarah ke schema yang bener.

Bikin migration database. Gue pake sqlx migration.

sqlx migrate add createschemaand_tables

Ini isi migration-nya:

-- migrations/20250101000001_create_schema_and_tables.sql
CREATE SCHEMA IF NOT EXISTS tenant_public;

-- Public schema for tenant registry
CREATE TABLE IF NOT EXISTS tenant_public.tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    schema_name VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Function to create tenant schema
CREATE OR REPLACE FUNCTION create_tenant_schema(tenant_id UUID, schema_name VARCHAR(100))
RETURNS VOID AS $$
BEGIN
    EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name);
    EXECUTE format('
        CREATE TABLE IF NOT EXISTS %I.payments (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            invoice_id VARCHAR(255) UNIQUE NOT NULL,
            amount BIGINT NOT NULL,
            currency VARCHAR(3) DEFAULT ''IDR'',
            status VARCHAR(50) DEFAULT ''PENDING'',
            external_id VARCHAR(255),
            payer_email VARCHAR(255),
            description TEXT,
            created_at TIMESTAMPTZ DEFAULT NOW(),
            updated_at TIMESTAMPTZ DEFAULT NOW()
        )
    ', schema_name);
END;
$$ LANGUAGE plpgsql;

Jalanin migration:

sqlx migrate run

Nanti kalo error "relation doesn't exist", itu karena search_path belum di-set. Gue sengaja bikin error biar nanti keliatan cara handle-nya.

Sekarang bikin model Rust:

// backend/src/models/tenant.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Tenant {
    pub id: Uuid,
    pub name: String,
    pub schema_name: String,
    pub created_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Payment {
    pub id: Uuid,
    pub invoice_id: String,
    pub amount: i64,
    pub currency: String,
    pub status: String,
    pub external_id: Option<String>,
    pub payer_email: Option<String>,
    pub description: Option<String>,
    pub created_at: Option<DateTime<Utc>>,
    pub updated_at: Option<DateTime<Utc>>,
}

Ini dia inti dari multi-tenant: setiap query/mutation harus nerima `tenant_id` sebagai parameter, dan kita set search_path sebelum eksekusi.

// backend/src/graphql/query.rs use async_graphql::{Context, Object, Result}; use crate::db::pool::DbPool; use crate::models::tenant::Tenant; use crate::models::payment::Payment;

#[derive(Default)] pub struct QueryRoot;

#[Object] impl QueryRoot { async fn tenants(&self, ctx: &Context<'>) -> Result<Vec<Tenant>> { let pool = ctx.data::<DbPool>()?; let tenants = sqlx::queryas!( Tenant, "SELECT id, name, schemaname, createdat FROM tenantpublic.tenants" ) .fetchall(&pool.pool) .await?; Ok(tenants) }

async fn payments( &self, ctx: &Context<'>, tenantid: String, // Dynamic parameter ) -> Result<Vec<Payment>> { let pool = ctx.data::<DbPool>()?; pool.settenantschema(&tenant_id).await?;

let payments = sqlx::queryas!( Payment, "SELECT id, invoiceid, amount, currency, status, externalid, payeremail, description, createdat, updatedat FROM payments" ) .fetch_all(&pool.pool) .await?; Ok(payments) } }

Kalo lu jalanin sekarang, bakal error: column "created_at" is of type timestamptz but the expression is of type timestamp. Itu karena chrono::DateTime<Utc> vs sqlx type mapping. Gue pake Option<DateTime<Utc>> yang otomatis handle.

Mutation buat create payment:

// backend/src/graphql/mutation.rs
use async_graphql::{Context, Object, Result};
use crate::db::pool::DbPool;
use crate::xendit::client::XenditClient;
use crate::models::payment::Payment;

#[derive(Default)]
pub struct MutationRoot;

#[Object]
impl MutationRoot {
    async fn create_payment(
        &self,
        ctx: &Context<'_>,
        tenant_id: String,
        amount: i64,
        payer_email: String,
        description: Option<String>,
    ) -> Result<Payment> {
        let pool = ctx.data::<DbPool>()?;
        let xendit = ctx.data::<XenditClient>()?;
        
        pool.set_tenant_schema(&tenant_id).await?;
        
        // Generate external_id unique
        let external_id = format!("{}-{}", tenant_id, uuid::Uuid::new_v4());
        
        // Call Xendit API
        let invoice = xendit.create_invoice(
            amount,
            &payer_email,
            &external_id,
            description.as_deref(),
        ).await?;
        
        // Save to database
        let payment = sqlx::query_as!(
            Payment,
            "INSERT INTO payments (invoice_id, amount, currency, status, external_id, payer_email, description) VALUES ($1, $2, 'IDR', 'PENDING', $3, $4, $5) RETURNING *",
            invoice.id,
            amount,
            external_id,
            payer_email,
            description
        )
        .fetch_one(&pool.pool)
        .await?;
        
        Ok(payment)
    }
}

// backend/src/xendit/client.rs use serde::{Deserialize, Serialize}; use reqwest::Client as HttpClient; use std::sync::Arc;

#[derive(Debug, Deserialize, Serialize)] pub struct XenditInvoice { pub id: String, pub invoiceurl: String, pub status: String, pub amount: i64, pub currency: String, pub externalid: String, }

pub struct XenditClient { httpclient: HttpClient, secretkey: String, callback_url: String, }

impl XenditClient { pub fn new(secretkey: String, callbackurl: String) -> Self { Self { httpclient: HttpClient::new(), secretkey, callback_url, } }

pub async fn createinvoice( &self, amount: i64, payeremail: &str, externalid: &str, description: Option<&str>, ) -> Result<XenditInvoice, anyhow::Error> { let body = serdejson::json!({ "externalid": externalid, "amount": amount, "payeremail": payeremail, "description": description.unwrapor(""), "callbackurl": self.callbackurl, "successredirecturl": "https://your-frontend.com/success", "failureredirect_url": "https://your-frontend.com/failure", });

let response = self.httpclient .post("https://api.xendit.co/v2/invoices") .header("Authorization", format!("Basic {}", base64::encode(format!("{}:", self.secretkey)))) .json(&body) .send() .await?;

if !response.status().issuccess() { let errortext = response.text().await?; anyhow::bail!("Xendit API error: {}", error_text); }

let invoice = response.json::<XenditInvoice>().await?; Ok(invoice) }

pub async fn getinvoice(&self, invoiceid: &str) -> Result<XenditInvoice, anyhow::Error> { let response = self.httpclient .get(format!("https://api.xendit.co/v2/invoices/{}", invoiceid)) .header("Authorization", format!("Basic {}", base64::encode(format!("{}:", self.secret_key)))) .send() .await?;

if !response.status().issuccess() { let errortext = response.text().await?; anyhow::bail!("Xendit API error: {}", error_text); }

let invoice = response.json::<XenditInvoice>().await?; Ok(invoice) } }

// Need base64 dependency

Tambahkan base64 = "0.22" di Cargo.toml.

// backend/src/main.rs
use axum::{routing::get, Router};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use async_graphql::{EmptySubscription, Schema};
use std::sync::Arc;

mod db;
mod graphql;
mod models;
mod xendit;

use db::pool::DbPool;
use graphql::{query::QueryRoot, mutation::MutationRoot};
use xendit::client::XenditClient;

type AppSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();
    
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let secret_key = std::env::var("XENDIT_SECRET_KEY").expect("XENDIT_SECRET_KEY must be set");
    let callback_url = std::env::var("XENDIT_CALLBACK_URL").expect("XENDIT_CALLBACK_URL must be set");
    
    let pool = DbPool::new(&database_url).await.expect("Failed to connect to database");
    let xendit = XenditClient::new(secret_key, callback_url);
    
    let schema = Schema::build(QueryRoot::default(), MutationRoot::default(), EmptySubscription)
        .data(pool.clone())
        .data(xendit)
        .finish();
    
    let app = Router::new()
        .route("/graphql", get(graphql_playground).post(graphql_handler))
        .with_state(schema);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
    println!("Server running on http://localhost:4000");
    axum::serve(listener, app).await.unwrap();
}

async fn graphql_handler(schema: axum::extract::State<AppSchema>, req: GraphQLRequest) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

async fn graphql_playground() -> axum::response::Html<String> {
    axum::response::Html(async_graphql::http::playground_source(
        async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"),
    ))
}

cd ../frontend npx create-next-app@latest . --typescript --app --src-dir --no-tailwind --no-eslint

Bikin file src/lib/graphql-client.ts:

const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql';

export async function graphqlRequest<T>(
  query: string,
  variables?: Record<string, unknown>
): Promise<T> {
  const response = await fetch(GRAPHQL_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });

  if (!response.ok) {
    throw new Error(`GraphQL error: ${response.statusText}`);
  }

  const { data, errors } = await response.json();
  if (errors) {
    throw new Error(errors[0]?.message || 'Unknown GraphQL error');
  }

  return data as T;
}

// src/app/payments/[tenantId]/page.tsx 'use client';

import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { graphqlRequest } from '@/lib/graphql-client';

interface CreatePaymentResponse { createPayment: { id: string; invoiceId: string; amount: number; status: string; invoiceUrl: string; }; }

const CREATEPAYMENTMUTATION = mutation CreatePayment($tenantId: String!, $amount: Int!, $payerEmail: String!, $description: String) { createPayment(tenantId: $tenantId, amount: $amount, payerEmail: $payerEmail, description: $description) { id invoiceId amount status invoiceUrl } } ;

export default function PaymentPage({ params }: { params: { tenantId: string } }) { const [amount, setAmount] = useState(0); const [email, setEmail] = useState(''); const [description, setDescription] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [invoiceUrl, setInvoiceUrl] = useState<string | null>(null); const router = useRouter();

const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null);

try { const data = await graphqlRequest<CreatePaymentResponse>(CREATEPAYMENTMUTATION, { tenantId: params.tenantId, amount: Math.round(amount * 100), // Convert to cents for Xendit payerEmail: email, description: description || null, });

setInvoiceUrl(data.createPayment.invoiceUrl); // Redirect to Xendit invoice window.location.href = data.createPayment.invoiceUrl; } catch (err) { setError(err instanceof Error ? err.message : 'Payment creation failed'); } finally { setLoading(false); } };

return ( <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow"> <h1 className="text-2xl font-bold mb-4">Pay for Tenant {params.tenantId}</h1>

{error && ( <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> {error} </div> )}

<form onSubmit={handleSubmit}> <div className="mb-4"> <label className="block text-sm font-medium mb-1">Amount (IDR)</label> <input type="number" value={amount} onChange={(e) => setAmount(Number(e.target.value))} className="w-full p-2 border rounded" required min={1000} /> </div>

<div className="mb-4"> <label className="block text-sm font-medium mb-1">Email</label> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full p-2 border rounded" required /> </div>

<div className="mb-4"> <label className="block text-sm font-medium mb-1">Description (optional)</label> <textarea value={description} onChange={(e) => setDescription(e.target.value)} className="w-full p-2 border rounded" /> </div>

<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50" > {loading ? 'Processing...' : 'Pay Now'} </button> </form>

{invoiceUrl && ( <div className="mt-4 text-green-600"> Redirecting to Xendit invoice... </div> )} </div> ); }

Jalanin npm run dev. Buka http://localhost:3000/payments/tenant-123. Coba isi form. Kalo error "Failed to fetch" dari GraphQL, itu karena CORS. Tambahin middleware di Rust:

// di main.rs, tambahin tower-http
use tower_http::cors::{CorsLayer, Any};

let cors = CorsLayer::new()
    .allow_origin(Any)
    .allow_methods(Any)
    .allow_headers(Any);

let app = Router::new()
    .route("/graphql", get(graphql_playground).post(graphql_handler))
    .layer(cors)
    .with_state(schema);

Bikin `docker-compose.yml` di root:

version: '3.8'

services: db: image: postgres:16-alpine environment: POSTGRESUSER: postgres POSTGRESPASSWORD: password POSTGRESDB: paymentgateway ports:

  • "5432:5432"

volumes:

  • pgdata:/var/lib/postgresql/data
  • ./backend/migrations:/docker-entrypoint-initdb.d

backend: build: ./backend environment: DATABASEURL: postgres://postgres:password@db:5432/paymentgateway XENDITSECRETKEY: ${XENDITSECRETKEY} XENDITCALLBACKURL: ${XENDITCALLBACKURL} ports:

  • "4000:4000"

depends_on:

  • db

frontend: build: ./frontend environment: NEXTPUBLICGRAPHQL_ENDPOINT: http://backend:4000/graphql ports:

  • "3000:3000"

depends_on:

  • backend

volumes: pgdata:

Bikin backend/Dockerfile:

FROM rust:1.75-slim-bookworm AS builder

WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true

COPY src ./src
RUN cargo build --release

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/payment-gateway-backend /usr/local/bin/
EXPOSE 4000
CMD ["payment-gateway-backend"]

Bikin `frontend/Dockerfile`:

FROM node:20-alpine AS builder

WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci

COPY . . RUN npm run build

FROM node:20-alpine WORKDIR /app COPY --from=builder /app/.next ./.next COPY --from=builder /app/package.json ./package.json RUN npm ci --production

EXPOSE 3000 CMD ["npm", "start"]

Jalanin:

docker compose up --build

Kalo error "connection refused" dari backend ke db, itu karena container db belum siap. Tambahin health check atau pake `depends_on` dengan condition.

Bikin `.github/workflows/deploy.yml`:

name: Deploy Payment Gateway

on: push: branches: [main] workflow_dispatch:

env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}

jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRESPASSWORD: test POSTGRESDB: testdb options: >- --health-cmd pgisready --health-interval 10s --health-timeout 5s --health-retries 5 ports:

  • 5432:5432

steps:

  • uses: actions/checkout@v4
  • name: Setup Rust

uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable

  • name: Run backend tests

run: | cd backend cargo test env: DATABASEURL: postgres://postgres:test@localhost:5432/testdb

  • name: Setup Node

uses: actions/setup-node@v4 with: node-version: 20

  • name: Run frontend tests

run: | cd frontend npm ci npm run test || true

build-and-push: needs: test runs-on: ubuntu-latest permissions: contents: read packages: write

steps:

  • uses: actions/checkout@v4
  • name: Log in to GitHub Container Registry

uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}

  • name: Build and push backend

uses: docker/build-push-action@v5 with: context: ./backend push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:latest cache-from: type=gha cache-to: type=gha,mode=max

  • name: Build and push frontend

uses: docker/build-push-action@v5 with: context: ./frontend push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest cache-from: type=gha cache-to: type=gha,mode=max

deploy: needs: build-and-push runs-on: ubuntu-latest environment: production

steps:

  • name: Deploy to VPS (example with SSH)

uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SSHHOST }} username: ${{ secrets.SSHUSER }} key: ${{ secrets.SSHKEY }} script: | docker pull ${{ env.REGISTRY }}/${{ env.IMAGENAME }}-backend:latest docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest docker compose -f /opt/payment-gateway/docker-compose.prod.yml up -d --force-recreate

Di Rust, tambahin middleware rate limiter. Pake crate governor:

use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
use std::num::NonZeroU32;

// Di main.rs
let quota = Quota::per_second(NonZeroU32::new(5).unwrap());
let limiter = DefaultKeyedRateLimiter::keyed(quota);

// Middleware per tenant
async fn rate_limit(
    limiter: &DefaultKeyedRateLimiter<String>,
    tenant_id: &str,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
    match limiter.check_key(tenant_id) {
        Ok(_) => Ok(()),
        Err(_) => Err((
            StatusCode::TOO_MANY_REQUESTS,
            Json(serde_json::json!({ "error": "Rate limit exceeded" })),
        )),
    }
}

Buat endpoint di Axum untuk handle callback Xendit:

use axum::{routing::post, Json};

#[derive(Deserialize)] struct XenditCallback { id: String, status: String, externalid: String, paidamount: Option<i64>, }

async fn xenditcallback( State(pool): State<DbPool>, Json(payload): Json<XenditCallback>, ) -> Result<Json<serdejson::Value>, (StatusCode, Json<serdejson::Value>)> { // Extract tenantid from externalid let tenantid = payload.externalid.split('-').next().unwrapor("public"); pool.settenantschema(tenantid).await.maperr(|e| { (StatusCode::INTERNALSERVERERROR, Json(serdejson::json!({ "error": e.tostring() }))) })?;

// Update payment status sqlx::query!( "UPDATE payments SET status = $1, updatedat = NOW() WHERE externalid = $2", payload.status, payload.externalid, ) .execute(&pool.pool) .await .maperr(|e| { (StatusCode::INTERNALSERVERERROR, Json(serdejson::json!({ "error": e.tostring() }))) })?;

Ok(Json(serde_json::json!({ "message": "OK" }))) }

Error: "could not determine data type of parameter $1" โ€” Itu karena sqlx butuh tipe eksplisit. Pake $1::UUID atau cast di query.

Xendit callback gak masuk โ€” Pastikan URL callback lu public (pake ngrok). Juga verifikasi signature Xendit di header.

Docker build lama โ€” Manfaatin layer caching dengan cara pisahin cargo build dependency dan source code.

Gitu aja. Sekarang lu punya payment gateway multi-tenant yang siap dipake. Tes dulu dengan bikin dua tenant berbeda, bikin payment, dan verifikasi statusnya lewat webhook.