add payment and integration with midtrans

This commit is contained in:
2026-05-08 21:44:34 +07:00
parent ecd4dc2ef4
commit 68ffaf2f69
14 changed files with 886 additions and 36 deletions
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { midtransWebhookSchema } from "@/lib/midtrans";
import { paymentService } from "@/server/services/payment.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Webhook callback dari Midtrans.
*
* Aturan response:
* - Body bukan JSON / shape tidak valid → 400 (Midtrans tetap retry, tapi mereka pasti
* kirim shape valid; 400 di sini = bug bukan dari Midtrans).
* - Signature mismatch → 401 (Midtrans tidak retry untuk auth error).
* - Sudah final / unknown order / amount mismatch → 200 OK + log
* (kita tidak mau Midtrans retry forever untuk kasus yang server-side perlu manual review).
* - Sukses update → 200 OK.
*
* URL ini harus didaftarkan di dashboard Midtrans:
* `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
*/
export async function POST(req: NextRequest) {
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Body bukan JSON valid" }, { status: 400 });
}
const parsed = midtransWebhookSchema.safeParse(raw);
if (!parsed.success) {
console.warn(
"[midtrans-webhook] payload schema invalid",
parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
);
return NextResponse.json(
{ error: "Payload schema invalid" },
{ status: 400 }
);
}
const body = parsed.data;
let outcome;
try {
outcome = await paymentService.handleMidtransWebhook(body);
} catch (err) {
console.error("[midtrans-webhook] gagal proses callback", err, {
order_id: body.order_id,
});
return NextResponse.json(
{ error: "Gagal memproses callback" },
{ status: 500 }
);
}
if (!outcome.ok) {
if (outcome.reason === "signature_mismatch") {
console.warn("[midtrans-webhook] signature mismatch", {
order_id: body.order_id,
});
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
if (outcome.reason === "amount_mismatch") {
console.warn("[midtrans-webhook] amount mismatch", {
order_id: body.order_id,
gross_amount: body.gross_amount,
});
// Return 200 supaya Midtrans tidak retry; investigasi via log.
return NextResponse.json({ status: "amount_mismatch_logged" });
}
}
if (outcome.ok && outcome.status === "booking_conflict") {
console.warn(
"[midtrans-webhook] PAID arrived for booking in conflict state — manual review required",
{ order_id: body.order_id, transaction_id: body.transaction_id }
);
}
return NextResponse.json({ status: outcome.ok ? outcome.status : "error" });
}
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -278,6 +278,7 @@ export type BookingOrderByWithRelationInput = {
export type BookingWhereUniqueInput = Prisma.AtLeast<{
id?: string
participantId?: string
tripId_userId?: Prisma.BookingTripIdUserIdCompoundUniqueInput
AND?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
OR?: Prisma.BookingWhereInput[]
NOT?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
@@ -292,7 +293,7 @@ export type BookingWhereUniqueInput = Prisma.AtLeast<{
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
payments?: Prisma.PaymentListRelationFilter
}, "id" | "participantId">
}, "id" | "participantId" | "tripId_userId">
export type BookingOrderByWithAggregationInput = {
id?: Prisma.SortOrder
@@ -426,6 +427,11 @@ export type BookingNullableScalarRelationFilter = {
isNot?: Prisma.BookingWhereInput | null
}
export type BookingTripIdUserIdCompoundUniqueInput = {
tripId: string
userId: string
}
export type BookingCountOrderByAggregateInput = {
id?: Prisma.SortOrder
tripId?: Prisma.SortOrder
+17 -2
View File
@@ -11,6 +11,7 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category";
import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
export const metadata: Metadata = {
@@ -293,8 +294,22 @@ async function PaidTripSection({
</div>
)}
{canMarkPaid && bankAvailable && (
<MarkPaidButton tripId={tripId} />
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{hasMarkedPaid && (