diff --git a/REFUND_ROADMAP.md b/REFUND_ROADMAP.md new file mode 100644 index 0000000..1499fdb --- /dev/null +++ b/REFUND_ROADMAP.md @@ -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. diff --git a/app/api/cron/auto-complete-trips/route.ts b/app/api/cron/auto-complete-trips/route.ts new file mode 100644 index 0000000..41aa448 --- /dev/null +++ b/app/api/cron/auto-complete-trips/route.ts @@ -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 } + ); + } +} diff --git a/docs/CRON_SETUP.md b/docs/CRON_SETUP.md new file mode 100644 index 0000000..47677d9 --- /dev/null +++ b/docs/CRON_SETUP.md @@ -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="" +``` + +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 `` 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 " 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 `. + +**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 `). diff --git a/TRUST_ROADMAP.md b/docs/archive/TRUST_ROADMAP.md similarity index 76% rename from TRUST_ROADMAP.md rename to docs/archive/TRUST_ROADMAP.md index d959e55..039c04b 100644 --- a/TRUST_ROADMAP.md +++ b/docs/archive/TRUST_ROADMAP.md @@ -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 | |---|---|---|---| -| 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.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.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.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. @@ -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: -- **A. Manual** — organizer klik "Tandai trip selesai" pasca-pulang. Pro: kontrol di organizer. Con: gampang lupa, `status` tidak accurate kalau organizer pasif. -- **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). -- **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). +**Keputusan asumsi yang dipakai:** +- Cutoff = `utcStartOfDay(new Date())` (start of today UTC). Trip dengan `endDate < cutoff` di-flip; trip yang berakhir *hari ini* belum. +- 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). +- 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 | -|---|---|---| -| 4.1 | Pilih opsi A/B/C | ⏳ | -| 4.2 | Implementasi sesuai pilihan (atau dokumentasikan keputusan kalau C) | ⏳ | +**Tindakan manual:** +1. Set env `CRON_SECRET` di hosting (random ≥32 char). Generate cepat: `openssl rand -hex 32`. +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. --- diff --git a/env.example b/env.example index fb031d2..b689210 100644 --- a/env.example +++ b/env.example @@ -26,4 +26,13 @@ NEXT_PUBLIC_MIDTRANS_CLIENT_KEY= # 'true' untuk production, 'false' atau kosong untuk sandbox. # Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL). NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false -# Webhook URL di Midtrans dashboard harus diset ke: /api/webhooks/midtrans \ No newline at end of file +# Webhook URL di Midtrans dashboard harus diset ke: /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= \ No newline at end of file diff --git a/server/repositories/trip.repo.ts b/server/repositories/trip.repo.ts index cec6c05..6091daf 100644 --- a/server/repositories/trip.repo.ts +++ b/server/repositories/trip.repo.ts @@ -211,4 +211,39 @@ export const tripRepo = { async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") { 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 }; + }, }; diff --git a/server/services/trip.service.ts b/server/services/trip.service.ts index 2c54d17..31ff84a 100644 --- a/server/services/trip.service.ts +++ b/server/services/trip.service.ts @@ -385,6 +385,20 @@ export const tripService = { 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( tripId: string, participantId: string,