Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions assets/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,4 +766,22 @@ window.addEventListener("phx:open_popup", (e: CustomEvent) => {
}
});

// Add event listener for storing session values
window.addEventListener("phx:store-session", (event) => {
const token = document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content");

console.log(event.detail);

fetch("/store-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
body: JSON.stringify(event.detail),
});
});

export default Hooks;
6 changes: 3 additions & 3 deletions lib/algora/accounts/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ defmodule Algora.Accounts do
end

def register_org(params) do
params |> User.org_registration_changeset() |> Repo.insert()
params |> User.org_registration_changeset() |> Repo.insert(returning: true)
end

def auto_join_orgs(user) do
Expand Down Expand Up @@ -441,10 +441,10 @@ defmodule Algora.Accounts do
orgs
end

def get_or_register_user(email) do
def get_or_register_user(email, attr \\ %{}) do
res =
case get_user_by_email(email) do
nil -> register_org(%{email: email})
nil -> attr |> Map.put(:email, email) |> register_org()
user -> {:ok, user}
end

Expand Down
17 changes: 11 additions & 6 deletions lib/algora/accounts/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ defmodule Algora.Accounts.User do

def org_registration_changeset(params) do
%User{}
|> cast(params, [:email])
|> cast(params, [:email, :display_name, :type])
|> generate_id()
|> validate_required([:email])
|> validate_email()
|> validate_unique_email()
end

@doc """
Expand Down Expand Up @@ -161,7 +161,7 @@ defmodule Algora.Accounts.User do
|> generate_id()
|> validate_required([:email, :handle])
|> validate_handle()
|> validate_email()
|> validate_unique_email()
|> unique_constraint(:email)
|> unique_constraint(:handle)
|> put_assoc(:identities, [identity_changeset])
Expand Down Expand Up @@ -219,7 +219,7 @@ defmodule Algora.Accounts.User do
|> generate_id()
|> validate_required([:email, :handle])
|> validate_handle()
|> validate_email()
|> validate_unique_email()
|> unique_constraint(:email)
|> unique_constraint(:handle)
else
Expand Down Expand Up @@ -249,7 +249,7 @@ defmodule Algora.Accounts.User do
])
|> generate_id()
|> validate_required([:type, :handle, :email])
|> validate_email()
|> validate_unique_email()
|> unique_constraint(:handle)
|> unique_constraint(:email)
end
Expand Down Expand Up @@ -286,11 +286,16 @@ defmodule Algora.Accounts.User do
cast(user, params, [:email, :signup_token])
end

defp validate_email(changeset) do
def validate_email(changeset) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
end

def validate_unique_email(changeset) do
changeset
|> validate_email()
|> unsafe_validate_unique(:email, Algora.Repo)
|> unique_constraint(:email)
end
Expand Down
110 changes: 110 additions & 0 deletions lib/algora/jobs/jobs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
defmodule Algora.Jobs do
@moduledoc false

import Ecto.Changeset
import Ecto.Query

alias Algora.Accounts
alias Algora.Bounties.LineItem
alias Algora.Jobs.JobApplication
alias Algora.Jobs.JobPosting
alias Algora.Payments
alias Algora.Payments.Transaction
alias Algora.Repo
alias Algora.Util

require Logger

def price, do: Money.new(:USD, 499, no_fraction_if_integer: true)

def list_jobs(opts \\ []) do
JobPosting
|> where([j], j.status == :active)
|> order_by([j], desc: j.inserted_at)
|> maybe_filter_by_tech_stack(opts[:tech_stack])
|> maybe_limit(opts[:limit])
|> Repo.all()
|> Repo.preload(:user)
end

def create_job_posting(attrs) do
%JobPosting{}
|> JobPosting.changeset(attrs)
|> Repo.insert()
end

defp maybe_filter_by_tech_stack(query, nil), do: query
defp maybe_filter_by_tech_stack(query, []), do: query

defp maybe_filter_by_tech_stack(query, tech_stack) do
where(query, [j], fragment("? && ?", j.tech_stack, ^tech_stack))
end

defp maybe_limit(query, nil), do: query
defp maybe_limit(query, limit), do: limit(query, ^limit)

@spec create_payment_session(job_posting: JobPosting.t()) ::
{:ok, String.t()} | {:error, atom()}
def create_payment_session(job_posting) do
line_items = [%LineItem{amount: price(), title: "Job posting - #{job_posting.company_name}"}]

gross_amount = LineItem.gross_amount(line_items)
group_id = Nanoid.generate()

Repo.transact(fn ->
with {:ok, user} <-
Accounts.get_or_register_user(job_posting.email, %{
type: :organization,
display_name: job_posting.company_name
}),
{:ok, _charge} <-
%Transaction{}
|> change(%{
id: Nanoid.generate(),
provider: "stripe",
type: :charge,
status: :initialized,
user_id: user.id,
job_id: job_posting.id,
gross_amount: gross_amount,
net_amount: gross_amount,
total_fee: Money.zero(:USD),
line_items: Util.normalize_struct(line_items),
group_id: group_id,
idempotency_key: "session-#{Nanoid.generate()}"
})
|> Algora.Validations.validate_positive(:gross_amount)
|> Algora.Validations.validate_positive(:net_amount)
|> foreign_key_constraint(:user_id)
|> unique_constraint([:idempotency_key])
|> Repo.insert(),
{:ok, session} <-
Payments.create_stripe_session(
user,
Enum.map(line_items, &LineItem.to_stripe/1),
%{
description: "Job posting - #{job_posting.company_name}",
metadata: %{"version" => Payments.metadata_version(), "group_id" => group_id}
},
success_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=paid",
cancel_url: "#{AlgoraWeb.Endpoint.url()}/jobs?status=canceled"
) do
{:ok, session.url}
end
end)
end

def create_application(job_id, user) do
%JobApplication{}
|> JobApplication.changeset(%{job_id: job_id, user_id: user.id})
|> Repo.insert()
end

def list_user_applications(user) do
JobApplication
|> where([a], a.user_id == ^user.id)
|> select([a], a.job_id)
|> Repo.all()
|> MapSet.new()
end
end
26 changes: 26 additions & 0 deletions lib/algora/jobs/schemas/job_application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Algora.Jobs.JobApplication do
@moduledoc false
use Algora.Schema

alias Algora.Accounts.User
alias Algora.Jobs.JobPosting

typed_schema "job_applications" do
field :status, Ecto.Enum, values: [:pending], null: false, default: :pending

belongs_to :job, JobPosting, null: false
belongs_to :user, User, null: false

timestamps()
end

def changeset(job_application, attrs) do
job_application
|> cast(attrs, [:status, :job_id, :user_id])
|> generate_id()
|> validate_required([:status, :job_id, :user_id])
|> unique_constraint([:job_id, :user_id])
|> foreign_key_constraint(:job_id)
|> foreign_key_constraint(:user_id)
end
end
42 changes: 42 additions & 0 deletions lib/algora/jobs/schemas/job_posting.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Algora.Jobs.JobPosting do
@moduledoc false
use Algora.Schema

alias Algora.Accounts.User

typed_schema "job_postings" do
field :title, :string
field :description, :string
field :tech_stack, {:array, :string}, default: []
field :url, :string
field :company_name, :string
field :company_url, :string
field :email, :string
field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized
field :expires_at, :utc_datetime_usec

belongs_to :user, User, null: false

timestamps()
end

def changeset(job_posting, attrs) do
job_posting
|> cast(attrs, [
:title,
:description,
:tech_stack,
:url,
:company_name,
:company_url,
:email,
:status,
:expires_at,
:user_id
])
|> generate_id()
|> validate_required([:url, :company_name, :company_url, :email])
|> User.validate_email()
|> foreign_key_constraint(:user)
end
end
12 changes: 8 additions & 4 deletions lib/algora/payments/payments.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,23 @@ defmodule Algora.Payments do
@spec create_stripe_session(
user :: User.t(),
line_items :: [PSP.Session.line_item_data()],
payment_intent_data :: PSP.Session.payment_intent_data()
payment_intent_data :: PSP.Session.payment_intent_data(),
opts :: [
{:success_url, String.t()},
{:cancel_url, String.t()}
]
) ::
{:ok, PSP.session()} | {:error, PSP.error()}
def create_stripe_session(user, line_items, payment_intent_data) do
def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) do
with {:ok, customer} <- fetch_or_create_customer(user) do
PSP.Session.create(%{
mode: "payment",
customer: customer.provider_id,
billing_address_collection: "required",
line_items: line_items,
invoice_creation: %{enabled: true},
success_url: "#{AlgoraWeb.Endpoint.url()}/payment/success",
cancel_url: "#{AlgoraWeb.Endpoint.url()}/payment/canceled",
success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success",
cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled",
payment_intent_data: payment_intent_data
})
end
Expand Down
1 change: 1 addition & 0 deletions lib/algora/payments/schemas/transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ defmodule Algora.Payments.Transaction do
belongs_to :claim, Algora.Bounties.Claim
belongs_to :bounty, Algora.Bounties.Bounty
belongs_to :tip, Algora.Bounties.Tip
belongs_to :job, Algora.Jobs.JobPosting
belongs_to :linked_transaction, Algora.Payments.Transaction

has_many :activities, {"transaction_activities", Activity}, foreign_key: :assoc_id
Expand Down
5 changes: 3 additions & 2 deletions lib/algora_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ defmodule AlgoraWeb.CoreComponents do
</div>
</div>
<% body -> %>
<p class="text-[0.8125rem] flex items-center gap-3 font-semibold">
<p class="pr-4 text-[0.8125rem] flex items-center gap-3 font-semibold">
<.icon :if={@kind == :info} name="tabler-circle-check" class="h-6 w-6 text-success" />
<.icon
:if={@kind == :warning}
Expand Down Expand Up @@ -1236,11 +1236,12 @@ defmodule AlgoraWeb.CoreComponents do
attr :title, :string, default: nil
attr :subtitle, :string, default: nil
attr :link, :string, default: nil
attr :class, :string, default: nil
slot :inner_block

def section(assigns) do
~H"""
<div class="relative h-full">
<div class={classes(["relative h-full", @class])}>
<div :if={@title} class="flex items-end justify-between pb-2">
<div class="flex flex-col space-y-1.5">
<h2 class="text-2xl font-semibold leading-none tracking-tight">{@title}</h2>
Expand Down
14 changes: 14 additions & 0 deletions lib/algora_web/controllers/store_session_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule AlgoraWeb.StoreSessionController do
use AlgoraWeb, :controller

def create(conn, params) do
dbg(params)

updated_conn =
Enum.reduce(params, conn, fn {key, value}, acc_conn ->
put_session(acc_conn, String.to_existing_atom(key), value)
end)

send_resp(updated_conn, 200, "")
end
end
9 changes: 9 additions & 0 deletions lib/algora_web/controllers/webhooks/stripe_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do
alias Algora.Bounties.Claim
alias Algora.Bounties.Tip
alias Algora.Contracts.Contract
alias Algora.Jobs.JobPosting
alias Algora.Payments
alias Algora.Payments.Customer
alias Algora.Payments.Transaction
Expand Down Expand Up @@ -147,13 +148,21 @@ defmodule AlgoraWeb.Webhooks.StripeController do
tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()
contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()
claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()
job_ids = txs |> Enum.map(& &1.job_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()

Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid])
Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid])
Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid])
# TODO: add and use a new "paid" status for claims
Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved])

{_, job_postings} =
Repo.update_all(from(j in JobPosting, where: j.id in ^job_ids, select: j), set: [status: :processing])

for job <- job_postings do
Algora.Admin.alert("Job payment received! #{job.company_name} #{job.email} #{job.url}", :info)
end

activities_result =
txs
|> Enum.filter(&(&1.type == :credit))
Expand Down
Loading