# Purchases

Purchase history, transaction details, and refunds

## GET /api/v1/purchase/lookup-session/{sessionId}

Look up purchase intent ID by Adyen session ID (stored in psp_request_ref). Used when Adyen redirects back with sessionId but no purchaseIntentId in URL.

**Permissions:** 🔵 `purchases.read`

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `sessionId` (string, required) — Adyen session ID (psp_request_ref)

Response 200 — Purchase intent found for session

```ts
{
  status?: "success"
  data?: {
    purchaseIntentId?: string // uuid
    locationId?: number
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases

List purchases with automatic BigQuery routing for historical data

Query parameters:
- `locationId` (number, required) — Location ID to filter purchases (required for permissions)
- `includeChildren` (boolean, optional) — Include authorized descendants of locationId.
- `locationIds` (string, optional) — Optional comma-separated subset of the resolved location hierarchy.
- `startDate` (string, optional) — Start date for date range filter (ISO 8601)
- `endDate` (string, optional) — End date for date range filter (ISO 8601)
- `userId` (number, optional) — Filter by user ID
- `minAmount` (number, optional) — Minimum amount in cents
- `maxAmount` (number, optional) — Maximum amount in cents
- `minRefundedAmount` (number, optional) — Minimum refunded amount in cents
- `maxRefundedAmount` (number, optional) — Maximum refunded amount in cents
- `paymentMethod` (string, optional) — Filter by payment method
- `psp` (string, optional) — Filter by payment provider (comma-separated: adyen, stripe, swish, invoice, prepaid, gift_card)
- `sourceBrand` (string, optional) — Filter by payment method brand (comma-separated, case-insensitive)
- `prepaidType` (string, optional) — Filter by prepaid type code stored on the payment event (comma-separated)
- `status` (string, optional) — Filter by payment status
- `customerEmail` (string, optional) — Filter by customer email (partial match)
- `cardLast4` (string, optional) — Filter by last 4 digits of the paying card (exact match)
- `itemId` (number, optional) — Filter purchases containing a specific inventory item ID
- `tableId` (string, optional) — Filter by table/storefront QR code UUID
- `tabId` (string, optional) — Filter by tab UUID
- `tabType` (string, optional) — Filter by tab type (comma-separated: takeaway, over_the_counter, table)
- `costCenterId` (string, optional) — Filter by cost center (comma-separated cost center ids). Resolved as COALESCE(tabs.cost_center_id, storefront_qr_codes.cost_center_id) — a tab tag beats the table default — the same rule analytics and the SIE export use.
- `scopeToUser` (boolean, optional) — Narrow to the sales credited to the calling staff member. Attribution is per line item — who rang the item in, falling back to the tab's initiator — the same rule the POS sales report uses. There is deliberately no parameter for scoping to another staff member: narrowing is only ever to the caller. Requires a staff (retailer user) token.
- `page` (number, optional) — Page number for pagination
- `limit` (number, optional) — Number of results per page (max 1000, enforced with a 400 error beyond that)
- `cursor` (string, optional) — Opaque keyset pagination cursor from a previous response's pagination.nextCursor. Recommended over page/offset for locations with a large purchase history, since deep offset pages get progressively more expensive to compute. When provided, page/offset params are ignored and the total count is not computed (pagination.total is omitted).
- `paginate` ("cursor" | "offset", optional) — Pagination mode. 'cursor' opts into keyset pagination: the expensive count query is skipped and pagination.hasMore/nextCursor are returned even on the first page (before any cursor value exists). Defaults to 'offset' (page/total) for backwards compatibility.

Response 200 — Purchases retrieved successfully

```ts
{
  status?: "success"
  data?: {
    purchases?: Array<{
        customFields?: unknown[]
        metadata?: { … } | null
        id?: number
        locationId?: number
        userId?: number | null
        amount?: number
        currency?: string
        paymentMethod?: string | null
        paymentProvider?: string | null
        status?: string
        createdAt?: string // date-time
        updatedAt?: string // date-time
        tabId?: string | null // uuid
        costCenterId?: number | null
        costCenterName?: string | null
        costCenterCode?: string | null
        costCenter?: { … } | null
        servedBy?: unknown[]
        lineItems?: unknown[]
      }>
    pagination?: {
      page: number
      limit: number
      total?: number
      totalPages?: number
      hasMore: boolean
      nextCursor?: string | null
    }
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases/{id}

Get a single purchase by ID

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `id` (string, required) — Purchase ID

Response 200 — Purchase retrieved successfully

```ts
{
  status?: "success"
  data?: {
    purchase?: {
      customFields?: { … }[]
      metadata?: {
        customFields?: { … }
      } | null
      id?: number
      locationId?: number
      userId?: number | null
      amount?: number
      currency?: string
      paymentMethod?: string | null
      paymentProvider?: string | null
      status?: string
      createdAt?: string // date-time
      updatedAt?: string // date-time
      tabId?: string | null // uuid
      costCenterId?: number | null
      costCenterName?: string | null
      costCenterCode?: string | null
      costCenter?: {
        customFields?: unknown[]
        metadata?: { … } | null
        id?: number
        name?: string
        code?: string | null
      } | null
      servedBy?: { … }[]
      lineItems?: { … }[]
    }
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## PATCH /api/v1/purchases/{id}

Update purchases custom fields

Custom fields: send customFields: [{ "key": "your_field_key", "value": "your_value", "expectedVersion": 0 }]. Use either fieldId or a stable key. expectedVersion is optional; supply the last-read version to reject stale edits. Omitted fields are unchanged; null clears an optional value. See [Custom field inputs](/docs/custom-fields#values-on-normal-create-and-update-calls).

Path parameters:
- `id` (string, required)

Request body:

```ts
{
  customFields: unknown | unknown[]
}
```

Response 200 — Default Response

```ts
{
  status?: string
  data?: {
    id?: string
    locationId?: integer
    customFields?: Array<{
        fieldId?: string
        key?: string
        label?: string
        group?: string
        type?: string
        displayValue?: string
        value?: unknown // The stored value; its JSON type follows the field type
        version?: integer
        status?: string
      }>
    metadata?: {
      customFields?: Record<string, unknown>
    } | null
  }
}
```

## POST /api/v1/purchases/{purchaseIntentId}/cash-register/fetch

Fetch cash register info from cash-register service for a purchase that has a receipt ID but missing other cash register fields. Used to backfill data for older purchases.

**Permissions:** ⚪ `purchases.write`

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Response 200 — Cash register info fetched successfully

```ts
{
  status?: "success"
  data?: {
    purchaseIntentId?: string // uuid
    cashRegister?: {
      receiptNumber?: number
      registerId?: string
      controlUnit?: string | null
    } | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/purchases/{purchaseIntentId}/cash-register/register

Register a purchase with the cash register service (efterregistrering). Used for purchases that were not registered at the time of payment. Location must have an active cash register configuration.

**Permissions:** ⚪ `purchases.write`

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Response 200 — Purchase registered with cash register successfully

```ts
{
  status?: "success"
  data?: {
    purchaseIntentId?: string // uuid
    cashRegister?: {
      receiptNumber?: number
      registerId?: string
      controlUnit?: string | null
    } | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/purchases/{purchaseIntentId}/connect-customer

Connect a customer to a purchase by email. Optionally enroll in loyalty and award retroactive points.

Custom fields: send customFields: [{ "key": "your_field_key", "value": "your_value", "expectedVersion": 0 }]. Use either fieldId or a stable key. expectedVersion is optional; supply the last-read version to reject stale edits. Omitted fields are unchanged; null clears an optional value. See [Custom field inputs](/docs/custom-fields#values-on-normal-create-and-update-calls).

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Request body:

```ts
{
  email: string // email
  name?: string
  phone?: string
  enrollInLoyalty?: boolean
  customFields?: unknown | unknown[]
}
```

Response 200 — Customer connected successfully

```ts
{
  status?: "success"
  data?: {
    success?: boolean
    userId?: number
    isNewUser?: boolean
    loyaltyEnrolled?: boolean
    pointsAwarded?: number
    message?: string
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## POST /api/v1/purchases/{purchaseIntentId}/email-receipt

Send a receipt email copy to a specified email address.

Validates the purchase is confirmed, then triggers the receipt email Cloud Function via PubSub.
The email uses the same template as automatic storefront receipt emails.

Path parameters:
- `purchaseIntentId` (string, required) — The purchase intent ID

Request body:

```ts
{
  email: string // email
}
```

Response 202 — Receipt email send initiated

```ts
{
  status?: "success"
  data?: {
    purchaseIntentId: string // uuid
    email: string
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases/{purchaseIntentId}/order-details

Get unified order purchase details by purchase intent ID (matches storefront-web format)

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Response 200 — Order purchase details retrieved successfully

```ts
{
  status?: "success"
  data?: {
    orderInfo?: {
      orderState?: "initial" | "sent_to_kitchen" | "accepted" | "ready_to_deliver" | "delivered" | "cancelled"
      deliveryKind?: "takeaway" | "table" | "room"
      orderNumber?: number | null
      tableName?: string | null
      paymentFlow?: "instant" | "open_tab"
      tabId?: string | null // uuid
      hasBeenScheduled?: boolean
      desiredPickupTime?: number | null
      phoneNumber?: string | null
    } | null
    purchaseInfo?: {
      purchasedAtEpochSeconds?: number
      totalAmountCents?: number | null
      serviceFeeCents?: number
      lineItems?: { … }[]
      vatBreakdown?: {
        vatPercentage?: number
        vatAmountCents?: number
        netAmountCents?: number
        grossAmountCents?: number
      }
      currency?: string
    } | null
    tabInfo?: {
      tabId?: string // uuid
      remainingAmountCents?: number | null
      qrCodeId?: string | null // uuid
    } | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases/{purchaseIntentId}/receipt

Get receipt data for email generation with hierarchical line items

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Response 200 — Receipt email data retrieved successfully

```ts
{
  status?: "success"
  data?: {
    purchaseIntentId?: string // uuid
    shopLayoutId?: string | null
    purchasedAt?: string // date-time
    currency?: string
    orderNumber?: number | null
    location?: {
      name?: string
      address?: string | null
      phone?: string | null
      vatId?: string | null
      logoUrl?: string | null
      storefrontSlug?: string | null
      language?: string | null
    }
    lineItems?: Array<{
        measurement?: { … } | null
        type?: "product" | "tip"
        title?: string
        quantity?: number
        unitPriceCents?: number
        totalPriceCents?: number
        appliedDiscountCents?: number | null
        appliedModifications?: unknown[]
        children?: unknown[]
      }>
    subtotalCents?: number
    totalDiscountCents?: number
    vatBreakdown?: Array<{
        rate?: number
        baseAmountCents?: number
        vatAmountCents?: number
      }>
    totalCents?: number
    serviceFeeCents?: number
    appliedVouchers?: Array<{
        code?: string
        appliedAmountCents?: number
      }>
    customerEmail?: string | null
    paymentMethod?: string | null
    paymentBrand?: string | null
    paymentLast4?: string | null
    tableName?: string | null
    deliveryKind?: string | null
    cashRegister?: {
      receiptNumber?: number
      registerId?: string
      controlUnit?: string | null
    } | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases/{purchaseIntentId}/receipt/print

Render a purchase receipt as Adyen terminal OutputText for printing. Proxies to printer-service.

**Permissions:** 🔵 `purchases.read`

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `purchaseIntentId` (string, required) — Purchase intent UUID

Response 200 — Adyen OutputText payload ready to ship in a Nexo PrintRequest envelope

```ts
{
  status?: "success"
  data?: {
    outputText: Array<{
        Text: string
        CharacterStyle?: "Bold" | "Normal"
        Alignment?: "Left" | "Centred" | "Right"
        EndOfLineFlag?: boolean
      }>
    qrCode?: {
      url: string
    } | null
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/purchases/by-intent/{intentId}

Get a single purchase by intent ID (UUID)

Query parameters:
- `locationId` (number, required) — Location ID for permission validation

Path parameters:
- `intentId` (string, required) — Purchase intent ID (UUID)

Response 200 — Purchase retrieved successfully

```ts
{
  status?: "success"
  data?: {
    purchase?: {
      customFields?: { … }[]
      metadata?: {
        customFields?: { … }
      } | null
      id?: number
      locationId?: number
      userId?: number | null
      amount?: number
      currency?: string
      paymentMethod?: string | null
      paymentProvider?: string | null
      status?: string
      createdAt?: string // date-time
      updatedAt?: string // date-time
      tabId?: string | null // uuid
      costCenterId?: number | null
      costCenterName?: string | null
      costCenterCode?: string | null
      costCenter?: {
        customFields?: unknown[]
        metadata?: { … } | null
        id?: number
        name?: string
        code?: string | null
      } | null
      servedBy?: { … }[]
      lineItems?: { … }[]
    }
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```

## GET /api/v1/refunds

List refunds


Response 200 — Default Response

## GET /api/v1/refunds/{refundId}

Get refund


Path parameters:
- `refundId` (string, required)

Response 200 — Default Response

## POST /api/v1/refunds/purchase

Process a purchase refund.

Validates the purchase belongs to the authenticated user's location, then asks the service that owns
the purchase (purchase-service for purchases it captured, payment-service otherwise) to initiate the
refund. On success, automatically triggers a refund receipt print job if a default printer group is
configured for the location.

**Flow:**
1. Validates auth context and location access
2. Verifies the purchase belongs to the location and resolves its refund owner (409 when neither
   service can refund the tender)
3. Calls the owning service to process the refund (idempotencyKey required for purchase-service)
4. Auto-prints a refund receipt (fire-and-forget) if a printer group exists
5. Returns the refund ID

Request body:

```ts
{
  refundMode?: "amount" | "products"
  idempotencyKey?: string
  purchaseIntentId: string
  reason: string
  feeCoveredBy: "karma" | "seller"
  lineItems: Array<{
      purchaseLineItemId: integer
      amountCents?: integer
      quantityCents?: integer
    }>
}
```

Response 200 — Refund processed successfully

```ts
{
  status?: "success"
  data?: {
    success: boolean
    purchaseRefundId: integer | null
    message: string
  }
  meta?: {
    requestId: string
    timestamp: string // date-time
    executionTimeMs: number
  }
}
```
