# Billej — for humans

Lej en bil på seks måneder eller mere. Fast pris om måneden med forsikring, service og grøn ejerafgift — og depositum retur, når bilen kommer hjem. Se flåden og bestil online.

## The product in one paragraph

Billej is **one Danish company renting out its own cars** on
agreements of six months or more. The company uploads its fleet as
`cars`; a customer browses `/biler`, opens a car, and checks out. That
checkout creates a `booking`, charges a card, and issues a
`rental-contract` — a legal record holding a snapshot of the issuing
company and of the template's printed copy. Rent is then collected
monthly on the saved card. **The contract is the product.**

## Things that trip people up

| | |
| --- | --- |
| **Conversion** | A checkout, not an inquiry. The customer pays on-site. |
| **Term** | Six months minimum, always. There is no per-day booking, and `cars.minTermMonths` may never be set below 6. |
| **Supply** | One company, and it is a Payload *global*, not a collection. Do not reintroduce lister accounts, categories or public profiles. |
| **Inventory** | One `car` row is one physical vehicle, keyed by `regNumber`. Availability is a status, never a stock count. |
| **Currency** | DKK everywhere, whole kroner **incl. 25 % moms**: `4.995 kr/md`, `12.400 kr`. Never `$`, `USD` or `toFixed(2)`. |
| **Units** | Kroner in the database and the UI; øre only at the Stripe boundary, converted once with `Math.round(kr * 100)`. |
| **Prices** | `src/lib/quote.ts` is the only place a rental's price is computed. The price panel, the quote endpoint, the PaymentIntent and the contract's line table all resolve through `buildQuote`. |
| **Dates** | `DayKey` — `YYYY-MM-DD` in `Europe/Copenhagen`. Keys compare lexicographically; never compare instants, and resolve "today" on the server. |
| **Schedule** | Derived from the booking every time it is drawn, never stored. Only real payment attempts get a `payments` row, keyed by `(booking, dueKey, kind)`. |
| **Deposit** | Charged, not authorised — a card auth expires in about seven days and cannot cover a six-month rental. Refunded at settlement. |
| **Locales** | `da` (default) and `en`. Public paths are the Danish words. |

## Public routes

| Path | Purpose |
| --- | --- |
| `/` | Landing — the featured car, the fleet strip, how it works |
| `/biler` | The fleet. All filter state lives in the query string |
| `/biler/[slug]` | One car: gallery, specs, price panel, booking CTA |
| `/reserver/[slug]` | Checkout, four steps on one URL (`?trin=`) |
| `/kvittering/[id]` | Confirmation and contract download |
| `/priser` | What a month covers, the deposit, km and the six-month term |
| `/saadan-virker-det` | How it works |
| `/blogs`, `/blogs/[slug]` | Guides |
| `/faq` | FAQ |
| `/om`, `/kontakt` | About, contact |
| `/vilkaar`, `/privatliv` | Terms, privacy |

The English aliases `/cars`, `/cars/[slug]`, `/pricing`, `/how-it-works`,
`/about`, `/contact`, `/terms` and `/privacy` answer with a permanent 308
to the Danish path. Link to the Danish path directly.

## Signed-in routes

`/konto` (the customer's own agreement, payments, profile and cards) and
`/kontrolpanel` (the operator's fleet, reservations, contracts and
payment ledger — role `staff` or `admin`). Payload's own `/admin` stays
available for raw data work.

## Elsewhere

- OpenAPI 3.1 spec: https://billej.vercel.app/api/openapi.json
- Interactive docs: https://billej.vercel.app/api/docs
- Agent-oriented summary: https://billej.vercel.app/llms.txt

---
# Billej API — API Reference

Browse the fleet, price a rental, and reserve a car. Billej lets its own cars on agreements of at least six months.

## Quick Start

1. **Browse the fleet** — `GET /api/cars` with filters (`fuelType`, `transmission`, `bodyType`, price range, availability)
2. **Read one car** — `GET /api/cars?where[slug][equals]={slug}`
3. **Price it** — `POST /api/checkout/quote` with `{ carSlug, startDate, termMonths }`. This is the single pricing authority: the figure it returns is the figure the pay button charges
4. **Reserve** — `POST /api/checkout/reserve` with the renter's details. The booking is created `pending`; only a confirmed payment promotes it
5. **Follow it** — `GET /api/bookings/{id}/status`

## Prices

Whole DKK including 25 % moms — format `5.495 kr/md`. A term below the car's minimum is clamped up rather than rejected, and the minimum is never under six months.

## Languages

Content is localised in Danish (`da`, default) and English (`en`). Pass `?locale=en` to Payload collection endpoints for English.

## Authentication

Browsing and pricing need no credentials. For the rest:

**Session Cookie** — Sign in via `POST /api/auth/sign-in/email` with `{ email, password }`. The `better-auth.session_token` cookie is set automatically.

**API Key** — Pass an `x-api-key` header with a scoped API key. Create keys at `/konto/developer` or via `POST /api/auth/api-key/create`.

## Error Format

All errors return `{ error: string }`. Validation errors additionally include `{ details: { fieldErrors, formErrors } }` with per-field messages.

## Table of Contents

- [Payments](#payments) — Payment amount calculation.
- [Discounts](#discounts) — Discount code validation.
- [Contact](#contact) — Contact form submissions.
- [Newsletter](#newsletter) — Email newsletter subscriptions.
- [Cart](#cart) — Cart-level discounts. Inherited from the ecommerce plugin and not part of the rental flow.
- [Orders](#orders) — Order history from the ecommerce plugin. Not part of the rental flow.

## Payments
> Payment amount calculation.

### POST `/api/payment-amount`

**Calculate final payment amount with optional discount** ` AUTH REQUIRED `

Retrieves the current amount of a Stripe PaymentIntent for a boost or plan purchase and optionally applies a discount code. Every amount is DKK øre — 14900 is 149,00 kr. The PaymentIntent must still be in the `requires_payment_method` status. For authenticated users, ownership is verified via the Stripe customer. For guests, the PaymentIntent ID acts as authorization. Rate limited to 20 requests per IP per minute.

> **Rate Limit:** 20 requests per 60s

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `paymentIntentId` | string | Yes | minLength: 1, maxLength: 255 | The Stripe PaymentIntent ID |
| `discountCode` | string | No | minLength: 1, maxLength: 50 | Optional discount code to apply to the payment |

**Example:**
```json
{
  "paymentIntentId": "pi_3Oc0X2Abc123def456",
  "discountCode": "SOMMER20"
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Payment amount calculated successfully |
| 400 | Missing paymentIntentId, invalid payment state, or discount error |
| 403 | PaymentIntent does not belong to the authenticated user |
| 429 | Rate limit exceeded (20 requests per minute per IP) |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/payment-amount" \
  -H "Content-Type: application/json" \
  -d '{"paymentIntentId":"pi_3Oc0X2Abc123def456","discountCode":"SOMMER20"}'
```

---

## Discounts
> Discount code validation.

### POST `/api/discount/validate`

**Validate a discount code** ` PUBLIC `

Validates a discount code for a boost or plan purchase: it must exist, be active, sit inside its valid date range, be under its usage limits and meet the minimum order amount. Optionally calculates the discount when a subtotal is supplied. All amounts are DKK øre — 14900 is 149,00 kr. Rate limited to 10 requests per IP per minute.

> **Rate Limit:** 10 requests per 60s

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `code` | string | Yes | minLength: 1, maxLength: 50 | The discount code to validate |
| `customerEmail` | string | No | minLength: 3, maxLength: 320, format: email | Customer email for per-customer usage limit checks |
| `subtotal` | integer | No | min: 0, max: 99999999 | Cart subtotal in DKK øre for the minimum-order check and discount calculation |

**Example:**
```json
{
  "code": "VELKOMMEN10",
  "customerEmail": "mette@eksempel.dk",
  "subtotal": 14900
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Validation result. Both valid and invalid codes return 200; check the `valid` field. |
| 400 | Missing or invalid request body |
| 429 | Rate limit exceeded (10 requests per minute per IP) |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/discount/validate" \
  -H "Content-Type: application/json" \
  -d '{"code":"VELKOMMEN10","customerEmail":"mette@eksempel.dk","subtotal":14900}'
```

---

## Contact
> Contact form submissions.

### POST `/api/contact`

**Submit contact form** ` PUBLIC `

Accepts a contact form submission and stores it in the Payload CMS `contact-form-submissions` collection. No authentication is required.

> **Dashboard:** Payload Admin > Contact Form Submissions

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `name` | string | Yes | minLength: 1, maxLength: 150, pattern | Full name of the person submitting the form |
| `email` | string | Yes | minLength: 5, maxLength: 320, format: email | Contact email address |
| `subject` | string | Yes | minLength: 1, maxLength: 200 | Subject line for the contact message |
| `message` | string | Yes | minLength: 1, maxLength: 5000 | Body of the contact message |

#### Responses

| Status | Description |
|--------|-------------|
| 201 | Contact form submitted successfully |
| 400 | Validation error — missing or invalid fields |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/contact"
```

---

## Newsletter
> Email newsletter subscriptions.

### POST `/api/newsletter`

**Subscribe to newsletter** ` PUBLIC `

Subscribes an email address to the newsletter. The address is stored in the Payload CMS `newsletter-subscribers` collection and optionally synced to a Resend audience when `RESEND_API_KEY` and `RESEND_AUDIENCE_ID` are configured. Duplicate emails are silently ignored.

> **Dashboard:** Payload Admin > Newsletter Subscribers

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `email` | string | Yes | minLength: 5, maxLength: 320, format: email | Email address to subscribe to the newsletter |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Successfully subscribed (or already subscribed) |
| 400 | Validation error — invalid email address |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/newsletter"
```

---

## Cart
> Cart-level discounts. Inherited from the ecommerce plugin and not part of the rental flow.

### POST `/api/cart/apply-discount`

**Apply a discount code to a cart** ` AUTH REQUIRED `

Validates and applies a discount code to a basket of boosts and plans. The caller must own the cart (via session) or supply the cart secret. Amounts are DKK øre — 14900 is 149,00 kr.

> **Dashboard:** Use from the basket or /checkout by entering a discount code and pressing Apply.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `code` | string | Yes | minLength: 1, maxLength: 50 | The discount code to apply to the cart |
| `cartId` | integer | Yes | min: 1, max: 2147483647 | The ID of the cart to apply the discount to |
| `secret` | string | No | minLength: 1, maxLength: 255 | Cart secret for guest users who are not authenticated but own the cart |

**Example:**
```json
{
  "code": "SOMMER20",
  "cartId": 42
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Discount applied successfully |
| 400 | Malformed request body, or a discount code that does not apply. The two are distinguishable: a rejected code carries `success: false`, a malformed body carries `details`. |
| 403 | Not authorised to modify this cart |
| 404 | Cart not found or already purchased |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/cart/apply-discount" \
  -H "Content-Type: application/json" \
  -d '{"code":"SOMMER20","cartId":42}'
```

---

### POST `/api/cart/remove-discount`

**Remove a discount code from a cart** ` AUTH REQUIRED `

Removes any previously applied discount code from a basket of boosts and plans. The caller must own the cart (via session) or supply the cart secret.

> **Dashboard:** Use from the basket or /checkout by clicking the remove button next to the applied code.

#### Request Body

| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| `cartId` | integer | Yes | min: 1, max: 2147483647 | The ID of the cart to remove the discount from |
| `secret` | string | No | minLength: 1, maxLength: 255 | Cart secret for guest users who are not authenticated but own the cart |

**Example:**
```json
{
  "cartId": 42
}
```

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Discount removed successfully |
| 400 | Missing or invalid cart ID |
| 403 | Not authorised to modify this cart |
| 404 | Cart not found or already purchased |
| 500 | Internal server error |

**cURL Example:**
```bash
curl -X POST "https://billej.vercel.app/api/cart/remove-discount" \
  -H "Content-Type: application/json" \
  -d '{"cartId":42}'
```

---

## Orders
> Order history from the ecommerce plugin. Not part of the rental flow.

### GET `/api/orders`

**List the authenticated user's orders** ` AUTH REQUIRED `

Returns a paginated list of orders belonging to the authenticated user. Orders are matched by the internal user ID or the user's email and are sorted by creation date (newest first).

#### Parameters

| Name | In | Type | Required | Description |
|------|-----|------|----------|-------------|
| `page` | query | integer | No | Page number for pagination (defaults to 1) |
| `limit` | query | integer | No | Number of orders per page (defaults to 10, max 50) |

#### Responses

| Status | Description |
|--------|-------------|
| 200 | Paginated order list |
| 401 | Not authenticated |
| 500 | Internal server error |

**cURL Example:**
```bash
curl "https://billej.vercel.app/api/orders"
```

---

## Error Reference

All errors return a JSON object with an `error` field:
```json
{ "error": "Human-readable error message" }
```

Validation errors (400) additionally include structured details:
```json
{
  "error": "Validation failed",
  "details": {
    "fieldErrors": { "email": ["Invalid email address"] },
    "formErrors": []
  }
}
```
