Sponsored by AppSignal
Would you like to see your link here? Contact us
Notesclub

Kickstarter budget

kickstarter_budget.livemd

Kickstarter budget

Mix.install(
  [
    {:ex_money, "~> 5.12"},
    {:decimal, "~> 2.0"},
    {:kino, "~> 0.8.0"},
    {:date_time_parser, "~> 1.1.2"},
    {:number, "~> 1.0.3"}
  ],
  config: [
    ex_money: [open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"}]
  ]
)
:ok

Introduction

I back projects that I like on Kickstarter without considering, even though I don’t have enough money. Unfortunately Kickstarter does not have a feature that allows backers to manage their own budgets, so I always have to calculate amounts like how much my total pledges are. Therefore, I thought it would be useful to have an easy way to calculate those. This notebook is it.

Actually I would like to implement this so that the content of the active pledges are automatically fetched, but I couldn’t think of a good way to do it, so I have taken the tedious method of manually copying and pasting.

Modules

defmodule Pledge do
  def to_active_pledges(backings, charge_currency, ftf) do
    backings
    |> trim()
    |> split_by_pledge()
    |> Enum.map(&(&1 |> parse() |> put_charge(charge_currency, ftf)))
    |> sort_by_deadline()
  end

  defp trim(backings) do
    String.replace(backings, ~r/[\s\S]*Deadline ?Messages\n|\nSuccessful pledges[\s\S]*/, "")
  end

  defp split_by_pledge(activity_pledges_content) do
    String.split(activity_pledges_content, "Photo-thumb\t", trim: true)
  end

  defp parse(text_of_activity_pledge) do
    [project_and_pledged, _estimated_delivery, deadline] =
      text_of_activity_pledge
      |> String.replace(~r/.*add-ons?\n|\.00.*/, "")
      |> String.replace(~r/.*messages?\n.*/, &String.slice(&1, 0..10))
      |> String.split("\n", trim: true)

    [project, pledged] =
      String.split(project_and_pledged, ~r/(AU\$|CA\$|HK\$|S\$|\$|€|£).*/,
        trim: true,
        include_captures: true
      )

    %{
      project: project,
      pledged: pledged,
      deadline: DateTimeParser.parse!(deadline)
    }
  end

  defp put_charge(active_pledge = %{pledged: pledged}, charge_currency, ftf) do
    amount = pledged |> String.replace(~r/\D/, "") |> Decimal.new()
    currency = pledged |> String.replace(~r/[ \d].*/, "") |> to_iso4217_currency_code()
    Map.put(active_pledge, :charge, exchange(amount, currency, charge_currency, ftf))
  end

  defp to_iso4217_currency_code("AU$"), do: :AUD
  defp to_iso4217_currency_code("CA$"), do: :CAD
  defp to_iso4217_currency_code("HK$"), do: :HKD
  defp to_iso4217_currency_code("S$"), do: :SGD
  defp to_iso4217_currency_code("$"), do: :USD
  defp to_iso4217_currency_code("€"), do: :EUR
  defp to_iso4217_currency_code("£"), do: :GBP

  defp exchange(amount, from_currency, to_currency, _ftf) when from_currency == to_currency do
    amount
  end

  defp exchange(amount, from_currency, to_currency, ftf) do
    Money.new(from_currency, amount)
    |> Money.to_currency!(to_currency)
    |> Money.mult!(1 + ftf / 100)
    |> Money.to_decimal()
    |> Decimal.round(0)
  end

  defp sort_by_deadline(active_pledges) do
    Enum.sort_by(active_pledges, & &1.deadline, Date)
  end

  def group_by_deadline_month(active_pledges) do
    Enum.group_by(active_pledges, &Calendar.strftime(&1.deadline, "%Y-%m"))
  end

  def total_charge(active_pledges) do
    active_pledges
    |> Enum.reduce(Decimal.new(0), &Decimal.add(&2, &1.charge))
    |> Number.Delimit.number_to_delimited(precision: 0)
  end
end

defmodule Currency do
  def currencies() do
    Enum.map(Money.known_current_currencies(), fn currency -> {currency, to_string(currency)} end)
  end
end
{:module, Currency, <<70, 79, 82, 49, 0, 0, 7, ...>>, {:currencies, 0}}

Inputs

charge_currency =
  Kino.Input.select("Select your currency", Currency.currencies(),
    default: System.get_env("DEFAULT_CURRENCY_CODE", "USD") |> String.to_atom()
  )
  |> Kino.render()

ftf =
  Kino.Input.number("Input foreign transaction fee [%]",
    default: System.get_env("DEFAULT_FOREIGN_TRANSACTION_FEE", "3") |> String.to_integer()
  )
  |> Kino.render()

backings =
  Kino.Input.textarea("Paste the content of https://www.kickstarter.com/profile/backings")
  |> Kino.render()

Kino.nothing()

Simulation

Just evaluate after completing the inputs. You can see active pledges and total charges by deadline month.

backings
|> Kino.Input.read()
|> Pledge.to_active_pledges(Kino.Input.read(charge_currency), Kino.Input.read(ftf))
|> Pledge.group_by_deadline_month()
|> Enum.map(fn {month, pledges} ->
  frame = Kino.Frame.new()
  Kino.Frame.append(frame, Kino.Markdown.new("**Total: `#{Pledge.total_charge(pledges)}`**"))
  Kino.Frame.append(frame, Kino.DataTable.new(pledges))
  {month, frame}
end)
|> Kino.Layout.tabs()

Revision

You can check how much the total charge will be if you cancel some pledges.

month_select_frame = Kino.Frame.new() |> Kino.render()
total_charge_frame = Kino.Frame.new() |> Kino.render()
pledge_form_frame = Kino.Frame.new() |> Kino.render()
Kino.nothing()
active_pledges =
  backings
  |> Kino.Input.read()
  |> Pledge.to_active_pledges(Kino.Input.read(charge_currency), Kino.Input.read(ftf))
  |> Pledge.group_by_deadline_month()

closest_deadline_month = active_pledges |> Map.keys() |> Enum.min() |> String.to_atom()

month_select =
  Kino.Input.select(
    "Deadline month",
    Enum.map(active_pledges, fn {month, _} -> {String.to_atom(month), month} end),
    default: closest_deadline_month
  )

Kino.Frame.render(month_select_frame, month_select)

pledge_forms =
  Enum.map(active_pledges, fn {month, pledges} ->
    form =
      pledges
      |> Enum.map(fn %{charge: charge, deadline: deadline, project: project} ->
        checkbox =
          Kino.Input.checkbox(
            "#{charge} #{Kino.Input.read(charge_currency)} on #{deadline.day}, #{project}",
            default: true
          )

        {String.to_atom("#{charge}"), checkbox}
      end)
      |> Kino.Control.form(report_changes: true)

    {String.to_atom(month), form}
  end)

Kino.Frame.render(pledge_form_frame, pledge_forms[closest_deadline_month])

for event <- Kino.Control.stream([month_select | Keyword.values(pledge_forms)]) do
  case event do
    %{data: charges} ->
      total_charge =
        charges
        |> Enum.reduce(0, fn
          {charge, true}, acc -> acc + String.to_integer("#{charge}")
          _, acc -> acc
        end)
        |> Number.Delimit.number_to_delimited(precision: 0)

      Kino.Frame.render(total_charge_frame, Kino.Markdown.new("**Total: `#{total_charge}`**"))

    %{value: month} ->
      Kino.Frame.render(pledge_form_frame, pledge_forms[month])
  end
end