Panduan · Terbit
Integrasi payment gateway di Angular: route Express di server.ts yang dipasang sebelum handler SSR, express.raw khusus untuk webhook, dan allowedHosts yang menolak semua host secara bawaan
Aplikasi Angular memasang Kasera Pay di src/server.ts, file Express yang dibuat ng new --ssr, dan tidak pernah di komponen. Dua route ditambahkan di sana, POST /api/bayar untuk membuat permintaan pembayaran dan POST /kasera-webhook untuk menerima kabar pembayaran, keduanya sebelum express.static dan handler Angular. Empat hal khas Angular menentukan berhasil atau tidaknya: kunci API dibaca dari process.env saat server berjalan, bukan dari environment.ts; webhook memakai express.raw di route-nya sendiri; domain produksi dimasukkan ke allowedHosts; dan aplikasinya di-build dengan outputMode server, karena build statis tidak punya tempat untuk webhook.
Semua kode di bawah dijalankan saat panduan ini ditulis pada @angular/cli dan @angular/ssr 22.2.4, @angular/core 22.2.3, Express 5.3.0, pg 8.23.1, Postgres 16.15, Node 22.23.4, dan SDK JavaScript resmi kasera-pay 0.1.0, dengan API Kasera Pay digantikan server tiruan dan pengiriman webhook ditandatangani persis seperti server Kasera Pay. Yang belum diuji: tombol di peramban sungguhan, dan API Kasera Pay yang asli. Kalau backend-nya bukan server.ts milik Angular, bentuk Express yang setara ada di panduan Node.js dan Express, dan untuk NestJS di panduan NestJS.
Alurnya, sebelum menulis kode
Komponen halaman pesanan memanggil /api/bayar dengan id pesanan. Server membaca total dari tabel orders, membuat permintaan pembayaran, dan mengembalikan checkout_url. Pembeli membayar dengan QRIS atau Virtual Account tujuh bank. Kasera Pay mengirim payment.paid bertanda tangan ke /kasera-webhook, dan server menandai pesanan lunas. Kepulangan pembeli ke return_url bukan bukti apa pun. Status permintaan pembayaran antara lain pending, succeeded, dan expired; payment.paid adalah nama event-nya, bukan status.
1. Proyek, paket, dan tempat rahasia disimpan
# Dijalankan apa adanya saat pengukuran (Node 22.23.4; @angular/cli 22.2.4 menolak Node < 22.22.3).
npx @angular/cli@22.2.4 new toko --ssr --style=css --skip-git --defaults
cd toko
npm install kasera-pay pg
npm install -D @types/pg
psql "$DATABASE_URL" -f schema.sql
# Build tidak membutuhkan secret apa pun.
npm run build
# Secret hanya diberikan saat server berjalan.
NG_ALLOWED_HOSTS=toko.example \
NODE_ENV=production \
DATABASE_URL=postgres://... \
KASERA_PAY_KEY=kp_test_... \
KASERA_PAY_WEBHOOK_SECRET=whsec_... \
node dist/toko/server/server.mjsCREATE TABLE orders (
id text PRIMARY KEY,
total bigint NOT NULL CHECK (total > 0), -- rupiah utuh
status text NOT NULL DEFAULT 'BARU',
attempt int NOT NULL DEFAULT 0,
payment_request_id text,
paid_at timestamptz
);
CREATE TABLE kasera_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Angular tidak punya mekanisme .env untuk kode aplikasi: @angular/build 22 tidak memuat file .env, tidak punya import.meta.env, dan tidak punya awalan variabel publik. Itu kabar baik, karena process.env di server.ts tetap menjadi pembacaan saat server berjalan. Build dengan kunci tes yang dipasang di lingkungan build tidak meninggalkan kunci itu di mana pun di dist/.
Dua tempat yang terlihat wajar justru membocorkan kunci. environment.ts adalah kode yang dibundel: diimpor komponen, nilainya muncul di dist/toko/browser/main-*.js yang diunduh setiap pengunjung. Opsi define di angular.json diterapkan ke bundel browser dan bundel server sekaligus. Kunci kp_test_ dan kp_live_ diganti bersamaan dengan signing secret-nya, karena endpoint webhook mode tes dan live punya secret masing-masing.
2. server.ts: dua route, dipasang sebelum Angular
// src/server.ts
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
import pg from 'pg';
import { KaseraPay, KaseraPayError, SignatureError, constructWebhookEvent } from 'kasera-pay';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
// Dibaca saat server berjalan, tidak ditanam oleh ng build. Jangan taruh di
// environment.ts. Jangan pula throw di sini kalau kosong: ng build ikut
// memuat file ini untuk mengekstrak route, dan build-nya gagal.
const kasera = new KaseraPay(process.env['KASERA_PAY_KEY'] ?? '');
const db = new pg.Pool({ connectionString: process.env['DATABASE_URL'], max: 10 });
// Koneksi idle yang diputus Postgres (restart, failover) memancarkan 'error'.
// Di Node biasa itu mematikan proses; di sini AngularNodeAppEngine diam-diam
// mencatatnya sebagai uncaughtException. Tangani secara eksplisit.
db.on('error', (e) => console.error('pg pool:', e.message));
// Route API dipasang SEBELUM express.static dan handler Angular.
app.post('/api/bayar', express.json(), async (req, res) => {
// Memastikan pesanan ini milik pengguna yang login adalah tugas aplikasi.
const orderId = req.body?.orderId;
if (typeof orderId !== 'string') {
res.status(400).json({ error: 'orderId' });
return;
}
const { rows } = await db.query('SELECT id, total, status, attempt FROM orders WHERE id = $1', [
orderId,
]);
const order = rows[0];
if (!order) {
res.status(404).json({ error: 'not_found' });
return;
}
if (order.status === 'LUNAS') {
res.status(409).json({ error: 'sudah_lunas' });
return;
}
try {
const tx = await kasera.createTransaction(
{
amount: Number(order.total), // bigint datang sebagai string
description: 'Pesanan ' + order.id,
external_id: order.id,
return_url: 'https://toko.example/pesanan/' + order.id,
checkout: {},
},
{ idempotencyKey: 'order-' + order.id + '-' + order.attempt },
);
await db.query(
"UPDATE orders SET payment_request_id = $1, status = 'MENUNGGU' WHERE id = $2 AND status <> 'LUNAS'",
[tx.id, order.id],
);
res.json({ checkoutUrl: tx.checkout_url });
} catch (e) {
if (e instanceof KaseraPayError) {
res.status(502).json({ error: e.code });
return;
}
throw e; // Express 5 meneruskannya ke error handler sebagai 500
}
});
// express.raw HANYA di route ini. Jangan pasang app.use(express.json()) global
// di atasnya: body sudah habis dibaca dan req.body bukan lagi Buffer.
app.post('/kasera-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let ev;
try {
ev = await constructWebhookEvent(
Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '', // byte mentah, bukan JSON.stringify(req.body)
req.get('kasera-signature-v1') ?? '',
process.env['KASERA_PAY_WEBHOOK_SECRET'] ?? '',
);
} catch (e) {
if (e instanceof SignatureError) {
res.status(400).send('bad signature');
return;
}
throw e;
}
// Tipe kasera-pay 0.1.0 baru mengenal payment.paid dan test.ping.
const type: string = ev.type;
const data = ('data' in ev ? ev.data : undefined) as
| { external_id?: string | null; payment_request_id?: string; paid_at?: string | null }
| undefined;
if (!data?.external_id) {
res.send('ignored');
return;
}
const client = await db.connect();
try {
await client.query('BEGIN');
const fresh = await client.query(
'INSERT INTO kasera_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
[ev.id, type],
);
if (fresh.rowCount === 1 && type === 'payment.paid') {
const paid = await client.query(
"UPDATE orders SET status = 'LUNAS', paid_at = coalesce($2, now()) WHERE id = $1",
[data.external_id, data.paid_at ?? null],
);
// UPDATE yang tidak menemukan pesanan tetap sukses di Postgres (rowCount 0).
// Lempar galat agar id event ikut di-rollback dan pengiriman diulang.
if (paid.rowCount !== 1) throw new Error('pesanan untuk event ini tidak ditemukan');
} else if (fresh.rowCount === 1 && type === 'payment.expired') {
await client.query(
`UPDATE orders SET status = 'KEDALUWARSA', attempt = attempt + 1
WHERE id = $1 AND status <> 'LUNAS' AND payment_request_id = $2`,
[data.external_id, data.payment_request_id ?? null],
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
console.error(e);
res.status(500).send('retry'); // Kasera Pay mengulang
return;
} finally {
client.release();
}
res.send('ok');
});
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
/**
* Start the server if this module is the main entry point, or it is ran via PM2.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, (error) => {
if (error) {
throw error;
}
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);Jangan throw di level modul. ng build ikut memuat server.ts untuk mengekstrak daftar route. Baris seperti if (!process.env['KASERA_PAY_KEY']) throw ... di bagian atas file membuat build gagal dengan pesan “An error occurred while extracting routes”, karena build memang tidak diberi secret. Pakai ?? '' dan biarkan permintaan pertama yang gagal.
Total dibaca dari database, tidak pernah dari body, supaya pembeli tidak bisa mengirim nominalnya sendiri. pg mengembalikan kolom bigint sebagai string, jadi Number() wajib sebelum dikirim. Kunci idempotensinya dibentuk dari id pesanan dan nomor percobaan: key yang sama dengan body yang sama selalu mengembalikan permintaan asli, termasuk yang sudah kedaluwarsa, sehingga nomor percobaan dinaikkan oleh webhook saat payment.expired tiba. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Alasannya ada di idempotency untuk pembayaran. Pada pengujian, dua klik bersamaan menghasilkan dua panggilan dengan Idempotency-Key: order-D1-0 yang sama dan body yang sama.
Jalankan dengan NODE_ENV=production. Saat tabel orders sengaja diganti namanya, /api/bayar menjawab 500 dari handler galat bawaan Express, dan tanpa NODE_ENV=production isinya adalah pesan galat lengkap beserta stack trace sebagai HTML.
3. Webhook: byte mentah, dan urutan pemasangan
express.raw({ type: 'application/json' }) dipasang hanya pada route webhook, dan string yang diverifikasi adalah req.body.toString('utf8'). Hasil pengukurannya:
| Susunan | Hasil pada pengiriman yang sah |
|---|---|
express.raw di route, termasuk application/json; charset=utf-8 | 200, pesanan lunas |
app.use(express.json()) global di atas route webhook | 400 bad signature untuk semua pengiriman, tidak ada yang tercatat |
JSON.stringify(req.body) sebagai pengganti body mentah | 200 pada body sederhana, 400 begitu nama pelanggan berisi & atau < |
| Route webhook dipasang sesudah handler Angular | Tergantung route Angular; bisa 200 berisi halaman 404 |
Baris kedua adalah kesalahan yang paling mudah terjadi: express.json() global sudah menghabiskan body, express.raw melihat aliran yang sudah terbaca lalu melewatkannya, dan req.body tetap objek, bukan Buffer. Pengiriman ditolak, Kasera Pay mengulang, dan tetap ditolak. Baris ketiga gagal karena server Kasera Pay menyandikan JSON dengan &, <, dan > yang di-escape; JSON.parse lalu JSON.stringify menghasilkan isi yang sama dengan byte yang berbeda. Pada pengujian, nama pelanggan “Budi & Sari <Toko>” cukup untuk membuat pesanan tertahan di status menunggu.
Baris keempat yang paling berbahaya. Aplikasi yang punya route { path: '**' } untuk halaman tidak ditemukan dan merender '**' di server menjawab POST bertanda tangan ke /kasera-webhook dengan 200 berisi halaman 404, karena mode render server tidak memeriksa metode HTTP. Kasera Pay menerima 2xx, berhenti mengirim ulang, dan pembayarannya tidak pernah tercatat. Pasang route webhook di atas handler Angular, seperti pada kode di atas.
Dedupe dan pembaruan pesanan berada dalam satu transaksi, dengan sisipan ke kasera_events sebagai penentunya. Pada pengujian, 40 pengiriman payment.paid yang sama secara bersamaan menghasilkan tepat satu UPDATE, dan 40 pengiriman payment.expired menaikkan nomor percobaan tepat satu kali. Tanpa pemeriksaan fresh.rowCount === 1, angka yang sama menjadi 40, padahal nomor itu ikut membentuk kunci idempotensi. Tanpa pemeriksaan paid.rowCount !== 1, event untuk pesanan yang belum tersimpan dijawab 200 dan di-commit, lalu pengiriman ulangnya dianggap duplikat dan pesanan tidak pernah lunas. Pengiriman bersifat at-least-once, dan jawaban selain 2xx dicoba lagi sampai tujuh kali dalam kurang lebih 33 jam; rinciannya ada di referensi webhook.
Satu hal lagi khas Angular: konstruktor AngularNodeAppEngine pada aplikasi tanpa Zone memasang handler uncaughtException dan unhandledRejection yang hanya mencetak galat. Proses Node biasa yang pool Postgres-nya tidak punya listener error mati saat Postgres di-restart; server Angular tidak mati dan diam-diam terus berjalan, sehingga galat apa pun yang tidak tertangkap hanya terlihat di log. Itu sebabnya db.on('error') dipasang secara eksplisit.
4. Komponen dan route
// src/app/pesanan.ts
import { Component, inject, input, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-pesanan',
template: `
<button type="button" (click)="bayar()" [disabled]="memproses()">
{{ memproses() ? 'Mengalihkan...' : 'Bayar' }}
</button>
@if (gagal()) {
<p>Pembayaran belum bisa dibuat. Coba lagi.</p>
}
`,
})
export class Pesanan {
private http = inject(HttpClient);
readonly id = input.required<string>(); // dari route pesanan/:id
readonly memproses = signal(false);
readonly gagal = signal(false);
// Dipanggil dari klik, jadi hanya berjalan di browser, tidak saat SSR.
bayar() {
this.memproses.set(true);
this.gagal.set(false);
this.http.post<{ checkoutUrl: string }>('/api/bayar', { orderId: this.id() }).subscribe({
next: ({ checkoutUrl }) => (window.location.href = checkoutUrl),
error: () => {
this.memproses.set(false);
this.gagal.set(true);
},
});
}
}// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { Pesanan } from './pesanan';
export const routes: Routes = [{ path: 'pesanan/:id', component: Pesanan }];// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: 'pesanan/:id', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Prerender },
];// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideClientHydration(),
],
};Di Angular 22, HttpClient sudah tersedia tanpa provideHttpClient() dan memakai fetch. withComponentInputBinding() membuat parameter :id masuk sebagai input. Pembayaran dibuat hanya dari klik, sehingga tidak pernah berjalan saat SSR; tombolnya dinonaktifkan setelah klik pertama, dan kunci idempotensi menutup sisa kemungkinan klik ganda.
5. Deploy: outputMode server dan allowedHosts
npm run build menghasilkan dist/toko/server/server.mjs yang dijalankan dengan Node, dan secret diberikan pada proses itu, bukan pada langkah build. Proyek baru menulis security.allowedHosts: [] di angular.json, dan daftar kosong menolak semua host, termasuk localhost:4000, dengan 400. Isi domain produksinya di sana atau lewat NG_ALLOWED_HOSTS; pola *.domain diterima. Header X-Forwarded-Host dibuang kecuali NG_TRUST_PROXY_HEADERS diisi, jadi di belakang load balancer yang dicocokkan adalah header Host. Route Express yang dipasang sebelum handler Angular tidak ikut diperiksa.
outputMode: "static" tidak cocok untuk aplikasi ini. Hasilnya hanya folder browser/, tanpa server.mjs, dan route RenderMode.Server yang tertinggal tetap di-build tanpa peringatan. URL webhook didaftarkan di dashboard, menu Developer, dan wajib https ke alamat publik.
6. Menguji sebelum kunci live dipasang
Dengan kunci kp_test_, halaman checkout menampilkan tombol simulasi, dan hasil berhasil maupun kedaluwarsa bisa dipicu lewat endpoint simulasi di dokumentasi mode tes. Lima hal yang layak dicoba: grep kunci tes di folder dist setelah build tidak menemukan apa pun; pengiriman yang ditandatangani dengan secret yang salah ke /kasera-webhook dijawab 400, bukan HTML; event yang dikirim ulang tidak mengubah apa pun; simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru; dan tabel yang sengaja diganti namanya membuat webhook dijawab 500. Urutan pemeriksaan sebelum go-live yang lebih lengkap ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Bolehkah kunci API Kasera Pay disimpan di environment.ts?
Tidak. environment.ts adalah kode yang dibundel. Saat panduan ini ditulis, kunci di environment.ts yang diimpor komponen muncul sebagai teks biasa di dist/toko/browser/main-*.js, yang diunduh setiap pengunjung; diimpor hanya oleh server.ts pun nilainya tetap tertanam di dist/toko/server/server.mjs. Opsi define di angular.json berlaku sama untuk bundel browser dan server. Baca kunci dari process.env di server.ts saat server berjalan.
Kenapa semua halaman SSR menjawab 400 setelah build pertama?
Karena proyek baru dari ng new --ssr menulis security.allowedHosts: [] di angular.json, dan daftar kosong berarti tidak ada host yang diizinkan, termasuk localhost:4000. Jawabannya 400 dengan pesan bahwa header host tidak diizinkan. Isi domain produksinya di allowedHosts atau lewat variabel NG_ALLOWED_HOSTS saat server berjalan. Route Express yang dipasang sebelum handler Angular, termasuk webhook, tidak terkena pemeriksaan ini.
Bolehkah pembayaran dibuat di ngOnInit supaya pembeli langsung diarahkan?
Jangan. Saat SSR, HttpClient menulis ulang URL relatif terhadap host permintaan yang masuk dan server memanggil dirinya sendiri. Pada pengujian, satu GET halaman pesanan membuat permintaan pembayaran sungguhan di server, lalu gagal pada window yang tidak ada di Node, dan halaman tetap dijawab 200. Buat pembayaran hanya dari tindakan pembeli, misalnya klik tombol.