{
  "info": {
    "name": "Tabby Simulator",
    "description": "Demo and test collection for the Tabby API simulator (/tabby-sim). All variables are inside the collection (Variables tab); no environment needed. Run the folders in order. See README.md next to this file.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{secret_key}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "base_url",
      "value": "http://78.47.213.209:4030/tabby-sim"
    },
    {
      "key": "secret_key",
      "value": "sk_test_000000000"
    },
    {
      "key": "merchant_code",
      "value": "demo"
    },
    {
      "key": "admin_user",
      "value": "admin"
    },
    {
      "key": "admin_token",
      "value": "admin-test-123"
    },
    {
      "key": "receiver_url",
      "value": "http://localhost:9099/ok"
    },
    {
      "key": "wrong_key",
      "value": "not-a-valid-key"
    },
    {
      "key": "session_id",
      "value": ""
    },
    {
      "key": "payment_id",
      "value": ""
    },
    {
      "key": "web_url",
      "value": ""
    },
    {
      "key": "form_token",
      "value": ""
    },
    {
      "key": "delivery_id",
      "value": ""
    },
    {
      "key": "fault_id",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "0. Setup",
      "description": "Run once before the demo. Needs the webhook receiver reachable by the SERVER at `receiver_url` and, for the admin folders, `admin_token` filled in.",
      "item": [
        {
          "name": "Register webhook",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Merchant-Code",
                "value": "{{merchant_code}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Registers `receiver_url` for `merchant_code` with the signing header `X-Sign` (Register a webhook: https://docs.tabby.ai/api-reference/webhooks/register-a-webhook). Tabby allows 4 webhooks per merchant code; the simulator answers 400 `bad_request` for the 5th, so running this again later may show the 400 branch.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"{{receiver_url}}\",\n  \"header\": {\n    \"title\": \"X-Sign\",\n    \"value\": \"postman-demo\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"200 with the webhook, or 400 bad_request when 4 are already registered\", function () { if (pm.response.code === 200) { pm.expect(j).to.have.keys(\"id\", \"url\", \"is_test\", \"header\"); pm.expect(j.url).to.eql(pm.collectionVariables.get(\"receiver_url\")); pm.expect(j.header.title).to.eql(\"X-Sign\"); pm.expect(j.is_test).to.eql(true); } else { pm.expect(pm.response.code).to.eql(400); pm.expect(j).to.eql({status: \"error\", errorType: \"bad_data\", error: \"bad_request\"}); } });"
                ]
              }
            }
          ]
        },
        {
          "name": "List webhooks",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Merchant-Code",
                "value": "{{merchant_code}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Lists the webhooks of the merchant code (`null` when there are none). Based on Retrieve all webhooks: https://docs.tabby.ai/api-reference/webhooks/retrieve-all-webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"the receiver is registered\", function () { pm.expect(j).to.be.an(\"array\"); pm.expect(j.map(function (w) { return w.url; })).to.include(pm.collectionVariables.get(\"receiver_url\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Get admin form token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin"
              ]
            },
            "description": "Loads the admin dashboard (Basic auth: `admin_user` / `admin_token`) and saves the hidden form `_token` to `form_token`. Every admin POST needs it; it is valid for one hour. Not a Tabby feature: the admin page is a test convenience.",
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "var m = /name=\"_token\" value=\"([^\"]*)\"/.exec(pm.response.text());",
                  "pm.test(\"a form token was found in the page\", function () { pm.expect(m, \"no _token in the page: is TABBY_SIM_ADMIN_TOKEN set and admin_token filled in?\").to.not.equal(null); });",
                  "if (m) { pm.collectionVariables.set(\"form_token\", m[1]); }"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "1. Successful payment",
      "description": "Create, open, approve, capture. The receiver should print three webhooks (Webhooks page, typical payment): `authorized` after the approval (no captures); `authorized` again after the full capture (the capture is in `captures`, `closed_at` is empty); then, about a second later, `closed` (`closed_at` is set).",
      "item": [
        {
          "name": "Create session (success)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "The happy path: `otp.success@tabby.ai` and +971500000001 (Testing Credentials, Success). Full payload with merchant_urls, lang, buyer name, buyer_history, shipping_address and items with category. The response `id` is the session, `payment.id` the payment: both are saved. Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });",
                  "pm.test(\"available product is installments with is_available\", function () { var p = j.configuration.products.installments; pm.expect(p.is_available).to.eql(true); pm.expect(p.rejection_reason).to.be.null; });",
                  "pm.test(\"amount is normalised like Tabby (no trailing zeros)\", function () { pm.expect(j.payment.amount).to.eql(\"100\"); pm.expect(j.payment.currency).to.eql(\"AED\"); });",
                  "pm.test(\"merchant_urls echoed\", function () { pm.expect(j.merchant_urls.success).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Open the hosted page (GET)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "The page a customer sees. In a browser open `{{web_url}}` (the saved web_url) to try the buttons by hand; here we just check it loads. Tabby HPP: Testing Credentials #1.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"shows the form and the Approve button\", function () { var t = pm.response.text(); pm.expect(t).to.include(\"Approve\"); pm.expect(t).to.include(\"Back to Store\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve payment (AUTHORIZED)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the payment back (Retrieve a payment: https://docs.tabby.ai/api-reference/payments/retrieve-a-payment). Status is UPPERCASE here, lowercase in webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is AUTHORIZED\", function () { pm.expect(j.status).to.eql(\"AUTHORIZED\"); });",
                  "pm.test(\"nothing captured yet\", function () { pm.expect(j.captures).to.eql([]); pm.expect(j.amount).to.eql(\"100\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Capture the full amount",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Captures 100 with a unique `reference_id` (idempotency key). A full capture closes the payment. Capture a payment: https://docs.tabby.ai/api-reference/payments/capture-a-payment.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-full\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"payment is CLOSED with one capture of 100\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); pm.expect(j.captures.length).to.eql(1); pm.expect(j.captures[0].amount).to.eql(\"100\"); pm.expect(j.captures[0].reference_id).to.eql(\"cap-full\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retrieve payment (CLOSED)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the payment back (Retrieve a payment: https://docs.tabby.ai/api-reference/payments/retrieve-a-payment). Status is UPPERCASE here, lowercase in webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is CLOSED\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); });",
                  "pm.test(\"one capture\", function () { pm.expect(j.captures.length).to.eql(1); pm.expect(j.refunds).to.eql([]); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "2. Pre-scoring reject",
      "description": "Background pre-scoring: the phone ending 0002 is rejected. A rejection is a 200 with status `rejected`, not an error (Error codes page).",
      "item": [
        {
          "name": "Create session (pre-scoring reject)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Phone +971500000002 is rejected up front (Testing Credentials, Pre-scoring reject). No hosted page is offered. The payment is still stored as CREATED.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000002\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'rejected' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"rejected\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"no available product and no web_url\", function () { pm.expect(j.configuration.available_products).to.eql({}); pm.expect(pm.collectionVariables.get(\"web_url\")).to.eql(\"\"); });",
                  "pm.test(\"installments is unavailable with rejection_reason not_available\", function () { var p = j.configuration.products.installments; pm.expect(p.is_available).to.eql(false); pm.expect(p.rejection_reason).to.eql(\"not_available\"); });",
                  "pm.test(\"rejection_reason_code is null for this reason\", function () { pm.expect(j.rejection_reason_code).to.be.null; });"
                ]
              }
            }
          ]
        },
        {
          "name": "Hosted page of a rejected session",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "A rejected session has no hosted page.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 404\", function () { pm.response.to.have.status(404); });",
                  "pm.test(\"Checkout not found\", function () { pm.expect(pm.response.text()).to.include(\"Checkout not found\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Create session (amount too high)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "With `TABBY_SIM_MAX_AMOUNT` set (for example 50) an amount above it is rejected with `order_amount_too_high`. Without that setting this request is created normally, so the test accepts both.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100000.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100000.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session is created or rejected, and the payment starts CREATED\", function () { pm.expect([\"created\", \"rejected\"]).to.include(j.status); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"either created (no limit configured) or rejected for the amount\", function () { if (j.status === \"rejected\") { pm.expect(j.configuration.products.installments.rejection_reason).to.eql(\"order_amount_too_high\"); pm.expect(j.rejection_reason_code).to.eql(\"not_enough_limit\"); } else { pm.expect(j.status).to.eql(\"created\"); } });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "3. Customer cancels",
      "description": "The customer presses Back to Store on the hosted page.",
      "item": [
        {
          "name": "Create session (cancel)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Back to Store (cancel)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "The cancel button is labelled \"Back to Store\" as in Testing Credentials #3. 302 to `merchant_urls.cancel?payment_id=...`; the payment becomes EXPIRED.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "cancel"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is cancel with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/cancel?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve payment (EXPIRED)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "EXPIRED is terminal (Payment Statuses: https://docs.tabby.ai/pay-in-4-custom-integration/payment-statuses). With the expired webhook enabled the receiver also prints `expired`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is EXPIRED\", function () { pm.expect(j.status).to.eql(\"EXPIRED\"); });",
                  "pm.test(\"the payment is terminal\", function () { pm.expect(j.captures).to.eql([]); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "4. Tabby rejects on the page",
      "description": "HPP rejection credentials: otp.rejected@tabby.ai.",
      "item": [
        {
          "name": "Create session (HPP rejection)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "`otp.rejected@tabby.ai` passes pre-scoring (the session is created) but is rejected on the hosted page (Testing Credentials, HPP rejection).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.rejected@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve (behaves as reject)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "With this e-mail Approve ends in rejection: 302 to `merchant_urls.failure?payment_id=...`. Testing Credentials #4.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is failure with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/failure?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve payment (REJECTED)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Payment status REJECTED (Testing Credentials #4, step 7). The receiver prints a `rejected` webhook."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is REJECTED\", function () { pm.expect(j.status).to.eql(\"REJECTED\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "5. Customer closes the tab",
      "description": "The corner case: OTP completed, tab closed before the redirect. The webhook is the only way the merchant learns of it.",
      "item": [
        {
          "name": "Create session (close tab)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve, then close the tab (no redirect)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Simulates the customer closing the tab after the success screen (Testing Credentials #5, Corner case). No redirect; a result page is shown.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve_no_redirect"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"no redirect: the result page is shown\", function () { pm.expect(pm.response.headers.get(\"Location\")).to.be.undefined; pm.expect(pm.response.text()).to.include(\"You can close this tab\"); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve payment (AUTHORIZED)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "The payment is AUTHORIZED even though the customer was never redirected. The receiver still prints an `authorized` webhook (Payment Processing: webhooks are sent on every status change regardless of redirection)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is AUTHORIZED\", function () { pm.expect(j.status).to.eql(\"AUTHORIZED\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "6. Partial capture, refund and idempotency",
      "description": "Amounts and reference_ids matter: a repeated reference_id replays, it never repeats the operation (Payment Processing, Idempotent requests).",
      "item": [
        {
          "name": "Create session (partial)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Capture 60 (partial)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Partial capture: the payment stays AUTHORIZED.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"60.00\",\n  \"reference_id\": \"cap-1\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"AUTHORIZED, one capture of 60\", function () { pm.expect(j.status).to.eql(\"AUTHORIZED\"); pm.expect(j.captures.length).to.eql(1); pm.expect(j.captures[0].amount).to.eql(\"60\"); pm.collectionVariables.set(\"first_capture_id\", j.captures[0].id); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Replay the same capture",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Replay with the same reference_id: 200 and NO new capture. \"A retry with the same reference_id will not create a second capture or refund.\"",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"60.00\",\n  \"reference_id\": \"cap-1\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"same single capture, same id (replay)\", function () { pm.expect(j.captures.length).to.eql(1); pm.expect(j.captures[0].id).to.eql(pm.collectionVariables.get(\"first_capture_id\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Over-capture 50 (400)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Only 40 remains, so 50 is refused with 400 (the simulator's own wording; Tabby documents 400 bad_data).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"50.00\",\n  \"reference_id\": \"cap-2\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"capture amount exceeds the remaining amount\\\"\", function () { pm.expect(j.error).to.eql(\"capture amount exceeds the remaining amount\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Capture the remaining 40",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "A failed capture's reference_id was not used up, so cap-2 works with a valid amount. The rest of the amount closes the payment. The receiver gets `authorized` (with both captures), then `closed`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"40.00\",\n  \"reference_id\": \"cap-2\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"full amount captured: CLOSED with two captures\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); pm.expect(j.captures.length).to.eql(2); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Refund 30",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Refund against the captured amount (Refund a payment: https://docs.tabby.ai/api-reference/payments/refund-a-payment). The payment stays CLOSED.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"30.00\",\n  \"reference_id\": \"ref-1\",\n  \"reason\": \"damaged\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"CLOSED with one refund of 30 and the reason\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); pm.expect(j.refunds.length).to.eql(1); pm.expect(j.refunds[0].amount).to.eql(\"30\"); pm.expect(j.refunds[0].reason).to.eql(\"damaged\"); pm.collectionVariables.set(\"first_refund_id\", j.refunds[0].id); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Replay the same refund",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Same reference_id again: 200, still ONE refund.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"30.00\",\n  \"reference_id\": \"ref-1\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"still one refund, same id\", function () { pm.expect(j.refunds.length).to.eql(1); pm.expect(j.refunds[0].id).to.eql(pm.collectionVariables.get(\"first_refund_id\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Refund over the captured total (400)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "30 already refunded; 80 more would exceed the 100 captured.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"80.00\",\n  \"reference_id\": \"ref-2\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"refund amount exceeds the captured amount\\\"\", function () { pm.expect(j.error).to.eql(\"refund amount exceeds the captured amount\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Refund the remaining 70",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Refunding the rest makes the payment `refunded` on the Merchant Dashboard (used by the list filter in folder 8).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"70.00\",\n  \"reference_id\": \"ref-3\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"two refunds totalling 100\", function () { pm.expect(j.refunds.length).to.eql(2); pm.expect(j.refunds[0].amount).to.eql(\"30\"); pm.expect(j.refunds[1].amount).to.eql(\"70\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "7. Close and update",
      "description": "Update changes only order.reference_id; close cancels what was not captured.",
      "item": [
        {
          "name": "Create session (close/update)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Update order.reference_id",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Only `order.reference_id` can be updated (Update a payment: https://docs.tabby.ai/api-reference/payments/update-a-payment). The receiver prints an `update` webhook with the status as before.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"order\": {\n    \"reference_id\": \"POSTMAN-UPDATED\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"reference changed, status unchanged\", function () { pm.expect(j.order.reference_id).to.eql(\"POSTMAN-UPDATED\"); pm.expect(j.status).to.eql(\"AUTHORIZED\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Close the payment",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/close",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "close"
              ]
            },
            "description": "Closes an AUTHORIZED payment without capturing (Close a payment: https://docs.tabby.ai/api-reference/payments/close-a-payment). It shows as `cancelled` on the Merchant Dashboard."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"CLOSED, no captures\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); pm.expect(j.captures).to.eql([]); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Close again (400)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/close",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "close"
              ]
            },
            "description": "A second close is refused."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"already closed\\\"\", function () { pm.expect(j.error).to.eql(\"already closed\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "8. List payments",
      "description": "Needs payments from folders 1-7 (run them first).",
      "item": [
        {
          "name": "List (default)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ]
            },
            "description": "Without `status` only AUTHORIZED and CLOSED payments are returned, newest first. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"only AUTHORIZED and CLOSED, limit 20, offset 0\", function () { pm.expect(j.payments.every(function (p) { return [\"AUTHORIZED\", \"CLOSED\"].indexOf(p.status) >= 0; })).to.eql(true); pm.expect(j.pagination.limit).to.eql(20); pm.expect(j.pagination.offset).to.eql(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List status=new",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=new",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "new"
                }
              ]
            },
            "description": "`new` is the Merchant Dashboard name for AUTHORIZED. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"all AUTHORIZED\", function () { pm.expect(j.payments.every(function (p) { return p.status === \"AUTHORIZED\"; })).to.eql(true); });",
                  "pm.test(\"has at least one payment (run folders 1-7 first)\", function () { pm.expect(j.payments.length).to.be.above(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List status=captured",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=captured",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "captured"
                }
              ]
            },
            "description": "`captured` = CLOSED with captures, not fully refunded. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"all CLOSED with captures\", function () { pm.expect(j.payments.every(function (p) { return p.status === \"CLOSED\" && p.captures.length > 0; })).to.eql(true); });",
                  "pm.test(\"has at least one payment (run folders 1-7 first)\", function () { pm.expect(j.payments.length).to.be.above(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List status=cancelled",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=cancelled",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "cancelled"
                }
              ]
            },
            "description": "`cancelled` = CLOSED with no captures. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"all CLOSED without captures\", function () { pm.expect(j.payments.every(function (p) { return p.status === \"CLOSED\" && p.captures.length === 0; })).to.eql(true); });",
                  "pm.test(\"has at least one payment (run folders 1-7 first)\", function () { pm.expect(j.payments.length).to.be.above(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List status=refunded",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=refunded",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "refunded"
                }
              ]
            },
            "description": "`refunded` = captured, then refunded in full. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"all CLOSED with refunds\", function () { pm.expect(j.payments.every(function (p) { return p.status === \"CLOSED\" && p.refunds.length > 0; })).to.eql(true); });",
                  "pm.test(\"has at least one payment (run folders 1-7 first)\", function () { pm.expect(j.payments.length).to.be.above(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List status=rejected",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=rejected",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "rejected"
                }
              ]
            },
            "description": "REJECTED payments (folder 4). List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"all REJECTED\", function () { pm.expect(j.payments.every(function (p) { return p.status === \"REJECTED\"; })).to.eql(true); });",
                  "pm.test(\"has at least one payment (run folders 1-7 first)\", function () { pm.expect(j.payments.length).to.be.above(0); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Page 1 (limit=2, offset=0)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?limit=2&offset=0",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "2"
                },
                {
                  "key": "offset",
                  "value": "0"
                }
              ]
            },
            "description": "Paging: two payments per page. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"at most 2 payments, offset 0\", function () { pm.expect(j.payments.length).to.be.at.most(2); pm.expect(j.pagination.limit).to.eql(2); pm.expect(j.pagination.offset).to.eql(0); pm.collectionVariables.set(\"page1_second_id\", j.payments.length > 1 ? j.payments[1].id : \"\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Page 2 (limit=2, offset=1)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?limit=2&offset=1",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "2"
                },
                {
                  "key": "offset",
                  "value": "1"
                }
              ]
            },
            "description": "Offset 1 starts at the second payment of page 1. List payments: https://docs.tabby.ai/api-reference/payments/list-of-all-payments."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"pagination has limit, offset and total_count\", function () { pm.expect(j.pagination).to.have.keys(\"limit\", \"offset\", \"total_count\"); });",
                  "pm.test(\"starts where page 1's second payment was\", function () { var want = pm.collectionVariables.get(\"page1_second_id\"); pm.expect(j.pagination.offset).to.eql(1); if (want) { pm.expect(j.payments[0].id).to.eql(want); } });"
                ]
              }
            }
          ]
        },
        {
          "name": "List with limit=21 (400)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?limit=21",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "21"
                }
              ]
            },
            "description": "The limit is at most 20."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"failed to decode request\\\"\", function () { pm.expect(j.error).to.eql(\"failed to decode request\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "List with an unknown status (400)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments?status=partially_refunded",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "partially_refunded"
                }
              ]
            },
            "description": "`partially_refunded` and other names are not list statuses."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"failed to decode request\\\"\", function () { pm.expect(j.error).to.eql(\"failed to decode request\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "9. Errors",
      "description": "Error codes page: https://docs.tabby.ai/api-reference/response-codes. Errors are `{status, errorType, error}`.",
      "item": [
        {
          "name": "No key on retrieve (401)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/00000000-0000-4000-8000-000000000000",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "00000000-0000-4000-8000-000000000000"
              ]
            },
            "description": "No Authorization header.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 401\", function () { pm.response.to.have.status(401); });",
                  "pm.test(\"errorType is 'not_authorized'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"not_authorized\"); });",
                  "pm.test(\"error text is \\\"missing Bearer scheme\\\"\", function () { pm.expect(j.error).to.eql(\"missing Bearer scheme\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Wrong key on retrieve (401)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/00000000-0000-4000-8000-000000000000",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "00000000-0000-4000-8000-000000000000"
              ]
            },
            "description": "A key that is not a test secret key.",
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "{{wrong_key}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 401\", function () { pm.response.to.have.status(401); });",
                  "pm.test(\"errorType is 'not_authorized'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"not_authorized\"); });",
                  "pm.test(\"error text is \\\"missing Bearer scheme\\\"\", function () { pm.expect(j.error).to.eql(\"missing Bearer scheme\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "No key on checkout (401)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Checkout answers with its own text (\"merchant is null\").",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 401\", function () { pm.response.to.have.status(401); });",
                  "pm.test(\"errorType is 'not_authorized'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"not_authorized\"); });",
                  "pm.test(\"error text is \\\"merchant is null\\\"\", function () { pm.expect(j.error).to.eql(\"merchant is null\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "No key on webhooks (401)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "X-Merchant-Code",
                "value": "{{merchant_code}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "The webhook routes answer with \"authorization\".",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"{{receiver_url}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 401\", function () { pm.response.to.have.status(401); });",
                  "pm.test(\"errorType is 'not_authorized'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"not_authorized\"); });",
                  "pm.test(\"error text is \\\"authorization\\\"\", function () { pm.expect(j.error).to.eql(\"authorization\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Webhook without X-Merchant-Code (400)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "The header is required. Every bad webhook request is `bad_request`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"{{receiver_url}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"bad_request\\\"\", function () { pm.expect(j.error).to.eql(\"bad_request\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Unknown payment id (404)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/00000000-0000-4000-8000-000000000000",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "00000000-0000-4000-8000-000000000000"
              ]
            },
            "description": "A well-formed id that does not exist."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 404\", function () { pm.response.to.have.status(404); });",
                  "pm.test(\"errorType is 'not_found'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"not_found\"); });",
                  "pm.test(\"error text is \\\"no such payment\\\"\", function () { pm.expect(j.error).to.eql(\"no such payment\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Not a UUID (400)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/not-a-uuid",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "not-a-uuid"
              ]
            },
            "description": "A malformed id."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"invalid payment id\\\"\", function () { pm.expect(j.error).to.eql(\"invalid payment id\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Checkout without amount (400)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "A required field is missing.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"currency\": \"AED\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\"\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"errorType is 'bad_data'\", function () { pm.expect(j.status).to.eql(\"error\"); pm.expect(j.errorType).to.eql(\"bad_data\"); });",
                  "pm.test(\"error text is \\\"payment.amount is required\\\"\", function () { pm.expect(j.error).to.eql(\"payment.amount is required\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Checkout with currency USD (400)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Only AED and SAR are accepted.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"USD\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 400\", function () { pm.response.to.have.status(400); });",
                  "pm.test(\"a bad_data error that names the currency\", function () { pm.expect(j.errorType).to.eql(\"bad_data\"); pm.expect(j.error).to.include(\"currency\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Checkout with a non-null token (403)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "\"You are passing the `token` parameter, but it is not enabled for your integration\" (Error codes, checkout 403). The body has no `error` field.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  },\n  \"token\": \"abc\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 403\", function () { pm.response.to.have.status(403); });",
                  "pm.test(\"exactly {status, errorType: no_permission}\", function () { pm.expect(j).to.eql({status: \"error\", errorType: \"no_permission\"}); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "10. Admin fault injection",
      "description": "Needs `admin_token`. Each fault makes the NEXT matching call fail with the error Tabby documents, then goes away. Docs: https://docs.tabby.ai/api-reference/response-codes (409 lock on capture/refund, 409 disputed on refund, 500 on any endpoint). The \"500 after\" mode is inferred from the docs' advice to check state before retrying.",
      "item": [
        {
          "name": "10.1 Get admin form token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin"
              ]
            },
            "description": "Loads the admin dashboard (Basic auth: `admin_user` / `admin_token`) and saves the hidden form `_token` to `form_token`. Every admin POST needs it; it is valid for one hour. Not a Tabby feature: the admin page is a test convenience.",
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "var m = /name=\"_token\" value=\"([^\"]*)\"/.exec(pm.response.text());",
                  "pm.test(\"a form token was found in the page\", function () { pm.expect(m, \"no _token in the page: is TABBY_SIM_ADMIN_TOKEN set and admin_token filled in?\").to.not.equal(null); });",
                  "if (m) { pm.collectionVariables.set(\"form_token\", m[1]); }"
                ]
              }
            }
          ]
        },
        {
          "name": "Create session (faults)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Arm 409 lock on capture",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "Arms a 409 \"could not acquire lock\" for the next capture of THIS payment.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "capture"
                },
                {
                  "key": "kind",
                  "value": "conflict_lock"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: conflict_lock on capture/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: conflict_lock on capture/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Capture: 409 lock",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Answered with the documented body, including `errorType: conflict`. Nothing is captured.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-lock\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 409\", function () { pm.response.to.have.status(409); });",
                  "pm.test(\"409 body is exactly the documented capture lock error\", function () { pm.expect(j).to.eql({status: \"error\", errorType: \"conflict\", error: \"could not acquire lock: payment is locked\"}); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retry the capture (200)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "The fault is used up, so the same call now works. \"Retry shortly with the same idempotency key.\"",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-lock\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"CLOSED with one capture\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); pm.expect(j.captures.length).to.eql(1); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Arm 409 lock on refund",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "Refund 409s have NO errorType on Tabby's error codes page.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "refund"
                },
                {
                  "key": "kind",
                  "value": "conflict_lock"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: conflict_lock on refund/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: conflict_lock on refund/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Refund: 409 lock (no errorType)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Body is `{status, error}` only.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"10.00\",\n  \"reference_id\": \"ref-lock\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 409\", function () { pm.response.to.have.status(409); });",
                  "pm.test(\"body without errorType\", function () { pm.expect(j).to.eql({status: \"error\", error: \"could not acquire lock: payment is locked\"}); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Arm 409 payment is disputed",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "The other refund 409: an active dispute.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "refund"
                },
                {
                  "key": "kind",
                  "value": "conflict_disputed"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: conflict_disputed on refund/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: conflict_disputed on refund/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Refund: 409 payment is disputed",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Body is exactly `{status, error}`. Detect it by HTTP 409 plus the text, never by errorType.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"10.00\",\n  \"reference_id\": \"ref-lock\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 409\", function () { pm.response.to.have.status(409); });",
                  "pm.test(\"body without errorType\", function () { pm.expect(j).to.eql({status: \"error\", error: \"payment is disputed\"}); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Refund retry (200)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/refunds",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "refunds"
              ]
            },
            "description": "Both faults are used up.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"10.00\",\n  \"reference_id\": \"ref-lock\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"one refund of 10\", function () { pm.expect(j.refunds.length).to.eql(1); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Create session (500 faults)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Arm 500 BEFORE the change",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "The change is NOT applied.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "capture"
                },
                {
                  "key": "kind",
                  "value": "internal_before"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: internal_before on capture/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: internal_before on capture/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Capture: 500",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Documented internal error.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-500\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 500\", function () { pm.response.to.have.status(500); });",
                  "pm.test(\"internal error body\", function () { pm.expect(j).to.eql({status: \"error\", errorType: \"internal\", error: \"Internal Server Error\"}); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retrieve: nothing applied",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "After a 500 \"before\" the payment is untouched."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is AUTHORIZED\", function () { pm.expect(j.status).to.eql(\"AUTHORIZED\"); });",
                  "pm.test(\"nothing was captured\", function () { pm.expect(j.captures).to.eql([]); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Arm 500 AFTER the change",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "The change IS applied, then the error is sent (inferred: Tabby tells you to check already-applied captures before retrying).",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "capture"
                },
                {
                  "key": "kind",
                  "value": "internal_after"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: internal_after on capture/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: internal_after on capture/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Capture: 500 after applying",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Answered 500...",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-500\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 500\", function () { pm.response.to.have.status(500); });",
                  "pm.test(\"internal error body\", function () { pm.expect(j.errorType).to.eql(\"internal\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retrieve: capture WAS applied",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Check the state before retrying, as the docs advise."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is CLOSED\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); });",
                  "pm.test(\"...but the capture was applied\", function () { pm.expect(j.captures.length).to.eql(1); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retry with the same reference_id (200)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "The idempotent retry returns 200 and still ONE capture.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"100.00\",\n  \"reference_id\": \"cap-500\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"still one capture\", function () { pm.expect(j.captures.length).to.eql(1); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Arm a fault to remove",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "Armed on close of this payment, only so there is something to list and remove.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "close"
                },
                {
                  "key": "kind",
                  "value": "internal_before"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Armed: internal_before on close/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Armed: internal_before on close/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Dashboard: list armed faults",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin"
              ]
            },
            "description": "Reads the armed faults from the admin page and saves the last one's id to `fault_id`.",
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "var ids = []; var re = /faults\\/(\\d+)\\/delete/g; var m; var t = pm.response.text(); while ((m = re.exec(t)) !== null) { ids.push(m[1]); }",
                  "pm.test(\"the armed fault is listed\", function () { pm.expect(ids.length).to.be.above(0); });",
                  "if (ids.length) { pm.collectionVariables.set(\"fault_id\", ids[ids.length - 1]); }"
                ]
              }
            }
          ]
        },
        {
          "name": "Remove the fault",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults/{{fault_id}}/delete",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults",
                "{{fault_id}}",
                "delete"
              ]
            },
            "description": "Deletes the fault saved in `fault_id`.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /removed/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/removed/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Undocumented combination is refused",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/faults",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "faults"
              ]
            },
            "description": "409 lock is documented for capture and refund only, so on close it is refused and nothing is armed.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "endpoint",
                  "value": "close"
                },
                {
                  "key": "kind",
                  "value": "conflict_lock"
                },
                {
                  "key": "payment_id",
                  "value": "{{payment_id}}"
                },
                {
                  "key": "count",
                  "value": "1"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=error\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"error\"); });",
                  "pm.test(\"message matches /not documented/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/not documented/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        }
      ]
    },
    {
      "name": "11. Admin webhook tools",
      "description": "Needs `admin_token`, the webhook registered (folder 0) and the receiver running. Docs: https://docs.tabby.ai/pay-in-4-custom-integration/webhooks (\"the delivery order is not guaranteed and the same event may occasionally arrive twice\"; \"A `closed` event can arrive before the `authorized` one\"). Force status is a test convenience.",
      "item": [
        {
          "name": "11.1 Get admin form token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin"
              ]
            },
            "description": "Loads the admin dashboard (Basic auth: `admin_user` / `admin_token`) and saves the hidden form `_token` to `form_token`. Every admin POST needs it; it is valid for one hour. Not a Tabby feature: the admin page is a test convenience.",
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "var m = /name=\"_token\" value=\"([^\"]*)\"/.exec(pm.response.text());",
                  "pm.test(\"a form token was found in the page\", function () { pm.expect(m, \"no _token in the page: is TABBY_SIM_ADMIN_TOKEN set and admin_token filled in?\").to.not.equal(null); });",
                  "if (m) { pm.collectionVariables.set(\"form_token\", m[1]); }"
                ]
              }
            }
          ]
        },
        {
          "name": "Create session (webhook tools)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Approve on the hosted page (OTP 8888)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "Approves with the test OTP 8888. The customer is redirected to `merchant_urls.success?payment_id=...` (302; redirects are not followed so you can see it). Based on Testing Credentials #1.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "action",
                  "value": "approve"
                },
                {
                  "key": "otp",
                  "value": "8888"
                }
              ]
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"Location is success with payment_id\", function () { pm.expect(loc).to.eql(pm.collectionVariables.get(\"base_url\") + \"/merchant/success?payment_id=\" + pm.collectionVariables.get(\"payment_id\")); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Payment page: find a delivery",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the delivery ids from the payment's admin page (webhook deliveries are logged by a background job, so the request waits a few seconds).",
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// the webhook is delivered by a background job: give it a moment",
                  "setTimeout(function () {}, 3000);"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "var m = /name=\"delivery_id\" value=\"(\\d+)\"/.exec(pm.response.text());",
                  "pm.test(\"a delivery exists (is a webhook registered for the merchant code?)\", function () { pm.expect(m).to.not.equal(null); });",
                  "if (m) { pm.collectionVariables.set(\"delivery_id\", m[1]); }"
                ]
              }
            }
          ]
        },
        {
          "name": "Resend a delivery (duplicate, delayed 5 s)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/webhooks/resend",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "webhooks",
                "resend"
              ]
            },
            "description": "Queues the stored payload again: the receiver gets the same event twice, 5 seconds later.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "delivery_id",
                  "value": "{{delivery_id}}"
                },
                {
                  "key": "delay",
                  "value": "5"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /queued again in 5 s/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/queued again in 5 s/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Capture 60",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Creates a second event.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"60.00\",\n  \"reference_id\": \"cap-a\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Capture 40",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}/captures",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}",
                "captures"
              ]
            },
            "description": "Creates a third event (the payment closes).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount\": \"40.00\",\n  \"reference_id\": \"cap-b\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"CLOSED\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Send this payment's webhooks out of order",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/payments/{{payment_id}}/out-of-order",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "payments",
                "{{payment_id}}",
                "out-of-order"
              ]
            },
            "description": "Queues the recorded events newest first, 2 seconds apart: `closed` arrives before `authorized`. Needs at least two events.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "delay",
                  "value": "3"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// the webhook is delivered by a background job: give it a moment",
                  "setTimeout(function () {}, 3000);"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /\\\\d+ webhook\\\\(s\\\\) queued newest first/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/\\d+ webhook\\(s\\) queued newest first/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Create session (force status)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Force CREATED to AUTHORIZED",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/payments/{{payment_id}}/force-status",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "payments",
                "{{payment_id}}",
                "force-status"
              ]
            },
            "description": "Moves along the documented lifecycle without the hosted page. The receiver prints an `authorized` webhook.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "status",
                  "value": "AUTHORIZED"
                },
                {
                  "key": "webhook",
                  "value": "true"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Status forced to AUTHORIZED/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Status forced to AUTHORIZED/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve: AUTHORIZED",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the payment back (Retrieve a payment: https://docs.tabby.ai/api-reference/payments/retrieve-a-payment). Status is UPPERCASE here, lowercase in webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is AUTHORIZED\", function () { pm.expect(j.status).to.eql(\"AUTHORIZED\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Force AUTHORIZED to CLOSED",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/payments/{{payment_id}}/force-status",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "payments",
                "{{payment_id}}",
                "force-status"
              ]
            },
            "description": "The `close` webhook carries `closed_at`.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "status",
                  "value": "CLOSED"
                },
                {
                  "key": "webhook",
                  "value": "true"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=ok\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"ok\"); });",
                  "pm.test(\"message matches /Status forced to CLOSED/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/Status forced to CLOSED/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve: CLOSED",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the payment back (Retrieve a payment: https://docs.tabby.ai/api-reference/payments/retrieve-a-payment). Status is UPPERCASE here, lowercase in webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is CLOSED\", function () { pm.expect(j.status).to.eql(\"CLOSED\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Create session (refused force)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Creates a fresh session for this folder (Create a session: https://docs.tabby.ai/api-reference/checkout/create-a-session).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Force CREATED to CLOSED (refused)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/admin/payments/{{payment_id}}/force-status",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "admin",
                "payments",
                "{{payment_id}}",
                "force-status"
              ]
            },
            "description": "Not a documented move (a payment is authorized before it can close), so the admin refuses it.",
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "_token",
                  "value": "{{form_token}}"
                },
                {
                  "key": "status",
                  "value": "CLOSED"
                },
                {
                  "key": "webhook",
                  "value": "true"
                }
              ]
            },
            "auth": {
              "type": "basic",
              "basic": [
                {
                  "key": "username",
                  "value": "{{admin_user}}",
                  "type": "string"
                },
                {
                  "key": "password",
                  "value": "{{admin_token}}",
                  "type": "string"
                }
              ]
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "function qp(loc, key) { var m = new RegExp(\"[?&]\" + key + \"=([^&]*)\").exec(loc || \"\"); return m ? decodeURIComponent(m[1].replace(/\\+/g, \" \")) : \"\"; }",
                  "pm.test(\"HTTP 302\", function () { pm.response.to.have.status(302); });",
                  "const loc = pm.response.headers.get(\"Location\") || \"\";",
                  "pm.test(\"redirect carries kind=error\", function () { pm.expect(qp(loc, \"kind\")).to.eql(\"error\"); });",
                  "pm.test(\"message matches /not a documented move/\", function () { pm.expect(qp(loc, \"msg\")).to.match(/not a documented move/); });"
                ]
              }
            }
          ],
          "protocolProfileBehavior": {
            "followRedirects": false
          }
        },
        {
          "name": "Retrieve: still CREATED",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Reads the payment back (Retrieve a payment: https://docs.tabby.ai/api-reference/payments/retrieve-a-payment). Status is UPPERCASE here, lowercase in webhooks."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is CREATED\", function () { pm.expect(j.status).to.eql(\"CREATED\"); });"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "12. Expiry",
      "description": "Needs the server started with short timers, for example `TABBY_SIM_SESSION_TTL_MIN=1 TABBY_SIM_EXPIRE_AFTER_MIN=2 TABBY_SIM_SEND_EXPIRED_WEBHOOK=true`. Two separate timers (Payment Statuses): the hosted page link dies first, the payment status becomes EXPIRED later.",
      "item": [
        {
          "name": "Create session (will expire)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/v2/checkout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "checkout"
              ]
            },
            "description": "Create it, then WAIT: about 70 s for the next request, about 2.5 minutes in total for the last one.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merchant_code\": \"{{merchant_code}}\",\n  \"payment\": {\n    \"amount\": \"100.00\",\n    \"currency\": \"AED\",\n    \"description\": \"Postman demo order\",\n    \"buyer\": {\n      \"phone\": \"+971500000001\",\n      \"email\": \"otp.success@tabby.ai\",\n      \"name\": \"Demo Buyer\"\n    },\n    \"order\": {\n      \"reference_id\": \"POSTMAN-{{$timestamp}}\",\n      \"items\": [\n        {\n          \"title\": \"Phone\",\n          \"quantity\": 1,\n          \"unit_price\": \"100.00\",\n          \"category\": \"Electronics\",\n          \"reference_id\": \"SKU-1\"\n        }\n      ]\n    },\n    \"buyer_history\": {\n      \"registered_since\": \"2020-01-01T00:00:00Z\",\n      \"loyalty_level\": 0\n    },\n    \"shipping_address\": {\n      \"city\": \"Dubai\",\n      \"address\": \"Demo street 1\",\n      \"zip\": \"00000\"\n    }\n  },\n  \"lang\": \"en\",\n  \"merchant_urls\": {\n    \"success\": \"{{base_url}}/merchant/success\",\n    \"cancel\": \"{{base_url}}/merchant/cancel\",\n    \"failure\": \"{{base_url}}/merchant/failure\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "if (j && j.id) { pm.collectionVariables.set(\"session_id\", j.id); }",
                  "if (j && j.payment) { pm.collectionVariables.set(\"payment_id\", j.payment.id); }",
                  "var inst = ((((j || {}).configuration || {}).available_products || {}).installments || [])[0];",
                  "pm.collectionVariables.set(\"web_url\", inst ? inst.web_url : \"\");",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"session status is 'created' and the payment starts CREATED\", function () { pm.expect(j.status).to.eql(\"created\"); pm.expect(j.payment.status).to.eql(\"CREATED\"); });",
                  "pm.test(\"ids were saved to the collection variables\", function () { pm.expect(pm.collectionVariables.get(\"session_id\")).to.eql(j.id); pm.expect(pm.collectionVariables.get(\"payment_id\")).to.eql(j.payment.id); });",
                  "pm.test(\"web_url points at the hosted page of this session\", function () { pm.expect(pm.collectionVariables.get(\"web_url\")).to.match(new RegExp(\"/hpp/\" + j.id + \"$\")); });",
                  "pm.test(\"expires_at is set\", function () { pm.expect(pm.collectionVariables.get(\"payment_id\")).to.be.a(\"string\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Hosted page after the session timer (410)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/hpp/{{session_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "hpp",
                "{{session_id}}"
              ]
            },
            "description": "After `TABBY_SIM_SESSION_TTL_MIN` the page says \"Session expired\" while the payment is still CREATED. Run this 70+ seconds after the session was created (with the default timers: 20+ minutes).",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test(\"HTTP 410\", function () { pm.response.to.have.status(410); });",
                  "pm.test(\"Session expired\", function () { pm.expect(pm.response.text()).to.include(\"Session expired\"); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Retrieve: EXPIRED",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v2/payments/{{payment_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v2",
                "payments",
                "{{payment_id}}"
              ]
            },
            "description": "Run this after `TABBY_SIM_EXPIRE_AFTER_MIN` (about 2.5 minutes with the short timers). If it still says CREATED, wait and send it again. The receiver prints an `expired` webhook with `expired_at` when the expired webhook is enabled."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const j = pm.response.json();",
                  "pm.test(\"HTTP 200\", function () { pm.response.to.have.status(200); });",
                  "pm.test(\"status is EXPIRED\", function () { pm.expect(j.status).to.eql(\"EXPIRED\"); });",
                  "pm.test(\"expired_at / is_expired are not part of the payment object, but the status is terminal\", function () { pm.expect(j.captures).to.eql([]); });"
                ]
              }
            }
          ]
        }
      ]
    }
  ]
}