PhoenixKitBilling
Billing module for PhoenixKit. Drop-in payments, subscriptions, invoices, orders, and multi-currency support with Stripe, PayPal, Razorpay, and EveryPay integration.
Features
- Orders & invoices — full order-to-invoice-to-receipt workflow with status tracking and print views
- Multi-provider payments — Stripe, PayPal, Razorpay, and EveryPay via a unified Provider behaviour with hosted checkout
- Internal subscription control — subscriptions managed in your database, not by providers; automatic renewals and dunning
- Multi-currency — currency definitions with exchange rates
- Billing profiles — individual and company billing details with address, tax ID, and IBAN validation
- Transactions & refunds — complete payment ledger with credit notes and partial refunds
- Real-time updates — PubSub events for orders, invoices, transactions, subscriptions, and profiles
- Admin dashboard — LiveViews for managing all billing entities, settings, and provider configuration
- User dashboard — "My Orders" and "Billing Profiles" pages for end users
- Print views — invoice, receipt, credit note, and payment confirmation print layouts
- Auto-discovery — implements
PhoenixKit.Modulebehaviour; PhoenixKit finds it at startup with zero config
Installation
Add phoenix_kit_billing to your dependencies in mix.exs:
def deps do
[
{:phoenix_kit_billing, "~> 0.3"}
]
end
Then fetch dependencies:
mix deps.get
Note: For development or if not yet published to Hex, you can use:
{:phoenix_kit_billing, github: "BeamLabEU/phoenix_kit_billing"}
PhoenixKit auto-discovers the module at startup — no additional configuration needed.
Quick Start
- Add the dependency to
mix.exs - Run
mix deps.get - Enable the module in admin settings (
billing_enabled: true) - Configure at least one payment provider in admin settings
- Orders and invoices are available at
/admin/billing
Usage
Order-to-Invoice Workflow
alias PhoenixKitBilling
# Create an order
{:ok, order} = Billing.create_order(user, %{
line_items: [%{description: "Widget", quantity: 1, unit_price: Decimal.new("29.99")}],
currency: "EUR"
})
# Confirm the order
{:ok, order} = Billing.confirm_order(order)
# Generate an invoice from the order
{:ok, invoice} = Billing.create_invoice_from_order(order)
# Send the invoice to the customer
{:ok, invoice} = Billing.send_invoice(invoice)
# Mark as paid (generates receipt automatically)
{:ok, invoice} = Billing.mark_invoice_paid(invoice)
Subscriptions
# Create a subscription type (plan)
{:ok, type} = Billing.create_subscription_type(%{
name: "Pro Monthly",
interval: "month",
price: Decimal.new("19.99"),
currency: "EUR"
})
# Create a subscription for a user
{:ok, subscription} = Billing.create_subscription(user, type)
Subscription renewals and failed-payment retries (dunning) are handled automatically by Oban workers.
Payment Providers
Supported payment providers:
| Provider | Code | Notes |
|---|---|---|
| Stripe | :stripe |
Cards and wallets; signed webhooks |
| PayPal | :paypal |
PayPal and cards; signed webhooks |
| Razorpay | :razorpay |
India (UPI, cards); signed webhooks |
| EveryPay | :everypay |
EveryPay AS gateway (Baltics), API v4; callbacks verified by server-side status re-fetch |
Providers are configured in admin settings. The system uses hosted checkout — users are redirected to the provider's payment page:
# Create a checkout session for an invoice
{:ok, session} = Billing.create_checkout_session(invoice, :stripe, %{
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel"
})
# Redirect user to session.url
Webhooks are handled automatically at /webhooks/billing/:provider.
Host setup — raw body reader. Webhook signature verification needs the raw request body, so your endpoint must wire
PhoenixKitBilling.Plugs.CacheBodyReaderintoPlug.Parsers:plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], body_reader: {PhoenixKitBilling.Plugs.CacheBodyReader, :read_body, []}, json_decoder: Phoenix.json_library()
mix phoenix_kit_billing.installdoes this for you. Without it, webhooks return400with:no_raw_bodybefore processing.
Testing Stripe locally
-
Configure test keys at
/admin/settings/billing/providers(Stripe card): paste yoursk_test_…/pk_test_…, tick Enabled, Save. The UI writes thebilling_stripe_secret_key/billing_stripe_publishable_key/billing_stripe_webhook_secretsettings; "Active Providers: Stripe" then confirmsavailable?/0is true. -
Forward webhooks to localhost with the Stripe CLI (no
stripe loginneeded — pass the key directly):brew install stripe/stripe-cli/stripe # once stripe listen --api-key sk_test_… \ --forward-to http://localhost:4000/phoenix_kit/webhooks/billing/stripeIt prints
Your webhook signing secret is whsec_…. Paste that value into the Webhook Secret field on the providers page and Save — it's what the controller verifies against (not the dashboard endpoint's secret). Adjust the/phoenix_kitprefix to your host's mount. -
Fire a test event:
stripe trigger checkout.session.completed --api-key sk_test_…Success = a
[200]in thestripe listenoutput, aphoenix_kit_webhook_eventsrow withprocessed=true, and aWebhook processed successfullylog line. A400with:no_raw_bodymeans the host hasn't wiredCacheBodyReader(see above).
Real-Time Events
Subscribe to billing events in your LiveViews:
def mount(_params, _session, socket) do
PhoenixKitBilling.Events.subscribe_orders()
{:ok, socket}
end
def handle_info({:order_created, order}, socket) do
# Update UI
{:noreply, socket}
end
Settings
| Key | Type | Default | Description |
|---|---|---|---|
billing_enabled |
boolean | false |
Enable/disable the billing system |
billing_default_currency |
string | "EUR" |
Default currency for new orders |
billing_tax_enabled |
boolean | false |
Enable tax calculations |
billing_company_name |
string | — | Company name on invoices |
billing_company_address |
string | — | Company address on invoices |
Provider-specific settings (API keys, webhook secrets) are configured per-provider in the admin panel at /admin/settings/billing/providers.
Invoice Status Workflow
| Status | Description |
|---|---|
"draft" |
Invoice created, not yet sent |
"sent" |
Invoice sent to customer |
"paid" |
Payment received, receipt generated |
"overdue" |
Past due date, not yet paid |
"void" |
Cancelled / voided |
draft → sent → paid
↘
overdue → paid
↘
void
Emails
Billing sends four emails to the customer: billing_invoice, billing_receipt,
billing_credit_note and billing_payment_confirmation. They go out through
phoenix_kit_emails (required_modules/0) and appear in core's email preview
(/admin/settings/email-sending/preview) with sample data.
Language. A send uses the customer's preferred locale, or the site's content language for a guest payer or an account without a preference. Billing explicitly installs that language for its own copy, including when the sender has set a Gettext backend locale. Dates use core's translated month names and locale-aware ordering, in both sends and previews. This works from the supported core floor of 2.44.0.
Each email's built-in copy (PhoenixKitBilling.EmailDefaults) has three parts:
| Part | Builds | Line items as |
|---|---|---|
subject |
the subject | — |
markdown |
the HTML version, inside core's layout | {{{line_items_table_html}}} — a ready-made table |
text |
the plain-text version | {{line_items_text}} — one line per item |
The link to the document online ({{invoice_url}}, {{receipt_url}},
{{credit_note_url}}, {{payment_url}}) is a button. The company's details close
the body; a blank one (an empty VAT number, say) is left out, and so is the
invoice's bank transfer section when there is no IBAN. Both HTML and plain text
omit missing document links, empty bank names and SWIFT codes, and absent company
details. An invoice without a due date omits its payment deadline.
Overriding. Put files under priv/phoenix_kit_templates/<email name>/ in the
host app, as for any email (subject.txt, markdown.md, html.html, text.txt,
each optionally per locale: markdown.et.md). A part you don't override keeps
billing's default. To keep the line-items table in the HTML version, place
{{{line_items_table_html}}} — three braces, because the value is HTML; billing
escapes and styles the table inline — in one of:
- a host
html.html. Billing'stextdefault still builds the plain-text version. - a host
markdown.mdand a hosttext.txtthat lists the items with{{line_items_text}}. A host'smarkdown.mdalso builds the plain-text version when there is no hosttext.txt— it outranks billing'stextdefault — and would put the table's HTML into it.
A host that overrides only text.txt changes the plain-text version, and,
on a core that builds the HTML from a host's text, the HTML version too — then
without the table. {{{line_items_html}}} is the older form: bare <tr> rows for a
template that wraps them in its own <table>.
Layout group. Billing emails are sent with layout: "billing", so a host can
give them their own chrome — company details in the footer, say — with
_layout-billing, _header-billing or _footer-billing, each falling back to the
shared _layout, _header or _footer.
An active database template from phoenix_kit_emails under the same name still
wins over all of the above.
Permissions
The module declares permissions via permission_metadata/0:
"billing"— access to billing admin dashboard and all sub-pages- Settings pages require the same
"billing"permission
Use Scope.has_module_access?/2 to check permissions in your application.
CSS Requirements
This module implements css_sources/0 returning [:phoenix_kit_billing], so PhoenixKit's installer automatically adds the correct @source directive to your app.css for Tailwind scanning. No manual configuration needed.
Architecture
lib/
├── phoenix_kit_billing.ex # Main module (context + PhoenixKit.Module behaviour)
└── phoenix_kit_billing/
├── mix_tasks/
│ └── phoenix_kit_billing.install.ex # Install mix task
├── events.ex # PubSub event broadcasts
├── paths.ex # Centralized URL path helpers
├── supervisor.ex # OTP Supervisor
├── providers/
│ ├── provider.ex # Provider behaviour (9 callbacks)
│ ├── providers.ex # Provider registry and routing
│ ├── stripe.ex # Stripe implementation
│ ├── paypal.ex # PayPal implementation
│ ├── razorpay.ex # Razorpay implementation
│ └── everypay.ex # EveryPay implementation
├── schemas/
│ ├── billing_profile.ex # User billing information
│ ├── currency.ex # Currency definitions
│ ├── invoice.ex # Invoice with receipt
│ ├── order.ex # Order with line items
│ ├── payment_method.ex # Saved payment methods
│ ├── subscription.ex # Subscription records
│ ├── subscription_type.ex # Subscription plan definitions
│ ├── transaction.ex # Payment/refund transactions
│ └── webhook_event.ex # Provider webhook log
├── workers/
│ ├── subscription_renewal_worker.ex # Oban: subscription renewals
│ └── subscription_dunning_worker.ex # Oban: failed payment retries
└── web/ # Admin LiveViews, controllers, components
Database Tables
| Table | Description |
|---|---|
phoenix_kit_billing_profiles |
User billing information (UUIDv7 PK) |
phoenix_kit_currencies |
Currency definitions |
phoenix_kit_orders |
Orders with line items |
phoenix_kit_invoices |
Invoices with receipt data |
phoenix_kit_transactions |
Payment/refund transaction ledger |
phoenix_kit_subscriptions |
Active subscriptions |
phoenix_kit_subscription_types |
Subscription plan definitions |
phoenix_kit_payment_methods |
Saved payment methods from providers |
phoenix_kit_payment_options |
Available payment options |
phoenix_kit_webhook_events |
Provider webhook event log |
Development
mix deps.get # Install dependencies
mix test # Run tests
mix format # Format code
mix credo --strict # Static analysis (strict mode)
mix dialyzer # Type checking
mix docs # Generate documentation
mix precommit # Compile + format + credo + dialyzer
mix quality # Format + credo + dialyzer
Testing
The module ships its own test harness — a DataCase/LiveCase, a test
Endpoint + Router, and core's versioned migrations run via
PhoenixKit.Migration.ensure_current/2 (no parent app required):
createdb phoenix_kit_billing_test # one-time: create the test database
mix test # run the suite
Use PhoenixKitBilling.DataCase for schema/context tests and
PhoenixKitBilling.LiveCase for admin LiveView tests.
Troubleshooting
Billing not appearing in admin
- Verify
billing_enabledistruein settings - Ensure the module is listed as a dependency in the parent app's
mix.exs - Check that
enabled?/0is not returningfalse(requires database access)
Webhooks not processing
- If webhooks return
400with:no_raw_body, the host endpoint is missing the raw body reader — wirePhoenixKitBilling.Plugs.CacheBodyReaderintoPlug.Parsers(body_reader:) as shown under "Payment Providers" above, or re-runmix phoenix_kit_billing.install - Verify webhook secrets are configured in provider settings
- Check that webhook URLs are registered with the provider (e.g.,
https://yourdomain.com/webhooks/billing/stripe) - Review
phoenix_kit_webhook_eventstable for received events
Subscription renewals not running
- Ensure Oban is configured in the parent app with the
billingqueue - Check Oban dashboard for failed jobs
License
MIT — see LICENSE for details.