Packages

phoenix_kit

1.7.61
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules billing utils webhook_processor.ex
Raw

lib/modules/billing/utils/webhook_processor.ex

defmodule PhoenixKit.Modules.Billing.WebhookProcessor do
@moduledoc """
Processes normalized webhook events from payment providers.
This module handles the business logic for webhook events after they've
been verified and normalized by the provider modules. It ensures:
- **Idempotency**: Events are tracked by event_id to prevent double-processing
- **Error handling**: Failed events are logged with retry counts
- **Business logic**: Invoices are marked paid, receipts generated, etc.
## Event Types
- `checkout.completed` - Checkout session completed (payment succeeded)
- `checkout.expired` - Checkout session expired
- `payment.succeeded` - Direct payment succeeded (for saved cards)
- `payment.failed` - Payment failed
- `refund.created` - Refund was processed
- `setup.completed` - Setup session completed (card saved)
## Usage
# Called by BillingWebhookController
WebhookProcessor.process(normalized_event)
"""
alias PhoenixKit.Modules.Billing
alias PhoenixKit.Modules.Billing.WebhookEvent
alias PhoenixKit.RepoHelper
alias PhoenixKit.Utils.Date, as: UtilsDate
require Logger
@doc """
Processes a normalized webhook event.
Checks for idempotency, processes the event, and logs the result.
## Returns
- `{:ok, result}` - Event processed successfully
- `{:error, :duplicate_event}` - Event already processed
- `{:error, reason}` - Processing failed
"""
@spec process(map()) :: {:ok, any()} | {:error, atom()}
def process(%{event_id: event_id, provider: provider, type: _type} = event) do
# Check idempotency
case check_idempotency(provider, event_id) do
:new ->
# Log event as processing
{:ok, webhook_event} = create_webhook_event(event)
# Process the event
result = process_event(event)
# Update event status
mark_event_processed(webhook_event, result)
result
:duplicate ->
{:error, :duplicate_event}
end
rescue
e ->
Logger.error("Webhook processing error: #{inspect(e)}")
{:error, :processing_error}
end
# ===========================================
# Event Handlers
# ===========================================
defp process_event(%{type: "checkout.completed", data: data}) do
Logger.info("Processing checkout.completed: #{inspect(data)}")
case data do
%{mode: "payment", invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) ->
# One-time payment for invoice
process_invoice_payment(invoice_uuid, data)
%{mode: "setup", user_uuid: user_uuid} when not is_nil(user_uuid) ->
# Setup session - card saved
process_setup_completed(data)
_ ->
Logger.warning("Unhandled checkout.completed mode: #{inspect(data)}")
{:ok, :ignored}
end
end
defp process_event(%{type: "checkout.expired", data: data}) do
Logger.info("Checkout session expired: #{inspect(data[:session_id])}")
# Clear checkout session from order if needed
{:ok, :expired}
end
defp process_event(%{type: "payment.succeeded", data: data}) do
Logger.info("Processing payment.succeeded: #{inspect(data)}")
case data do
%{invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) ->
# Payment for invoice (e.g., subscription renewal)
process_invoice_payment(invoice_uuid, data)
_ ->
Logger.warning("Payment succeeded without invoice_uuid: #{inspect(data)}")
{:ok, :ignored}
end
end
defp process_event(%{type: "payment.failed", data: data}) do
Logger.warning("Payment failed: #{inspect(data)}")
case data do
%{invoice_uuid: invoice_uuid} when not is_nil(invoice_uuid) ->
# Update invoice/subscription status
process_payment_failure(invoice_uuid, data)
_ ->
{:ok, :ignored}
end
end
defp process_event(%{type: "refund.created", data: data}) do
Logger.info("Processing refund.created: #{inspect(data)}")
# Record refund transaction
process_refund(data)
end
defp process_event(%{type: "setup.completed", data: data}) do
Logger.info("Processing setup.completed: #{inspect(data)}")
# Save payment method for user
process_setup_completed(data)
end
defp process_event(%{type: type}) do
Logger.debug("Unhandled webhook event type: #{type}")
{:ok, :unhandled}
end
# ===========================================
# Business Logic
# ===========================================
defp process_invoice_payment(invoice_uuid, data) do
invoice_uuid = parse_id(invoice_uuid)
with {:ok, invoice} <- get_invoice(invoice_uuid),
:ok <- validate_invoice_status(invoice) do
# Determine amount from event data
amount = calculate_payment_amount(invoice, data)
# Record the payment
payment_attrs = %{
amount: amount,
payment_method: to_string(data[:provider] || "stripe"),
description: "Online payment via #{data[:provider] || "Stripe"}",
provider_transaction_id: data[:charge_id] || data[:payment_intent_id],
provider_data: data
}
# Pass nil for admin_user - system/webhook initiated payment
case Billing.record_payment(invoice, payment_attrs, nil) do
{:ok, updated_invoice} ->
Logger.info("Invoice #{invoice.invoice_number} marked as paid")
# Generate receipt if fully paid
if updated_invoice.status == "paid" do
Billing.generate_receipt(updated_invoice)
Billing.send_receipt(updated_invoice, [])
end
{:ok, updated_invoice}
{:error, reason} ->
Logger.error("Failed to record payment for invoice #{invoice_uuid}: #{inspect(reason)}")
{:error, reason}
end
else
{:error, :invoice_not_found} ->
Logger.warning("Invoice not found for webhook: #{invoice_uuid}")
{:error, :invoice_not_found}
{:error, :already_paid} ->
Logger.debug("Invoice #{invoice_uuid} already paid")
{:ok, :already_paid}
{:error, reason} ->
{:error, reason}
end
end
defp process_payment_failure(invoice_uuid, data) do
invoice_uuid = parse_id(invoice_uuid)
# Log the failure for dunning/retry logic
Logger.warning(
"Payment failed for invoice #{invoice_uuid}: #{data[:error_code]} - #{data[:error_message]}"
)
# If this invoice is tied to a subscription, update subscription status
# This will be handled by the subscription renewal worker
{:ok, :logged}
end
defp process_refund(data) do
# Find the original transaction by charge_id and record a refund
# This is handled by Billing.record_refund if we have the invoice
case data do
%{charge_id: charge_id, amount_refunded: amount_cents} when not is_nil(charge_id) ->
Logger.info("Refund recorded: #{charge_id} - #{amount_cents} cents")
{:ok, :refund_logged}
_ ->
{:ok, :ignored}
end
end
defp process_setup_completed(data) do
# Save the payment method for the user
case data do
%{provider_payment_method_id: pm_id, customer_id: _customer_id, user_uuid: user_uuid}
when not is_nil(pm_id) ->
Logger.info("Payment method saved for user #{user_uuid}: #{pm_id}")
# Get payment method details from provider and save
# This should create a PaymentMethod record
{:ok, :payment_method_saved}
_ ->
{:ok, :ignored}
end
end
# ===========================================
# Idempotency & Event Logging
# ===========================================
defp check_idempotency(provider, event_id) do
repo = RepoHelper.repo()
import Ecto.Query
query =
from we in WebhookEvent,
where: we.provider == ^to_string(provider) and we.event_id == ^event_id,
select: we.uuid
case repo.one(query) do
nil -> :new
_uuid -> :duplicate
end
rescue
_ -> :new
end
defp create_webhook_event(%{event_id: event_id, provider: provider, type: type} = event) do
repo = RepoHelper.repo()
attrs = %{
provider: to_string(provider),
event_id: event_id,
event_type: type,
payload: event.raw_payload || %{},
processed: false,
retry_count: 0,
inserted_at: UtilsDate.utc_now(),
updated_at: UtilsDate.utc_now()
}
case repo.insert_all("phoenix_kit_webhook_events", [attrs], returning: [:id]) do
{1, [%{id: id}]} -> {:ok, %{id: id}}
_ -> {:error, :insert_failed}
end
rescue
e ->
Logger.error("Failed to create webhook event: #{inspect(e)}")
{:ok, %{id: nil}}
end
defp mark_event_processed(%{id: nil}, _result), do: :ok
defp mark_event_processed(%{id: id}, result) do
repo = RepoHelper.repo()
import Ecto.Query
{error_message, processed} =
case result do
{:ok, _} -> {nil, true}
{:error, reason} -> {inspect(reason), false}
end
query =
from we in "phoenix_kit_webhook_events",
where: we.id == ^id
repo.update_all(query,
set: [
processed: processed,
processed_at: UtilsDate.utc_now(),
error_message: error_message,
updated_at: UtilsDate.utc_now()
]
)
:ok
rescue
_ -> :ok
end
# ===========================================
# Helpers
# ===========================================
defp get_invoice(invoice_id) do
case Billing.get_invoice(invoice_id) do
nil -> {:error, :invoice_not_found}
invoice -> {:ok, invoice}
end
end
defp validate_invoice_status(%{status: status}) when status in ["draft", "sent", "overdue"] do
:ok
end
defp validate_invoice_status(%{status: "paid"}) do
{:error, :already_paid}
end
defp validate_invoice_status(%{status: status}) do
{:error, {:invalid_status, status}}
end
defp calculate_payment_amount(invoice, data) do
# Use amount from webhook if available, otherwise use invoice total
case data do
%{amount_total: amount_cents} when is_integer(amount_cents) ->
Decimal.div(Decimal.new(amount_cents), 100)
%{amount: amount_cents} when is_integer(amount_cents) ->
Decimal.div(Decimal.new(amount_cents), 100)
_ ->
# Use remaining balance on invoice
Decimal.sub(invoice.total, invoice.paid_amount || Decimal.new(0))
end
end
defp parse_id(id) when is_binary(id), do: id
defp parse_id(id) when is_integer(id), do: id
defp parse_id(_), do: nil
end