lib/bling/customers.ex

defmodule Bling.Customers do
  @moduledoc """
  Module for interacting with Bling customers. Each method expects the first argument to be an ecto struct.
  """

  alias Bling.PaymentMethod
  alias Bling.Subscriptions
  alias Bling.Entity

  @default_plan "default"

  import Ecto.Changeset, only: [change: 2]

  @doc """
  Fetches all subscriptions for a customer.
  """
  def subscriptions(customer) do
    repo = Entity.repo(customer)

    customer
    |> repo.preload(subscriptions: [:subscription_items])
    |> Map.get(:subscriptions, [])
    |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime})
  end

  @doc """
  Fetches a single subscription for a customer by plan name.

  ## Examples
      # gets subscription with name "default"
      subscription = Bling.Customers.subscription(customer)

      # gets subscription with specific name
      subscription = Bling.Customers.subscription(customer, plan: "pro")
  """
  def subscription(customer, opts \\ []) do
    name = plan_from_opts(opts)
    customer |> subscriptions() |> Enum.find(&(&1.name == name))
  end

  @doc """
  Returns whether or not the customer is subscribed to a plan, and that plan is valid.

  ## Examples
      # checks if the customer is subscribed to the default plan
      Bling.Customers.subscribed?(customer)

      # checks if the customer is subscribed to a specific plan
      Bling.Customers.subscribed?(customer, plan: "pro")
  """
  def subscribed?(customer, opts \\ []) do
    name = plan_from_opts(opts)

    customer
    |> subscriptions()
    |> Enum.filter(&Subscriptions.valid?/1)
    |> Enum.map(& &1.name)
    |> Enum.member?(name)
  end

  @doc """
  Returns whether or not the customer is subscribed to a specific product and the subscription is valid.

  ## Examples
      # checks if the customer is subscribed to a product on the default plan
      Bling.Customers.subscribed_to_product?(customer, "prod_123")

      # checks if the customer is subscribed to a product on a specific plan
      Bling.Customers.subscribed_to_product?(customer, "prod_123", plan: "pro")
  """
  def subscribed_to_product?(customer, product, opts \\ []) do
    name = plan_from_opts(opts)

    customer
    |> subscriptions()
    |> Enum.filter(&Subscriptions.valid?/1)
    |> Enum.filter(&(&1.name == name))
    |> Enum.flat_map(fn sub -> Enum.map(sub.subscription_items, & &1.stripe_product_id) end)
    |> Enum.member?(product)
  end

  @doc """
  Returns whether or not the customer is subscribed to a specific price and the subscription is valid.

  ## Examples
      # checks if the customer is subscribed to a price on the default plan
      Bling.Customers.subscribed_to_price?(customer, "price_123")

      # checks if the customer is subscribed to a price on a specific plan
      Bling.Customers.subscribed_to_price?(customer, "price_123", plan: "pro")
  """
  def subscribed_to_price?(customer, price, opts \\ []) do
    name = plan_from_opts(opts)

    customer
    |> subscriptions()
    |> Enum.filter(&Subscriptions.valid?/1)
    |> Enum.filter(&(&1.name == name))
    |> Enum.flat_map(fn sub -> Enum.map(sub.subscription_items, & &1.stripe_price_id) end)
    |> Enum.member?(price)
  end

  @doc """
  Returns true if the customer is on a generic trial or if the specified subscription is on a trial.

  ## Examples

      # checks trial_ends_at on customer and "default" subscription plan
      Bling.Customers.trial?(customer)

      # checks trial_ends_at on customer and "swimming" subscription plan
      Bling.Customers.trial?(customer, plan: "swimming")
  """
  def trial?(customer, opts \\ []) do
    subscription = subscription(customer, opts)

    cond do
      generic_trial?(customer) -> true
      is_nil(subscription) -> false
      Subscriptions.trial?(subscription) -> true
      true -> false
    end
  end

  @doc """
  Returns whether the customer is on a generic trial.

  Checks the `trial_ends_at` column on the customer.
  """
  def generic_trial?(%{trial_ends_at: nil} = _customer), do: false

  def generic_trial?(customer) do
    ends_at = DateTime.compare(customer.trial_ends_at, DateTime.utc_now())

    ends_at == :gt
  end

  @doc """
  Creates a customer in stripe from the given database customer. If `to_stripe_params/1` is present on your Bling module,
  the map returned there will be merged with the params passed to stripe.

  ## Examples
      # Can pass any valid Stripe.customer.create/1 params.
      customer = Bling.Customers.create_stripe_customer(current_user, stripe: %{ name: "Jane Doe" })
  """
  def create_stripe_customer(customer, opts \\ []) do
    stripe_params = stripe_params(opts)
    bling = Entity.bling(customer)
    customer_metadata = Bling.Util.maybe_call({bling, :to_stripe_params, [customer]}, %{})

    params = Map.merge(customer_metadata, stripe_params)

    {:ok, stripe_customer} = Stripe.Customer.create(params)

    repo = Entity.repo(customer)
    customer |> change(%{stripe_id: stripe_customer.id}) |> repo.update!()
  end

  @doc """
  Returns the stripe customer for the given database customer.
  """
  def as_stripe_customer(customer) do
    {:ok, stripe_customer} = Stripe.Customer.retrieve(customer.stripe_id)
    stripe_customer
  end

  @doc """
  Creates a setup intent for the given customer. Defaults to off_session usage.

  ## Examples
      intent = Bling.Customers.create_setup_intent(current_user)

      # Can pass any valid Stripe.SetupIntent.create/1 params.
      intent = Bling.Customers.create_setup_intent(current_user, stripe: %{ payment_method_types: ["card"] })
  """
  def create_setup_intent(customer, opts \\ []) do
    stripe_params = stripe_params(opts)

    params =
      Map.merge(
        %{
          customer: customer.stripe_id,
          usage: "off_session"
        },
        stripe_params
      )

    {:ok, intent} = Stripe.SetupIntent.create(params)

    intent
  end

  @doc """
  Returns the defauly payment method for a customer, or nil. The payment method returned is a Bling.PaymentMethod struct.
  """
  def default_payment_method(customer) do
    result =
      Stripe.Customer.retrieve(customer.stripe_id,
        expand: ["default_source", "invoice_settings.default_payment_method"]
      )

    case result do
      {:ok, stripe_customer} ->
        with payment_method when not is_nil(payment_method) <-
               stripe_customer.invoice_settings.default_payment_method do
          PaymentMethod.from_source(payment_method)
        else
          _ ->
            if is_nil(stripe_customer.default_source) do
              nil
            else
              PaymentMethod.from_source(stripe_customer.default_source)
            end
        end

      _ ->
        nil
    end
  end

  @doc """
  Updates the customer's default payment method with the one stored in stripe.
  """
  def update_default_payment_method_from_stripe(customer) do
    repo = Entity.repo(customer)
    payment_method = default_payment_method(customer)

    case payment_method do
      nil ->
        customer
        |> change(%{
          payment_id: nil,
          payment_type: nil,
          payment_last_four: nil
        })
        |> repo.update!()

      _ ->
        customer
        |> change(%{
          payment_id: payment_method.id,
          payment_type: payment_method.type,
          payment_last_four: payment_method.last_four
        })
        |> repo.update!()
    end
  end

  @doc """
  Updates the customers default payment method with the provided payment_method_id.
  """
  def update_default_payment_method(customer, payment_method_id) do
    repo = Entity.repo(customer)

    Stripe.Customer.update(customer.stripe_id, %{
      invoice_settings: %{default_payment_method: payment_method_id}
    })

    payment_method = default_payment_method(customer)

    customer
    |> change(%{
      payment_id: payment_method.id,
      payment_type: payment_method.type,
      payment_last_four: payment_method.last_four
    })
    |> repo.update!()
  end

  @doc """
  Returns whether the customer has a default payment method.
  """
  def has_default_payment_method?(customer) do
    !!customer.payment_id
  end

  @doc """
  Creates a subscription in stripe and stores it in the database. Tries to confirm the payment immediately.
  If the payment requires further authentication or errors, the subscription will be marked as incomplete and you
  must use the payment intent to confirm the payment on your frontend.

  https://stripe.com/docs/billing/subscriptions/overview

  ## Examples
      result =
        create_subscription(
          current_user,
          return_url: "http://localhost:4000/billing/user/1/finalize", # the route you configured during installation
          prices: [{price_id, quantity}],
          plan: "default", # can be omitted
          stripe: %{ coupon: "coupon_id" }, # any valid Stripe.Subscription.create/1 params
        )

      case result do
        {:ok, subscription} -> # success, db subscription
        {:requires_action, payment_intent} -> # payment requires further authentication
        {:error, error} -> # Stripe.Error, card could have been declined
      end
  """
  def create_subscription(customer, opts \\ []) do
    name = plan_from_opts(opts)
    stripe_params = stripe_params(opts)
    return_url = opts[:return_url]
    repo = Entity.repo(customer)
    prices = opts[:prices]

    items = prices |> Enum.map(fn {id, quantity} -> %{price: id, quantity: quantity} end)

    params =
      %{
        customer: customer.stripe_id,
        items: items,
        payment_behavior: "default_incomplete",
        metadata: %{"name" => name},
        default_payment_method: customer.payment_id,
        expand: ["latest_invoice"]
      }
      |> Map.merge(stripe_params)
      |> maybe_add_tax(customer)

    case Stripe.Subscription.create(params) do
      {:error, errors} ->
        {:error, errors}

      {:ok, stripe_subscription} ->
        case maybe_confirm(stripe_subscription, return_url) do
          {:error, error} ->
            {:error, error}

          {:ok, %{status: "requires_action"} = payment_intent} ->
            {:requires_action, payment_intent}

          {:ok, _payment_intent} ->
            # "subscription created" webhook may have already been handled by here
            # need to reload the subscription after payment intent confirmation
            {:ok, stripe_subscription} = Stripe.Subscription.retrieve(stripe_subscription.id)
            subscription = maybe_create_subscription(customer, stripe_subscription)

            {:ok, subscription |> repo.preload(:subscription_items)}
        end
    end
  end

  @doc """
  Returns all Stripe.Invoice structs for a customer.

  ## Examples
      invoices = invoices(current_user)

      # Can pass any valid Stripe.Invoice.list/1 params.
      invocies = invoices(current_user, stripe: %{ limit: 10 })
  """
  def invoices(customer, opts \\ []) do
    stripe_params = stripe_params(opts)
    params = Map.merge(stripe_params, %{customer: customer.stripe_id})

    {:ok, invoices} = Stripe.Invoice.list(params)

    invoices.data
  end

  @doc """
  Creates and returns a url to the stripe billing portal.

  ## Examples

      url = billing_portal_url(customer, stripe: %{ return_url: "http://localhost:4000/users/settings" } })
  """
  def billing_portal_url(customer, opts \\ []) do
    stripe_params = stripe_params(opts)

    params =
      Map.merge(
        %{
          customer: customer.stripe_id
        },
        stripe_params
      )

    {:ok, %Stripe.BillingPortal.Session{} = session} = Stripe.BillingPortal.Session.create(params)

    session.url
  end

  @doc """
  Creates and returns a url to make a new subscription using stripe checkout.

  ## Examples
      url = subscription_checkout_url(customer,
        plan: "default",
        prices: [{price_id, quantity}]
        stripe: %{
          cancel_url: "http://localhost:4000/users/settings",
          success_url: "http://localhost:4000/users/settings"
      })
  """
  def subscription_checkout_url(customer, opts \\ []) do
    name = plan_from_opts(opts)
    stripe_params = stripe_params(opts)

    line_items =
      opts
      |> Keyword.get(:prices, [])
      |> Enum.map(fn {price, quantity} -> %{price: price, quantity: quantity} end)

    default_params = %{
      customer: customer.stripe_id,
      mode: "subscription",
      line_items: line_items,
      subscription_data:
        maybe_add_tax(
          %{
            metadata: %{
              "name" => name
            }
          },
          customer
        )
    }

    params = Map.merge(default_params, stripe_params)

    {:ok, %Stripe.Session{} = session} = Stripe.Session.create(params)

    session.url
  end

  defp maybe_confirm(%{status: "incomplete"} = stripe_subscription, return_url) do
    Stripe.PaymentIntent.confirm(stripe_subscription.latest_invoice.payment_intent, %{
      return_url: return_url
    })
  end

  defp maybe_confirm(stripe_subscription, _) do
    Stripe.PaymentIntent.retrieve(stripe_subscription.latest_invoice.payment_intent, %{})
  end

  defp maybe_create_subscription(customer, stripe_subscription) do
    repo = Entity.repo(customer)
    existing = subscriptions(customer) |> Enum.find(&(&1.stripe_id == stripe_subscription.id))

    if existing do
      existing
    else
      subscription =
        customer
        |> Ecto.build_assoc(:subscriptions)
        |> Subscriptions.subscription_struct_from_stripe_subscription(stripe_subscription)
        |> repo.insert!()

      subscription_items =
        Subscriptions.subscription_item_structs_from_stripe_items(
          stripe_subscription.items.data,
          subscription
        )

      Enum.each(subscription_items, fn item -> repo.insert!(item) end)

      subscription
    end
  end

  defp build_opts(opts, defaults \\ %{}) do
    opts = opts |> Enum.into(%{})

    Map.merge(defaults, opts)
  end

  defp plan_from_opts(opts), do: Keyword.get(opts, :plan, @default_plan)

  defp stripe_params(opts), do: opts |> Keyword.get(:stripe, []) |> build_opts()

  defp maybe_add_tax(params, customer) do
    bling = Entity.bling(customer)
    tax_rates = Bling.Util.maybe_call({bling, :tax_rate_ids, [customer]}, [])

    cond do
      is_list(tax_rates) && tax_rates != [] -> Map.merge(params, %{default_tax_rates: tax_rates})
      true -> params
    end
  end
end