# Sumup

[![Hex.pm](https://img.shields.io/hexpm/v/sumup.svg)](https://hex.pm/packages/sumup)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/sumup)
[![CI](https://github.com/voidspace/sumup/actions/workflows/ci.yml/badge.svg)](https://github.com/voidspace/sumup/actions/workflows/ci.yml)
[![License](https://img.shields.io/hexpm/l/sumup.svg)](https://github.com/voidspace/sumup/blob/main/LICENSE)

A production-grade, fully-typed Elixir client for the
[SumUp API](https://developer.sumup.com/api) — Checkouts, Readers,
Customers, Payment Instruments, Transactions, Payouts, Receipts, Members,
Memberships, Roles, and Merchants.

## Why this exists

SumUp publishes official SDKs for Node, Go, Python, Java, .NET, PHP and
Rust, but not Elixir. This package fills that gap, and does so by
modeling the API's real shape rather than papering over it:

- **Two error formats, normalized into one.** Legacy resources
  (Checkouts, Customers, Transactions, Payouts) return
  `{"error_code", "message", "param"}` (sometimes as a list); newer
  resources (Readers, Members, Merchants) and every `401` return
  [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details.
  `Sumup.Error` decodes both into one struct so your code never has to
  care which generation of the API it's talking to.
- **Two money formats, kept distinct.** Legacy endpoints represent money
  as a float major-unit amount (`Sumup.Money.Float`); Readers use an
  integer minor-unit `Sumup.Money.MinorUnit`. They're deliberately not
  unified into one type — doing so would either lose precision or
  require guessing a currency's decimal exponent.
- **Per-resource API versioning.** Each resource module hits whatever
  version SumUp actually ships for it (`v0.1` for Checkouts, `v2.1` for
  Transactions reads but `v1.0` for refunds, `v1` for Merchants, etc.)
  rather than assuming one version for the whole client.
- **Reader checkouts modeled as genuinely async.** Pushing a payment to a
  physical Solo reader returns as soon as it's been sent to the device;
  this library returns that intermediate state honestly instead of
  faking synchronicity with an internal poll loop.

## Installation

```elixir
def deps do
  [
    {:sumup, "~> 1.0"}
  ]
end
```

## Quick start

```elixir
config = Sumup.Config.new!(api_key: {:system, "SUMUP_API_KEY"})

{:ok, checkout} =
  Sumup.Checkouts.create(config,
    checkout_reference: "order-1234",
    amount: 10.50,
    currency: "EUR",
    merchant_code: "MC12345"
  )

{:ok, paid} =
  Sumup.Checkouts.process(config, checkout.id,
    payment_type: "card",
    card: %{number: "4111111111111111", expiry_month: 12, expiry_year: 2030, cvv: "123", name: "Jane Doe"}
  )
```

## Configuration

`Sumup.Config.new!/1` validates its options via `NimbleOptions` — see
`Sumup.Config`'s module docs for the full option list (timeouts, retry
policy, telemetry prefix, custom `Req` options, etc). Auth accepts a raw
string or a lazily-resolved `{:system, "ENV_VAR"}` / `{module, function,
args}` tuple, so secrets never need to be resolved before your
supervision tree starts:

```elixir
Sumup.Config.new!(
  api_key: {:system, "SUMUP_API_KEY"},
  max_retries: 5,
  receive_timeout: 15_000
)
```

## Resources

| Module | Covers |
|---|---|
| `Sumup.Checkouts` | create, get, find by reference, process (card/token/APM), deactivate, Apple Pay sessions, list payment methods |
| `Sumup.Readers` | pair, list, get (with `If-Modified-Since` support), get live status, rename/update, delete, push a checkout, terminate |
| `Sumup.Customers` | create, get, update |
| `Sumup.PaymentInstruments` | list, deactivate |
| `Sumup.Transactions` | get by id/code/foreign id/client id, list (with bracketed array filters), `list_all/3` lazy hypermedia-link stream, refund |
| `Sumup.Payouts` | date-ranged report, JSON or CSV format |
| `Sumup.Receipts` | detailed receipt lookup |
| `Sumup.Members` | create, get, list, `list_all/3`, update (full replace via `PUT`), delete |
| `Sumup.Memberships` | list the *current authenticated user's* own memberships (no merchant/user id in the path) |
| `Sumup.Roles` | create, list, get, update, delete custom roles |
| `Sumup.Merchants` | get profile (version + change-status pattern), list/get persons — **read-only**, SumUp's public API has no merchant-update endpoint |

`Sumup.Webhooks` isn't a REST resource — see [Webhooks](#webhooks) below.

## Error handling

Every function returns `{:ok, result}` or `{:error, %Sumup.Error{}}`.
`Sumup.Error` exposes a consistent set of fields (`:status`, `:code`,
`:message`, `:param`, `:type`, `:title`, `:instance`, `:errors`) no matter
which underlying shape the API returned, plus the original `:raw` body if
you need it:

```elixir
case Sumup.Checkouts.process(config, checkout_id, payment_type: "card", card: card) do
  {:ok, checkout} -> checkout
  {:error, %Sumup.Error{code: "CARD_DECLINED"} = error} -> {:declined, Sumup.Error.message(error)}
  {:error, error} -> {:failed, Sumup.Error.message(error)}
end
```

## Retries and telemetry

`429` and `5xx` responses (and transport failures) are retried with
exponential backoff and jitter, honoring a numeric `retry-after` header
when SumUp sends one. Every request emits `:telemetry` spans — see
`Sumup.Telemetry` for the full event list — so you can hook up logging,
metrics, or tracing without modifying this library:

```elixir
:telemetry.attach(
  "sumup-logger",
  [:sumup, :request, :stop],
  fn _event, measurements, metadata, _config ->
    Logger.info("sumup #{metadata.resource}.#{metadata.operation} -> #{metadata.status} (#{measurements.duration}ns)")
  end,
  nil
)
```

## Webhooks

SumUp's Checkout webhooks are **not signed** — there's no HMAC or
signature header to check for this API (that's a different SumUp product
entirely; see `Sumup.Webhooks`'s moduledoc for the full explanation).
SumUp's own recommendation is to treat the webhook body as an
unauthenticated pointer and re-fetch the real state from the API:

```elixir
def handle_webhook(conn, _params) do
  config = my_sumup_config()

  with {:ok, raw_body, conn} <- Plug.Conn.read_body(conn),
       {:ok, checkout} <- Sumup.Webhooks.handle(raw_body, config) do
    MyApp.handle_checkout_update(checkout)
  end

  # Always ack quickly with 2xx, however processing went — SumUp retries
  # non-2xx deliveries at 1 min, 5 min, 20 min, and 2 hours.
  Plug.Conn.send_resp(conn, 200, "")
end
```

`Sumup.Webhooks.parse/1` decodes the payload without trusting it;
`Sumup.Webhooks.verify/2` does the recommended re-fetch;
`Sumup.Webhooks.handle/2` does both in one call, as above.

## Streaming large result sets

`Sumup.Transactions.list_all/3` and `Sumup.Members.list_all/3` return a
lazy `Stream` built on `Sumup.Pagination`, so you can `Stream.take/2`,
`Stream.filter/2`, etc. without fetching more pages than you need:

```elixir
Sumup.Transactions.list_all(config, merchant_code, statuses: ["SUCCESSFUL"])
|> Stream.filter(&(&1.amount.amount > 100.0))
|> Stream.take(50)
|> Enum.to_list()
```

## Testing your own integration

This library's own test suite uses `Req.Test` (shipped inside `:req`) to
stub HTTP without a real server — no `Bypass`/`Plug.Cowboy` needed. You
can do the same in your app: build a `Sumup.Config` with `req_options:
[plug: {Req.Test, MyStubName}]` and drive `Req.Test.stub/2` /
`Req.Test.expect/3` from your tests.

## Development

```sh
mix deps.get
mix test
mix credo --strict
mix dialyzer
mix format --check-formatted
```

## A note on endpoint coverage

Every path, parameter, and response field in this library was
cross-checked against SumUp's published
[OpenAPI spec](https://github.com/sumup/sumup-openapi) — including some
easy-to-miss details it corrects for: `Sumup.Memberships` has no
merchant/user id in its path (it's always the calling user's own
memberships), `Sumup.Transactions.refund/4` is scoped under
`/merchants/{merchant_code}/payments/{id}/refunds` and returns no body,
`Sumup.Merchants` has no update endpoint in the public API, reader
checkouts use `tip_rates`/`tip_timeout` rather than a flat `tip_amount`,
and transaction history's array filters (`statuses[]`, `payment_types[]`,
`entry_modes[]`, `types[]`) are sent as repeated bracketed query keys, not
comma-joined values. SumUp does still evolve its API surface over time,
so if you hit a mismatch, please open an issue or PR — every resource
module follows the same pattern, so adding or correcting an endpoint is
usually a small, self-contained change.

## License

MIT. See `LICENSE`.
