# Test helpers API

Play the payer's part from your own tests, without a browser: complete a card payment, pay or expire a cash code.

The test helpers let an automated test do what a person does in test mode: type a test card on the
payment page, or pay a cash code at an agent. They live under `/v1/test_helpers` and accept a **test
secret key** only. With a live key they answer `403`, `permission_error`, `test_mode_only`.

A helper acts on an **attempt** (`ch_…`): the `latest_charge` of a payment intent that is waiting for
its payer, in `requires_action`. It plays the payer's or the network's part, nothing more: the payment
then changes the way it would in live mode, through the provider's notification or a status check,
usually within seconds. Read the payment intent again, or wait for the webhook.

## Complete a card payment

`POST /v1/test_helpers/charges/{charge}/complete` submits a card number on the payment page of a
waiting card attempt, as your customer would.

* `card_number`: one of the [test cards](https://pay-docs.adpromax.com/en/sandbox/test-cards), with or without spaces. Any other
  number is refused.
* `three_d_secure`, optional: for the 3-D Secure card, the answer to the simulated challenge (see
  the API reference).

```bash
curl https://pay-api.adpromax.com/v1/test_helpers/charges/ch_01k7d2j8m3n6b9v2c5x8z1a4qd/complete \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"card_number": "4242424242424242"}'
```

```js title="complete.mjs"
const charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
const path = `/v1/test_helpers/charges/${charge}/complete`
await fetch(`https://pay-api.adpromax.com${path}`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ card_number: '4242424242424242' }),
})
```

```ts title="complete.ts"
interface CompleteCharge {
  /** One of the test cards, with or without spaces. */
  card_number: string
}

const charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
const path = `/v1/test_helpers/charges/${charge}/complete`
const body: CompleteCharge = { card_number: '4242424242424242' }
await fetch(`https://pay-api.adpromax.com${path}`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
})
```

```python title="complete.py"
import os

import requests

charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd"
requests.post(
    f"https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    json={"card_number": "4242424242424242"},
)
```

```php title="complete.php"
<?php
$charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd';
$path = "/v1/test_helpers/charges/{$charge}/complete";
$ch = curl_init('https://pay-api.adpromax.com' . $path);
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode(['card_number' => '4242424242424242']),
    CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
```

```ruby title="complete.rb"
require 'json'
require 'net/http'

charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
uri = URI("https://pay-api.adpromax.com/v1/test_helpers/charges/#{charge}/complete")
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request.body = JSON.generate(card_number: '4242424242424242')

Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
```

```go title="complete.go"
package main

import (
	"log"
	"net/http"
	"os"
	"strings"
)

func main() {
	charge := "ch_01k7d2j8m3n6b9v2c5x8z1a4qd"
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/test_helpers/charges/"+charge+"/complete",
		strings.NewReader(`{"card_number": "4242424242424242"}`))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	res.Body.Close()
}
```

```java title="Complete.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Complete {
    public static void main(String[] args) throws Exception {
        String charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
        String body = """
            {"card_number": "4242424242424242"}
            """;
        HttpRequest request = HttpRequest.newBuilder(URI.create(
                "https://pay-api.adpromax.com/v1/test_helpers/charges/" + charge + "/complete"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.discarding());
    }
}
```

```csharp title="Complete.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

var charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
using var response = await http.PostAsJsonAsync(
    $"https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete",
    new { card_number = "4242424242424242" });
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
    reqwest::blocking::Client::new()
        .post(format!(
            "https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete"
        ))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&json!({ "card_number": "4242424242424242" }))
        .send()?;
    Ok(())
}
```

## Pay, expire, refuse or suspend a cash code

Four helpers act on a waiting cash attempt, as the payment network would. Each one is a `POST` with
no body, except `pay`:

| Helper                                            | What happens                                                                              |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `/v1/test_helpers/cash_vouchers/{charge}/pay`     | The code is paid. `amount`, optional, in centimes: pay another amount than the voucher's. |
| `/v1/test_helpers/cash_vouchers/{charge}/expire`  | The code reaches its deadline unpaid.                                                     |
| `/v1/test_helpers/cash_vouchers/{charge}/refuse`  | The network refuses the code.                                                             |
| `/v1/test_helpers/cash_vouchers/{charge}/suspend` | The code is put on hold; `pay` it afterwards.                                             |

What each outcome does to the payment is in [Test cash scenarios](https://pay-docs.adpromax.com/en/sandbox/cash-scenarios).

```bash
CH=ch_01k7d5f3a1s5d9f2g6h4j8k3nb
curl -X POST https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/$CH/pay \
  -u "$APX_SECRET_KEY:"

# or pay another amount: half of a 150.00 MAD payment
curl https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/$CH/pay \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"amount": 7500}'
```

```js title="cash.mjs"
const charge = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'
const helper = action =>
  `https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/${charge}/${action}`
const headers = {
  'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
  'Content-Type': 'application/json',
}

await fetch(helper('pay'), { method: 'POST', headers })
// or: helper('expire'), helper('refuse'), helper('suspend'),
// or pay another amount:
// fetch(helper('pay'), { method: 'POST', headers,
//   body: JSON.stringify({ amount: 7500 }) })
```

```ts title="cash.ts"
type CashAction = 'pay' | 'expire' | 'refuse' | 'suspend'

const charge = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'
const helper = (action: CashAction): string =>
  `https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/${charge}/${action}`
const headers = {
  'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
  'Content-Type': 'application/json',
}

await fetch(helper('pay'), { method: 'POST', headers })
// or: helper('expire'), helper('refuse'), helper('suspend'),
// or pay another amount:
// fetch(helper('pay'), { method: 'POST', headers,
//   body: JSON.stringify({ amount: 7500 }) })
```

```python title="cash.py"
import os

import requests

AUTH = (os.environ["APX_SECRET_KEY"], "")
charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb"

def cash_helper(action, body=None):
    url = f"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{charge}/{action}"
    return requests.post(url, auth=AUTH, json=body or {})

cash_helper("pay")
# or "expire", "refuse", "suspend"; another amount:
# cash_helper("pay", {"amount": 7500})
```

```php title="cash.php"
<?php
function cash_helper(string $charge, string $action, array $body = []): void
{
    $path = "/v1/test_helpers/cash_vouchers/{$charge}/{$action}";
    $ch = curl_init('https://pay-api.adpromax.com' . $path);
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode((object) $body),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    curl_exec($ch);
}

cash_helper('ch_01k7d5f3a1s5d9f2g6h4j8k3nb', 'pay');
// or 'expire', 'refuse', 'suspend'; another amount:
// cash_helper('ch_…', 'pay', ['amount' => 7500]);
```

```ruby title="cash.rb"
require 'json'
require 'net/http'

CHARGE = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'

def cash_helper(action, body = {})
  uri = URI("https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/#{CHARGE}/#{action}")
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
  request['Content-Type'] = 'application/json'
  request.body = JSON.generate(body)
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.request(request)
  end
end

cash_helper('pay')
# or 'expire', 'refuse', 'suspend'; another amount:
# cash_helper('pay', amount: 7500)
```

```go title="cash.go"
package main

import (
	"bytes"
	"encoding/json"
	"log"
	"net/http"
	"os"
)

const charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb"

func cashHelper(action string, body map[string]any) error {
	payload, err := json.Marshal(body)
	if err != nil {
		return err
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/"+charge+"/"+action,
		bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	return res.Body.Close()
}

func main() {
	if err := cashHelper("pay", map[string]any{}); err != nil {
		log.Fatal(err)
	}
	// or "expire", "refuse", "suspend"; another amount:
	// cashHelper("pay", map[string]any{"amount": 7500})
}
```

```java title="Cash.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Cash {
    static final String CHARGE = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

    static void cashHelper(String action, String body) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(
                "https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/"
                    + CHARGE + "/" + action))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.discarding());
    }

    public static void main(String[] args) throws Exception {
        cashHelper("pay", "{}");
        // or "expire", "refuse", "suspend"; another amount:
        // cashHelper("pay", "{\"amount\": 7500}");
    }
}
```

```csharp title="Cash.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

const string charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

Task<HttpResponseMessage> CashHelper(string action, object? body = null) =>
    http.PostAsJsonAsync(
        $"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{charge}/{action}",
        body ?? new { });

await CashHelper("pay");
// or "expire", "refuse", "suspend"; another amount:
// await CashHelper("pay", new { amount = 7500 });
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::{json, Value};

const CHARGE: &str = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

fn cash_helper(action: &str, body: Value) -> Result<(), Box<dyn std::error::Error>> {
    reqwest::blocking::Client::new()
        .post(format!(
            "https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{CHARGE}/{action}"
        ))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&body)
        .send()?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    cash_helper("pay", json!({}))?;
    // or "expire", "refuse", "suspend"; another amount:
    // cash_helper("pay", json!({ "amount": 7500 }))?;
    Ok(())
}
```

## A whole card payment, without a browser

A test of your integration can run a payment end to end: create a payment intent, confirm it for a
card, complete its attempt with a test card, then wait for `succeeded`. Each creation and
confirmation carries its own `Idempotency-Key`; a fresh one per test run.

```bash
API=https://pay-api.adpromax.com/v1
RUN=$(date +%s)
PI=$(curl -s $API/payment_intents -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: test-$RUN-create" \
  -d '{"amount": 50000, "currency": "mad",
       "payment_method_types": ["card"]}' | jq -r .id)

curl -s $API/payment_intents/$PI/confirm -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: test-$RUN-confirm" \
  -d '{"payment_method_type": "card",
       "return_url": "https://shop.example.com/done"}' > /dev/null

CH=$(curl -s $API/payment_intents/$PI -u "$APX_SECRET_KEY:" \
  | jq -r .latest_charge)
curl -s $API/test_helpers/charges/$CH/complete -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"card_number": "4242424242424242"}' > /dev/null

for i in 1 2 3 4 5 6 7 8 9 10; do
  STATUS=$(curl -s $API/payment_intents/$PI -u "$APX_SECRET_KEY:" \
    | jq -r .status)
  [ "$STATUS" = succeeded ] && break
  sleep 1
done
echo "$PI $STATUS"
```

```js title="card-payment.test.mjs"
import { randomUUID } from 'node:crypto'

const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()

async function call(method, path, body, key) {
  const headers = { Authorization: auth }
  if (body !== undefined)
    headers['Content-Type'] = 'application/json'
  if (key !== undefined)
    headers['Idempotency-Key'] = `${run}-${key}`
  const response = await fetch(`${api}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  const answer = await response.json()
  if (!response.ok)
    throw new Error(answer.error.message)
  return answer
}

const intent = await call('POST', '/payment_intents', {
  amount: 50000,
  currency: 'mad',
  payment_method_types: ['card'],
}, 'create')
await call('POST', `/payment_intents/${intent.id}/confirm`, {
  payment_method_type: 'card',
  return_url: 'https://shop.example.com/done',
}, 'confirm')
const charge = (await call('GET', `/payment_intents/${intent.id}`)).latest_charge
await call('POST', `/test_helpers/charges/${charge}/complete`, {
  card_number: '4242424242424242',
})

let status
for (let i = 0; i < 10 && status !== 'succeeded'; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  status = (await call('GET', `/payment_intents/${intent.id}`)).status
}
console.log(intent.id, status)
```

```ts title="card-payment.test.ts"
import { randomUUID } from 'node:crypto'

interface PaymentIntent {
  id: string
  status: string
  latest_charge: string | null
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()

async function call<T = PaymentIntent>(
  method: 'GET' | 'POST',
  path: string,
  body?: object,
  key?: string,
): Promise<T> {
  const headers: Record<string, string> = { Authorization: auth }
  if (body !== undefined)
    headers['Content-Type'] = 'application/json'
  if (key !== undefined)
    headers['Idempotency-Key'] = `${run}-${key}`
  const response = await fetch(`${api}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  if (!response.ok) {
    const { error } = (await response.json()) as ApiError
    throw new Error(`${error.code}: ${error.message}`)
  }
  return (await response.json()) as T
}

const intent = await call('POST', '/payment_intents', {
  amount: 50000,
  currency: 'mad',
  payment_method_types: ['card'],
}, 'create')
await call('POST', `/payment_intents/${intent.id}/confirm`, {
  payment_method_type: 'card',
  return_url: 'https://shop.example.com/done',
}, 'confirm')
const charge = (await call('GET', `/payment_intents/${intent.id}`)).latest_charge
await call<unknown>('POST', `/test_helpers/charges/${charge}/complete`, {
  card_number: '4242424242424242',
})

let status: string | undefined
for (let i = 0; i < 10 && status !== 'succeeded'; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  status = (await call('GET', `/payment_intents/${intent.id}`)).status
}
console.log(intent.id, status)
```

```python title="card_payment_test.py"
import os
import time
import uuid

import requests

API = "https://pay-api.adpromax.com/v1"
AUTH = (os.environ["APX_SECRET_KEY"], "")
RUN = uuid.uuid4().hex

def call(method, path, body=None, key=None):
    headers = {"Idempotency-Key": f"{RUN}-{key}"} if key else {}
    response = requests.request(
        method, f"{API}{path}", auth=AUTH, json=body, headers=headers
    )
    answer = response.json()
    response.raise_for_status()
    return answer

intent = call("POST", "/payment_intents", {
    "amount": 50000,
    "currency": "mad",
    "payment_method_types": ["card"],
}, "create")
call("POST", f"/payment_intents/{intent['id']}/confirm", {
    "payment_method_type": "card",
    "return_url": "https://shop.example.com/done",
}, "confirm")
charge = call("GET", f"/payment_intents/{intent['id']}")["latest_charge"]
call("POST", f"/test_helpers/charges/{charge}/complete", {
    "card_number": "4242424242424242",
})

status = None
for _ in range(10):
    time.sleep(1)
    status = call("GET", f"/payment_intents/{intent['id']}")["status"]
    if status == "succeeded":
        break
print(intent["id"], status)
```

```php title="card_payment_test.php"
<?php
function call(
    string $method,
    string $path,
    ?array $body = null,
    ?string $key = null
): array {
    $headers = ['Content-Type: application/json'];
    if ($key !== null) {
        $headers[] = "Idempotency-Key: {$key}";
    }
    $ch = curl_init('https://pay-api.adpromax.com/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $answer = json_decode(curl_exec($ch), true);
    return $answer;
}

$run = bin2hex(random_bytes(8));
$intent = call('POST', '/payment_intents', [
    'amount' => 50000,
    'currency' => 'mad',
    'payment_method_types' => ['card'],
], "{$run}-create");
call('POST', "/payment_intents/{$intent['id']}/confirm", [
    'payment_method_type' => 'card',
    'return_url' => 'https://shop.example.com/done',
], "{$run}-confirm");
$charge = call('GET', "/payment_intents/{$intent['id']}")['latest_charge'];
call('POST', "/test_helpers/charges/{$charge}/complete", [
    'card_number' => '4242424242424242',
]);

for ($i = 0, $status = null; $i < 10 && $status !== 'succeeded'; $i++) {
    sleep(1);
    $status = call('GET', "/payment_intents/{$intent['id']}")['status'];
}
echo $intent['id'], ' ', $status, PHP_EOL;
```

```ruby title="card_payment_test.rb"
require 'json'
require 'minitest/autorun'
require 'net/http'
require 'securerandom'

class CardPaymentTest < Minitest::Test
  API = 'https://pay-api.adpromax.com/v1'
  RUN = SecureRandom.uuid

  def api(method, path, body = nil, key = nil)
    uri = URI("#{API}#{path}")
    request = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
    request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
    request['Idempotency-Key'] = "#{RUN}-#{key}" if key
    if body
      request['Content-Type'] = 'application/json'
      request.body = JSON.generate(body)
    end
    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
      http.request(request)
    end
    answer = JSON.parse(response.body)
    raise answer.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

    answer
  end

  def test_a_card_payment_succeeds
    intent = api(:post, '/payment_intents', {
      amount: 50_000,
      currency: 'mad',
      payment_method_types: ['card']
    }, 'create')
    api(:post, "/payment_intents/#{intent['id']}/confirm", {
      payment_method_type: 'card',
      return_url: 'https://shop.example.com/done'
    }, 'confirm')
    charge = api(:get, "/payment_intents/#{intent['id']}")['latest_charge']
    api(:post, "/test_helpers/charges/#{charge}/complete",
        { card_number: '4242424242424242' })

    status = nil
    10.times do
      sleep 1
      status = api(:get, "/payment_intents/#{intent['id']}")['status']
      break if status == 'succeeded'
    end
    assert_equal 'succeeded', status
  end
end
```

```go title="card_payment_test.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"testing"
	"time"
)

const api = "https://pay-api.adpromax.com/v1"

type paymentIntent struct {
	ID           string `json:"id"`
	Status       string `json:"status"`
	LatestCharge string `json:"latest_charge"`
}

func call(t *testing.T, method, path string, body any, key string) paymentIntent {
	t.Helper()
	var payload io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			t.Fatal(err)
		}
		payload = bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, api+path, payload)
	if err != nil {
		t.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if key != "" {
		req.Header.Set("Idempotency-Key", key)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		t.Fatalf("%s %s: %s", method, path, res.Status)
	}
	var answer paymentIntent
	if err := json.NewDecoder(res.Body).Decode(&answer); err != nil {
		t.Fatal(err)
	}
	return answer
}

func TestCardPayment(t *testing.T) {
	run := fmt.Sprint(time.Now().UnixNano())
	intent := call(t, "POST", "/payment_intents", map[string]any{
		"amount":               50000,
		"currency":             "mad",
		"payment_method_types": []string{"card"},
	}, run+"-create")
	path := "/payment_intents/" + intent.ID
	call(t, "POST", path+"/confirm", map[string]any{
		"payment_method_type": "card",
		"return_url":          "https://shop.example.com/done",
	}, run+"-confirm")
	charge := call(t, "GET", path, nil, "").LatestCharge
	call(t, "POST", "/test_helpers/charges/"+charge+"/complete",
		map[string]any{"card_number": "4242424242424242"}, "")

	status := ""
	for i := 0; i < 10 && status != "succeeded"; i++ {
		time.Sleep(time.Second)
		status = call(t, "GET", path, nil, "").Status
	}
	if status != "succeeded" {
		t.Fatalf("%s is %q, want succeeded", intent.ID, status)
	}
}
```

```java title="CardPaymentTest.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class CardPaymentTest {
    static final String API = "https://pay-api.adpromax.com/v1";
    static final String RUN = UUID.randomUUID().toString();
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String call(String method, String path, String body, String key)
            throws Exception {
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .method(method, body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body));
        if (key != null) {
            request.header("Idempotency-Key", RUN + "-" + key);
        }
        HttpResponse<String> response = HTTP.send(
            request.build(), HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 400) {
            throw new IllegalStateException(response.body());
        }
        return response.body();
    }

    // No JSON library here: a small regex reads the first field of that name.
    static String field(String json, String name) {
        Matcher match = Pattern
            .compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"")
            .matcher(json);
        if (!match.find()) {
            throw new IllegalStateException("no " + name + " in " + json);
        }
        return match.group(1);
    }

    public static void main(String[] args) throws Exception {
        String intent = field(call("POST", "/payment_intents", """
            {
              "amount": 50000,
              "currency": "mad",
              "payment_method_types": ["card"]
            }
            """, "create"), "id");
        String path = "/payment_intents/" + intent;
        call("POST", path + "/confirm", """
            {
              "payment_method_type": "card",
              "return_url": "https://shop.example.com/done"
            }
            """, "confirm");
        String charge = field(call("GET", path, null, null), "latest_charge");
        call("POST", "/test_helpers/charges/" + charge + "/complete", """
            {"card_number": "4242424242424242"}
            """, null);

        String status = null;
        for (int i = 0; i < 10 && !"succeeded".equals(status); i++) {
            Thread.sleep(1000);
            status = field(call("GET", path, null, null), "status");
        }
        if (!"succeeded".equals(status)) {
            throw new AssertionError(intent + " is " + status);
        }
        System.out.println(intent + " " + status);
    }
}
```

```csharp title="CardPaymentTest.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));
var api = "https://pay-api.adpromax.com/v1";
var run = Guid.NewGuid().ToString();

async Task<PaymentIntent> Call(
    HttpMethod method, string path, object? body = null, string? key = null)
{
    using var request = new HttpRequestMessage(method, api + path);
    if (body is not null)
        request.Content = JsonContent.Create(body);
    if (key is not null)
        request.Headers.Add("Idempotency-Key", $"{run}-{key}");
    using var response = await http.SendAsync(request);
    if (!response.IsSuccessStatusCode)
    {
        var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
        throw new InvalidOperationException(failure?.Error.Message);
    }
    return (await response.Content.ReadFromJsonAsync<PaymentIntent>(json))!;
}

var intent = await Call(HttpMethod.Post, "/payment_intents", new
{
    amount = 50000,
    currency = "mad",
    payment_method_types = new[] { "card" },
}, "create");
var path = $"/payment_intents/{intent.Id}";
await Call(HttpMethod.Post, $"{path}/confirm", new
{
    payment_method_type = "card",
    return_url = "https://shop.example.com/done",
}, "confirm");
var charge = (await Call(HttpMethod.Get, path)).LatestCharge;
await Call(HttpMethod.Post, $"/test_helpers/charges/{charge}/complete",
    new { card_number = "4242424242424242" });

string? status = null;
for (var i = 0; i < 10 && status != "succeeded"; i++)
{
    await Task.Delay(1000);
    status = (await Call(HttpMethod.Get, path)).Status;
}
if (status != "succeeded")
    throw new InvalidOperationException($"{intent.Id} is {status}");
Console.WriteLine($"{intent.Id} {status}");

record PaymentIntent(string Id, string Status, string? LatestCharge);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
// Run it with `cargo test`.
fn main() {}

#[cfg(test)]
mod tests {
    use std::thread;
    use std::time::{Duration, SystemTime, UNIX_EPOCH};

    use reqwest::{blocking::Client, Method};
    use serde_json::{json, Value};

    const API: &str = "https://pay-api.adpromax.com/v1";
    type Error = Box<dyn std::error::Error>;

    fn call(
        method: Method,
        path: &str,
        body: Option<Value>,
        key: Option<String>,
    ) -> Result<Value, Error> {
        let mut request = Client::new()
            .request(method, format!("{API}{path}"))
            .bearer_auth(std::env::var("APX_SECRET_KEY")?);
        if let Some(body) = body {
            request = request.json(&body);
        }
        if let Some(key) = key {
            request = request.header("Idempotency-Key", key);
        }
        let response = request.send()?;
        let failed = !response.status().is_success();
        let answer: Value = response.json()?;
        if failed {
            let message = answer["error"]["message"].as_str();
            return Err(message.unwrap_or("request failed").into());
        }
        Ok(answer)
    }

    #[test]
    fn a_card_payment_succeeds() -> Result<(), Error> {
        let run = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        let intent = call(
            Method::POST,
            "/payment_intents",
            Some(json!({
                "amount": 50000,
                "currency": "mad",
                "payment_method_types": ["card"]
            })),
            Some(format!("{run}-create")),
        )?;
        let id = intent["id"].as_str().ok_or("no id")?;
        let path = format!("/payment_intents/{id}");
        call(
            Method::POST,
            &format!("{path}/confirm"),
            Some(json!({
                "payment_method_type": "card",
                "return_url": "https://shop.example.com/done"
            })),
            Some(format!("{run}-confirm")),
        )?;
        let waiting = call(Method::GET, &path, None, None)?;
        let charge = waiting["latest_charge"].as_str().ok_or("no charge")?;
        call(
            Method::POST,
            &format!("/test_helpers/charges/{charge}/complete"),
            Some(json!({ "card_number": "4242424242424242" })),
            None,
        )?;

        let mut status = String::new();
        for _ in 0..10 {
            thread::sleep(Duration::from_secs(1));
            let current = call(Method::GET, &path, None, None)?;
            status = current["status"].as_str().unwrap_or_default().to_owned();
            if status == "succeeded" {
                break;
            }
        }
        assert_eq!(status, "succeeded", "{id}");
        Ok(())
    }
}
```

## Elsewhere in test mode

* The voucher page your customer sees carries **Simulate payment** and the other cash outcomes.
* On the dashboard, a test payment's page has a **test helpers** card with the same actions.
* Neither exists in live mode, where only a real payment moves a payment.
