add cron and partial update refund schema

This commit is contained in:
arifal
2026-05-10 22:27:21 +07:00
parent 9a163c4f13
commit 744ee3446b
7 changed files with 466 additions and 17 deletions
+226
View File
@@ -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.
+50
View File
@@ -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 }
);
}
}
+107
View File
@@ -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) | ⏳ |
--- ---
+10 -1
View File
@@ -26,4 +26,13 @@ NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
# 'true' untuk production, 'false' atau kosong untuk sandbox. # 'true' untuk production, 'false' atau kosong untuk sandbox.
# 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=
+35
View File
@@ -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 };
},
}; };
+14
View File
@@ -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,