create public layout and admin and fix escrow and refund
This commit is contained in:
@@ -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
|
||||
- **3–6 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 1–3 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ declare module "next-auth" {
|
||||
email: string;
|
||||
image?: string | null;
|
||||
acceptedTermsAndPrivacy: boolean;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user