add payment and integration with midtrans
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user