Bien démarrer

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.

Ouvrir en Markdown
Sur cette page
  1. Avant de commencer
  2. Paiements par carte
  3. Paiements en espèces
  4. Remboursements
  5. Webhooks
  6. Requêtes
  7. Un test automatisé : un code espèces qui expire
  8. Ce que voient vos clients
  9. Laissez le tableau de bord vous aider
  10. Et ensuite

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). 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.
  • 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.
  • 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 indiquée, ou sans navigateur avec les assistants de test.

ScénarioCommentCe que fait votre intégration
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. Ce que fait chaque issue est dans Scénarios de test en espèces.

ScénarioCommentCe que fait votre intégration
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

ScénarioCommentCe que fait votre intégration
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.

Webhooks

ScénarioCommentCe que fait votre intégration
Une mauvaise signature

Envoyez à votre gestionnaire une livraison au corps modifié (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.

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

ScénarioCommentCe que fait votre intégration
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.

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.

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.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
cash-expiry.test.mjscash-expiry.test.tscash_expiry_test.pycash_expiry_test.phpcash_expiry_test.rbcash_expiry_test.goCashExpiryTest.javaCashExpiryTest.csmain.rs
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)"
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)
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)
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
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;
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
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)
	}
}
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);
    }
}
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}");
// 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.