# Tester votre intégration

Chaque parcours possible de vos clients, chaque échec et le traitement de vos webhooks, testés de bout en bout dans le bac à sable avant la production.

Le mode test exécute le même code que la production : la même validation, les mêmes statuts, les
mêmes événements et les mêmes webhooks (voir
[Bac à sable et production : les différences](https://pay-docs.adpromax.com/fr/sandbox/differences)). Ce qui marche ici marche en
production. Testez d’abord le parcours de vos clients, puis chaque façon dont un paiement peut
échouer, puis ce que votre serveur fait des webhooks qu’il reçoit.

## Avant de commencer

* **Une clé secrète de test** dans `APX_SECRET_KEY` : voir [Vos clés d’API](https://pay-docs.adpromax.com/fr/get-started/api-keys).
* **Un endpoint de webhook en mode test**, avec son secret dans `APX_WEBHOOK_SECRET`. Pour recevoir
  les événements sur votre propre ordinateur, exposez votre serveur avec un tunnel HTTPS : voir le
  [démarrage rapide carte](https://pay-docs.adpromax.com/fr/get-started/quickstart-card#5-créer-un-endpoint-de-webhook).
* **Le tableau de bord, en mode test**, pour suivre ce qui se passe :
  * **Développeurs → Journaux de requêtes** liste chaque appel à l’API avec son statut et son
    `Request-Id` ;
  * **Développeurs → Événements** liste chaque événement, avec ses livraisons à chaque endpoint ;
  * la page d’un endpoint, sous **Développeurs → Webhooks**, montre chaque livraison et vos réponses.

## Paiements par carte

Payez chaque scénario sur la page hébergée avec la [carte de test](https://pay-docs.adpromax.com/fr/sandbox/test-cards) indiquée, ou
sans navigateur avec les [assistants de test](https://pay-docs.adpromax.com/fr/sandbox/test-helpers).

- **Succès** (`4242 4242 4242 4242`): Honore la commande une seule fois, sur `payment_intent.succeeded`.

- **Refusée** (`4000 0000 0000 0002`): Indique que le paiement a échoué et laisse votre client réessayer. La commande reste ouverte.

- **3-D Secure** (`4000 0000 0000 3220`, une fois avec **Approuver** et une fois avec **Échouer**): Approuvé : comme un succès. Échoué : comme un refus, avec `authentication_failed`.

- **Confirmation lente** (`4000 0000 0000 0077`): Votre client revient sur `success_url` avant la confirmation du paiement : affichez « Nous confirmons votre paiement », et livrez sur le webhook, environ 60 secondes plus tard.

- **Notifications répétées** (`4000 0000 0000 0085`): Une seule exécution : un e-mail, un mouvement de stock.

- **Confirmation perdue** (`4000 0000 0000 0093`): Ne livre jamais : le paiement ne réussit pas, et sa tentative expire au bout d’environ 30 minutes.

## Paiements en espèces

Agissez sur chaque code de test avec les boutons de la page du bon, ou avec les
[assistants de test](https://pay-docs.adpromax.com/fr/sandbox/test-helpers). Ce que fait chaque issue est dans
[Scénarios de test en espèces](https://pay-docs.adpromax.com/fr/sandbox/cash-scenarios).

- **Payé** (**Simuler le paiement**, ou `pay`): Honore la commande sur `payment_intent.succeeded`.

- **Expiré** (`expire`): Libère la commande ou le stock, et laisse votre client obtenir un nouveau code.

- **Payé d’un autre montant** (`pay` avec un autre `amount`): Ne livre rien : le paiement reçoit une `review` et rien n’est crédité. Elle attend.

- **Payé après expiration** (`expire`, puis `pay`): De même : une `review`, rien de crédité, rien de livré.

- **Moins de 100,00 MAD** (Un paiement en espèces de `5000`): Traite le refus, `amount_too_small` : ne proposez que la carte pour les petits montants.

## Remboursements

- **Remboursement partiel par carte** (Remboursez une partie d’un paiement par carte réussi): Enregistre ce qui a été remboursé ; l’`amount_refunded` de l’intention de paiement augmente.

- **Remboursement total par carte** (Remboursez le reste): Marque la commande remboursée quand le remboursement réussit.

- **Remboursement en espèces** (Essayez de rembourser un paiement en espèces): Traite le refus, `refund_unsupported_payment_method` : votre propre procédure pour les espèces prend le relais. Voir [Remboursements](https://pay-docs.adpromax.com/fr/payments/refunds).

## Webhooks

- **Une mauvaise signature** (Envoyez à votre gestionnaire une livraison au corps modifié ([Tester votre gestionnaire](https://pay-docs.adpromax.com/fr/webhooks/signatures#tester-votre-gestionnaire))): Répond `400` et ne fait rien d’autre.

- **Le même événement deux fois** (**Renvoyez** une livraison depuis le tableau de bord): Le traite une seule fois : l’`id` de l’événement est déjà connu. Voir la [déduplication](https://pay-docs.adpromax.com/fr/webhooks/retries).

- **Votre endpoint est en panne** (Arrêtez votre serveur, payez, redémarrez-le, puis **Renvoyer** ou attendez la nouvelle tentative): Honore la commande quand la livraison passe.

## Requêtes

- **La même création deux fois** (Envoyez une création deux fois avec la même `Idempotency-Key`): Un seul paiement ; la deuxième réponse porte `Idempotent-Replayed: true`. Voir [Idempotence](https://pay-docs.adpromax.com/fr/api-reference/idempotency).

- **Trop de requêtes** (Dans vos propres tests, répondez `429` à votre client): Attend le délai `Retry-After`, puis réessaie avec la même clé. Voir [Limites de débit](https://pay-docs.adpromax.com/fr/api-reference/rate-limits).

- **Une mauvaise clé** (Une clé de test à laquelle il manque un caractère): Traite `401`, `api_key_invalid` : une alerte pour vous, jamais une nouvelle tentative.

## Un test automatisé : un code espèces qui expire

Votre propre suite de tests peut exécuter chacun de ces scénarios contre le bac à sable. Celui-ci
crée un paiement en espèces, le confirme avec le nom et la ville de votre client, fait expirer son
code avec l’assistant de test, puis attend que le paiement revienne en `requires_payment_method` avec
`voucher_expired`.

Chaque création et chaque confirmation porte sa propre `Idempotency-Key`, renouvelée à chaque
exécution. La réponse de la confirmation nomme la tentative dans `latest_charge` : l’assistant agit
sur elle.

```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": 15000, "currency": "mad",
       "payment_method_types": ["cash"]}' | jq -r .id)

CH=$(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": "cash",
       "payer": {"name": "Salma Idrissi", "city": "Casablanca"}}' \
  | jq -r .latest_charge)

curl -s -X POST $API/test_helpers/cash_vouchers/$CH/expire \
  -u "$APX_SECRET_KEY:" > /dev/null

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

```js title="cash-expiry.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: 15000,
  currency: 'mad',
  payment_method_types: ['cash'],
}, 'create')
const confirmed = await call('POST', `/payment_intents/${intent.id}/confirm`, {
  payment_method_type: 'cash',
  payer: { name: 'Salma Idrissi', city: 'Casablanca' },
}, 'confirm')
const charge = confirmed.latest_charge
await call('POST', `/test_helpers/cash_vouchers/${charge}/expire`)

let latest
for (let i = 0; i < 10; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  latest = await call('GET', `/payment_intents/${intent.id}`)
  if (latest.status === 'requires_payment_method')
    break
}
// Expected: requires_payment_method voucher_expired
console.log(intent.id, latest.status, latest.last_payment_error?.code)
```

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

interface PaymentIntent {
  id: string
  status: string
  latest_charge: string | null
  last_payment_error: { code: string, message: 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>(
  method: string,
  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<PaymentIntent>('POST', '/payment_intents', {
  amount: 15000,
  currency: 'mad',
  payment_method_types: ['cash'],
}, 'create')
const confirm = `/payment_intents/${intent.id}/confirm`
const confirmed = await call<PaymentIntent>('POST', confirm, {
  payment_method_type: 'cash',
  payer: { name: 'Salma Idrissi', city: 'Casablanca' },
}, 'confirm')
const charge = confirmed.latest_charge
await call('POST', `/test_helpers/cash_vouchers/${charge}/expire`)

let latest = confirmed
for (let i = 0; i < 10; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  latest = await call<PaymentIntent>('GET', `/payment_intents/${intent.id}`)
  if (latest.status === 'requires_payment_method')
    break
}
// Expected: requires_payment_method voucher_expired
console.log(intent.id, latest.status, latest.last_payment_error?.code)
```

```python title="cash_expiry_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": 15000,
    "currency": "mad",
    "payment_method_types": ["cash"],
}, "create")
confirmed = call("POST", f"/payment_intents/{intent['id']}/confirm", {
    "payment_method_type": "cash",
    "payer": {"name": "Salma Idrissi", "city": "Casablanca"},
}, "confirm")
charge = confirmed["latest_charge"]
call("POST", f"/test_helpers/cash_vouchers/{charge}/expire")

latest = None
for _ in range(10):
    time.sleep(1)
    latest = call("GET", f"/payment_intents/{intent['id']}")
    if latest["status"] == "requires_payment_method":
        break
# Expected: requires_payment_method voucher_expired
error = latest["last_payment_error"] or {}
print(intent["id"], latest["status"], error.get("code"))
```

```php title="cash_expiry_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' => 15000,
    'currency' => 'mad',
    'payment_method_types' => ['cash'],
], "{$run}-create");
$confirmed = call('POST', "/payment_intents/{$intent['id']}/confirm", [
    'payment_method_type' => 'cash',
    'payer' => ['name' => 'Salma Idrissi', 'city' => 'Casablanca'],
], "{$run}-confirm");
$charge = $confirmed['latest_charge'];
call('POST', "/test_helpers/cash_vouchers/{$charge}/expire");

for ($i = 0; $i < 10; $i++) {
    sleep(1);
    $latest = call('GET', "/payment_intents/{$intent['id']}");
    if ($latest['status'] === 'requires_payment_method') {
        break;
    }
}
// Expected: requires_payment_method voucher_expired
echo $intent['id'], ' ', $latest['status'], ' ',
    $latest['last_payment_error']['code'] ?? '', PHP_EOL;
```

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

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

  def call(verb, path, body = nil, key = nil)
    uri = URI("#{API}#{path}")
    request = Net::HTTP.const_get(verb).new(uri)
    request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
    request['Content-Type'] = 'application/json'
    request['Idempotency-Key'] = "#{RUN}-#{key}" if key
    request.body = JSON.generate(body) if body
    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_an_expired_code_reopens_the_payment
    intent = call(:Post, '/payment_intents', {
      amount: 15000,
      currency: 'mad',
      payment_method_types: ['cash']
    }, 'create')
    confirmed = call(:Post, "/payment_intents/#{intent['id']}/confirm", {
      payment_method_type: 'cash',
      payer: { name: 'Salma Idrissi', city: 'Casablanca' }
    }, 'confirm')
    charge = confirmed['latest_charge']
    call(:Post, "/test_helpers/cash_vouchers/#{charge}/expire")

    latest = nil
    10.times do
      sleep 1
      latest = call(:Get, "/payment_intents/#{intent['id']}")
      break if latest['status'] == 'requires_payment_method'
    end
    assert_equal 'requires_payment_method', latest['status']
    assert_equal 'voucher_expired', latest.dig('last_payment_error', 'code')
  end
end
```

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

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"os"
	"testing"
	"time"
)

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

// A fresh run, so that every run's Idempotency-Key is new.
var run = rand.Text()

type paymentError struct {
	Code string `json:"code"`
}

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

func call(
	t *testing.T, method, path string, body any, key string,
) paymentIntent {
	t.Helper()
	var payload bytes.Buffer
	if body != nil {
		json.NewEncoder(&payload).Encode(body)
	}
	req, err := http.NewRequest(method, api+path, &payload)
	if err != nil {
		t.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	if key != "" {
		req.Header.Set("Idempotency-Key", run+"-"+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
	json.NewDecoder(res.Body).Decode(&answer)
	return answer
}

func TestAnExpiredCodeReopensThePayment(t *testing.T) {
	intent := call(t, http.MethodPost, "/payment_intents", map[string]any{
		"amount":               15000,
		"currency":             "mad",
		"payment_method_types": []string{"cash"},
	}, "create")
	payer := map[string]string{"name": "Salma Idrissi", "city": "Casablanca"}
	confirmed := call(t, http.MethodPost,
		"/payment_intents/"+intent.ID+"/confirm", map[string]any{
			"payment_method_type": "cash",
			"payer":               payer,
		}, "confirm")
	expire := "/test_helpers/cash_vouchers/" + confirmed.LatestCharge + "/expire"
	call(t, http.MethodPost, expire, nil, "")

	var latest paymentIntent
	for i := 0; i < 10; i++ {
		time.Sleep(time.Second)
		latest = call(t, http.MethodGet, "/payment_intents/"+intent.ID, nil, "")
		if latest.Status == "requires_payment_method" {
			break
		}
	}
	code := ""
	if latest.LastPaymentError != nil {
		code = latest.LastPaymentError.Code
	}
	if latest.Status != "requires_payment_method" || code != "voucher_expired" {
		t.Fatalf("%s: %s %s", intent.ID, latest.Status, code)
	}
}
```

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

public class CashExpiryTest {
    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
                ? BodyPublishers.noBody() : 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();
    }

    // The JDK has no JSON parser: a small regex reads one text field, the
    // first with that name after `after`.
    static String field(String json, String after, String name) {
        Matcher match = Pattern
            .compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"")
            .matcher(json);
        int from = Math.max(json.indexOf(after), 0);
        return match.find(from) ? match.group(1) : "";
    }

    public static void main(String[] args) throws Exception {
        String created = call("POST", "/payment_intents", """
            {
              "amount": 15000,
              "currency": "mad",
              "payment_method_types": ["cash"]
            }
            """, "create");
        String intent = field(created, "", "id");
        String confirm = "/payment_intents/" + intent + "/confirm";
        String confirmed = call("POST", confirm, """
            {
              "payment_method_type": "cash",
              "payer": { "name": "Salma Idrissi", "city": "Casablanca" }
            }
            """, "confirm");
        String charge = field(confirmed, "", "latest_charge");
        String expire = "/test_helpers/cash_vouchers/" + charge + "/expire";
        call("POST", expire, null, null);

        String status = "";
        String code = "";
        for (int i = 0; i < 10; i++) {
            Thread.sleep(1000);
            String latest = call("GET", "/payment_intents/" + intent, null, null);
            status = field(latest, "", "status");
            code = field(latest, "\"last_payment_error\"", "code");
            if (status.equals("requires_payment_method")) {
                break;
            }
        }
        // Expected: requires_payment_method voucher_expired
        boolean reopened = status.equals("requires_payment_method");
        if (!reopened || !code.equals("voucher_expired")) {
            throw new AssertionError(intent + " " + status + " " + code);
        }
        System.out.println(intent + " " + status + " " + code);
    }
}
```

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

const string Api = "https://pay-api.adpromax.com/v1";
var run = Guid.NewGuid().ToString();
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

async Task<JsonElement> 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);
    var answer = await response.Content.ReadFromJsonAsync<JsonElement>();
    if (!response.IsSuccessStatusCode)
        throw new InvalidOperationException(
            answer.GetProperty("error").GetProperty("message").GetString());
    return answer;
}

var intent = await Call(HttpMethod.Post, "/payment_intents", new
{
    amount = 15000,
    currency = "mad",
    payment_method_types = new[] { "cash" },
}, "create");
var id = intent.GetProperty("id").GetString();
var confirmed = await Call(HttpMethod.Post, $"/payment_intents/{id}/confirm", new
{
    payment_method_type = "cash",
    payer = new { name = "Salma Idrissi", city = "Casablanca" },
}, "confirm");
var charge = confirmed.GetProperty("latest_charge").GetString();
await Call(HttpMethod.Post, $"/test_helpers/cash_vouchers/{charge}/expire");

var latest = confirmed;
for (var i = 0; i < 10; i++)
{
    await Task.Delay(1000);
    latest = await Call(HttpMethod.Get, $"/payment_intents/{id}");
    if (latest.GetProperty("status").GetString() == "requires_payment_method")
        break;
}
var status = latest.GetProperty("status").GetString();
var error = latest.GetProperty("last_payment_error");
var code = error.ValueKind == JsonValueKind.Object
    ? error.GetProperty("code").GetString()
    : null;
// Expected: requires_payment_method voucher_expired
if (status != "requires_payment_method" || code != "voucher_expired")
    throw new InvalidOperationException($"{id} {status} {code}");
Console.WriteLine($"{id} {status} {code}");
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
//             uuid = { version = "1", features = ["v4"] }
// Run it with `cargo test`.
use std::{thread::sleep, time::Duration};

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

const API: &str = "https://pay-api.adpromax.com/v1";

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

fn main() {}

#[test]
fn an_expired_code_reopens_the_payment() {
    // A fresh run, so that every run's Idempotency-Key is new.
    let run = uuid::Uuid::new_v4();
    let intent = call(
        Method::POST,
        "/payment_intents",
        Some(json!({
            "amount": 15000,
            "currency": "mad",
            "payment_method_types": ["cash"]
        })),
        Some(format!("{run}-create")),
    );
    let id = intent["id"].as_str().unwrap();
    let confirmed = call(
        Method::POST,
        &format!("/payment_intents/{id}/confirm"),
        Some(json!({
            "payment_method_type": "cash",
            "payer": { "name": "Salma Idrissi", "city": "Casablanca" }
        })),
        Some(format!("{run}-confirm")),
    );
    let charge = confirmed["latest_charge"].as_str().unwrap();
    let expire = format!("/test_helpers/cash_vouchers/{charge}/expire");
    call(Method::POST, &expire, None, None);

    let mut latest = confirmed.clone();
    for _ in 0..10 {
        sleep(Duration::from_secs(1));
        latest = call(Method::GET, &format!("/payment_intents/{id}"), None, None);
        if latest["status"] == "requires_payment_method" {
            break;
        }
    }
    assert_eq!(latest["status"], "requires_payment_method");
    assert_eq!(latest["last_payment_error"]["code"], "voucher_expired");
}
```

## Ce que voient vos clients

Vos clients paient sur nos pages, le plus souvent sur un téléphone. Regardez-les comme eux :

* Ouvrez l’`url` d’une session de paiement sur un téléphone, en français, en anglais et en arabe (la
  page suit la langue du navigateur). Payez par carte, puis demandez un code espèces.
* Sur la page du bon, imprimez le bon et partagez-le : le code, le montant à payer et l’échéance
  doivent se lire clairement.
* Quittez la page pour votre boutique, puis rouvrez l’`url` de la session : un client parti peut
  revenir sur une session encore ouverte.

## Laissez le tableau de bord vous aider

* La carte **Intégrer** de l’accueil se coche toute seule à mesure qu’arrivent vos paiements de test,
  vos webhooks et vos codes espèces.
* En mode test, *Générer des paiements d’exemple* sur l’accueil remplit les écrans de paiements et de
  clients d’exemple, pour voir ce que montre chaque page.
* *Supprimer toutes les données de test*, sous **Paramètres → Organisation**, vous fait repartir d’un
  bac à sable vide. Vos clés et votre équipe sont gardées.

## Et ensuite

Quand chaque scénario de cette page passe, lisez la
[liste de contrôle avant la production](https://pay-docs.adpromax.com/fr/get-started/go-live).
