البدء

اختبر تكاملك

كل مسار قد يسلكه عملاؤك، وكل حالة فشل، ومعالجتك لإشعارات webhook، مختبرة من البداية إلى النهاية في بيئة الاختبار قبل الانتقال إلى الوضع المباشر.

فتح بصيغة Markdown
في هذه الصفحة
  1. قبل أن تبدأ
  2. الدفع بالبطاقة
  3. الدفع نقدًا
  4. الاسترداد
  5. إشعارات webhook
  6. الطلبات
  7. اختبار آلي: رمز دفع نقدي تنتهي صلاحيته
  8. ما يراه عملاؤك
  9. دع لوحة التحكم تساعدك
  10. الخطوات التالية

يشغّل وضع الاختبار الكود نفسه الذي يشغّله الوضع المباشر: التحقق من صحة البيانات نفسه، والحالات نفسها، والأحداث نفسها، وإشعارات الـ webhook نفسها (انظر بيئة الاختبار والوضع المباشر: الفروق). ما يعمل هنا يعمل في الوضع المباشر. اختبر أولًا المسار الذي يسلكه عملاؤك، ثم كل طريقة قد تفشل بها دفعة، ثم ما يفعله خادمك بإشعارات الـ webhook التي يتلقاها.

قبل أن تبدأ

  • مفتاح سري للاختبار في APX_SECRET_KEY: انظر مفاتيح API الخاصة بك.
  • نقطة استقبال webhook في وضع الاختبار، مع سرّها في APX_WEBHOOK_SECRET. ولاستقبال الأحداث على حاسوبك، اجعل خادمك متاحًا عبر نفق HTTPS: انظر البدء السريع للبطاقة.
  • لوحة التحكم، في وضع الاختبار، لمتابعة ما يحدث:
    • تعرض المطوّرون ← سجلات الطلبات كل استدعاء لـ API مع حالته وRequest-Id الخاص به؛
    • تعرض المطوّرون ← الأحداث كل حدث، مع عمليات تسليمه إلى كل نقطة استقبال؛
    • تعرض صفحة نقطة الاستقبال في المطوّرون ← Webhooks كل عملية تسليم وإجاباتك عنها.

الدفع بالبطاقة

ادفع في كل سيناريو على الصفحة المستضافة باستخدام بطاقة الاختبار المذكورة، أو دون متصفح عبر أدوات الاختبار المساعدة.

السيناريوالطريقةما يفعله تكاملك
نجاح
4242 4242 4242 4242

ينفّذ الطلبية مرة واحدة، عند payment_intent.succeeded.

مرفوضة
4000 0000 0000 0002

يُظهر أن الدفعة فشلت ويتيح لعميلك المحاولة من جديد. تبقى الطلبية مفتوحة.

3-D Secure
4000 0000 0000 3220

مرة بخيار موافقة ومرة بخيار فشل

عند الموافقة: كما في النجاح. عند الفشل: كما في الرفض، مع authentication_failed.

تأكيد بطيء
4000 0000 0000 0077

يعود عميلك إلى success_url قبل تأكيد الدفعة: اعرض «نحن بصدد تأكيد دفعتك»، وسلّم الطلبية عند وصول الـ webhook، بعد نحو 60 ثانية.

إشعارات متكررة
4000 0000 0000 0085

تنفيذ واحد: بريد إلكتروني واحد، وحركة مخزون واحدة.

تأكيد مفقود
4000 0000 0000 0093

لا يسلّم أبدًا: لا تنجح الدفعة، وتنتهي صلاحية محاولتها بعد نحو 30 دقيقة.

الدفع نقدًا

تصرّف في كل رمز اختبار بأزرار صفحة القسيمة، أو عبر أدوات الاختبار المساعدة. وتجد ما تفعله كل نتيجة في سيناريوهات اختبار الدفع النقدي.

السيناريوالطريقةما يفعله تكاملك
مدفوع

محاكاة الدفع، أو pay

ينفّذ الطلبية عند payment_intent.succeeded.

منتهي الصلاحية

expire

يحرّر الطلبية أو المخزون، ويتيح لعميلك الحصول على رمز جديد.

مدفوع بمبلغ آخر

pay مع amount مختلف

لا يسلّم شيئًا: تحصل الدفعة على review ولا يُضاف شيء إلى الرصيد. وينتظر.

مدفوع بعد انتهاء الصلاحية

expire، ثم pay

الأمر نفسه: review، ولا إضافة إلى الرصيد، ولا تسليم.

أقل من 100.00 MAD

دفعة نقدية بمبلغ 5000

يعالج الرفض، amount_too_small: اعرض البطاقة فقط للمبالغ الصغيرة.

الاسترداد

السيناريوالطريقةما يفعله تكاملك
استرداد جزئي لدفعة بالبطاقة

استردّ جزءًا من دفعة ناجحة بالبطاقة

يسجّل ما تم استرداده؛ ويزداد amount_refunded في نية الدفع (payment intent).

استرداد كامل لدفعة بالبطاقة

استردّ الباقي

يضع على الطلبية علامة «مستردة» عند نجاح الاسترداد.

استرداد دفعة نقدية

حاول استرداد دفعة نقدية

يعالج الرفض، refund_unsupported_payment_method: يتولى الأمر إجراؤك الخاص بالدفع نقدًا. انظر المبالغ المستردة.

إشعارات webhook

السيناريوالطريقةما يفعله تكاملك
توقيع خاطئ

أرسل إلى معالجك عملية تسليم بنص معدَّل (اختبر معالجك)

يجيب بـ 400 ولا يفعل شيئًا آخر.

الحدث نفسه مرتين

اختر إعادة الإرسال لعملية تسليم من لوحة التحكم

يعالجه مرة واحدة: id الحدث معروف مسبقًا. انظر إزالة التكرار.

نقطة استقبالك متوقفة

أوقف خادمك، وادفع، وشغّله من جديد، ثم اختر إعادة الإرسال أو انتظر إعادة المحاولة

ينفّذ الطلبية عندما تنجح عملية التسليم.

الطلبات

السيناريوالطريقةما يفعله تكاملك
الإنشاء نفسه مرتين

أرسل طلب إنشاء مرتين بالقيمة نفسها لـ Idempotency-Key

دفعة واحدة؛ وتحمل الاستجابة الثانية Idempotent-Replayed: true. انظر عدم التكرار (idempotency).

طلبات كثيرة جدًا

في اختباراتك الخاصة، أعِد إلى برنامجك الرد 429

ينتظر مدة Retry-After، ثم يعيد المحاولة بالمفتاح نفسه. انظر حدود المعدل.

مفتاح خاطئ

مفتاح اختبار ينقصه حرف واحد

يعالج 401، api_key_invalid: تنبيه لك، لا إعادة محاولة أبدًا.

اختبار آلي: رمز دفع نقدي تنتهي صلاحيته

يمكن لمجموعة اختباراتك تشغيل أي من هذه السيناريوهات على بيئة الاختبار. ينشئ هذا الاختبار دفعة نقدية، ويؤكدها باسم عميلك ومدينته، وينهي صلاحية رمزها بأداة الاختبار المساعدة، ثم ينتظر حتى تعود الدفعة إلى requires_payment_method مع voucher_expired.

يحمل كل طلب إنشاء وكل طلب تأكيد Idempotency-Key خاصًا به، يُنشأ من جديد في كل تشغيل. وتذكر استجابة التأكيد المحاولة في latest_charge: وعليها تعمل الأداة المساعدة.

  • 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");
}

ما يراه عملاؤك

يدفع عملاؤك في صفحاتنا، وغالبًا من الهاتف. انظر إليها كما سيراها عملاؤك:

  • افتح url جلسة دفع (checkout session) على هاتف، بالفرنسية والإنجليزية والعربية (تتبع الصفحة لغة المتصفح). ادفع بالبطاقة، ثم اطلب رمز دفع نقدي.
  • في صفحة القسيمة، اطبع القسيمة وشاركها: يجب أن يُقرأ الرمز والمبلغ الواجب دفعه والأجل بوضوح.
  • غادر الصفحة إلى متجرك، ثم افتح url الجلسة من جديد: العميل الذي غادر يستطيع العودة إلى جلسة ما زالت مفتوحة.

دع لوحة التحكم تساعدك

  • بطاقة التكامل في الصفحة الرئيسية تؤشّر بنودها تلقائيًا كلما وصلت مدفوعات الاختبار وإشعارات الـ webhook ورموز الدفع النقدي.
  • في وضع الاختبار، يملأ إنشاء مدفوعات نموذجية في الصفحة الرئيسية الشاشات بمدفوعات وعملاء نموذجيين، لترى ما تعرضه كل صفحة.
  • حذف كل بيانات الاختبار، في الإعدادات ← المؤسسة، يعيدك إلى بيئة اختبار نظيفة. وتبقى مفاتيحك وفريقك.

الخطوات التالية

عندما ينجح كل سيناريو في هذه الصفحة، اقرأ قائمة التحقق قبل الوضع المباشر.