# Paiements par carte

Le déroulement d’un paiement par carte, 3-D Secure, les refus et les nouvelles tentatives, et comment envoyer votre client sur la page carte depuis votre propre page.

Votre client saisit sa carte sur la **page carte sécurisée de notre partenaire carte**, jamais sur
votre site et jamais sur le nôtre. Vous ne voyez, n’envoyez ni ne stockez jamais un numéro de carte,
et l’API refuse tout texte qui y ressemble. En mode test, cette page est notre simulation marquée
TEST, qui n’accepte que les [cartes de test](https://pay-docs.adpromax.com/fr/sandbox/test-cards).

Il y a deux façons d’y amener votre client :

* **La page de paiement hébergée** (recommandée) : notre page propose la carte, et les espèces si
  vous le voulez, et fait pour vous chaque étape ci-dessous. Voir
  [Page de paiement hébergée](https://pay-docs.adpromax.com/fr/payments/hosted-checkout).
* **Votre propre page** : vous confirmez vous-même l’intention de paiement et envoyez le navigateur
  de votre client sur la page carte, comme le montre cette page.

Un formulaire de carte intégré, sur votre propre page, est prévu pour plus tard. Nous l’annoncerons
dans le [journal des modifications](https://pay-docs.adpromax.com/fr/changelog).

## Le déroulement d’un paiement par carte

1. Une intention de paiement est confirmée pour une carte : une **tentative** (`ch_…`) commence, et
   l’intention de paiement passe en `requires_action`.
2. Le navigateur de votre client arrive sur la page carte, et il saisit sa carte.
3. Si sa banque le demande, il confirme avec **3-D Secure** sur une page de sa banque. Vous n’avez
   rien à faire : cela se passe sur la page carte. Une vérification échouée termine la tentative
   avec `authentication_failed`.
4. Le partenaire carte nous confirme le paiement. L’intention de paiement devient `succeeded` et vous
   recevez `payment_intent.succeeded`.

Une page carte reste utilisable environ **30 minutes** ; ensuite la tentative finit `expired` et
l’intention de paiement revient à `requires_payment_method`. Un paiement par carte vaut au moins
**1,00 MAD** (`100`).

## Depuis votre propre page

Créez une intention de paiement pour une carte, puis confirmez-la avec :

* `payment_method_type` : `card` ;
* `return_url` : là où votre client revient après la page carte, en `https`. Mettez-y votre propre
  référence de commande, comme `https://shop.example.com/orders/1042/return` : le retour ne porte
  aucune preuve de paiement.

Chacune des deux requêtes a besoin de sa propre `Idempotency-Key`. La réponse est l’intention de
paiement, en `requires_action`, avec un `next_action` de type `form_post` : une adresse (`url`), une
méthode (`method`) et des champs (`fields`) que le navigateur de votre client doit envoyer pour
arriver sur la page carte. Les champs changent d’un paiement à l’autre : envoyez-les tous, tels
quels.

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

curl https://pay-api.adpromax.com/v1/payment_intents/$PI/confirm \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-confirm" \
  -d '{"payment_method_type": "card",
       "return_url": "https://shop.example.com/orders/1042/return"}'
```

```js title="card.mjs"
const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`

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

const intent = await post('/payment_intents', 'order-1042-create', {
  amount: 25000,
  currency: 'mad',
  payment_method_types: ['card'],
})
const confirmed = await post(`/payment_intents/${intent.id}/confirm`,
  'order-1042-confirm', {
    payment_method_type: 'card',
    return_url: 'https://shop.example.com/orders/1042/return',
  })
const formPost = confirmed.next_action.form_post
```

```ts title="card.ts"
interface FormPost {
  url: string
  method: string
  fields: Record<string, string>
}

interface PaymentIntent {
  id: string
  object: 'payment_intent'
  status: string
  next_action: { type: 'form_post', form_post: FormPost } | 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}`

async function post(path: string, key: string, body: object): Promise<PaymentIntent> {
  const response = await fetch(`${api}${path}`, {
    method: 'POST',
    headers: {
      'Authorization': auth,
      'Content-Type': 'application/json',
      'Idempotency-Key': key,
    },
    body: 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 PaymentIntent
}

const intent = await post('/payment_intents', 'order-1042-create', {
  amount: 25000,
  currency: 'mad',
  payment_method_types: ['card'],
})
const confirmed = await post(`/payment_intents/${intent.id}/confirm`,
  'order-1042-confirm', {
    payment_method_type: 'card',
    return_url: 'https://shop.example.com/orders/1042/return',
  })
const formPost = confirmed.next_action?.form_post
```

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

import requests

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

def post(path, key, body):
    response = requests.post(
        f"{API}{path}", auth=AUTH, json=body,
        headers={"Idempotency-Key": key},
    )
    answer = response.json()
    response.raise_for_status()
    return answer

intent = post("/payment_intents", "order-1042-create", {
    "amount": 25000,
    "currency": "mad",
    "payment_method_types": ["card"],
})
confirmed = post(f"/payment_intents/{intent['id']}/confirm",
                 "order-1042-confirm", {
                     "payment_method_type": "card",
                     "return_url": "https://shop.example.com/orders/1042/return",
                 })
form_post = confirmed["next_action"]["form_post"]
```

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

$intent = post('/payment_intents', 'order-1042-create', [
    'amount' => 25000,
    'currency' => 'mad',
    'payment_method_types' => ['card'],
]);
$confirmed = post("/payment_intents/{$intent['id']}/confirm",
    'order-1042-confirm', [
        'payment_method_type' => 'card',
        'return_url' => 'https://shop.example.com/orders/1042/return',
    ]);
$formPost = $confirmed['next_action']['form_post'];
```

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

API = 'https://pay-api.adpromax.com/v1'

def post(path, key, body)
  uri = URI("#{API}#{path}")
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
  request['Content-Type'] = 'application/json'
  request['Idempotency-Key'] = key
  request.body = JSON.generate(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

intent = post('/payment_intents', 'order-1042-create',
              amount: 25000, currency: 'mad', payment_method_types: ['card'])
confirmed = post("/payment_intents/#{intent['id']}/confirm", 'order-1042-confirm',
                 payment_method_type: 'card',
                 return_url: 'https://shop.example.com/orders/1042/return')
form_post = confirmed.dig('next_action', 'form_post')
```

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

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

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

type FormPost struct {
	URL    string            `json:"url"`
	Method string            `json:"method"`
	Fields map[string]string `json:"fields"`
}

type PaymentIntent struct {
	ID         string `json:"id"`
	Status     string `json:"status"`
	NextAction struct {
		FormPost FormPost `json:"form_post"`
	} `json:"next_action"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func post(path, key string, body any) (*PaymentIntent, error) {
	payload, _ := json.Marshal(body)
	req, err := http.NewRequest(http.MethodPost, api+path, bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", key)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		var apiErr APIError
		json.NewDecoder(res.Body).Decode(&apiErr)
		return nil, errors.New(apiErr.Error.Message)
	}
	var intent PaymentIntent
	return &intent, json.NewDecoder(res.Body).Decode(&intent)
}

func main() {
	intent, err := post("/payment_intents", "order-1042-create", map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card"},
	})
	if err != nil {
		log.Fatal(err)
	}
	confirmed, err := post("/payment_intents/"+intent.ID+"/confirm",
		"order-1042-confirm", map[string]any{
			"payment_method_type": "card",
			"return_url":          "https://shop.example.com/orders/1042/return",
		})
	if err != nil {
		log.Fatal(err)
	}
	formPost := confirmed.NextAction.FormPost
	fmt.Println(formPost.Method, formPost.URL)
}
```

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

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

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

    public static void main(String[] args) throws Exception {
        String intent = post("/payment_intents", "order-1042-create", """
            {"amount": 25000, "currency": "mad", "payment_method_types": ["card"]}
            """);
        // The JDK has no JSON parser: a regex reads the id here.
        var id = Pattern.compile("\"id\"\\s*:\\s*\"(pi_[^\"]+)\"").matcher(intent);
        if (!id.find()) {
            throw new IllegalStateException("No id: " + intent);
        }
        String confirmed = post("/payment_intents/" + id.group(1) + "/confirm",
            "order-1042-confirm", """
            {"payment_method_type": "card",
             "return_url": "https://shop.example.com/orders/1042/return"}
            """);
        // next_action.form_post: url, method, fields
        System.out.println(confirmed);
    }
}
```

```csharp title="Card.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 { BaseAddress = new Uri("https://pay-api.adpromax.com/v1/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

var intent = await Post("payment_intents", "order-1042-create", new
{
    amount = 25000,
    currency = "mad",
    payment_method_types = new[] { "card" },
});
var confirm = $"payment_intents/{intent.Id}/confirm";
var confirmed = await Post(confirm, "order-1042-confirm", new
{
    payment_method_type = "card",
    return_url = "https://shop.example.com/orders/1042/return",
});
var formPost = confirmed.NextAction!.FormPost;
Console.WriteLine($"{formPost.Method} {formPost.Url}");

async Task<PaymentIntent> Post(string path, string key, object body)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, path)
    {
        Content = JsonContent.Create(body),
    };
    request.Headers.Add("Idempotency-Key", 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))!;
}

record PaymentIntent(string Id, string Status, NextAction? NextAction);
record NextAction(string Type, FormPost FormPost);
record FormPost(string Url, string Method, Dictionary<string, string> Fields);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

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

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

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

#[derive(Deserialize)]
struct PaymentIntent {
    id: String,
    next_action: Option<NextAction>,
}

#[derive(Deserialize)]
struct NextAction {
    form_post: FormPost,
}

#[derive(Deserialize)]
struct FormPost {
    url: String,
    method: String,
    fields: HashMap<String, String>,
}

#[derive(Deserialize)]
struct ApiError {
    error: ErrorBody,
}

#[derive(Deserialize)]
struct ErrorBody {
    message: String,
}

fn post(
    client: &Client,
    path: &str,
    key: &str,
    body: Value,
) -> Result<PaymentIntent, Box<dyn std::error::Error>> {
    let response = client
        .post(format!("{API}{path}"))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .header("Idempotency-Key", key)
        .json(&body)
        .send()?;
    if !response.status().is_success() {
        let failure: ApiError = response.json()?;
        return Err(failure.error.message.into());
    }
    Ok(response.json()?)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    let create = json!({
        "amount": 25000,
        "currency": "mad",
        "payment_method_types": ["card"],
    });
    let intent = post(&client, "/payment_intents", "order-1042-create", create)?;
    let confirm = json!({
        "payment_method_type": "card",
        "return_url": "https://shop.example.com/orders/1042/return",
    });
    let path = format!("/payment_intents/{}/confirm", intent.id);
    let confirmed = post(&client, &path, "order-1042-confirm", confirm)?;
    let next_action = confirmed.next_action.ok_or("no form to post")?;
    let form_post = next_action.form_post;
    let fields = form_post.fields.len();
    println!("{} {} ({fields} fields)", form_post.method, form_post.url);
    Ok(())
}
```

```json title="200 OK" response
{
  "id": "pi_01k7e5r2t8w4y6b9c3n7m1x5qa",
  "object": "payment_intent",
  "amount": 25000,
  "currency": "mad",
  "status": "requires_action",
  "payment_method_types": ["card"],
  "latest_charge": "ch_01k7e5r3a6s9d2f5g8h1j4k7mb",
  "next_action": {
    "type": "form_post",
    "form_post": {
      "url": "https://pay.example.com/…",
      "method": "POST",
      "fields": { "…": "…" }
    }
  },
  "livemode": false
}
```

## Envoyer votre client sur la page carte

Répondez au navigateur de votre client avec une page qui contient un formulaire construit à partir
de `next_action.form_post` : un champ caché par champ, envoyé à `url`. Le formulaire s’envoie de
lui-même tout de suite ; un bouton *Continuer* visible sert de solution de repli quand les scripts
sont bloqués. Échappez chaque valeur que vous écrivez dans la page.

Si votre client recharge la page avant de payer, relisez l’intention de paiement et envoyez le même
`form_post` : il reste valable tant que la tentative attend.

```bash
# Shows what the page must post: the form_post of the confirmed payment intent.
curl -s https://pay-api.adpromax.com/v1/payment_intents/pi_01k7e5r2t8w4y6b9c3n7m1x5qa \
  -u "$APX_SECRET_KEY:" | jq .next_action.form_post
```

```js title="card-form.mjs"
const escape = value => String(value)
  .replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`)

// formPost is next_action.form_post of the confirmed payment intent.
export function cardForm(formPost) {
  const inputs = Object.entries(formPost.fields)
    .map(([name, value]) =>
      `<input type="hidden" name="${escape(name)}" value="${escape(value)}">`)
    .join('\n')
  return `<!doctype html>
<form id="card" method="${escape(formPost.method)}" action="${escape(formPost.url)}">
${inputs}
<button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>`
}

// Express: res.send(cardForm(confirmed.next_action.form_post))
```

```ts title="card-form.ts"
import type { Response } from 'express'

interface FormPost {
  url: string
  method: string
  fields: Record<string, string>
}

const escape = (value: string): string => value
  .replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`)

// formPost is next_action.form_post of the confirmed payment intent.
export function cardForm(formPost: FormPost): string {
  const inputs = Object.entries(formPost.fields)
    .map(([name, value]) =>
      `<input type="hidden" name="${escape(name)}" value="${escape(value)}">`)
    .join('\n')
  return `<!doctype html>
<form id="card" method="${escape(formPost.method)}" action="${escape(formPost.url)}">
${inputs}
<button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>`
}

// In an Express route: sendCardForm(res, confirmed.next_action.form_post)
export function sendCardForm(res: Response, formPost: FormPost): void {
  res.type('html').send(cardForm(formPost))
}
```

```python title="app.py"
from flask import render_template_string

CARD_FORM = """<!doctype html>
<form id="card" method="{{ form.method }}" action="{{ form.url }}">
  {% for name, value in form.fields.items() %}
  <input type="hidden" name="{{ name }}" value="{{ value }}">
  {% endfor %}
  <button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>"""

def card_form(form_post):
    # Jinja escapes every value.
    return render_template_string(CARD_FORM, form=form_post)
```

```php title="card-form.php"
<?php
// $formPost is next_action.form_post of the confirmed payment intent.
$e = fn ($value) => htmlspecialchars((string) $value, ENT_QUOTES);
?>
<!doctype html>
<form id="card" method="<?= $e($formPost['method']) ?>" action="<?= $e($formPost['url']) ?>">
<?php foreach ($formPost['fields'] as $name => $value): ?>
  <input type="hidden" name="<?= $e($name) ?>" value="<?= $e($value) ?>">
<?php endforeach; ?>
  <button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>
```

```ruby title="card_form.rb"
require 'sinatra'

CARD_FORM = <<~HTML
  <!doctype html>
  <form id="card" method="<%= h form['method'] %>" action="<%= h form['url'] %>">
  <% form['fields'].each do |name, value| %>
    <input type="hidden" name="<%= h name %>" value="<%= h value %>">
  <% end %>
    <button type="submit">Continue</button>
  </form>
  <script>document.getElementById('card').submit()</script>
HTML

helpers do
  def h(value)
    Rack::Utils.escape_html(value.to_s)
  end

  # form_post is next_action.form_post of the confirmed payment intent.
  def card_form(form_post)
    erb CARD_FORM, locals: { form: form_post }
  end
end
```

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

import (
	"html/template"
	"net/http"
)

type FormPost struct {
	URL    string            `json:"url"`
	Method string            `json:"method"`
	Fields map[string]string `json:"fields"`
}

// html/template escapes every value.
var cardFormPage = template.Must(template.New("card").Parse(`<!doctype html>
<form id="card" method="{{.Method}}" action="{{.URL}}">
  {{range $name, $value := .Fields}}
  <input type="hidden" name="{{$name}}" value="{{$value}}">
  {{end}}
  <button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>`))

// formPost is next_action.form_post of the confirmed payment intent.
func cardForm(w http.ResponseWriter, formPost FormPost) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	cardFormPage.Execute(w, formPost)
}
```

```java title="CardFormController.java"
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.util.HtmlUtils;

public class CardFormController {
    record FormPost(String url, String method, Map<String, String> fields) {}

    // formPost is next_action.form_post of the confirmed payment intent.
    static ResponseEntity<String> cardForm(FormPost formPost) {
        StringBuilder inputs = new StringBuilder();
        formPost.fields().forEach((name, value) -> inputs.append(
            "<input type=\"hidden\" name=\"%s\" value=\"%s\">\n"
                .formatted(escape(name), escape(value))));
        String page = """
            <!doctype html>
            <form id="card" method="%s" action="%s">
            %s<button type="submit">Continue</button>
            </form>
            <script>document.getElementById('card').submit()</script>
            """.formatted(escape(formPost.method()), escape(formPost.url()), inputs);
        return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(page);
    }

    static String escape(String value) {
        return HtmlUtils.htmlEscape(value);
    }
}
```

```csharp title="Program.cs"
using System.Net;
using System.Text;

// formPost is next_action.form_post of the confirmed payment intent.
// In a route: return CardForm(confirmed.NextAction.FormPost);
static IResult CardForm(FormPost formPost)
{
    var inputs = new StringBuilder();
    foreach (var (name, value) in formPost.Fields)
    {
        inputs.AppendLine(
            $"<input type=\"hidden\" name=\"{E(name)}\" value=\"{E(value)}\">");
    }
    var page = $"""
        <!doctype html>
        <form id="card" method="{E(formPost.Method)}" action="{E(formPost.Url)}">
        {inputs}<button type="submit">Continue</button>
        </form>
        <script>document.getElementById('card').submit()</script>
        """;
    return Results.Content(page, "text/html");
}

static string E(string value) => WebUtility.HtmlEncode(value);

record FormPost(string Url, string Method, Dictionary<string, string> Fields);
```

```rust title="main.rs"
// Cargo.toml: axum = "0.8", serde = { version = "1", features = ["derive"] }
use std::collections::BTreeMap;

use axum::response::Html;
use serde::Deserialize;

#[derive(Deserialize)]
struct FormPost {
    url: String,
    method: String,
    fields: BTreeMap<String, String>,
}

fn escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

// form_post is next_action.form_post of the confirmed payment intent.
fn card_form(form_post: &FormPost) -> Html<String> {
    let inputs: String = form_post
        .fields
        .iter()
        .map(|(name, value)| {
            format!(
                "<input type=\"hidden\" name=\"{}\" value=\"{}\">\n",
                escape(name),
                escape(value)
            )
        })
        .collect();
    Html(format!(
        r#"<!doctype html>
<form id="card" method="{}" action="{}">
{}<button type="submit">Continue</button>
</form>
<script>document.getElementById('card').submit()</script>"#,
        escape(&form_post.method),
        escape(&form_post.url),
        inputs
    ))
}
```

## Quand votre client revient

Après la page carte, le navigateur de votre client revient sur votre `return_url`, quoi qu’il se
soit passé. Ce retour ne prouve rien : affichez « Nous confirmons votre paiement », puis lisez
l’intention de paiement avec votre clé secrète, et livrez sur le webhook `payment_intent.succeeded`.
Une confirmation peut arriver après le retour de votre client : voir
[Le fonctionnement des paiements](https://pay-docs.adpromax.com/fr/payments/how-payments-work#redirections-et-webhooks--auquel-se-fier).

## Refus et nouvelles tentatives

Une tentative refusée ramène l’intention de paiement à `requires_payment_method`, avec la raison dans
`last_payment_error`, et vous recevez `payment_intent.payment_failed`.

| `last_payment_error.code` | Ce qui s’est passé                                                                      | Ce que votre client peut faire         |
| ------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------- |
| `card_declined`           | La banque a refusé. `decline_code` peut en dire plus, par exemple `insufficient_funds`. | Essayer une autre carte.               |
| `expired_card`            | La carte a expiré.                                                                      | Essayer une autre carte.               |
| `authentication_failed`   | 3-D Secure n’a pas abouti.                                                              | Réessayer, ou essayer une autre carte. |
| `processing_error`        | Le paiement n’a pas pu être traité.                                                     | Réessayer.                             |

Votre client peut réessayer sur la **même** intention de paiement : chaque essai est une nouvelle
tentative, et l’intention de paiement ne réussit toujours qu’une fois.

* Avec la page hébergée, la page propose *Essayer une autre carte*, et *Payer en espèces* quand vous
  avez proposé les espèces.
* Avec votre propre page, confirmez de nouveau l’intention de paiement avec une **nouvelle**
  `Idempotency-Key`, et envoyez votre client sur le nouveau `form_post`.

Montrez à votre client une phrase claire, pas le code. Les numéros qui produisent chaque cas en mode
test sont dans [Cartes de test](https://pay-docs.adpromax.com/fr/sandbox/test-cards).

## Remboursements

Un paiement par carte réussi peut être remboursé, en totalité ou en partie : voir
[Remboursements](https://pay-docs.adpromax.com/fr/payments/refunds).
