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.

Memuat komentar...