# Checkout / Register API Documentation

**Date:** 2026-08-29
**Base URL:** `/api`

---

## Endpoints

| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/checkout/register` | Register / checkout |
| `GET` | `/checkout/{member_id}/status` | Poll payment & membership status |
| `GET` | `/checkout/{member_id}/payment-url` | Re-fetch or regenerate payment URL |

---

## 1. POST `/checkout/register`

### Request

**Body Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `plan` | string | Yes | Plan slug (must be active) |
| `name` | string | Yes | Member's full name |
| `email` | string | Yes | Valid email address |
| `whatsapp` | string | Yes | WhatsApp number |
| `discord` | string | Yes | Discord username |
| `telegram` | string | No | Telegram username |
| `coupon` | string | No | Coupon code |
| `amount` | integer | No | Override amount (required for `class` type plans, min: 1000) |

### Existing Member Detection Flow

When an email is submitted, the system first checks for an existing member with that email.

| # | payment_status | payment_expires_at | Plan State | Action |
|---|---|---|---|---|
| 1 | `paid` | any | any | **Error:** `EMAIL_EXISTS` |
| 2 | `expired` | any | any | **Error:** `EMAIL_EXISTS` |
| 3 | `pending` | expired / null | any | **Error:** `EMAIL_EXISTS` |
| 4 | `pending` | valid, future | active, same plan, same/no coupon | Update fields → return **existing URL**, `is_existing: true` |
| 5 | `pending` | valid, future | active, same plan, **diff coupon** | Update + regenerate URL, `is_existing: true` |
| 6 | `pending` | valid, future | **inactive** same plan, same/no coupon | Update fields → return **existing URL**, `is_existing: true` |
| 7 | `pending` | valid, future | **diff plan** | Update + regenerate URL, `is_existing: true` |
| 8 | `failed` | any | any | Update + regenerate URL, `is_existing: true` |

**No existing member found:** Reuses existing `User` if email taken, otherwise creates new. Returns `is_existing: false`, HTTP 201.

### Success Response — New Registration (HTTP 201)

```json
{
  "success": true,
  "data": {
    "member_id": "VLB-2026-0001",
    "plan": "vip_membership_3_bulan",
    "plan_label": "VIP Membership 3 Bulan",
    "plan_type": "membership",
    "amount": "Rp 500k",
    "payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/...",
    "payment_expires_at": "2026-08-29T10:30:00+07:00",
    "payment_expires_remaining": "29m 45s",
    "payment_status": "pending",
    "status": "pending",
    "membership_expires_at": "2026-11-29T10:30:00+07:00",
    "is_existing": false,
    "is_regenerated": true,
    "next_steps": {
      "check_email": "A payment link has been sent to your email.",
      "complete_within": "Complete payment within 30 minutes.",
      "auto_activate": "Your membership activates automatically after payment is confirmed."
    }
  },
  "message": "Checkout initiated. Complete payment within 30 minutes."
}
```

### Success Response — Existing URL Reused (Case 4, 6)

```json
{
  "success": true,
  "data": {
    "member_id": "VLB-2026-0001",
    "plan": "vip_membership_3_bulan",
    "plan_label": "VIP Membership 3 Bulan",
    "plan_type": "membership",
    "amount": null,
    "payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/...",
    "payment_expires_at": "2026-08-29T10:30:00+07:00",
    "payment_expires_remaining": "29m 45s",
    "payment_status": "pending",
    "status": "pending",
    "membership_expires_at": "2026-11-29T10:30:00+07:00",
    "is_existing": true,
    "is_regenerated": false,
    "next_steps": {
      "check_email": "A payment link has been sent to your email.",
      "complete_within": "Complete payment within 30 minutes.",
      "auto_activate": "Your membership activates automatically after payment is confirmed."
    }
  },
  "message": "Existing pending payment returned."
}
```

### Success Response — Existing Updated & Regenerated (Case 5, 7, 8)

Same structure as new registration, with `is_existing: true`, `is_regenerated: true`, and `amount` populated.

### Error Response — Email Already Exists (HTTP 422)

```json
{
  "success": false,
  "error": {
    "code": "EMAIL_EXISTS",
    "message": "Email already exists."
  }
}
```

### Error Response — Invalid Plan (HTTP 422)

```json
{
  "success": false,
  "error": {
    "code": "INVALID_PLAN",
    "message": "Plan not found."
  }
}
```

### Error Response — Amount Required for Class Plan (HTTP 422)

```json
{
  "success": false,
  "error": {
    "code": "AMOUNT_REQUIRED",
    "message": "Amount is required for class purchases."
  }
}
```

---

## 2. GET `/checkout/{member_id}/status`

Poll for payment and membership status after returning from Midtrans.

### Path Parameters

| Field | Type | Description |
|---|---|---|
| `member_id` | string | The member ID (e.g. `VLB-2026-0001`) |

### Success Response (HTTP 200)

```json
{
  "success": true,
  "data": {
    "member_id": "VLB-2026-0001",
    "plan": "vip_membership_3_bulan",
    "plan_label": "VIP Membership 3 Bulan",
    "plan_type": "membership",
    "payment_status": "pending",
    "payment_transaction_id": "57145982-4315-4f3e-bb91-a9bee254a0f7",
    "payment_expires_at": "2026-08-29T10:30:00+07:00",
    "payment_expires_remaining": "29m 45s",
    "payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/...",
    "status": "pending",
    "member_since": "2026-08-29T10:00:00+07:00",
    "membership_expires_at": "2026-11-29T10:30:00+07:00"
  }
}
```

### Error Response — Member Not Found (HTTP 404)

```json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Member not found."
  }
}
```

---

## 3. GET `/checkout/{member_id}/payment-url`

Re-fetch the payment URL for a member. If the payment window has expired and the member is still `pending`, a new URL is automatically regenerated.

### Path Parameters

| Field | Type | Description |
|---|---|---|
| `member_id` | string | The member ID (e.g. `VLB-2026-0001`) |

### Success Response — Active URL (HTTP 200)

```json
{
  "success": true,
  "data": {
    "member_id": "VLB-2026-0001",
    "plan_label": "VIP Membership 3 Bulan",
    "payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/...",
    "payment_expires_at": "2026-08-29T10:30:00+07:00",
    "payment_expires_remaining": "29m 45s",
    "is_regenerated": false,
    "is_expired": false
  }
}
```

### Success Response — Expired, Auto-Regenerated (HTTP 200)

When the payment window expired, a new URL is generated and the member is notified.

```json
{
  "success": true,
  "data": {
    "member_id": "VLB-2026-0001",
    "plan_label": "VIP Membership 3 Bulan",
    "payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/new-uuid",
    "payment_expires_at": "2026-08-29T11:00:00+07:00",
    "payment_expires_remaining": "30m 0s",
    "is_regenerated": true,
    "is_expired": true
  }
}
```

### Error Response — Member Not Found (HTTP 404)

```json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Member not found."
  }
}
```

---

## Response Field Reference

### Data Fields

| Field | Type | Description |
|---|---|---|
| `member_id` | string | Unique member ID (e.g. `VLB-2026-0001`) |
| `plan` | string | Plan slug |
| `plan_label` | string | Human-readable plan name |
| `plan_type` | string | `membership` or `class` |
| `amount` | string\|null | Formatted price string (e.g. `Rp 500k`) |
| `payment_url` | string | Midtrans Snap payment URL |
| `payment_expires_at` | string\|null | ISO 8601 expiry timestamp |
| `payment_expires_remaining` | string\|null | Human-readable countdown (e.g. `29m 45s`) |
| `payment_status` | string | `pending`, `paid`, `failed`, `expired` |
| `payment_transaction_id` | string\|null | Midtrans transaction ID |
| `status` | string | Member status: `pending`, `active`, `cancelled` |
| `member_since` | string\|null | ISO 8601 registration timestamp |
| `membership_expires_at` | string\|null | ISO 8601 membership expiry |
| `is_existing` | boolean | Whether a member record already existed |
| `is_regenerated` | boolean | Whether a new payment URL was generated |
| `is_expired` | boolean | Whether the previous payment window expired (payment-url endpoint only) |
| `next_steps` | object | Structured next-step instructions for the UI |

### `payment_expires_remaining` Format

| Time remaining | Example |
|---|---|
| ≥ 1 day | `2d 5h` |
| ≥ 1 hour | `1h 30m` |
| ≥ 1 minute | `29m 45s` |
| < 1 minute | `45s` |

### `payment_status` Values

| Value | Description |
|---|---|
| `pending` | Awaiting payment |
| `paid` | Payment completed |
| `failed` | Payment failed (can retry via payment-url endpoint) |
| `expired` | Payment window expired (different flow) |

### `status` Values (Member)

| Value | Description |
|---|---|
| `pending` | Member pending activation |
| `active` | Member active |
| `cancelled` | Membership cancelled |

### `plan_type` Values

| Value | Description |
|---|---|
| `membership` | Subscription membership plan |
| `class` | One-time class plan (requires `amount` in request) |

---

## Frontend UX Guide

### Success Page

Use these fields from the register response:

- `plan_label` — Display plan name
- `amount` — Display price
- `payment_expires_remaining` — Countdown timer (refresh with `paymentUrl` endpoint)
- `payment_url` — "Pay Now" button
- `next_steps` — Structured instructions to show below the CTA

### After Returning from Midtrans

Poll `GET /checkout/{member_id}/status` to show the correct final state:

- `payment_status: "paid"` → Show success / welcome page
- `payment_status: "pending"` → Show "payment is being processed" or redirect to `payment_url`
- `payment_status: "failed"` → Show retry option with `GET /checkout/{member_id}/payment-url`

### Payment URL Lost / Browser Closed

Call `GET /checkout/{member_id}/payment-url`:
- If `is_expired: true` → New URL auto-generated, show "new link sent to email"
- If `is_expired: false` → Return existing URL

---

## Payment URL Fix

Midtrans PHP SDK may return `redirect_url` as a double-encoded JSON string (e.g. `"https:\/\/app.sandbox.midtrans.com\/..."`). This is fixed in `PricingService::getSnapRedirectUrl` by applying `stripslashes()` before returning.
