add cron and partial update refund schema
This commit is contained in:
@@ -0,0 +1,226 @@
|
|||||||
|
# Setrip — Refund Roadmap
|
||||||
|
|
||||||
|
Status implementasi sistem refund yang dapat dipercaya dan auditable — dari schema, policy, sampai integrasi gateway.
|
||||||
|
|
||||||
|
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit state sekarang (baseline)
|
||||||
|
|
||||||
|
**Schema (~10% sudah ada):**
|
||||||
|
- ✅ `BookingStatus.REFUNDED` & `PaymentStatus.REFUNDED` — enum value ada di [prisma/schema.prisma](prisma/schema.prisma).
|
||||||
|
- ❌ Model `Refund` belum ada — refund saat ini cuma flag, tanpa entity tersendiri.
|
||||||
|
- ❌ Tidak ada audit trail (siapa, kapan, alasan, approval).
|
||||||
|
- ❌ Tidak bisa partial refund.
|
||||||
|
- ❌ Tidak bisa multiple refund per booking (mis. refund deposit lalu sisa).
|
||||||
|
- ❌ Tidak bisa bedakan refund vs chargeback (dispute bank).
|
||||||
|
|
||||||
|
**Service/UI (~0% sudah ada):**
|
||||||
|
- ❌ Tidak ada `refundService`.
|
||||||
|
- ❌ Tidak ada flow "organizer cancel trip → auto refund peserta PAID".
|
||||||
|
- ❌ Tidak ada UI peserta untuk request cancel + refund.
|
||||||
|
- ❌ Tidak ada UI admin untuk approve/eksekusi refund.
|
||||||
|
- ❌ Tidak ada integrasi Midtrans Refund API.
|
||||||
|
- ❌ Tidak ada reconciliation harian.
|
||||||
|
|
||||||
|
**Konteks pendukung yang sudah ada:**
|
||||||
|
- ✅ `Booking` + `Payment` model dengan `amount` (Int, IDR — money math safe).
|
||||||
|
- ✅ Midtrans webhook handler ([app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts)) — pola untuk refund webhook bisa di-mirror.
|
||||||
|
- ✅ `bookingService` dengan transaksi serializable + retry — pola untuk `refundService`.
|
||||||
|
- ✅ Cron infra (system crontab + `CRON_SECRET`, lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md)) — siap untuk reconciliation job.
|
||||||
|
|
||||||
|
File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/booking.service.ts](server/services/booking.service.ts), [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R1 — Refund Schema + Service Stub (foundation) ⏳
|
||||||
|
|
||||||
|
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- `Refund.amount` Int (IDR) — bisa < `payment.amount` untuk partial. Constraint: `SUM(refunds.amount WHERE status=SUCCEEDED) <= payment.amount` di-enforce di service layer.
|
||||||
|
- `idempotencyKey` di-generate sekali saat Refund dibuat — dipakai saat panggil gateway nanti (R-4) supaya retry tidak double-refund.
|
||||||
|
- `BookingStatus.REFUNDED` di-set sebagai **derived state** saat full refund SUCCEEDED. Untuk partial: tambah `BookingStatus.PARTIALLY_REFUNDED` (enum baru) — atau biarkan PAID + lihat Booking.refunds[]. **Saran: tambah PARTIALLY_REFUNDED** supaya filter list "refunded bookings" bisa pakai status saja, tidak perlu join.
|
||||||
|
- `RefundReason` enum lengkap dari hari pertama — supaya laporan finance tidak butuh string parsing.
|
||||||
|
- Approval admin **wajib** untuk semua refund di MVP (4-eyes principle). Auto-approve bisa di-relax di R-2 untuk SYSTEM refund.
|
||||||
|
- Admin UI sederhana — list refund PENDING + tombol approve / mark-succeeded. Reuse pattern KYC verification UI yang sudah ada.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| R1.3 | Migration `add_refund_model` | ⏳ | `prisma/migrations/` |
|
||||||
|
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ⏳ | `server/repositories/refund.repo.ts` |
|
||||||
|
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ⏳ | `app/admin/refunds/page.tsx` |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Run migration di staging → smoke test → run di production.
|
||||||
|
2. Tambah `/admin/refunds` ke admin nav.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ⏳
|
||||||
|
|
||||||
|
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- Trigger di service `tripService.closeTrip()` (yang nge-set `status = CLOSED`). Pakai serializable transaction — close trip + create refunds atomic.
|
||||||
|
- SYSTEM refund bisa **auto-approve** (skip admin approval) — karena policy clear: organizer cancel = 100% refund. Tapi eksekusi (mark succeeded) tetap manual atau via gateway (R-4), tidak skip.
|
||||||
|
- Notifikasi peserta: kirim email/notif "Trip dibatalkan, refund Rp X sedang diproses". (Notification system di luar scope PR ini — assume sudah ada atau di-deferred.)
|
||||||
|
- Edge case: peserta yang `AWAITING_PAY` (belum bayar) tidak perlu refund — cuma update `Booking.status = CANCELLED`. Yang `PAID` saja yang dapat refund.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||||
|
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ⏳ | `features/trip/components/cancel-trip-button.tsx` |
|
||||||
|
| R2.4 | Server action `cancelTripAction` | ⏳ | `features/trip/actions.ts` |
|
||||||
|
|
||||||
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R3 — Self-Service User Cancel dengan Refund Window ⏳
|
||||||
|
|
||||||
|
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- **Kebijakan default hardcoded** (akan jadi data-driven di R-5):
|
||||||
|
- ≥7 hari sebelum berangkat → 80% refund (organizer ambil 20% admin fee)
|
||||||
|
- 3-7 hari sebelum berangkat → 50% refund
|
||||||
|
- <3 hari atau no-show → 0% refund (tetap create Refund record amount=0 untuk audit, atau skip — pilih skip lebih sederhana)
|
||||||
|
- Konstanta di `lib/refund-policy.ts` — supaya satu sumber kebenaran, mudah diubah.
|
||||||
|
- User refund **tidak auto-approve** — tetap butuh admin approval di MVP. Alasan: cegah abuse (spam cancel), dan validasi window calculation di sisi admin.
|
||||||
|
- Setelah refund SUCCEEDED, slot di trip kembali tersedia (`status: FULL → OPEN` kalau participantCount turun).
|
||||||
|
- UI user: tombol "Cancel & request refund" di trip detail (kalau status booking = PAID dan trip belum berangkat).
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R3.1 | `lib/refund-policy.ts` — `calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ⏳ | `lib/refund-policy.ts` |
|
||||||
|
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ⏳ | `features/booking/components/cancel-booking-button.tsx` |
|
||||||
|
| R3.4 | Server action `cancelBookingAction` | ⏳ | `features/booking/actions.ts` |
|
||||||
|
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
|
||||||
|
2. Tambah link kebijakan di footer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R4 — Integrasi Midtrans Refund API (async, idempotent) ⏳
|
||||||
|
|
||||||
|
Sambungkan eksekusi refund ke Midtrans Refund API. Untuk channel yang support refund online (BCA VA, GoPay, dst). Channel manual transfer tetap mark-succeeded manual oleh admin.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- Refund API Midtrans **async** — POST refund return `pending`, callback datang via webhook (extend [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) atau buat endpoint terpisah).
|
||||||
|
- **Idempotency key** wajib di header request — pakai `Refund.idempotencyKey` yang sudah di-generate R-1.
|
||||||
|
- State transition: APPROVED → PROCESSING (saat request dikirim) → SUCCEEDED/FAILED (via webhook).
|
||||||
|
- Eksekusi via job queue (Vercel Cron daily) atau sync di server action — **saran cron** supaya retry-able kalau gateway down. Job: pick all APPROVED refunds, kirim ke Midtrans, update PROCESSING.
|
||||||
|
- Validasi `Payment.method` — kalau `manual_transfer`, refund tetap manual (no gateway call). Skip Midtrans path.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R4.1 | Helper `lib/midtrans-refund.ts` — POST refund dengan idempotency key | ⏳ | `lib/midtrans-refund.ts` |
|
||||||
|
| R4.2 | `refundService.executeRefund(refundId)` — APPROVED → PROCESSING + call Midtrans | ⏳ | `server/services/refund.service.ts` |
|
||||||
|
| R4.3 | Webhook handler refund — PROCESSING → SUCCEEDED/FAILED via callback | ⏳ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
|
||||||
|
| R4.4 | Cron `/api/cron/execute-refunds` — pick APPROVED refunds, kirim ke gateway | ⏳ | `app/api/cron/execute-refunds/route.ts` |
|
||||||
|
| R4.5 | Daftarkan cron `*/15 * * * *` (every 15 min) di system crontab | ⏳ | [docs/CRON_SETUP.md](docs/CRON_SETUP.md) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Aktifkan Refund API di dashboard Midtrans (perlu request ke Midtrans support untuk channel tertentu).
|
||||||
|
2. Test refund di sandbox dengan dummy transaction.
|
||||||
|
3. Set webhook URL refund di Midtrans dashboard (kalau beda dari payment webhook).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R5 — Refund Policy Model (per-trip customization) ⏳
|
||||||
|
|
||||||
|
Pindah refund policy dari hardcoded ke data-driven. Organizer bisa pilih policy per-trip (mis. trip premium = strict cancellation).
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- Model `RefundPolicy` dengan tier predefined (FLEXIBLE, MODERATE, STRICT) — **bukan** field bebas. Mengikuti pola Airbnb. Mencegah organizer set policy yang aneh-aneh dan membingungkan peserta.
|
||||||
|
- 1 Trip → 1 RefundPolicy (foreign key di Trip). Default ke MODERATE.
|
||||||
|
- Setiap policy punya array `tiers` (JSON) berisi `{ minDaysBefore: number, refundPercentage: number }`.
|
||||||
|
- Migration: existing trip default ke MODERATE.
|
||||||
|
- UI organizer di create-trip form: dropdown pilih policy, dengan preview tier-nya.
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R5.1 | Model `RefundPolicy` + seed 3 tier (FLEXIBLE, MODERATE, STRICT) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| R5.2 | Foreign key `Trip.refundPolicyId` (default MODERATE) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
| R5.3 | Migration + backfill existing trip ke MODERATE | ⏳ | `prisma/migrations/` |
|
||||||
|
| R5.4 | Update `lib/refund-policy.ts` — `calculateRefundAmount` baca dari policy | ⏳ | `lib/refund-policy.ts` |
|
||||||
|
| R5.5 | UI organizer create-trip: dropdown policy + preview | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
|
||||||
|
| R5.6 | UI trip detail: tampilkan policy aktif (link ke detail tier) | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Run migration + backfill.
|
||||||
|
2. Update copy halaman terms — sebut 3 policy tier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-R6 — Reconciliation + Dispute Model (operational maturity) ⏳
|
||||||
|
|
||||||
|
Daily job match refund di DB vs settlement Midtrans. Model Dispute terpisah untuk chargeback/komplain pasca-refund.
|
||||||
|
|
||||||
|
**Keputusan asumsi yang diusulkan:**
|
||||||
|
- Reconciliation job: pull settlement report dari Midtrans (Settlement API) → match dengan `refund.externalRefundId` → flag drift.
|
||||||
|
- Dispute model **tertunda** sampai ada chargeback riil. Pakai `RefundReason.DISPUTE_RESOLVED` dulu di Refund model. Bikin model terpisah hanya kalau volume dispute > X per bulan.
|
||||||
|
- Alert: kalau drift > 1% dari total refund harian, kirim notif ke admin (email/Slack).
|
||||||
|
|
||||||
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R6.1 | Helper `lib/midtrans-settlement.ts` — pull settlement report | ⏳ | `lib/midtrans-settlement.ts` |
|
||||||
|
| R6.2 | Cron `/api/cron/reconcile-refunds` daily | ⏳ | `app/api/cron/reconcile-refunds/route.ts` |
|
||||||
|
| R6.3 | UI admin `/admin/refunds/reconciliation` — drift report | ⏳ | `app/admin/refunds/reconciliation/page.tsx` |
|
||||||
|
| R6.4 | (opsional, defer) Model `Dispute` + flow chargeback | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
|
||||||
|
|
||||||
|
**Tindakan manual:**
|
||||||
|
1. Set up alert channel (email atau Slack webhook).
|
||||||
|
2. Tetapkan threshold drift (saran: 1%).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
||||||
|
|
||||||
|
- **Pakai `BookingStatus.REFUNDED` sebagai sumber kebenaran tanpa Refund model** — flag-only tidak bisa partial, tidak punya audit trail. Stuck di kasus pertama.
|
||||||
|
- **Hapus Refund row kalau gagal** — never delete financial records. Set `status = FAILED` + log alasan. Audit trail wajib.
|
||||||
|
- **Sync call ke Midtrans Refund API tanpa idempotency key** — kalau retry karena timeout atau network error → double-refund. Kerugian finansial nyata.
|
||||||
|
- **Auto-execute refund tanpa approval admin di MVP** — fraud risk. Auto-approve OK untuk SYSTEM refund (organizer cancel = clear policy), tapi eksekusi tetap controlled. Bisa di-relax setelah volume + trust matang.
|
||||||
|
- **Polymorphic refund policy dari awal** — lompat langsung ke data-driven sebelum hardcoded teruji = over-engineering. Phasing R-3 (hardcoded) lalu R-5 (data-driven) lebih sehat.
|
||||||
|
- **Trigger refund di server action user-facing tanpa state machine** — user spam click → multiple refund request. Idempotency check via `(bookingId, status IN ('PENDING','APPROVED','PROCESSING'))` unique-ish.
|
||||||
|
- **Refund partial dengan float math** — selalu integer (rupiah). Hitung % dengan `Math.floor(amount * percentage / 100)` supaya tidak ada sub-rupiah.
|
||||||
|
- **Mention "kami akan refund X%" di UI tanpa lock policy** — kebijakan harus visible di trip detail SEBELUM user join, bukan kejutan saat cancel.
|
||||||
|
- **Skip approval admin untuk refund di atas threshold (mis. > 5jt)** — fraud risk internal. 4-eyes principle wajib untuk nominal besar, walau policy clear.
|
||||||
|
- **Bundle Refund + Dispute di model yang sama** — beda flow, beda inisiator (refund = merchant, dispute = bank). Separation of concern penting walau di MVP belum perlu Dispute model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Saran phasing
|
||||||
|
|
||||||
|
PR berurutan, masing-masing mandiri (siap di-deploy). **Don't bundle.**
|
||||||
|
|
||||||
|
1. **R-1** — Schema + service stub + UI admin. Foundation, blocker untuk semua PR berikut.
|
||||||
|
2. **R-2** — Auto-trigger saat organizer CLOSED. Paling sering kepakai, low-complexity. **Mulai dari sini setelah R-1.**
|
||||||
|
3. **R-3** — Self-service user cancel + hardcoded policy. Komplet flow user-side.
|
||||||
|
4. **R-4** — Midtrans Refund API. Paling kompleks (async, idempotent, webhook). Bisa hidup tanpa ini selama admin willing manual transfer.
|
||||||
|
5. **R-5** — Refund policy data-driven. Quality-of-life untuk organizer, bukan blocker.
|
||||||
|
6. **R-6** — Reconciliation + dispute. Operational maturity, untuk volume yang lebih besar.
|
||||||
|
|
||||||
|
**Bobot effort kasar:** R-1 (M) → R-2 (M) → R-3 (M) → R-4 (L) → R-5 (M) → R-6 (L).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pertanyaan terbuka sebelum mulai R-1
|
||||||
|
|
||||||
|
1. **MVP scope** — mau prioritaskan **organizer-cancel** dulu (R-2, paling sering), atau langsung **user cancel dengan window** (R-3)? Saran: R-2 dulu — clear policy, low-complexity.
|
||||||
|
2. **Approval flow** — semua refund butuh approval admin (lebih aman), atau auto-approve untuk SYSTEM refund? Saran: auto-approve SYSTEM, manual approve untuk USER + ADMIN_ADJUSTMENT.
|
||||||
|
3. **Partial refund booking status** — tambah `BookingStatus.PARTIALLY_REFUNDED` (lebih eksplisit) atau biarkan tetap PAID + lihat `booking.refunds[]`? Saran: tambah enum baru — query-friendly.
|
||||||
|
4. **Midtrans Refund API channel** — apakah anda sudah cek channel mana yang support online refund? BCA VA + GoPay biasanya support, manual_transfer pasti tidak. Cek dashboard sebelum mulai R-4.
|
||||||
|
5. **Dispute model timing** — bikin Dispute model di R-1 (early separation) atau defer sampai R-6? Saran: defer — YAGNI sampai ada kasus chargeback riil.
|
||||||
|
6. **Threshold approval admin** — ada nominal di atas mana refund wajib approval 2 admin (4-eyes)? Saran: > Rp 1jt butuh dual approval, < Rp 1jt single approval cukup.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { tripService } from "@/server/services/trip.service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron daily — flip trip yang sudah lewat tanggal selesai dari OPEN/FULL ke
|
||||||
|
* COMPLETED. Idempotent: run berulang aman.
|
||||||
|
*
|
||||||
|
* Trigger via system crontab (lihat [docs/CRON_SETUP.md](../../../docs/CRON_SETUP.md))
|
||||||
|
* atau cron service apapun. Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
|
||||||
|
*
|
||||||
|
* Set env `CRON_SECRET` (random ≥32 char) di hosting. Kalau env tidak di-set,
|
||||||
|
* endpoint hard-fail 500 supaya tidak accidentally jalan tanpa proteksi.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const secret = process.env.CRON_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
console.error("[cron/auto-complete-trips] CRON_SECRET tidak di-set");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Server misconfigured" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader !== `Bearer ${secret}`) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tripService.autoCompletePastTrips();
|
||||||
|
console.log("[cron/auto-complete-trips] selesai", {
|
||||||
|
count: result.count,
|
||||||
|
ids: result.ids,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
completed: result.count,
|
||||||
|
ids: result.ids,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[cron/auto-complete-trips] gagal", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Gagal menjalankan auto-complete" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Cron Setup (PM2 / self-hosted Linux)
|
||||||
|
|
||||||
|
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
|
||||||
|
|
||||||
|
## Daftar cron job
|
||||||
|
|
||||||
|
| Endpoint | Schedule | Tujuan |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
|
||||||
|
|
||||||
|
## Setup di server
|
||||||
|
|
||||||
|
### 1. Set `CRON_SECRET` di env production
|
||||||
|
|
||||||
|
Generate random secret 32 byte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambah ke file `.env` yang dibaca PM2 (atau yang pasti ter-load saat process boot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CRON_SECRET="<hasil-openssl-tadi>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart PM2 supaya proses re-load env:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart setrip --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Daftarkan crontab
|
||||||
|
|
||||||
|
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# Setrip — auto-complete trips harian (jam 01:00 WIB)
|
||||||
|
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifikasi crontab tersimpan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Siapkan file log
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo touch /var/log/setrip-cron.log
|
||||||
|
sudo chown $(whoami) /var/log/setrip-cron.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test manual
|
||||||
|
|
||||||
|
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
|
||||||
|
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}`
|
||||||
|
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}`
|
||||||
|
|
||||||
|
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
|
||||||
|
|
||||||
|
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Tail log cron:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/setrip-cron.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek log app PM2 (untuk `console.log` dari endpoint):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs setrip --lines 100 | grep cron
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Cron jalan tapi tidak ada efek di DB:**
|
||||||
|
- Cek `pm2 logs setrip` untuk error.
|
||||||
|
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC).
|
||||||
|
|
||||||
|
**Cron tidak jalan sama sekali:**
|
||||||
|
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
|
||||||
|
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
|
||||||
|
|
||||||
|
**Secret bocor:**
|
||||||
|
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2.
|
||||||
|
|
||||||
|
## Hari kalau pindah ke Vercel / PaaS lain
|
||||||
|
|
||||||
|
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`).
|
||||||
@@ -27,15 +27,15 @@ File baseline: [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx), [server/s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR-1 — Trip Detail Polish (UI only) ⏳
|
## PR-1 — Trip Detail Polish (UI only) ✅
|
||||||
|
|
||||||
Cosmetic. Tidak ada migration, tidak ada perubahan service/repo.
|
Selesai. Cosmetic. Tidak ada migration, tidak ada perubahan service/repo.
|
||||||
|
|
||||||
| # | Item | Status | File |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1.1 | Urgency badge mencolok saat `spotsLeft <= 3` ("⚡ Tinggal X spot!") di header progress bar | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
| 1.1 | Urgency badge mencolok saat `spotsLeft <= 3` ("⚡ Tinggal X spot!") di header progress bar | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
| 1.2 | Participant preview ringkas di blok progress ("👥 Sudah join: Andi, Rina, Budi +4") — first impression tanpa scroll | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
| 1.2 | Participant preview ringkas di blok progress ("👥 Sudah join: Andi, Rina, Budi +4") — first impression tanpa scroll | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
||||||
| 1.3 | Hint deskriptif + placeholder lebih konkret di field itinerary form create-trip | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
|
| 1.3 | Hint deskriptif + placeholder lebih konkret di field itinerary form create-trip | ✅ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
|
||||||
|
|
||||||
**Tindakan manual:** tidak ada.
|
**Tindakan manual:** tidak ada.
|
||||||
|
|
||||||
@@ -92,21 +92,29 @@ Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PR-4 — Trip Completion Mechanism (opsional, butuh diskusi) ⏳
|
## PR-4 — Trip Completion Mechanism (cron daily) ✅
|
||||||
|
|
||||||
Saat ini `Trip.status = COMPLETED` tidak pernah di-set oleh kode mana pun. PR ini hanya perlu kalau ingin pakai `status` sebagai sumber kebenaran formal (bukan computed-from-`endDate`).
|
Selesai. Pilihan **B** (cron daily) — `Trip.status = COMPLETED` di-set otomatis untuk trip yang `endDate` (atau `date` kalau endDate null) sudah lewat hari ini UTC.
|
||||||
|
|
||||||
Opsi:
|
**Keputusan asumsi yang dipakai:**
|
||||||
- **A. Manual** — organizer klik "Tandai trip selesai" pasca-pulang. Pro: kontrol di organizer. Con: gampang lupa, `status` tidak accurate kalau organizer pasif.
|
- Cutoff = `utcStartOfDay(new Date())` (start of today UTC). Trip dengan `endDate < cutoff` di-flip; trip yang berakhir *hari ini* belum.
|
||||||
- **B. Cron job** — daily job set `status = COMPLETED` untuk trip dengan `endDate < today() AND status IN ('OPEN','FULL')`. Pro: otomatis akurat. Con: butuh infra cron (belum ada di project).
|
- Hanya trip dengan status `OPEN` atau `FULL` yang di-flip. `CLOSED` tidak disentuh (organizer eksplisit membatalkan; tetap dibedakan dari COMPLETED untuk perhitungan `tripsCancelled` di trust panel).
|
||||||
- **C. Skip — biarkan computed-from-`endDate`** di service layer. Pro: paling sederhana, sejalan dengan PR-2 yang juga compute on-the-fly. Con: field `status` jadi sebagian "live" (OPEN/FULL/CLOSED murni, COMPLETED computed).
|
- Idempotent: dua kali run di hari yang sama, run kedua match 0 row.
|
||||||
|
- Endpoint diproteksi via `Authorization: Bearer ${CRON_SECRET}`. Kalau `CRON_SECRET` tidak di-set, endpoint hard-fail 500 (mencegah accidentally jalan tanpa proteksi).
|
||||||
|
- Schedule cron: `0 18 * * *` (jam 18:00 UTC = 01:00 WIB hari berikutnya) — buffer ~7 jam pasca-akhir hari WIB sebelum flip.
|
||||||
|
- **`trustService` tetap pakai computed-from-`endDate`** (tidak diganti ke `status = COMPLETED`). Alasan: trust calc tetap correct walau cron telat / down sehari, dan backward-compat untuk trip lama yang dibuat sebelum cron aktif.
|
||||||
|
- Vercel Cron via `vercel.json` — host lain tinggal panggil endpoint yang sama dari cron eksternal apa saja (GitHub Actions, cron-job.org, dst) dengan header yang sama.
|
||||||
|
|
||||||
**Rekomendasi:** **C** dulu sampai ada kebutuhan riil untuk transisi formal (mis. trigger payout organizer pasca-trip atau notif post-trip continuity di Phase C SOCIAL_ROADMAP).
|
| # | Item | Status | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4.1 | Repo helper `bulkCompletePastTrips(cutoff)` (idempotent, batch update) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
|
||||||
|
| 4.2 | Service `tripService.autoCompletePastTrips()` | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
||||||
|
| 4.3 | API route `/api/cron/auto-complete-trips` (GET, proteksi `CRON_SECRET`) | ✅ | [app/api/cron/auto-complete-trips/route.ts](app/api/cron/auto-complete-trips/route.ts) |
|
||||||
|
| 4.4 | Schedule `0 18 * * *` di Vercel Cron | ✅ | [vercel.json](vercel.json) |
|
||||||
|
|
||||||
| # | Item | Status |
|
**Tindakan manual:**
|
||||||
|---|---|---|
|
1. Set env `CRON_SECRET` di hosting (random ≥32 char). Generate cepat: `openssl rand -hex 32`.
|
||||||
| 4.1 | Pilih opsi A/B/C | ⏳ |
|
2. Kalau host bukan Vercel: panggil endpoint dari cron eksternal apa saja (GitHub Actions schedule, cron-job.org, EasyCron, dst) dengan header `Authorization: Bearer ${CRON_SECRET}`. `vercel.json` bisa dihapus.
|
||||||
| 4.2 | Implementasi sesuai pilihan (atau dokumentasikan keputusan kalau C) | ⏳ |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,3 +27,12 @@ NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
|
|||||||
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
|
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
|
||||||
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
|
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
|
||||||
# Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans
|
# Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans
|
||||||
|
|
||||||
|
|
||||||
|
# === Cron jobs (auto-complete trip, dst) ===
|
||||||
|
# Bearer token yang harus di-kirim cron eksternal (system crontab / Vercel Cron / dst)
|
||||||
|
# saat memanggil endpoint `/api/cron/*`. Kalau kosong, endpoint hard-fail 500.
|
||||||
|
# Generate ≥32-byte hex secret:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
# Setup detail: lihat docs/CRON_SETUP.md
|
||||||
|
CRON_SECRET=
|
||||||
@@ -211,4 +211,39 @@ export const tripRepo = {
|
|||||||
async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") {
|
async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") {
|
||||||
return prisma.trip.update({ where: { id }, data: { status } });
|
return prisma.trip.update({ where: { id }, data: { status } });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk transisi trip yang sudah lewat `cutoff` (start of today UTC) dari
|
||||||
|
* status OPEN/FULL ke COMPLETED. Idempotent — second run tidak akan match
|
||||||
|
* apa-apa karena status sudah berubah.
|
||||||
|
*
|
||||||
|
* Returns daftar id yang ter-update untuk telemetri/log.
|
||||||
|
*/
|
||||||
|
async bulkCompletePastTrips(cutoff: Date) {
|
||||||
|
const trips = await prisma.trip.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["OPEN", "FULL"] },
|
||||||
|
OR: [
|
||||||
|
{ endDate: { lt: cutoff } },
|
||||||
|
{ AND: [{ endDate: null }, { date: { lt: cutoff } }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (trips.length === 0) {
|
||||||
|
return { count: 0, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = trips.map((t) => t.id);
|
||||||
|
const result = await prisma.trip.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
status: { in: ["OPEN", "FULL"] },
|
||||||
|
},
|
||||||
|
data: { status: "COMPLETED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { count: result.count, ids };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -385,6 +385,20 @@ export const tripService = {
|
|||||||
return bookingService.markPaidManual(booking.id, userId);
|
return bookingService.markPaidManual(booking.id, userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
|
||||||
|
*
|
||||||
|
* Cutoff = start of today UTC. Trip dengan endDate < cutoff (atau, kalau
|
||||||
|
* endDate null, date < cutoff) di-set status COMPLETED — selama statusnya
|
||||||
|
* masih OPEN/FULL. CLOSED trip tidak disentuh (organizer eksplisit batalkan).
|
||||||
|
*
|
||||||
|
* Idempotent: dua kali run di hari sama, run kedua nge-match 0 row.
|
||||||
|
*/
|
||||||
|
async autoCompletePastTrips() {
|
||||||
|
const cutoff = utcStartOfDay(new Date());
|
||||||
|
return tripRepo.bulkCompletePastTrips(cutoff);
|
||||||
|
},
|
||||||
|
|
||||||
async confirmParticipantPayment(
|
async confirmParticipantPayment(
|
||||||
tripId: string,
|
tripId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user