# PaperTiger
A 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`:
```elixir
def deps do
[
{:paper_tiger, "~> 0.1.0"}
]
end
```
## Quick Start
[](https://livebook.dev/run?url=https://github.com/EnaiaInc/paper_tiger/blob/main/examples/getting_started.livemd)
Try the [interactive Livebook tutorial](examples/getting_started.livemd) for a hands-on introduction!
```elixir
# 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:
```elixir
# mix.exs
def deps do
[
{:paper_tiger, "~> 0.1.0", only: [:dev, :test]},
{:stripity_stripe, "~> 3.0"}
]
end
```
### Configuration
Use PaperTiger's configuration helper in your config files:
```elixir
# 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):
```elixir
# 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
end
```
### Environment Variables
PaperTiger respects environment variables for runtime configuration:
- `PAPER_TIGER_AUTO_START` - Set to "true" to enable HTTP server
- `PAPER_TIGER_PORT` - Port to run on (default: 4001)
This is useful for Heroku, Render, or other PaaS deployments:
```bash
# 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.deleted`
- `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`
- `invoice.created`, `invoice.updated`, `invoice.finalized`, `invoice.paid`, `invoice.payment_succeeded`
- `payment_intent.created`, `product.created`, `price.created`
#### Option 1: Auto-registration via Config
```elixir
# 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
end
```
#### Option 2: Manual Registration
```elixir
# In test setup or IEx
PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")
```
#### Webhook Controller
Your Phoenix webhook controller works unchanged:
```elixir
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
end
```
### Development Workflow
```bash
# 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:
```elixir
# 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"}}
end
```
### Port Configuration
PaperTiger uses port 4001 by default to avoid conflicts with Phoenix's port 4000.
If you need a different port:
```elixir
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4002)
# Or via environment variable
# PAPER_TIGER_PORT=4002 mix test
```
## Architecture
### 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:
1. **Auth**: Validates `Authorization: Bearer sk_test_*` headers (lenient mode)
2. **CORS**: Cross-origin request support
3. **Idempotency**: Prevents duplicate POST requests via `Idempotency-Key` header
4. **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:
```elixir
# 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 hour
```
## Contract 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)
```bash
mix test
```
Zero configuration required. Tests run against the in-memory mock server.
### Validation Mode (Real Stripe)
```bash
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 (sk*test*\*).
### Writing Contract Tests
```elixir
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
end
```
The `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](https://github.com/EnaiaInc/paper_tiger/issues) for planned additions or open a feature request.
## API Examples
### Customer Operations
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
# 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
```bash
# 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
```bash
# 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:
```elixir
# 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:
```elixir
# 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:
```elixir
# Request: GET /v1/customers/cus_123?expand[]=default_payment_method
# Response includes full PaymentMethod object instead of ID string
```
### ID 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`:
```elixir
%{
"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:
1. Add the resource store using `use PaperTiger.Store` macro
2. Implement resource handlers in `lib/paper_tiger/resources/`
3. Add routes to `lib/paper_tiger/router.ex`
4. Write comprehensive tests in `test/paper_tiger/resources/`
5. Add contract tests to validate against real Stripe API
6. Update this README with new capabilities
## License
MIT
## Links
- [Stripe API Documentation](https://stripe.com/docs/api)
- [Stripity Stripe](https://github.com/beam-community/stripity-stripe) (Elixir Stripe client)
- [Hex Package](https://hex.pm/packages/paper_tiger)