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
- Billing Engine: Automated subscription billing simulation
- Chaos Testing: Unified ChaosCoordinator for payment, event, and API chaos
Installation
Add paper_tiger to your dependencies in mix.exs:
def deps do
[
{:paper_tiger, "~> 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, "~> 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: Start HTTP server (runs on port 4001 by default)
config :paper_tiger, 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, start: true
endEnvironment Variables
PaperTiger respects environment variables for runtime configuration:
PAPER_TIGER_START- Set to "true" to enable HTTP serverPAPER_TIGER_PORT- Port to run on (default: 4001)PAPER_TIGER_PORT_DEV- Port for dev environment (overridesPAPER_TIGER_PORT)PAPER_TIGER_PORT_TEST- Port for test environment (overridesPAPER_TIGER_PORT)
Port precedence: PAPER_TIGER_PORT_{ENV} > PAPER_TIGER_PORT > config > 4001
This allows running dev server and tests simultaneously on different ports:
# In .env or shell
export PAPER_TIGER_PORT_DEV=4001
export PAPER_TIGER_PORT_TEST=4003
This is also useful for Fly.io or other PaaS deployments:
# Enable PaperTiger for PR apps
fly secrets set PAPER_TIGER_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,
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 start for zero-config testing:
# config/test.exs
config :paper_tiger, 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"}}
endWebhook Collection Mode
For tests that need to verify webhooks without running a web server, use :collect mode. This stores webhooks in-memory for inspection instead of delivering them via HTTP.
defmodule MyApp.SubscriptionTest do
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tiger
test "creating subscription triggers correct webhook" do
# Enable webhook collection for this test
enable_webhook_collection()
# Create subscription - this triggers the webhook
{:ok, subscription} = Stripe.Subscription.create(%{
customer: customer_id,
items: [%{price: price_id}]
})
# Verify the webhook that Stripe would send
[delivery] = assert_webhook_delivered("customer.subscription.created")
# Inspect the webhook payload
assert delivery.event_data.object.id == subscription.id
assert delivery.event_data.object.status == "active"
assert delivery.event_data.object.customer == customer_id
end
test "cancellation triggers delete webhook" do
enable_webhook_collection()
{:ok, subscription} = Stripe.Subscription.create(%{...})
clear_delivered_webhooks() # Clear creation webhook
{:ok, _} = Stripe.Subscription.cancel(subscription.id)
[delivery] = assert_webhook_delivered("customer.subscription.deleted")
assert delivery.event_data.object.status == "canceled"
end
test "update does not trigger delete webhook" do
enable_webhook_collection()
{:ok, subscription} = Stripe.Subscription.create(%{...})
clear_delivered_webhooks()
{:ok, _} = Stripe.Subscription.update(subscription.id, %{metadata: %{foo: "bar"}})
refute_webhook_delivered("customer.subscription.deleted")
assert_webhook_delivered("customer.subscription.updated")
end
endAvailable helpers:
enable_webhook_collection/0- Enables:collectmode for the test (auto-cleanup on exit)get_delivered_webhooks/0- Returns all collected webhooksget_delivered_webhooks/1- Filter by event type (supports wildcards like"customer.*")assert_webhook_delivered/1- Asserts webhook was delivered, returns matchesrefute_webhook_delivered/1- Asserts webhook was NOT deliveredclear_delivered_webhooks/0- Clears collected webhooks (useful between operations)
When to use each mode:
| Mode | Use Case |
|---|---|
:collect | Unit tests verifying webhook payloads without HTTP |
:sync | Integration tests with a real webhook endpoint |
| Default (async) | Development with Phoenix running |
Port Configuration
PaperTiger automatically picks a random available port in the 59000-60000 range by default, eliminating port conflicts when running multiple instances (tests + dev server, parallel test suites).
Port selection:
- Random by default - Picks available port, retries if conflict detected
- Early resolution -
stripity_stripe_config()andPaperTiger.get_port()resolve and cache the port before startup - Manual override - Set explicit port via env var or config
If you need a specific port:
# Via environment variable (recommended)
PAPER_TIGER_PORT=4001 mix test
# Via config
config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4001)
# Discover which port was selected
iex> PaperTiger.get_port()
59342Concurrent Test Support
PaperTiger supports running tests concurrently with async: true through a sandbox mechanism similar to Ecto's SQL Sandbox.
Setup
Use checkout_paper_tiger in your test setup:
defmodule MyApp.StripeTest do
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tiger
test "creates a customer" do
# Data is isolated to this test process
conn = post("/v1/customers", %{email: "test@example.com"})
assert conn.status == 200
end
endHow It Works
checkout_paper_tiger/1stores the test process PID as a namespace- All PaperTiger operations (reads/writes) are scoped to that namespace
- On test exit, only that namespace's data is automatically cleaned up
This allows hundreds of tests to run in parallel without data interference.
Stripity Stripe Integration
When using stripity_stripe, configure it to use PaperTiger.StripityStripeHackney for automatic sandbox isolation:
# config/test.exs
config :stripity_stripe,
api_key: "sk_test_paper_tiger",
api_base_url: "http://localhost:4001",
http_module: PaperTiger.StripityStripeHackneyOr use the helper:
# config/test.exs
config :stripity_stripe, PaperTiger.stripity_stripe_config()This ensures that all Stripe API calls made via stripity_stripe automatically include the namespace header for test isolation - including calls made from child processes like Phoenix LiveView.
How it works:
checkout_paper_tiger/1sets a shared namespace via Application envPaperTiger.StripityStripeHackneyinjects thex-paper-tiger-namespaceheader into HTTP requests- Child processes (LiveView, async tasks) automatically pick up the shared namespace
- All Stripe operations are scoped to the test's namespace
Example with LiveView:
defmodule MyApp.BillingLiveTest do
use MyApp.ConnCase, async: true
import PaperTiger.Test
setup do
checkout_paper_tiger(%{})
# ... rest of setup
end
test "billing page shows subscription", %{conn: conn} do
# Create subscription in test process
{:ok, _sub} = Stripe.Subscription.create(%{...})
# LiveView's Stripe calls are automatically isolated to same sandbox
{:ok, live, _html} = live(conn, "/billing")
assert has_element?(live, "[data-subscription]")
end
endMigration from sync tests
Replace:
# Before
use ExUnit.Case
setup do
PaperTiger.flush()
:ok
endWith:
# After
use ExUnit.Case, async: true
import PaperTiger.Test
setup :checkout_paper_tigerArchitecture
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 hourBilling Engine
PaperTiger includes a BillingEngine for simulating subscription billing cycles. This enables testing of payment failures, retry logic, and subscription lifecycle without waiting for real time to pass.
Basic Usage
# Enable billing engine in config
config :paper_tiger, :billing_engine, true
# Or start manually
PaperTiger.BillingEngine.start_link([])
# Process all due subscriptions
{:ok, stats} = PaperTiger.BillingEngine.process_billing()
# => %{processed: 5, succeeded: 4, failed: 1}Chaos Testing
PaperTiger provides a unified ChaosCoordinator for comprehensive chaos testing across payment processing, webhook delivery, and API responses.
Payment Chaos
Simulate payment failures with configurable rates and decline codes:
# Configure global payment failure rate
PaperTiger.ChaosCoordinator.configure(
payment_failure_rate: 0.3, # 30% failure rate
decline_codes: [:card_declined, :insufficient_funds, :expired_card]
)
# Per-customer failure simulation
PaperTiger.ChaosCoordinator.simulate_failure("cus_123", :insufficient_funds)
# Clear per-customer simulation
PaperTiger.ChaosCoordinator.clear_simulation("cus_123")
# Reset all chaos settings
PaperTiger.ChaosCoordinator.reset()Event Chaos
Test webhook handler resilience with out-of-order and duplicate events:
PaperTiger.ChaosCoordinator.configure(
event_out_of_order_rate: 0.2, # 20% of events delivered out of order
event_duplicate_rate: 0.1, # 10% duplicate events
event_delay_range: {100, 5000} # Random delay 100-5000ms
)API Chaos
Simulate API failures and rate limiting:
PaperTiger.ChaosCoordinator.configure(
api_timeout_rate: 0.1, # 10% of requests timeout
api_error_rate: 0.05, # 5% server errors
rate_limit_rate: 0.02 # 2% rate limit responses
)Extended Decline Codes
PaperTiger supports 22 Stripe decline codes for realistic failure simulation:
- Common:
card_declined,insufficient_funds,expired_card,do_not_honor - Authentication:
authentication_required,incorrect_cvc,incorrect_zip - Fraud:
fraudulent,stolen_card,lost_card,pickup_card - Limits:
card_velocity_exceeded,withdrawal_count_limit_exceeded - Technical:
processing_error,try_again_later,issuer_not_available
Chaos Statistics
Track chaos events for test assertions:
stats = PaperTiger.ChaosCoordinator.stats()
# => %{
# payment_failures: 5,
# events_reordered: 3,
# events_duplicated: 2,
# api_timeouts: 1
# }Subscription Lifecycle
The billing engine handles the full subscription lifecycle:
- Finds subscriptions where
current_period_endhas passed - Creates invoice with line items
- Creates payment intent and attempts charge
- On success: Updates invoice to
paid, advances subscription period - On failure: Increments
attempt_count, marks subscriptionpast_dueafter 4 failures
Combined with time control, you can simulate months of billing in seconds:
# Set up subscription due for billing
PaperTiger.set_clock_mode(:manual, timestamp: :os.system_time(:second))
# Process billing cycle
{:ok, _} = PaperTiger.BillingEngine.process_billing()
# Advance 30 days
PaperTiger.advance_time(30 * 24 * 60 * 60)
# Process next cycle
{:ok, _} = PaperTiger.BillingEngine.process_billing()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)
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.
🛡️ Safety Guard: PaperTiger performs two-layer validation before running against real Stripe:
- Validates the API key prefix (rejects
sk_live_*,rk_live_*)- Makes a live API call to
/v1/balanceand verifieslivemode: falseIf you accidentally configure a live-mode key, the tests will refuse to run with a clear error message. This prevents accidental charges to real customers.
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"Initial Data
PaperTiger can pre-populate products, prices, and customers on startup via the init_data config. Since ETS is ephemeral, this runs on every application start - useful for development environments where you need consistent Stripe data available immediately.
# config/dev.exs - From a JSON file
config :paper_tiger,
init_data: "priv/paper_tiger/init_data.json"
# Or inline in config
config :paper_tiger,
init_data: %{
products: [
%{
id: "prod_dev_standard",
name: "Standard Plan",
active: true,
metadata: %{credits: "100"}
}
],
prices: [
%{
id: "price_dev_standard_monthly",
product: "prod_dev_standard",
unit_amount: 7900,
currency: "usd",
recurring: %{interval: "month", interval_count: 1}
}
]
}Use custom IDs (like prod_dev_*) to ensure deterministic data across restarts. This is particularly useful when your app syncs from Stripe on startup - the data will be there before your sync runs.
Database Synchronization
For development environments where you have existing billing data in your database (e.g., from previous Stripe syncs), PaperTiger can load that data on startup using the DataSource behaviour. This is more dynamic than init_data since it reads directly from your application's database.
When to use each approach:
| Approach | Use Case |
|---|---|
init_data | Static seed data (JSON file or config) |
data_source | Sync from your application's database tables |
Step 1: Implement the DataSource Behaviour
Create an adapter module that implements PaperTiger.DataSource:
# lib/my_app/paper_tiger_adapter.ex
defmodule MyApp.PaperTigerAdapter do
@moduledoc """
Loads billing data from database into PaperTiger on startup.
"""
@behaviour PaperTiger.DataSource
import Ecto.Query
alias MyApp.Repo
alias MyApp.Billing.{Product, Price, Customer, Subscription}
@impl true
def load_products do
from(p in Product, where: not is_nil(p.stripe_id))
|> Repo.all()
|> Enum.map(&map_product/1)
end
@impl true
def load_prices do
from(p in Price,
left_join: prod in assoc(p, :product),
where: not is_nil(p.stripe_id),
select: %{p | product: prod}
)
|> Repo.all()
|> Enum.map(&map_price/1)
end
@impl true
def load_customers do
from(c in Customer,
left_join: u in assoc(c, :user),
where: not is_nil(c.stripe_id),
select: %{c | user: u}
)
|> Repo.all()
|> Enum.map(&map_customer/1)
end
@impl true
def load_subscriptions do
from(s in Subscription,
left_join: c in assoc(s, :customer),
left_join: p in assoc(s, :price),
where: not is_nil(s.stripe_id),
select: %{s | customer: c, price: p}
)
|> Repo.all()
|> Enum.map(&map_subscription/1)
end
# Return empty lists for resources you don't need to sync
@impl true
def load_plans, do: []
@impl true
def load_payment_methods do
# Create generic payment methods from customer default_source
from(c in Customer,
where: not is_nil(c.stripe_id) and not is_nil(c.default_source),
select: %{stripe_id: c.stripe_id, default_source: c.default_source}
)
|> Repo.all()
|> Enum.map(&map_payment_method/1)
end
# Mapping functions - convert your schemas to Stripe format with atom keys
defp map_product(product) do
%{
id: product.stripe_id,
object: "product",
name: product.name,
active: product.active,
metadata: product.metadata || %{},
created: to_unix(product.inserted_at)
}
end
defp map_price(price) do
%{
id: price.stripe_id,
object: "price",
product: price.product.stripe_id,
unit_amount: price.amount,
currency: price.currency || "usd",
type: if(price.recurring_interval, do: "recurring", else: "one_time"),
recurring: if(price.recurring_interval,
do: %{
interval: price.recurring_interval,
interval_count: price.recurring_interval_count || 1
}
),
created: to_unix(price.inserted_at)
}
end
defp map_customer(customer) do
%{
id: customer.stripe_id,
object: "customer",
email: customer.user && customer.user.email,
name: customer.user && customer.user.name,
default_source: customer.default_source,
created: to_unix(customer.inserted_at),
invoice_settings: %{
default_payment_method: customer.default_source
},
metadata: %{}
}
end
defp map_subscription(subscription) do
%{
id: subscription.stripe_id,
object: "subscription",
customer: subscription.customer.stripe_id,
status: subscription.status || "active",
current_period_start: to_unix(subscription.current_period_start_at),
current_period_end: to_unix(subscription.current_period_end_at),
cancel_at: to_unix(subscription.cancel_at),
items: %{
object: "list",
data: [
%{
id: "si_#{subscription.stripe_id}",
object: "subscription_item",
price: subscription.price.stripe_id,
quantity: 1
}
]
},
created: to_unix(subscription.inserted_at)
}
end
defp map_payment_method(%{stripe_id: customer_stripe_id, default_source: pm_id}) do
%{
id: pm_id,
object: "payment_method",
type: "card",
customer: customer_stripe_id,
card: %{
brand: "visa",
last4: "4242",
exp_month: 12,
exp_year: 2030
},
created: DateTime.utc_now() |> DateTime.to_unix()
}
end
defp to_unix(%NaiveDateTime{} = ndt) do
DateTime.from_naive!(ndt, "Etc/UTC") |> DateTime.to_unix()
end
defp to_unix(_), do: nil
endStep 2: Configure PaperTiger
Point PaperTiger to your adapter and enable bootstrap:
# config/dev.exs
config :paper_tiger,
start: true,
repo: MyApp.Repo, # Required for bootstrap
data_source: MyApp.PaperTigerAdapter,
enable_bootstrap: trueImportant: The data_source sync runs during application startup via the Bootstrap worker. Data loads only once when PaperTiger starts, not on every request.
Step 3: Verify Data Loading
Start your application and check the logs:
[info] PaperTiger bootstrap starting
[info] PaperTiger loaded 13 prices from data_source
[info] PaperTiger loaded 7 products from data_source
[info] PaperTiger loaded 2 customers from data_source
[info] PaperTiger loaded 4 subscriptions from data_source
[info] PaperTiger loaded 2 payment_methods from data_source
[info] PaperTiger bootstrap completeMapping Tips
- Use atom keys - PaperTiger expects maps with atom keys, not string keys
- Map IDs correctly - Your
stripe_idcolumn maps to theidfield - Convert timestamps - Use Unix timestamps (integers), not
NaiveDateTimeorDateTimestructs - Map relationships - Use Stripe IDs for associations (e.g.,
customer: "cus_123", not database IDs) - Include invoice_settings - If your app uses
invoice_settings.default_payment_method, include it on customers
Benefits
- Realistic testing - Use actual billing data from your database
- Automatic sync - Data loads on startup, no manual setup needed
- Development parity - Dev environment matches production data structure
- Fast iteration - Modify data in database, restart to reload
Combining with init_data
You can use both init_data and data_source together. PaperTiger loads them in this order:
- Test tokens (e.g.,
pm_card_visa) - DataSource (your adapter)
- init_data (JSON/config file)
This allows test tokens to be available first, then real data from your database, then any additional seed data.
Development
Optional: Setup with Nix
This repository includes flake.nix for contributors who use Nix.
If you do not use Nix, you can ignore this section.
Enter the dev shell manually:
nix develop
Optional: enable automatic shell loading with direnv:
cp .envrc.example .envrc
direnv allow
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