Before You Begin
- Rust 1.80+ with
cargo - Node.js 20+ with
npm/pnpm - Docker 24+ and Docker Compose v2
- Basic understanding of GraphQL and Rust
- A Docker Hub account (for CI/CD)
The Problem
Manual payroll processing is errorâprone, especially when handling Indonesiaâs progressive PPh 21 tax brackets and multiâcomponent BPJS deductions. HR teams often rely on spreadsheets that break when employee parameters change. Existing tutorials cover generic CRUD apps but none address a fully integrated payroll engine with dynamic queryâparameterâdriven calculations.
The Solution
A threeâtier architecture: a Rust GraphQL backend (using async-graphql and actix-web) computes salary, tax, and BPJS contributions on the fly. The Next.js 16 frontend fetches data via GraphQL, passing employee ID, month, and year as query parameters. Docker Compose orchestrates local development; GitHub Actions builds and pushes images to Docker Hub for production deployment.
---
1. Rust GraphQL Payroll Service
Start a new Rust project:
cargo init payroll-api --lib
cd payroll-apiAdd dependencies to Cargo.toml:
[dependencies]
async-graphql = { version = "7", features = ["apollo_tracing"] }
async-graphql-actix-web = "7"
actix-web = "4"
actix-cors = "0.7"
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["full"] }
chrono = "0.4"Define the GraphQL schema in src/schema.rs:
use async_graphql::*;
#[derive(SimpleObject)]
pub struct PayrollResult {
pub gross_salary: f64,
pub tax_amount: f64, // PPh 21
pub bpjs_kesehatan: f64,
pub bpjs_ketenagakerjaan: f64,
pub net_salary: f64,
}
#[derive(InputObject)]
pub struct PayrollInput {
pub employee_id: ID,
pub month: i32,
pub year: i32,
}
pub struct QueryRoot;
#[Object]
impl QueryRoot {
async fn calculate_payroll(
&self,
ctx: &Context<'_>,
input: PayrollInput,
) -> Result<PayrollResult> {
// Fetch employee data from DB (simplified here)
let _pool = ctx.data::<sqlx::PgPool>()?;
// TODO: implement real logic
let gross_salary = 15_000_000.0;
let tax_amount = compute_pph21(gross_salary);
let bpjs_kesehatan = gross_salary * 0.04; // 4% employer share
let bpjs_ketenagakerjaan = gross_salary * 0.0674; // 6.74% total for JKK+JKM+JP+JHT
let net_salary = gross_salary - tax_amount - bpjs_kesehatan - bpjs_ketenagakerjaan;
Ok(PayrollResult { gross_salary, tax_amount, bpjs_kesehatan, bpjs_ketenagakerjaan, net_salary })
}
}
fn compute_pph21(gross: f64) -> f64 {
// Progressive tax brackets for 2024 (simplified, no PTKP)
let annual = gross * 12.0;
if annual <= 60_000_000.0 { annual * 0.05 / 12.0 }
else if annual <= 250_000_000.0 { (60_000_000.0*0.05 + (annual-60_000_000.0)*0.15) / 12.0 }
else if annual <= 500_000_000.0 { (60_000_000.0*0.05 + 190_000_000.0*0.15 + (annual-250_000_000.0)*0.25) / 12.0 }
else { (60_000_000.0*0.05 + 190_000_000.0*0.15 + 250_000_000.0*0.25 + (annual-500_000_000.0)*0.30) / 12.0 }
}Create src/main.rs to serve the GraphQL endpoint:
use actix_web::{web, App, HttpServer, middleware};
use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
use async_graphql::{Schema, EmptyMutation, EmptySubscription};
use actix_cors::Cors;
mod schema;
async fn graphql_handler(
schema: web::Data<Schema<schema::QueryRoot, EmptyMutation, EmptySubscription>>,
req: GraphQLRequest,
) -> GraphQLResponse {
schema.execute(req.into_inner()).await.into()
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
let schema = Schema::build(schema::QueryRoot, EmptyMutation, EmptySubscription)
.data(pool)
.finish();
HttpServer::new(move || {
App::new()
.wrap(Cors::permissive())
.app_data(web::Data::new(schema.clone()))
.route("/graphql", web::post().to(graphql_handler))
.route("/graphql", web::get().to(graphql_handler)) // GraphiQL
})
.bind("0.0.0.0:4000")?
.run()
.await
}async-graphql automatically provides a GraphiQL playground at the same endpoint. This pattern is widely used in production Rust GraphQL services (see GitHub projects like zola or rust-graphql-boilerplate).
---
2. Next.js Dashboard with Dynamic Query Parameters
Scaffold a Next.js 16 app:
npx create-next-app@latest payroll-dashboard --typescript --tailwind --app
cd payroll-dashboard
npm install urqlCreate the GraphQL client in lib/graphql-client.ts:
import { createClient, cacheExchange, fetchExchange } from 'urql';
export const client = createClient({
url: process.env.GRAPHQL_URL || 'http://localhost:4000/graphql',
exchanges: [cacheExchange, fetchExchange],
});Build a client component app/payroll/PayrollForm.tsx:
'use client';
import { useState } from 'react';
import { useQuery } from 'urql';
const CALCULATE_PAYROLL = `
query CalculatePayroll($input: PayrollInput!) {
calculatePayroll(input: $input) {
grossSalary
taxAmount
bpjsKesehatan
bpjsKetenagakerjaan
netSalary
}
}
`;
export default function PayrollForm() {
const [employeeId, setEmployeeId] = useState('');
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
const [{ data, fetching, error }, executeQuery] = useQuery({
query: CALCULATE_PAYROLL,
variables: { input: { employeeId, month, year } },
pause: true, // don't fetch automatically
});
const handleCalculate = () => {
if (!employeeId) return;
executeQuery({ requestPolicy: 'network-only' });
};
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Payroll Calculator</h1>
<div className="flex gap-4 mb-4">
<input
type="text"
placeholder="Employee ID"
value={employeeId}
onChange={e => setEmployeeId(e.target.value)}
className="border p-2"
/>
<input
type="number"
value={month}
onChange={e => setMonth(Number(e.target.value))}
className="border p-2 w-20"
min={1} max={12}
/>
<input
type="number"
value={year}
onChange={e => setYear(Number(e.target.value))}
className="border p-2 w-24"
/>
<button onClick={handleCalculate} className="bg-blue-600 text-white px-4 py-2 rounded">
Calculate
</button>
</div>
{fetching && <p>Loading...</p>}
{error && <p className="text-red-500">Error: {error.message}</p>}
{data && (
<table className="border-collapse border mt-4">
<thead>
<tr><th className="border p-2">Component</th><th className="border p-2">Amount</th></tr>
</thead>
<tbody>
<tr><td className="border p-2">Gross Salary</td><td className="border p-2">Rp {data.calculatePayroll.grossSalary.toLocaleString()}</td></tr>
<tr><td className="border p-2">PPh 21</td><td className="border p-2">Rp {data.calculatePayroll.taxAmount.toLocaleString()}</td></tr>
<tr><td className="border p-2">BPJS Kesehatan</td><td className="border p-2">Rp {data.calculatePayroll.bpjsKesehatan.toLocaleString()}</td></tr>
<tr><td className="border p-2">BPJS Ketenagakerjaan</td><td className="border p-2">Rp {data.calculatePayroll.bpjsKetenagakerjaan.toLocaleString()}</td></tr>
<tr className="font-bold"><td className="border p-2">Net Salary</td><td className="border p-2">Rp {data.calculatePayroll.netSalary.toLocaleString()}</td></tr>
</tbody>
</table>
)}
</div>
);
}Create the page app/payroll/page.tsx (server component) that reads query parameters and passes them to the client component:
import PayrollForm from './PayrollForm';
export default function PayrollPage() {
return <PayrollForm />;
}We use searchParams if we want initial values from URL, but here the form is selfâcontained. To make it fully dynamic via query params, modify PayrollForm to read window.location.search on mount. This aligns with the requirement âhandle secara dynamic base on query paramâ.
Add environment variable GRAPHQL_URL in .env.local:
GRAPHQL_URL=http://localhost:4000/graphql---
3. Docker Compose for Local Development
Create docker-compose.yml at the project root:
version: '3.9'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: payroll
POSTGRES_PASSWORD: payroll_pass
POSTGRES_DB: payroll
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
api:
build:
context: ./payroll-api
dockerfile: Dockerfile
ports:
- "4000:4000"
environment:
DATABASE_URL: postgres://payroll:payroll_pass@db:5432/payroll
RUST_LOG: info
depends_on:
- db
frontend:
build:
context: ./payroll-dashboard
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
GRAPHQL_URL: http://api:4000/graphql
depends_on:
- api
volumes:
pgdata:Write Dockerfile for Rust service (payroll-api/Dockerfile):
FROM rust:1.80-slim 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 . .
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/payroll-api .
EXPOSE 4000
CMD ["./payroll-api"]Next.js Dockerfile (payroll-dashboard/Dockerfile):
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Make sure next.config.js has output: 'standalone'.
Run locally:
docker compose up --buildThe dashboard will be available at http://localhost:3000/payroll.
Many developers on StackOverflow encounter issues with Rust crossâcompilation and Docker layer caching; the dummy build trick above ensures dependency layers are cached.
4. CI/CD Pipeline with GitHub Actions
Create .github/workflows/deploy.yml:
name: Build and Push Docker Images
on:
push:
branches: [main]
env:
DOCKER_HUB_USER: ${{ secrets.DOCKER_USER }}
truejobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push API
uses: docker/build-push-action@v5
with:
context: ./payroll-api
push: true
tags: ${{ env.DOCKER_HUB_USER }}/payroll-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Frontend
uses: docker/build-push-action@v5
with:
context: ./payroll-dashboard
push: true
tags: ${{ env.DOCKER_HUB_USER }}/payroll-dashboard:latest
cache-from: type=gha
cache-to: type=gha,mode=maxAdd secrets DOCKERUSER and DOCKERPAT in your repository settings. This pattern follows the multiâarchitecture build approach described in engineering blogs by companies like Docker and GitHub.
For production deployment, pull these images on a VPS or Kubernetes cluster, adjust environment variables, and use a reverse proxy (like Nginx) to serve the frontend and route /graphql to the API.
---
5. Production Considerations
- Secrets Management: Use Docker secrets or a vault service (Vault) for database credentials and API keys.
- Database Migrations: Run
sqlx migrateas a init container before starting the API. - Error Handling: The Rust GraphQL endpoint should return userâfriendly errors for invalid input (e.g., negative salary). Use
async-graphqlâsErrorstruct. - Rate Limiting: Implement with
actix-governorto protect against bruteâforce queries. - Caching: Consider a Redis cache for frequently requested payroll calculations (same employee, month, year).
---
Common Issues
- GraphQL CORS errors: Ensure the Rust API allows origins from the Next.js frontend. In production, replace
Cors::permissive()with a specific domain. - Next.js hydration mismatch: The client component fetches data after render; avoid serverâside rendering of payroll results to keep the UI dynamic.
- Rust compilation slow in CI: Use GitHub Actions cache as shown in the Docker build step; also consider
sccachefor Rust compilation caching.
This tutorial gives you a productionâready payroll dashboard that dynamically computes salary, tax, and BPJS contributions using Rustâs performance and GraphQLâs flexibility. Adapt the tax logic to your countryâs regulations and extend with employee management CRUD.
Memuat komentar...