Direct debit

View Source

Direct debit lets you collect from a customer repeatedly without prompting them each time. The customer authorises a mandate once; you then charge against it.

The lifecycle is: create → pay → query → cancel.

Create a mandate

ElixirMpesa.direct_debit_creation(%{
  "input_AgreedTC" => "1",
  "input_CustomerMSISDN" => "255700000000",
  "input_ThirdPartyReference" => "SUB-42",
  "input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})

"input_AgreedTC" asserts that the customer accepted the terms and conditions. You are responsible for actually obtaining that consent and for keeping a record of it.

"input_ThirdPartyReference" is your identifier for the mandate. Keep it — every later operation needs it.

The customer is prompted on their handset to approve the mandate. Approval is not instant, so treat a successful response as "requested", and confirm with a query before charging.

Collect a payment

ElixirMpesa.direct_debit_payment(%{
  "input_Amount" => "10",
  "input_CustomerMSISDN" => "255700000000",
  "input_ThirdPartyReference" => "SUB-42",
  "input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})

A fresh conversation ID per charge — the mandate reference stays the same, the conversation ID does not. Reusing a conversation ID across two different charges will cause the second to be rejected as a duplicate.

Check a mandate

ElixirMpesa.query_direct_debit(%{
  "input_CustomerMSISDN" => "255700000000",
  "input_ThirdPartyReference" => "SUB-42"
})

Use this before the first charge to confirm the customer approved, and afterwards to detect a mandate the customer has since cancelled from their handset.

Cancel a mandate

ElixirMpesa.direct_debit_cancel(%{
  "input_CustomerMSISDN" => "255700000000",
  "input_ThirdPartyReference" => "SUB-42",
  "input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})

Cancel when a subscription ends. A customer can also cancel from their handset without telling you, so a charge against a mandate you believe is live can still fail — handle that case rather than assuming it cannot happen.

A subscription loop

defmodule Billing do
  require Logger

  def charge(subscription) do
    attrs = %{
      "input_Amount" => to_string(subscription.amount),
      "input_CustomerMSISDN" => subscription.msisdn,
      "input_ThirdPartyReference" => subscription.mandate_reference,
      "input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
    }

    case ElixirMpesa.direct_debit_payment(attrs) do
      {:ok, response} ->
        Subscriptions.record_payment(subscription, response.transaction_id)

      {:error, %ElixirMpesa.Error{category: :api} = error} ->
        # M-Pesa gave a definite answer: the charge did not happen.
        Subscriptions.record_failure(subscription, error.code, Exception.message(error))

      {:error, %ElixirMpesa.Error{} = error} ->
        # Transport or HTTP failure — the outcome is unknown. Do not retry blindly;
        # query the status first. See guides/error-codes.md.
        Logger.warning("charge outcome unknown: #{Exception.message(error)}")
        Subscriptions.mark_for_reconciliation(subscription)
    end
  end
end