PaperTiger
View SourceA stateful mock Stripe server for testing Elixir applications.
Rationale
Testing payment processing requires simulating complex Stripe workflows: subscription billing cycles, invoice finalization, webhook delivery, idempotency handling. Using real Stripe test accounts introduces external dependencies, network latency, rate limits, and non-deterministic test data. Stubbing individual Stripe API calls leads to brittle tests that break when Stripe's API evolves.
PaperTiger solves this by providing a complete, stateful implementation of the Stripe API that runs in-process. Tests execute in milliseconds instead of seconds, work offline, and produce deterministic results. The dual-mode contract testing system validates that PaperTiger's behavior matches production Stripe, catching API drift automatically.
Philosophy
State over stubs: PaperTiger maintains actual resource state (customers, subscriptions, invoices) instead of returning canned responses. This enables testing complex workflows like subscription lifecycle management, trial expiration, and invoice finalization.
Contract validation: The same test suite runs against both PaperTiger and real Stripe. This ensures the mock accurately reflects production behavior while maintaining zero-setup development experience.
Time control: Subscription billing, trial periods, and webhook retry logic depend on time progression. PaperTiger provides accelerated and manual clock modes for testing time-dependent behavior without waiting.
Elixir-native: Built with ETS, GenServers, and Plug. No external databases or runtimes required.
Features
- Complete Stripe API Coverage: Customers, Subscriptions, Invoices, PaymentMethods, and more
- Stateful In-Memory Storage: ETS-backed GenServers with concurrent reads and serialized writes
- Webhook Delivery: HMAC-signed webhook events with retry logic
- Dual-Mode Contract Testing: Run same tests against PaperTiger or real Stripe API
- Zero External Dependencies: No Stripe account or API keys required for normal testing
- Idempotency: Request deduplication with 24-hour TTL
- Object Expansion: Hydrator system for nested resource expansion
- Time Control: Accelerated, manual, or real-time clock for testing
Installation
Add paper_tiger to your dependencies in mix.exs:
def deps do
[
{:paper_tiger, "~> 0.1.0"}
]
endQuick Start
Try the interactive Livebook tutorial for a hands-on introduction!
# Start PaperTiger in your test setup
{:ok, _} = PaperTiger.start()
# Make requests using any HTTP client (e.g., Req)
response = Req.post!(
"http://localhost:4001/v1/customers",
form: [email: "user@example.com", name: "Test User"],
auth: {:bearer, "sk_test_mock"}
)
# Or use the TestClient for dual-mode testing
alias PaperTiger.TestClient
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "Test User"
})
# Clean up between tests
PaperTiger.flush()Phoenix Integration
PaperTiger integrates seamlessly with Phoenix applications for local development and testing.
Setup
Add PaperTiger to your dependencies:
# mix.exs
def deps do
[
{:paper_tiger, "~> 0.1.0", only: [:dev, :test]},
{:stripity_stripe, "~> 3.0"}
]
endConfiguration
Use PaperTiger's configuration helper in your config files:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config()
# Optional: Auto-start HTTP server (runs on port 4001 by default)
config :paper_tiger, auto_start: true
# Optional: Register webhooks automatically
config :paper_tiger,
webhooks: [
[url: "http://localhost:4000/webhooks/stripe"]
]For conditional use in development (e.g., PR apps):
# config/runtime.exs
if System.get_env("USE_PAPER_TIGER") == "true" do
config :stripity_stripe, PaperTiger.stripity_stripe_config()
config :paper_tiger, auto_start: true
endEnvironment Variables
PaperTiger respects environment variables for runtime configuration:
PAPER_TIGER_AUTO_START- Set to "true" to enable HTTP serverPAPER_TIGER_PORT- Port to run on (default: 4001)
This is useful for Heroku, Render, or other PaaS deployments:
# Enable PaperTiger for PR apps
heroku config:set PAPER_TIGER_AUTO_START=true -a my-app-pr-123
Webhook Integration
PaperTiger automatically emits Stripe events when resources are created, updated, or deleted. These events are delivered to registered webhook endpoints with proper HMAC signatures.
Supported events:
customer.created,customer.updated,customer.deletedcustomer.subscription.created,customer.subscription.updated,customer.subscription.deletedinvoice.created,invoice.updated,invoice.finalized,invoice.paid,invoice.payment_succeededpayment_intent.created,product.created,price.created
Option 1: Auto-registration via Config
# config/test.exs
config :paper_tiger,
auto_start: true,
webhooks: [
[url: "http://localhost:4000/webhooks/stripe"]
]
# In your test setup
setup do
PaperTiger.register_configured_webhooks()
PaperTiger.flush()
:ok
endOption 2: Manual Registration
# In test setup or IEx
PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")Webhook Controller
Your Phoenix webhook controller works unchanged:
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller
def webhook(conn, params) do
# PaperTiger signs webhooks identically to Stripe
case Stripe.Webhook.construct_event(
conn.assigns.raw_body,
get_req_header(conn, "stripe-signature"),
Application.get_env(:stripity_stripe, :webhook_signing_key)
) do
{:ok, event} ->
handle_event(event)
send_resp(conn, 200, "ok")
{:error, _} ->
send_resp(conn, 400, "invalid signature")
end
end
endDevelopment Workflow
# Terminal 1: Start Phoenix (port 4000)
mix phx.server
# Terminal 2: Start PaperTiger (port 4001)
iex -S mix
iex> PaperTiger.start()
iex> PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")
# Now Stripe API calls from your app go to PaperTiger
# Webhooks are delivered to your Phoenix app
Or use auto-start for zero-config testing:
# config/test.exs
config :paper_tiger, auto_start: true
config :stripity_stripe, PaperTiger.stripity_stripe_config()
# In tests - PaperTiger runs automatically
test "subscription creation triggers webhook" do
# Create subscription via Stripe client
{:ok, _sub} = Stripe.Subscription.create(%{customer: customer_id, ...})
# Webhook delivered to your Phoenix app
assert_receive {:webhook, %{type: "customer.subscription.created"}}
endPort Configuration
PaperTiger uses port 4001 by default to avoid conflicts with Phoenix's port 4000.
If you need a different port:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4002)
# Or via environment variable
# PAPER_TIGER_PORT=4002 mix testArchitecture
Storage Layer
Each Stripe resource has a dedicated ETS-backed GenServer store:
- Reads: Direct ETS access (concurrent, no GenServer bottleneck)
- Writes: Through GenServer (serialized, prevents race conditions)
- Operations:
get/1,list/1,insert/1,update/1,delete/1,clear/0
All stores use a shared PaperTiger.Store macro to eliminate boilerplate.
HTTP Layer
Plug-based request pipeline:
- Auth: Validates
Authorization: Bearer sk_test_*headers (lenient mode) - CORS: Cross-origin request support
- Idempotency: Prevents duplicate POST requests via
Idempotency-Keyheader - UnflattenParams: Converts form-encoded bracket notation to nested Elixir structures
Router uses macro-based route generation for DRY resource definitions.
Webhook System
PaperTiger.WebhookDelivery GenServer handles asynchronous webhook delivery:
- HMAC SHA256 signing with configurable secrets
- Exponential backoff retry logic (5 attempts)
- Event type filtering per endpoint
- Delivery tracking and logging
Time Control
PaperTiger.Clock provides deterministic time for testing:
# Real time (default)
PaperTiger.set_clock_mode(:real)
# Accelerated time (10x speed)
PaperTiger.set_clock_mode(:accelerated, multiplier: 10)
# Manual time control
PaperTiger.set_clock_mode(:manual, timestamp: 1234567890)
PaperTiger.advance_time(3600) # Advance 1 hourContract Testing
PaperTiger includes a dual-mode testing system that runs the same tests against both the mock server and real Stripe API, ensuring accuracy.
Default Mode (PaperTiger)
mix test
Zero configuration required. Tests run against the in-memory mock server.
Validation Mode (Real Stripe)
export STRIPE_API_KEY=sk_test_your_key_here
export VALIDATE_AGAINST_STRIPE=true
mix test test/paper_tiger/contract_test.exs
Tests run against stripe.com to validate that PaperTiger behavior matches production. Requires a Stripe test account. Only use test mode API keys (sktest*).
Writing Contract Tests
defmodule MyApp.ContractTest do
use ExUnit.Case
alias PaperTiger.TestClient
setup do
if TestClient.paper_tiger?() do
PaperTiger.flush()
end
:ok
end
test "customer CRUD lifecycle" do
# Create
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "Test User"
})
# Retrieve
{:ok, retrieved} = TestClient.get_customer(customer["id"])
assert retrieved["email"] == "user@example.com"
# Update
{:ok, updated} = TestClient.update_customer(customer["id"], %{
"name" => "Updated Name"
})
assert updated["name"] == "Updated Name"
# Delete
{:ok, deleted} = TestClient.delete_customer(customer["id"])
assert deleted["deleted"] == true
# Cleanup for real Stripe
if TestClient.real_stripe?() do
# Already deleted above
end
end
endThe TestClient module routes operations to the appropriate backend based on environment variables, normalizing responses to ensure consistent map structures with string keys.
Supported Contract Operations
Customers:
create_customer/1,get_customer/1,update_customer/2,delete_customer/1,list_customers/1
Subscriptions:
create_subscription/1,get_subscription/1,update_subscription/2,delete_subscription/1,list_subscriptions/1
PaymentMethods:
create_payment_method/1,get_payment_method/1
Invoices:
create_invoice/1,get_invoice/1
Supported Resources
PaperTiger provides comprehensive coverage of core Stripe resources with full CRUD operations:
Billing & Subscriptions: Customers, Subscriptions, SubscriptionItems, Invoices, InvoiceItems, Products, Prices, Plans, Coupons, TaxRates
Payments: PaymentMethods, PaymentIntents, SetupIntents, Charges, Refunds
Payment Sources: Cards, BankAccounts, Sources, Tokens
Platform & Connect: Payouts, BalanceTransactions, ApplicationFees, Disputes
Checkout & Events: CheckoutSessions, WebhookEndpoints, Events, Reviews, Topups
Note: PaperTiger implements Stripe API v1 resources. Some v2-only resources (e.g., v2 billing features) are not yet supported. Check the issues page for planned additions or open a feature request.
API Examples
Customer Operations
# Create
{:ok, customer} = TestClient.create_customer(%{
"email" => "user@example.com",
"name" => "John Doe",
"metadata" => %{"user_id" => "12345"}
})
# Retrieve
{:ok, customer} = TestClient.get_customer("cus_123")
# Update
{:ok, updated} = TestClient.update_customer("cus_123", %{
"name" => "Jane Doe"
})
# Delete
{:ok, deleted} = TestClient.delete_customer("cus_123")
# List with pagination
{:ok, list} = TestClient.list_customers(%{
"limit" => 10,
"starting_after" => "cus_123"
})Subscription Operations
# Create subscription with inline price
{:ok, subscription} = TestClient.create_subscription(%{
"customer" => "cus_123",
"items" => [
%{
"price_data" => %{
"currency" => "usd",
"product_data" => %{"name" => "Premium Plan"},
"recurring" => %{"interval" => "month"},
"unit_amount" => 2000
}
}
]
})
# Update
{:ok, updated} = TestClient.update_subscription("sub_123", %{
"metadata" => %{"tier" => "premium"}
})
# Cancel
{:ok, canceled} = TestClient.delete_subscription("sub_123")Invoice Operations
# Create draft invoice
{:ok, invoice} = TestClient.create_invoice(%{
"customer" => "cus_123"
})
# Finalize and pay (PaperTiger HTTP API)
conn = post("/v1/invoices/#{invoice["id"]}/finalize")
conn = post("/v1/invoices/#{invoice["id"]}/pay")Configuration
# config/test.exs
config :paper_tiger,
port: 4001, # Avoids conflict with Phoenix's default 4000
clock_mode: :real,
webhook_secret: "whsec_test_secret"
# For contract testing (optional)
config :stripity_stripe,
api_key: System.get_env("STRIPE_API_KEY") || "sk_test_mock"Development
Running Tests
# All tests
mix test
# Specific resource tests
mix test test/paper_tiger/resources/customer_test.exs
# Contract tests (PaperTiger mode)
mix test test/paper_tiger/contract_test.exs
# Contract tests (Stripe validation mode)
STRIPE_API_KEY=sk_test_xxx VALIDATE_AGAINST_STRIPE=true \
mix test test/paper_tiger/contract_test.exs
Quality Checks
# Compilation warnings as errors
mix compile --warnings-as-errors
# Code formatting
mix format --check-formatted
# Static analysis
mix credo --strict --all
# Type checking
mix dialyzer
# All quality checks
mix compile --warnings-as-errors && \
mix format --check-formatted && \
mix credo --strict --all && \
mix dialyzer && \
mix test
Implementation Details
Type Coercion
Form-encoded parameters are automatically coerced to proper types:
# Input: "cancel_at_period_end=true&quantity=5"
# Output: %{cancel_at_period_end: true, quantity: 5}Array Parameter Flattening
Bracket notation is converted to Elixir lists:
# Input: "items[0][price]=price_123&items[1][price]=price_456"
# Output: %{items: [%{price: "price_123"}, %{price: "price_456"}]}Object Expansion
The Hydrator system supports Stripe's expand[] parameter:
# Request: GET /v1/customers/cus_123?expand[]=default_payment_method
# Response includes full PaymentMethod object instead of ID stringID Generation
All resources generate Stripe-compatible IDs:
- Customers:
cus_+ 24 random chars - Subscriptions:
sub_+ 24 random chars - Invoices:
in_+ 24 random chars - etc.
Pagination
Cursor-based pagination using starting_after, ending_before, and limit:
%{
"object" => "list",
"data" => [...],
"has_more" => true,
"url" => "/v1/customers"
}Known Limitations
- No persistent storage (in-memory only)
- Webhook delivery is asynchronous but not distributed
- Some Stripe API edge cases may differ from production
- Time-based features (trials, billing periods) require manual time control
Contributing
Contributions are welcome. When adding support for new Stripe resources or operations:
- Add the resource store using
use PaperTiger.Storemacro - Implement resource handlers in
lib/paper_tiger/resources/ - Add routes to
lib/paper_tiger/router.ex - Write comprehensive tests in
test/paper_tiger/resources/ - Add contract tests to validate against real Stripe API
- Update this README with new capabilities
License
MIT
Links
- Stripe API Documentation
- Stripity Stripe (Elixir Stripe client)
- Hex Package