create public layout and admin and fix escrow and refund

This commit is contained in:
arifal
2026-05-12 00:05:30 +07:00
parent a07942c4b4
commit 958514d575
48 changed files with 1928 additions and 18 deletions
+16 -7
View File
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
---
# 6. Pembayaran
# 6. Pembayaran & Escrow
- Pembayaran dilakukan sesuai metode yang tersedia di platform
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
- Buffer 3 hari memberi waktu peserta dan organizer melaporkan masalah trip sebelum uang cair
- Pembayaran di luar platform tidak dijamin keamanannya oleh SeTrip — kami tidak dapat memediasi sengketa untuk transaksi off-platform
---
# 7. Pembatalan & Refund
- Kebijakan pembatalan ditentukan oleh organizer
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
- **≥ 7 hari** sebelum tanggal berangkat → refund **80%** dari nominal booking
- **36 hari** sebelum tanggal berangkat → refund **50%** dari nominal booking
- **< 3 hari** sebelum tanggal berangkat / setelah berangkat → **tidak ada refund**
**Saat organizer membatalkan trip:** peserta yang sudah bayar mendapat refund **100%**.
**Pengembalian dana** diproses manual oleh admin SeTrip — perlu 13 hari kerja sejak refund disetujui untuk uang masuk ke rekening kamu. Setiap pengajuan refund tercatat (tidak pernah dihapus) untuk audit trail.
Kebijakan di atas berlaku platform-wide; organizer tidak dapat menetapkan policy yang lebih ketat tanpa persetujuan tertulis dari SeTrip.
---
+25
View File
@@ -0,0 +1,25 @@
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { Footer } from "@/components/shared/footer";
/**
* Layout user-facing (semua halaman publik + dashboard organizer/peserta).
* Berisi navbar global, profile-nudge banner, dan footer.
*
* Tidak berlaku untuk halaman admin — admin punya layout sendiri di
* app/admin/layout.tsx dengan sidebar khusus.
*/
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState, Suspense } from "react";
import { signIn } from "next-auth/react";
import { signIn, getSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -38,7 +38,13 @@ function LoginForm() {
if (result?.error) {
setError(result.error);
} else {
const next = safeInternalPath(searchParams.get("callbackUrl"));
const rawCallback = searchParams.get("callbackUrl");
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession();
if (session?.user?.isAdmin) next = "/admin";
}
router.push(next);
router.refresh();
}
@@ -5,9 +5,11 @@ import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { profileService } from "@/server/services/profile.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
export const metadata: Metadata = {
title: "Profil Saya",
@@ -20,9 +22,10 @@ export default async function ProfilePage() {
redirect("/login?callbackUrl=/profile");
}
const [data, ownProfile] = await Promise.all([
const [data, ownProfile, payouts] = await Promise.all([
profileService.getProfileDashboard(session.user.id),
profileService.getOwnProfile(session.user.id),
payoutRepo.listForOrganizer(session.user.id),
]);
const {
user,
@@ -84,6 +87,9 @@ export default async function ProfilePage() {
</Link>
</div>
{/* Pendapatan dari peserta (escrow payout) */}
<EarningsSection payouts={payouts} />
{/* Profil sosial publik */}
<div className="mb-6">
<ProfileEditor
+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
export const metadata: Metadata = {
title: "Admin · SeTrip",
alternates: { canonical: "/admin" },
robots: { index: false, follow: false },
};
/**
* Layout admin — terpisah penuh dari layout user (navbar/footer publik tidak
* dipakai). Sidebar kiri jadi shell global untuk semua /admin/*.
*
* Auth gate di layout ini berlaku ke seluruh sub-page admin sehingga
* sub-page tidak perlu re-check (boleh disederhanakan di iterasi berikutnya).
*/
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/admin");
}
if (!session.user.isAdmin) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p>
<h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin
</h1>
<p className="mt-1 text-sm text-neutral-500">
Akun kamu tidak punya akses ke panel admin SeTrip.
</p>
<a
href="/"
className="mt-4 inline-block rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Kembali ke beranda
</a>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-neutral-50 lg:flex-row">
<AdminSidebar
user={{ name: session.user.name, email: session.user.email }}
/>
<main className="flex-1 min-w-0">{children}</main>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo";
import { payoutRepo } from "@/server/repositories/payout.repo";
const REFUND_REASON_LABEL: Record<string, string> = {
USER_CANCELLATION: "Peserta cancel",
ORGANIZER_CANCELLED: "Organizer cancel",
TRIP_ISSUE: "Masalah trip",
ADMIN_ADJUSTMENT: "Penyesuaian admin",
DISPUTE_RESOLVED: "Dispute selesai",
OTHER: "Lainnya",
};
function formatIDR(n: number) {
return `Rp${n.toLocaleString("id-ID")}`;
}
function timeAgo(d: Date) {
const diff = Date.now() - new Date(d).getTime();
const h = Math.floor(diff / 3600000);
if (h < 1) return `${Math.max(1, Math.floor(diff / 60000))} mnt lalu`;
if (h < 24) return `${h} jam lalu`;
const days = Math.floor(h / 24);
return `${days} hari lalu`;
}
export default async function AdminDashboardPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin");
if (!session.user.isAdmin) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const [
pendingVerif,
approvedVerif,
rejectedVerif,
pendingRefund,
approvedRefund,
succeededRefund,
heldPayout,
releasedPayout,
paidPayout,
recentPendingVerif,
recentPendingRefund,
recentApprovedRefund,
recentReleasedPayout,
] = await Promise.all([
organizerRepo.countByStatus("PENDING"),
organizerRepo.countByStatus("APPROVED"),
organizerRepo.countByStatus("REJECTED"),
refundRepo.countByStatus("PENDING"),
refundRepo.countByStatus("APPROVED"),
refundRepo.countByStatus("SUCCEEDED"),
payoutRepo.countByStatus("HELD"),
payoutRepo.countByStatus("RELEASED"),
payoutRepo.countByStatus("PAID"),
organizerRepo.listRecent("PENDING", 3),
refundRepo.listRecent("PENDING", 3),
refundRepo.listRecent("APPROVED", 3),
payoutRepo.listRecent("RELEASED", 3),
]);
const stats: Array<{
label: string;
value: number;
hint: string;
href: string;
accent: "amber" | "blue" | "primary";
}> = [
{
label: "Verifikasi menunggu",
value: pendingVerif,
hint: "KYC organizer perlu ditinjau",
href: "/admin/verifications?tab=PENDING",
accent: "amber",
},
{
label: "Refund baru",
value: pendingRefund,
hint: "Perlu disetujui / ditolak",
href: "/admin/refunds?tab=PENDING",
accent: "amber",
},
{
label: "Refund siap transfer",
value: approvedRefund,
hint: "Refund APPROVED — transfer ke peserta lalu mark SUCCEEDED",
href: "/admin/refunds?tab=APPROVED",
accent: "blue",
},
{
label: "Payout siap transfer",
value: releasedPayout,
hint: "Escrow lepas — transfer ke organizer",
href: "/admin/payouts?tab=RELEASED",
accent: "blue",
},
];
const accentClasses: Record<typeof stats[number]["accent"], string> = {
amber: "bg-amber-50 text-amber-900 ring-amber-200",
blue: "bg-blue-50 text-blue-900 ring-blue-200",
primary: "bg-primary-50 text-primary-900 ring-primary-200",
};
const totalAttention =
pendingVerif + pendingRefund + approvedRefund + releasedPayout;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-neutral-900 sm:text-3xl">
Dashboard
</h1>
<p className="mt-1 text-sm text-neutral-500">
Halo {session.user.name}.{" "}
{totalAttention > 0 ? (
<>
Ada <strong className="text-neutral-800">{totalAttention}</strong>{" "}
hal yang menunggu tindakan kamu.
</>
) : (
<>Tidak ada antrian pending semua sudah beres </>
)}
</p>
</header>
{/* Stats row */}
<section className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Link
key={s.label}
href={s.href}
className={`group rounded-2xl bg-white p-5 ring-1 transition-shadow hover:shadow-md ${
s.value > 0 ? "ring-neutral-200" : "ring-neutral-100"
}`}
>
<div className="flex items-center justify-between">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-[11px] font-semibold ring-1 ${accentClasses[s.accent]}`}
>
{s.label}
</span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600">
Buka
</span>
</div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
<p className="mt-1 text-xs text-neutral-500">{s.hint}</p>
</Link>
))}
</section>
{/* Pending Verifikasi */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Verifikasi Organizer
</h2>
<p className="text-xs text-neutral-500">
{approvedVerif} disetujui · {rejectedVerif} ditolak ·{" "}
{pendingVerif} menunggu
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau pending ({pendingVerif})
</Link>
</div>
{recentPendingVerif.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada pengajuan menunggu review.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentPendingVerif.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{v.fullName}
</p>
<p className="truncate text-xs text-neutral-500">
{v.user.name} · {v.user.email} · {timeAgo(v.createdAt)}
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
{/* Refund — Pending & Siap Transfer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">Refund</h2>
<p className="text-xs text-neutral-500">
{succeededRefund} selesai · {approvedRefund} siap transfer ·{" "}
{pendingRefund} baru
</p>
</div>
<Link
href="/admin/refunds?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau refund
</Link>
</div>
<div className="grid divide-neutral-100 sm:grid-cols-2 sm:divide-x">
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-700">
Pending ({pendingRefund})
</p>
{recentPendingRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund baru.
</p>
) : (
<ul className="space-y-2">
{recentPendingRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=PENDING"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700">
Siap transfer ({approvedRefund})
</p>
{recentApprovedRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund siap transfer.
</p>
) : (
<ul className="space-y-2">
{recentApprovedRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=APPROVED"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</section>
{/* Payout — escrow ke organizer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Payout Organizer (Escrow)
</h2>
<p className="text-xs text-neutral-500">
{paidPayout} dibayar · {releasedPayout} siap transfer ·{" "}
{heldPayout} ditahan
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Transfer payout ({releasedPayout})
</Link>
</div>
{recentReleasedPayout.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada payout siap transfer.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentReleasedPayout.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{formatIDR(p.amount)} · {p.organizer.name}
</p>
<p className="truncate text-xs text-neutral-500">
{p.trip.title} ·{" "}
{p.releasedAt
? `release ${timeAgo(p.releasedAt)}`
: `hold sampai ${new Date(p.heldUntil).toLocaleDateString("id-ID")}`}
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
<section className="rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-5 py-4 text-xs text-neutral-500">
<p className="mb-1">
<span className="font-semibold">Refund APPROVED:</span> admin transfer
manual ke peserta lalu tandai <span className="font-semibold">SUCCEEDED</span>.
</p>
<p>
<span className="font-semibold">Payout RELEASED:</span> escrow dilepas
karena trip sudah selesai + 3 hari. Admin transfer ke organizer lalu
tandai <span className="font-semibold">PAID</span>.
</p>
</section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Payout Organizer",
description:
"Halaman admin untuk meneruskan uang escrow ke rekening organizer setelah trip selesai.",
alternates: { canonical: "/admin/payouts" },
robots: { index: false, follow: false },
};
export default function AdminPayoutsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+110
View File
@@ -0,0 +1,110 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import {
PayoutReviewCard,
type PayoutCardData,
} from "@/features/payout/components/payout-review-card";
type Tab = "RELEASED" | "HELD" | "PAID" | "CANCELLED";
const TABS: { key: Tab; label: string }[] = [
{ key: "RELEASED", label: "Siap transfer" },
{ key: "HELD", label: "Ditahan (escrow)" },
{ key: "PAID", label: "Selesai" },
{ key: "CANCELLED", label: "Dibatalkan" },
];
interface PageProps {
searchParams: Promise<{ tab?: string }>;
}
export default async function AdminPayoutsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/payouts");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "RELEASED";
const rows = await payoutRepo.listByStatus(tab);
const items: PayoutCardData[] = rows.map((p) => ({
id: p.id,
amount: p.amount,
currency: p.currency,
status: p.status,
heldUntil: p.heldUntil,
releasedAt: p.releasedAt,
paidAt: p.paidAt,
cancelledAt: p.cancelledAt,
bankName: p.bankName,
bankAccountNumber: p.bankAccountNumber,
bankAccountName: p.bankAccountName,
adminNote: p.adminNote,
createdAt: p.createdAt,
trip: p.trip,
organizer: p.organizer,
booking: {
id: p.booking.id,
amount: p.booking.amount,
status: p.booking.status,
user: p.booking.user,
},
processedBy: p.processedBy,
}));
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Payout Organizer
</h1>
<p className="mt-1 text-sm text-neutral-500">
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
status <strong>Siap transfer</strong>, admin transfer manual ke
rekening organizer lalu tandai sudah dibayar.
</p>
</header>
<div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/payouts?tab=${t.key}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</a>
))}
</div>
{items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">Tidak ada payout pada status ini.</p>
</div>
) : (
<div className="space-y-4">
{items.map((p) => (
<PayoutReviewCard key={p.id} payout={p} />
))}
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -31,14 +32,19 @@ export async function GET(req: NextRequest) {
try {
const result = await tripService.autoCompletePastTrips();
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible();
console.log("[cron/auto-complete-trips] selesai", {
count: result.count,
completed: result.count,
ids: result.ids,
payoutsReleased: releaseResult.releasedIds.length,
});
return NextResponse.json({
ok: true,
completed: result.count,
ids: result.ids,
payoutsReleased: releaseResult.releasedIds,
});
} catch (err) {
console.error("[cron/auto-complete-trips] gagal", err);
+1 -7
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/providers/session-provider";
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { siteConfig, siteUrl } from "@/lib/site";
import "./globals.css";
@@ -79,11 +77,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-neutral-50">
<SessionProvider>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
</SessionProvider>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { signOut } from "next-auth/react";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
];
interface AdminSidebarProps {
user: { name: string; email: string };
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile top bar */}
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4 lg:hidden">
<Link href="/admin" className="flex items-center gap-2">
<Image
src="/images/SeTrip.png"
alt=""
width={28}
height={28}
className="h-7 w-7 object-contain"
/>
<span className="text-sm font-bold text-neutral-800">
SeTrip <span className="text-primary-600">Admin</span>
</span>
</Link>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100"
aria-label="Toggle menu"
aria-expanded={open}
>
{open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)}
</button>
</header>
{/* Mobile drawer backdrop */}
{open && (
<button
type="button"
aria-label="Tutup menu"
onClick={() => setOpen(false)}
className="fixed inset-0 z-30 bg-neutral-900/30 lg:hidden"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-neutral-200 bg-white transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-5">
<Image
src="/images/SeTrip.png"
alt=""
width={32}
height={32}
className="h-8 w-8 object-contain"
/>
<div className="leading-tight">
<p className="text-base font-bold text-neutral-800">
SeTrip
</p>
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary-600">
Admin Panel
</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href));
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary-600 text-white"
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
<span aria-hidden className="text-base">
{item.icon}
</span>
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
<div className="my-4 border-t border-neutral-100" />
<ul className="space-y-1">
<li>
<Link
href="/"
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
>
<span aria-hidden></span>
<span>Lihat situs publik</span>
</Link>
</li>
</ul>
</nav>
<div className="border-t border-neutral-100 p-3">
<div className="flex items-center gap-2 rounded-lg px-2 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{user.name}
</p>
<p className="truncate text-[10px] text-neutral-500">
{user.email}
</p>
</div>
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-lg px-2 py-1 text-[11px] font-medium text-neutral-500 hover:bg-red-50 hover:text-red-600"
title="Keluar"
>
Keluar
</button>
</div>
</div>
</aside>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { siteConfig } from "@/lib/site";
const LEGAL_LINKS = [
{ href: "/terms", label: "Syarat & Ketentuan" },
{ href: "/privacy", label: "Kebijakan Privasi" },
] as const;
const EXPLORE_LINKS = [
{ href: "/trips", label: "Open Trip" },
{ href: "/people", label: "Cari Teman" },
{ href: "/create-trip", label: "Buat Trip" },
] as const;
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-12 border-t border-neutral-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
<div className="grid gap-8 sm:grid-cols-3">
<div>
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-lg font-bold text-neutral-800">
Se<span className="text-primary-600">Trip</span>
</span>
</Link>
<p className="mt-2 max-w-xs text-xs text-neutral-500">
{siteConfig.slogan} Gabung trip & aktivitas, kenal stranger jadi
travel buddies.
</p>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Jelajah
</p>
<ul className="space-y-2 text-sm">
{EXPLORE_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Kebijakan
</p>
<ul className="space-y-2 text-sm">
{LEGAL_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-8 flex flex-col items-start justify-between gap-2 border-t border-neutral-100 pt-4 text-xs text-neutral-500 sm:flex-row sm:items-center">
<p>
© {year} {siteConfig.name}. Pergi bareng, bukan sendiri.
</p>
<p className="text-[11px] text-neutral-400">
Pembayaran ditahan (escrow) sampai trip selesai · refund manual oleh
admin
</p>
</div>
</div>
</footer>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
export async function markPayoutPaidAction(formData: FormData) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
const parsed = payoutMarkPaidSchema.safeParse({
payoutId: formData.get("payoutId") as string,
adminNote: (formData.get("adminNote") as string) ?? "",
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
try {
await payoutService.markPaid({
payoutId: parsed.data.payoutId,
adminId: admin.id,
adminNote: parsed.data.adminNote,
});
revalidatePath("/admin/payouts");
revalidatePath("/admin");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,161 @@
import Link from "next/link";
import { formatRupiah } from "@/lib/utils";
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
interface EarningRow {
id: string;
amount: number;
status: PayoutStatus;
heldUntil: Date;
releasedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
trip: { id: string; title: string; date: Date; endDate: Date | null };
booking: {
id: string;
amount: number;
user: { id: string; name: string };
};
}
interface EarningsSectionProps {
payouts: EarningRow[];
}
function formatDay(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
const STATUS_META: Record<
PayoutStatus,
{ label: string; cls: string; hint: (row: EarningRow) => string }
> = {
HELD: {
label: "Ditahan",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
hint: (r) => `Cair setelah ${formatDay(r.heldUntil)}`,
},
RELEASED: {
label: "Siap transfer",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
hint: (r) =>
r.releasedAt
? `Antri admin transfer sejak ${formatDay(r.releasedAt)}`
: "Antri admin transfer",
},
PAID: {
label: "Diterima",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
hint: (r) =>
r.paidAt ? `Ditransfer ${formatDay(r.paidAt)}` : "Sudah ditransfer admin",
},
CANCELLED: {
label: "Dibatalkan",
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
hint: () => "Trip dibatalkan / di-refund — tidak ada payout",
},
};
export function EarningsSection({ payouts }: EarningsSectionProps) {
if (payouts.length === 0) return null;
const totals = {
held: 0,
released: 0,
paid: 0,
};
for (const p of payouts) {
if (p.status === "HELD") totals.held += p.amount;
else if (p.status === "RELEASED") totals.released += p.amount;
else if (p.status === "PAID") totals.paid += p.amount;
}
return (
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="mb-4">
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Pendapatan dari peserta
</h2>
<p className="mt-0.5 text-xs text-neutral-500">
Uang peserta ditahan oleh SeTrip (escrow) sampai trip selesai + 3 hari,
lalu admin transfer ke rekening bank yang kamu daftarkan.
</p>
</header>
<div className="mb-5 grid grid-cols-3 gap-2 text-xs sm:text-sm">
<Stat label="Ditahan" value={totals.held} accent="amber" />
<Stat label="Siap transfer" value={totals.released} accent="blue" />
<Stat label="Sudah diterima" value={totals.paid} accent="primary" />
</div>
<ul className="divide-y divide-neutral-100">
{payouts.map((p) => {
const meta = STATUS_META[p.status];
return (
<li
key={p.id}
className="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1">
<Link
href={`/trips/${p.trip.id}`}
className="block truncate text-sm font-semibold text-neutral-800 hover:text-primary-700"
>
{p.trip.title}
</Link>
<p className="truncate text-xs text-neutral-500">
{p.booking.user.name} · trip {formatDay(p.trip.date)}
</p>
<p className="mt-1 text-[11px] text-neutral-500">
{meta.hint(p)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-sm font-bold text-neutral-900">
{formatRupiah(p.amount)}
</span>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ${meta.cls}`}
>
{meta.label}
</span>
</div>
</li>
);
})}
</ul>
</section>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: number;
accent: "amber" | "blue" | "primary";
}) {
const cls = {
amber: "bg-amber-50 text-amber-900",
blue: "bg-blue-50 text-blue-900",
primary: "bg-primary-50 text-primary-900",
}[accent];
return (
<div className={`rounded-xl px-3 py-2 ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide opacity-70">
{label}
</p>
<p className="mt-0.5 text-sm font-bold sm:text-base">
{formatRupiah(value)}
</p>
</div>
);
}
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils";
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
export type PayoutCardData = {
id: string;
amount: number;
currency: string;
status: PayoutStatus;
heldUntil: Date;
releasedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
bankName: string | null;
bankAccountNumber: string | null;
bankAccountName: string | null;
adminNote: string | null;
createdAt: Date;
trip: { id: string; title: string; date: Date; endDate: Date | null; status: string };
organizer: { id: string; name: string; email: string };
booking: {
id: string;
amount: number;
status: string;
user: { id: string; name: string; email: string };
};
processedBy: { id: string; name: string; email: string } | null;
};
function formatDate(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatDay(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit() {
setError("");
setLoading(true);
const fd = new FormData();
fd.set("payoutId", payout.id);
fd.set("adminNote", note);
const result = await markPayoutPaidAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
setOpen(false);
setNote("");
router.refresh();
}
const bankAvailable =
payout.bankName && payout.bankAccountNumber && payout.bankAccountName;
return (
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
<div className="min-w-0">
<h3 className="truncate text-base font-bold text-neutral-900">
{payout.trip.title}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
Booking {payout.booking.user.name} · trip{" "}
{formatDay(payout.trip.date)}
{" · "}
<span className="font-mono">{payout.id.slice(0, 8)}</span>
</p>
</div>
<StatusPill status={payout.status} />
</header>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field
label="Organizer"
value={`${payout.organizer.name} · ${payout.organizer.email}`}
/>
<Field
label="Nominal payout"
value={`${formatRupiah(payout.amount)} ${payout.currency !== "IDR" ? `(${payout.currency})` : ""}`}
highlight
/>
<Field label="Booking" value={`${formatRupiah(payout.booking.amount)} · ${payout.booking.status}`} />
<Field
label="Hold sampai"
value={formatDay(payout.heldUntil)}
/>
{payout.releasedAt && (
<Field label="Direlease" value={formatDate(payout.releasedAt)} />
)}
{payout.paidAt && (
<Field label="Dibayar" value={formatDate(payout.paidAt)} />
)}
{payout.cancelledAt && (
<Field label="Dibatalkan" value={formatDate(payout.cancelledAt)} />
)}
</div>
<div className="mt-4 rounded-xl bg-neutral-50 p-3 text-sm">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Rekening tujuan transfer
</p>
{bankAvailable ? (
<div className="space-y-0.5 text-neutral-800">
<p className="font-semibold">{payout.bankName}</p>
<p className="font-mono text-sm">{payout.bankAccountNumber}</p>
<p className="text-xs text-neutral-600">
a/n {payout.bankAccountName}
</p>
</div>
) : (
<p className="text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer.
</p>
)}
</div>
{payout.adminNote && (
<div className="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-800">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-blue-600">
Catatan admin
</p>
<p className="whitespace-pre-wrap">{payout.adminNote}</p>
</div>
)}
{payout.processedBy && (
<p className="mt-3 text-xs text-neutral-500">
Diproses oleh {payout.processedBy.name}
{payout.paidAt && ` · transfer ${formatDate(payout.paidAt)}`}
</p>
)}
{payout.status === "RELEASED" && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
{open ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
Tandai Sudah Transfer
</p>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
placeholder="Referensi transfer / nomor mutasi bank (wajib)"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={submit}
disabled={loading || note.trim().length < 3}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Memproses…" : "Tandai PAID"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setNote("");
setError("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
💸 Tandai sudah ditransfer ke organizer
</button>
)}
</div>
)}
</article>
);
}
function Field({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p
className={`mt-0.5 text-sm ${
highlight ? "font-bold text-primary-700" : "text-neutral-800"
}`}
>
{value}
</p>
</div>
);
}
function StatusPill({ status }: { status: PayoutStatus }) {
const cfg: Record<PayoutStatus, { label: string; cls: string }> = {
HELD: {
label: "Ditahan (escrow)",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
},
RELEASED: {
label: "Siap transfer",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
},
PAID: {
label: "Selesai",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
},
CANCELLED: {
label: "Dibatalkan",
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
},
};
const c = cfg[status];
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ${c.cls}`}
>
{c.label}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
export const payoutMarkPaidSchema = z.object({
payoutId: z.string().trim().min(1, "Payout ID wajib"),
adminNote: z
.string()
.trim()
.min(3, "Catatan/referensi transfer minimal 3 karakter")
.max(
LIMITS.MAX_REFUND_NOTE_LENGTH,
`Catatan maksimal ${LIMITS.MAX_REFUND_NOTE_LENGTH} karakter`
),
});
export type PayoutMarkPaidInput = z.infer<typeof payoutMarkPaidSchema>;
+2
View File
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { isAdminEmail } from "@/lib/admin";
// Adapter dipakai untuk persist User + Account saat OAuth (Google).
// Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider.
@@ -89,6 +90,7 @@ export const authOptions: AuthOptions = {
if (session.user) {
session.user.id = token.id as string;
session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false;
session.user.isAdmin = isAdminEmail(session.user.email);
}
return session;
},
@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('HELD', 'RELEASED', 'PAID', 'CANCELLED');
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"organizerId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'IDR',
"status" "PayoutStatus" NOT NULL DEFAULT 'HELD',
"heldUntil" TIMESTAMP(3) NOT NULL,
"releasedAt" TIMESTAMP(3),
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"bankName" TEXT,
"bankAccountNumber" TEXT,
"bankAccountName" TEXT,
"adminNote" TEXT,
"processedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Payout_bookingId_key" ON "Payout"("bookingId");
-- CreateIndex
CREATE INDEX "Payout_organizerId_status_idx" ON "Payout"("organizerId", "status");
-- CreateIndex
CREATE INDEX "Payout_status_heldUntil_idx" ON "Payout"("status", "heldUntil");
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_processedById_fkey" FOREIGN KEY ("processedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+75
View File
@@ -34,6 +34,11 @@ model User {
reviewedRefunds Refund[] @relation("RefundReviewer")
/// Payout yang diterima user ini sebagai organizer (escrow trip selesai).
payouts Payout[] @relation("PayoutOrganizer")
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
processedPayouts Payout[] @relation("PayoutProcessor")
profile UserProfile?
}
@@ -161,6 +166,7 @@ model Trip {
images TripImage[]
reviews TripReview[]
bookings Booking[]
payouts Payout[]
@@index([category, status, date])
@@index([vibe, status, date])
@@ -261,6 +267,7 @@ model Booking {
payments Payment[]
refunds Refund[]
payout Payout?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -436,3 +443,71 @@ enum RefundReporter {
PARTICIPANT
ORGANIZER
}
/// Escrow payout ke organizer. Uang peserta ditahan sejak Booking → PAID sampai
/// trip selesai + buffer beberapa hari, baru di-release untuk ditransfer admin.
///
/// State machine:
/// HELD → diciptakan saat booking PAID, heldUntil = endDate/date + 3 hari
/// RELEASED → cron flip setelah heldUntil lewat + trip COMPLETED
/// PAID → admin sudah transfer manual ke rekening organizer
/// CANCELLED → booking di-refund / trip dibatalkan; payout tidak jadi
///
/// Audit: 1-1 dengan Booking (unique). Refund SUCCEEDED mengurangi amount
/// (partial) atau membatalkan payout (full).
model Payout {
id String @id @default(cuid())
bookingId String @unique
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
tripId String
trip Trip @relation(fields: [tripId], references: [id])
organizerId String
organizer User @relation("PayoutOrganizer", fields: [organizerId], references: [id])
/// Nominal yg organizer terima (IDR integer). Default = booking.amount saat
/// payout dibuat. Refund SUCCEEDED memotong nilai ini supaya total payout +
/// total refund = uang yang dibayar peserta.
amount Int
currency String @default("IDR")
status PayoutStatus @default(HELD)
/// Tanggal payout boleh di-release ke organizer
/// (= trip.endDate ?? trip.date + buffer days).
heldUntil DateTime
releasedAt DateTime?
paidAt DateTime?
cancelledAt DateTime?
/// Snapshot bank info organizer dari OrganizerVerification saat payout dibuat.
/// Disimpan inline supaya audit-friendly walau organizer ganti bank nanti.
bankName String?
bankAccountNumber String?
bankAccountName String?
/// Catatan admin: referensi transfer manual, alasan cancel, dst.
adminNote String?
/// Admin yang menandai PAID/CANCELLED.
processedById String?
processedBy User? @relation("PayoutProcessor", fields: [processedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizerId, status])
@@index([status, heldUntil])
}
enum PayoutStatus {
/// Menunggu trip selesai + buffer beberapa hari sebelum boleh ditransfer.
HELD
/// Buffer lewat & trip COMPLETED, siap di-transfer admin ke rekening organizer.
RELEASED
/// Admin sudah transfer ke rekening organizer.
PAID
/// Booking di-refund penuh / trip dibatalkan — uang tidak jadi ke organizer.
CANCELLED
}
+19
View File
@@ -36,6 +36,25 @@ export const organizerRepo = {
});
},
async countByStatus(status: "PENDING" | "APPROVED" | "REJECTED") {
return prisma.organizerVerification.count({ where: { status } });
},
/** Verifikasi terbaru (default PENDING) untuk preview di dashboard admin. */
async listRecent(status: "PENDING" | "APPROVED" | "REJECTED", limit = 3) {
return prisma.organizerVerification.findMany({
where: { status },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
fullName: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
});
},
async updateReview(
id: string,
data: {
+122
View File
@@ -0,0 +1,122 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { PayoutStatus } from "@/app/generated/prisma/enums";
const payoutListInclude = {
booking: {
select: {
id: true,
amount: true,
status: true,
user: { select: { id: true, name: true, email: true } },
},
},
trip: {
select: { id: true, title: true, date: true, endDate: true, status: true },
},
organizer: { select: { id: true, name: true, email: true } },
processedBy: { select: { id: true, name: true, email: true } },
} satisfies Prisma.PayoutInclude;
export const payoutRepo = {
async findById(id: string) {
return prisma.payout.findUnique({
where: { id },
include: payoutListInclude,
});
},
async findByBookingId(bookingId: string, tx?: Prisma.TransactionClient) {
const client = tx ?? prisma;
return client.payout.findUnique({ where: { bookingId } });
},
async listByStatus(status: PayoutStatus) {
return prisma.payout.findMany({
where: { status },
orderBy: { heldUntil: "asc" },
include: payoutListInclude,
});
},
async listForOrganizer(organizerId: string) {
return prisma.payout.findMany({
where: { organizerId },
orderBy: { createdAt: "desc" },
include: {
trip: {
select: { id: true, title: true, date: true, endDate: true, status: true },
},
booking: {
select: {
id: true,
amount: true,
user: { select: { id: true, name: true } },
},
},
},
});
},
async countByStatus(status: PayoutStatus) {
return prisma.payout.count({ where: { status } });
},
/** Payout terbaru untuk satu status — dipakai dashboard admin. */
async listRecent(status: PayoutStatus, limit = 3) {
return prisma.payout.findMany({
where: { status },
orderBy: status === "HELD" ? { heldUntil: "asc" } : { updatedAt: "desc" },
take: limit,
select: {
id: true,
amount: true,
heldUntil: true,
releasedAt: true,
organizer: { select: { id: true, name: true } },
trip: { select: { id: true, title: true } },
},
});
},
async create(
data: Pick<
Prisma.PayoutUncheckedCreateInput,
| "bookingId"
| "tripId"
| "organizerId"
| "amount"
| "heldUntil"
| "bankName"
| "bankAccountNumber"
| "bankAccountName"
>,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.payout.create({ data });
},
async update(
id: string,
data: Prisma.PayoutUncheckedUpdateInput,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.payout.update({ where: { id }, data });
},
/** Cari semua HELD payout yang sudah lewat heldUntil & trip-nya COMPLETED. */
async findEligibleForRelease(now: Date) {
return prisma.payout.findMany({
where: {
status: "HELD",
heldUntil: { lte: now },
trip: { status: "COMPLETED" },
},
select: { id: true },
});
},
};
export type PayoutWithRelations = Awaited<ReturnType<typeof payoutRepo.findById>>;
+30
View File
@@ -49,6 +49,36 @@ export const refundRepo = {
});
},
async countByStatus(
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
) {
return prisma.refund.count({ where: { status } });
},
/** Refund terbaru untuk satu status — dipakai dashboard admin. */
async listRecent(
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
limit = 3
) {
return prisma.refund.findMany({
where: { status },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
amount: true,
reason: true,
createdAt: true,
booking: {
select: {
user: { select: { id: true, name: true } },
trip: { select: { id: true, title: true } },
},
},
},
});
},
async listByBooking(bookingId: string) {
return prisma.refund.findMany({
where: { bookingId },
+4
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { paymentRepo } from "@/server/repositories/payment.repo";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6;
@@ -191,6 +192,9 @@ export const bookingService = {
data: { paymentConfirmedAt: now },
});
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
await payoutService.createForPaidBooking(tx, { bookingId });
return { ok: true as const };
},
{
+5
View File
@@ -8,6 +8,7 @@ import {
type MidtransWebhookBody,
} from "@/lib/midtrans";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6;
@@ -299,6 +300,10 @@ export const paymentService = {
where: { id: payment.booking.participantId },
data: { paymentConfirmedAt: now, markedPaidAt: now },
});
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
await payoutService.createForPaidBooking(tx, {
bookingId: payment.bookingId,
});
}
},
{
+248
View File
@@ -0,0 +1,248 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { payoutRepo } from "@/server/repositories/payout.repo";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
async function runSerializable<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(fn, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
});
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal memproses payout. Coba lagi sebentar.");
}
/** Buffer hari setelah trip selesai sebelum payout boleh ditransfer. */
export const PAYOUT_HOLD_BUFFER_DAYS = 3;
/** Hitung heldUntil dari trip date. Pakai endDate kalau ada, kalau tidak pakai date. */
function computeHeldUntil(tripDate: Date, tripEndDate: Date | null): Date {
const baseDate = tripEndDate ?? tripDate;
const result = new Date(baseDate);
result.setUTCDate(result.getUTCDate() + PAYOUT_HOLD_BUFFER_DAYS);
return result;
}
export const payoutService = {
/**
* Dipanggil saat Booking → PAID (webhook Midtrans atau organizer confirm manual).
* Idempotent: kalau Payout untuk booking ini sudah ada, no-op (return existing).
*
* Snapshot bank info dari OrganizerVerification (kalau ada) supaya audit-friendly
* walau organizer ganti bank nanti.
*/
async createForPaidBooking(
tx: Prisma.TransactionClient,
input: { bookingId: string }
) {
const booking = await tx.booking.findUnique({
where: { id: input.bookingId },
select: {
id: true,
amount: true,
status: true,
userId: true,
trip: {
select: {
id: true,
organizerId: true,
date: true,
endDate: true,
},
},
},
});
if (!booking) {
throw new Error("Booking tidak ditemukan saat membuat payout");
}
if (booking.amount <= 0) {
// Trip gratis — tidak ada uang yang perlu di-payout.
return null;
}
const existing = await payoutRepo.findByBookingId(booking.id, tx);
if (existing) {
return existing;
}
const bankInfo = await tx.organizerVerification.findUnique({
where: { userId: booking.trip.organizerId },
select: {
status: true,
bankName: true,
bankAccountNumber: true,
bankAccountName: true,
},
});
const heldUntil = computeHeldUntil(booking.trip.date, booking.trip.endDate);
return payoutRepo.create(
{
bookingId: booking.id,
tripId: booking.trip.id,
organizerId: booking.trip.organizerId,
amount: booking.amount,
heldUntil,
bankName:
bankInfo?.status === "APPROVED" ? bankInfo.bankName : null,
bankAccountNumber:
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountNumber : null,
bankAccountName:
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountName : null,
},
tx
);
},
/**
* Cron-callable: cari semua HELD payout yang sudah lewat heldUntil & trip-nya
* COMPLETED, lalu flip ke RELEASED. Idempotent.
*/
async releaseEligible() {
const now = new Date();
const eligible = await payoutRepo.findEligibleForRelease(now);
if (eligible.length === 0) {
return { releasedIds: [] as string[] };
}
const ids = eligible.map((p) => p.id);
await prisma.payout.updateMany({
where: { id: { in: ids }, status: "HELD" },
data: { status: "RELEASED", releasedAt: now },
});
return { releasedIds: ids };
},
/** RELEASED → PAID. Catatan/referensi transfer wajib (audit trail). */
async markPaid(input: { payoutId: string; adminId: string; adminNote: string }) {
if (!input.adminNote.trim()) {
throw new Error("Catatan/referensi transfer wajib diisi");
}
return runSerializable(async (tx) => {
const payout = await tx.payout.findUnique({
where: { id: input.payoutId },
});
if (!payout) {
throw new Error("Payout tidak ditemukan");
}
if (payout.status !== "RELEASED") {
throw new Error(
"Hanya payout RELEASED yang bisa ditandai PAID. Tunggu trip selesai + buffer."
);
}
return payoutRepo.update(
input.payoutId,
{
status: "PAID",
paidAt: new Date(),
processedById: input.adminId,
adminNote: input.adminNote.trim(),
},
tx
);
});
},
/**
* Cancel payout — biasanya dipanggil internal saat refund SUCCEEDED penuh
* atau trip dibatalkan. Tidak boleh untuk payout yang sudah PAID (uang sudah
* keluar ke organizer; admin perlu clawback manual).
*/
async cancel(
tx: Prisma.TransactionClient,
input: { payoutId: string; reason: string; adminId?: string | null }
) {
const payout = await tx.payout.findUnique({
where: { id: input.payoutId },
});
if (!payout) return null;
if (payout.status === "CANCELLED") return payout;
if (payout.status === "PAID") {
// Uang sudah ditransfer — tidak bisa undo otomatis. Catat note saja.
return payoutRepo.update(
input.payoutId,
{
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
`[!] ${input.reason} setelah PAID — perlu clawback manual.`,
},
tx
);
}
return payoutRepo.update(
input.payoutId,
{
status: "CANCELLED",
cancelledAt: new Date(),
processedById: input.adminId ?? null,
adminNote: input.reason,
},
tx
);
},
/**
* Refund SUCCEEDED — kurangi nominal payout sesuai nominal refund. Kalau
* jatuh ke 0 atau lebih, cancel payout. Dipanggil dari refund.service.
*/
async applyRefundDelta(
tx: Prisma.TransactionClient,
input: { bookingId: string; refundAmount: number }
) {
const payout = await payoutRepo.findByBookingId(input.bookingId, tx);
if (!payout) return null;
if (payout.status === "CANCELLED") return payout;
if (payout.status === "PAID") {
// Uang sudah ditransfer ke organizer — flag untuk clawback manual.
return payoutRepo.update(
payout.id,
{
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
`[!] Refund Rp${input.refundAmount.toLocaleString("id-ID")} terjadi setelah payout PAID. Perlu clawback manual dari organizer.`,
},
tx
);
}
const nextAmount = payout.amount - input.refundAmount;
if (nextAmount <= 0) {
return payoutRepo.update(
payout.id,
{
status: "CANCELLED",
cancelledAt: new Date(),
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
"Dibatalkan otomatis karena refund penuh.",
},
tx
);
}
return payoutRepo.update(payout.id, { amount: nextAmount }, tx);
},
};
+7
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6;
@@ -236,6 +237,12 @@ export const refundService = {
tx
);
// Escrow: kurangi (atau cancel) payout organizer sesuai nominal refund.
await payoutService.applyRefundDelta(tx, {
bookingId: refund.bookingId,
refundAmount: refund.amount,
});
const totalRefunded = await refundRepo.sumSucceededAmount(
refund.bookingId,
tx
+12
View File
@@ -6,6 +6,8 @@ import { participantRepo } from "@/server/repositories/participant.repo";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service";
import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
@@ -546,6 +548,16 @@ export const tripService = {
}
);
refundsCreated.push(refund.id);
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
// booking ini. Payout PAID di-flag clawback otomatis.
const payout = await payoutRepo.findByBookingId(b.id, tx);
if (payout) {
await payoutService.cancel(tx, {
payoutId: payout.id,
reason: "Trip dibatalkan organizer.",
});
}
} else {
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
await tx.booking.update({
+1
View File
@@ -8,6 +8,7 @@ declare module "next-auth" {
email: string;
image?: string | null;
acceptedTermsAndPrivacy: boolean;
isAdmin: boolean;
};
}
}