defmodule PaperTiger do @moduledoc """ PaperTiger - A stateful mock Stripe server for testing. ## Quick Start # Start the server {:ok, _pid} = PaperTiger.start() # Make API calls via HTTP response = HTTP.post!("/v1/customers", ...) # Clean up between tests PaperTiger.flush() ## Time Control PaperTiger supports three clock modes for deterministic testing: - `:real` - Use system time (default) - `:accelerated` - Time moves faster (useful for subscription billing tests) - `:manual` - Freeze time and advance manually ## Examples # Manual time control PaperTiger.set_clock_mode(:manual, timestamp: 1234567890) PaperTiger.advance_time(3600) # Advance 1 hour # Accelerated time (10x speed) PaperTiger.set_clock_mode(:accelerated, multiplier: 10) ## Resource Cleanup # Flush specific resource PaperTiger.flush(:customers) # Flush all resources PaperTiger.flush() """ alias PaperTiger.Store.{ Accounts, ApplicationFeeRefunds, ApplicationFees, BalanceTransactions, BankAccounts, BillingPortalConfigurations, BillingPortalSessions, Cards, Charges, CheckoutSessions, ConfirmationTokens, Coupons, CreditNotes, CustomerBalanceTransactions, Customers, Disputes, Events, InvoiceItems, Invoices, Mandates, PaymentIntents, PaymentLinks, PaymentMethodConfigurations, PaymentMethodDomains, PaymentMethods, Payouts, Plans, Prices, Products, PromotionCodes, Refunds, Reviews, SetupAttempts, SetupIntents, Sources, SubscriptionItems, Subscriptions, TaxRates, Tokens, Topups, TransferReversals, Transfers, Webhooks } @doc """ Starts the PaperTiger application. ## Options - `:port` - HTTP port (default: random port 59000-60000, reserved before startup) - `:transport_options` - TCP listen options passed to Thousand Island (default: `[]`) - `:clock_mode` - Time mode (default: `:real`) ## Examples {:ok, _pid} = PaperTiger.start() {:ok, _pid} = PaperTiger.start(port: 4002, clock_mode: :manual) """ @spec start(keyword()) :: {:ok, pid()} | {:error, term()} def start(opts \\ []) do opts = Keyword.merge([clock_mode: :real, auto_start: true], opts) # Configure and start application # Only set port if explicitly provided - otherwise Application will pick random available port if Keyword.has_key?(opts, :port) do Application.put_env(:paper_tiger, :port, opts[:port]) end if Keyword.has_key?(opts, :transport_options) do Application.put_env(:paper_tiger, :transport_options, opts[:transport_options]) end Application.put_env(:paper_tiger, :clock_mode, opts[:clock_mode]) Application.put_env(:paper_tiger, :auto_start, opts[:auto_start]) Application.ensure_all_started(:paper_tiger) end @doc """ Returns the port PaperTiger will use. If PaperTiger is already running, returns the actual port in use. If not started yet, it resolves and caches the port so config and runtime stay consistent. ## Examples PaperTiger.get_port() #=> 59342 """ @spec get_port() :: integer() def get_port do PaperTiger.Port.resolve() end @doc """ Returns the current Unix timestamp according to the configured clock. ## Examples PaperTiger.now() #=> 1234567890 """ @spec now() :: integer() defdelegate now(), to: PaperTiger.Clock @doc """ Sets the clock mode. ## Modes - `:real` - Use system time - `:accelerated` - Time runs faster (specify `:multiplier` option) - `:manual` - Manual control (specify `:timestamp` option) ## Examples PaperTiger.set_clock_mode(:real) PaperTiger.set_clock_mode(:accelerated, multiplier: 10) PaperTiger.set_clock_mode(:manual, timestamp: 1234567890) """ @spec set_clock_mode(atom(), keyword()) :: :ok defdelegate set_clock_mode(mode, opts \\ []), to: PaperTiger.Clock, as: :set_mode @doc """ Flushes (clears) all resources or a specific resource type. PaperTiger supports namespace-based isolation (used by `PaperTiger.Test`) so multiple test suites can run concurrently. `flush/0` clears data **only for the current namespace** (default `:global`). If you need to wipe *all* namespaces, use `flush_all/0`. ## Examples PaperTiger.flush() # Clear all resources in current namespace PaperTiger.flush(:customers) # Clear only customers """ @spec flush() :: :ok def flush do namespace = PaperTiger.Test.current_namespace() # Clear only the current namespace so concurrent sandboxed tests don't # destroy each other's state. :ok = PaperTiger.Test.cleanup_namespace(namespace) # Reset ChaosCoordinator for this namespace. PaperTiger.ChaosCoordinator.reset() # Pre-defined Stripe test tokens live in the global namespace. if namespace == :global do {:ok, _stats} = PaperTiger.TestTokens.load() end :ok end @doc """ Flushes (clears) all resources across all namespaces. This is the legacy behavior of `flush/0`. Prefer `flush/0` when running tests concurrently with `PaperTiger.Test` sandboxing. """ @spec flush_all() :: :ok def flush_all do # Dynamically find all PaperTiger ETS tables :ets.all() |> Enum.filter(fn table_name -> # Only process atom table names (some ETS tables use references) if is_atom(table_name) do table_str = Atom.to_string(table_name) String.starts_with?(table_str, "paper_tiger_") else false end end) |> Enum.each(fn table_name -> :ets.delete_all_objects(table_name) end) # Also clear idempotency cache via its API PaperTiger.Idempotency.clear() # Reset chaos coordinator (global namespace) PaperTiger.ChaosCoordinator.reset() # Reload pre-defined Stripe test tokens (pm_card_visa, tok_visa, etc.) {:ok, _stats} = PaperTiger.TestTokens.load() :ok end @spec flush(atom()) :: :ok | {:error, :unknown_resource} def flush(:customers), do: Customers.clear() def flush(:accounts), do: Accounts.clear() def flush(:subscriptions), do: Subscriptions.clear() def flush(:subscription_items), do: SubscriptionItems.clear() def flush(:invoices), do: Invoices.clear() def flush(:invoice_items), do: InvoiceItems.clear() def flush(:credit_notes), do: CreditNotes.clear() def flush(:products), do: Products.clear() def flush(:prices), do: Prices.clear() def flush(:payment_links), do: PaymentLinks.clear() def flush(:plans), do: Plans.clear() def flush(:payment_methods), do: PaymentMethods.clear() def flush(:payment_method_domains), do: PaymentMethodDomains.clear() def flush(:payment_method_configurations), do: PaymentMethodConfigurations.clear() def flush(:confirmation_tokens), do: ConfirmationTokens.clear() def flush(:mandates), do: Mandates.clear() def flush(:payment_intents), do: PaymentIntents.clear() def flush(:setup_attempts), do: SetupAttempts.clear() def flush(:setup_intents), do: SetupIntents.clear() def flush(:charges), do: Charges.clear() def flush(:refunds), do: Refunds.clear() def flush(:disputes), do: Disputes.clear() def flush(:coupons), do: Coupons.clear() def flush(:promotion_codes), do: PromotionCodes.clear() def flush(:customer_balance_transactions), do: CustomerBalanceTransactions.clear() def flush(:tax_rates), do: TaxRates.clear() def flush(:cards), do: Cards.clear() def flush(:bank_accounts), do: BankAccounts.clear() def flush(:sources), do: Sources.clear() def flush(:tokens), do: Tokens.clear() def flush(:checkout_sessions), do: CheckoutSessions.clear() def flush(:billing_portal_configurations), do: BillingPortalConfigurations.clear() def flush(:billing_portal_sessions), do: BillingPortalSessions.clear() def flush(:webhooks), do: Webhooks.clear() def flush(:events), do: Events.clear() def flush(:payouts), do: Payouts.clear() def flush(:transfers), do: Transfers.clear() def flush(:transfer_reversals), do: TransferReversals.clear() def flush(:balance_transactions), do: BalanceTransactions.clear() def flush(:application_fees), do: ApplicationFees.clear() def flush(:application_fee_refunds), do: ApplicationFeeRefunds.clear() def flush(:reviews), do: Reviews.clear() def flush(:topups), do: Topups.clear() def flush(_), do: {:error, :unknown_resource} @doc """ Advances time in manual mode. ## Examples PaperTiger.advance_time(seconds: 3600) PaperTiger.advance_time(days: 30) PaperTiger.advance_time(86400) # 1 day """ @spec advance_time(integer() | keyword()) :: :ok defdelegate advance_time(amount), to: PaperTiger.Clock, as: :advance @doc """ Returns the current clock mode. ## Examples PaperTiger.clock_mode() #=> :real """ @spec clock_mode() :: atom() defdelegate clock_mode(), to: PaperTiger.Clock, as: :get_mode @doc """ Registers a webhook endpoint for test orchestration. This is used by the `POST /_config/webhooks` endpoint for test setup. ## Parameters - `:url` - Webhook endpoint URL (required) - `:secret` - Webhook signing secret (default: "whsec_paper_tiger_test") - `:events` - List of event types to subscribe to (default: ["*"] for all events) ## Examples # Register with all events PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe") # Register with specific events PaperTiger.register_webhook( url: "http://localhost:4000/webhooks/stripe", secret: "whsec_custom", events: ["customer.created", "invoice.paid"] ) """ @spec register_webhook(keyword()) :: {:ok, map()} def register_webhook(opts) do import PaperTiger.Resource webhook = %{ api_version: "2023-10-16", connect: false, created: now(), enabled_events: Keyword.get(opts, :events, ["*"]), id: generate_id("we"), livemode: false, metadata: %{}, object: "webhook_endpoint", secret: Keyword.get(opts, :secret, "whsec_paper_tiger_test"), status: "enabled", url: Keyword.fetch!(opts, :url), version: nil } Webhooks.insert(webhook) end @doc """ Registers webhook endpoints from application configuration. This function reads webhook endpoints from the `:paper_tiger, :webhooks` config and registers them automatically. Useful for setting up test webhooks at startup. ## Configuration # In config/test.exs config :paper_tiger, webhooks: [ [url: "http://localhost:4000/webhooks/stripe"], [url: "http://localhost:4000/webhooks/events", events: ["invoice.paid"]] ] ## Examples # Register all configured webhooks PaperTiger.register_configured_webhooks() #=> {:ok, [%{id: "we_..."}, %{id: "we_..."}]} ## Returns `{:ok, webhooks}` where webhooks is a list of registered webhook maps. """ @spec register_configured_webhooks() :: {:ok, [map()]} def register_configured_webhooks do webhooks = Application.get_env(:paper_tiger, :webhooks, []) registered = webhooks |> Enum.map(fn webhook_opts -> {:ok, webhook} = register_webhook(webhook_opts) webhook end) {:ok, registered} end @doc """ Returns configuration for stripity_stripe to use PaperTiger as the Stripe API backend. This helper generates the configuration needed to point stripity_stripe at PaperTiger instead of the real Stripe API. Use this in your config files or test setup to simplify integration. ## Options - `:port` - PaperTiger port (default: resolve and cache before startup) - `:host` - PaperTiger host (default: "localhost") - `:webhook_secret` - Webhook signing secret (default: "whsec_paper_tiger_test") - `:sandbox` - Enable sandbox isolation for concurrent tests (default: true) ## Sandbox Mode When `sandbox: true` (the default), the returned config includes `http_module: PaperTiger.StripityStripeHackney`, which injects namespace headers for test isolation. This allows tests to run concurrently without data leakage between tests. Set `sandbox: false` if you want global (non-isolated) mode, though this is rarely needed. ## Examples # In config/test.exs (recommended) config :stripity_stripe, PaperTiger.stripity_stripe_config() # With custom options config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4002) # Disable sandbox (not recommended for concurrent tests) config :stripity_stripe, PaperTiger.stripity_stripe_config(sandbox: false) ## Returns Keyword list with: - `:api_key` - Mock API key - `:public_key` - Mock publishable key - `:api_base_url` - URL pointing to PaperTiger - `:webhook_signing_key` - Webhook signing secret - `:http_module` - Custom HTTP module for sandbox isolation (when sandbox: true) """ @spec stripity_stripe_config(keyword()) :: keyword() def stripity_stripe_config(opts \\ []) do # Resolve port deterministically even before PaperTiger starts port = Keyword.get_lazy(opts, :port, fn -> get_port() end) host = Keyword.get(opts, :host, "localhost") webhook_secret = Keyword.get(opts, :webhook_secret, "whsec_paper_tiger_test") sandbox = Keyword.get(opts, :sandbox, true) base_config = [ api_key: "sk_test_paper_tiger", public_key: "pk_test_paper_tiger", # NOTE: stripity_stripe appends endpoints like "/v1/products" to this URL, # so do NOT include "/v1" here api_base_url: "http://#{host}:#{port}", webhook_signing_key: webhook_secret ] if sandbox do base_config ++ [http_module: PaperTiger.StripityStripeHackney] else base_config end end end