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
3 changes: 2 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ end

config :gallium,
from_email_name: System.get_env("FROM_EMAIL_NAME") || "Jantar de Gala",
from_email_address: System.get_env("FROM_EMAIL_ADDRESS") || "no-reply@cesium.pt"
from_email_address: System.get_env("FROM_EMAIL_ADDRESS") || "no-reply@cesium.pt",
ticket_capacity: String.to_integer(System.get_env("TICKET_CAPACITY", "100"))

config :gallium, GalliumWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
Expand Down
28 changes: 27 additions & 1 deletion lib/gallium/ticketing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ defmodule Gallium.Ticketing do
alias Gallium.Ticketing.{Accompany, Attendee, Payment, Ticket, TicketType}

@pubsub Gallium.PubSub

@doc """
Returns all attendees with their payment and accompany preloaded.
"""
Expand All @@ -20,6 +19,30 @@ defmodule Gallium.Ticketing do
|> Repo.preload([:payment, :accompany, user: :ticket])
end

def ticket_capacity, do: Application.get_env(:gallium, :ticket_capacity, 100)

def paid_people_count do
query =
from(a in Attendee,
join: p in assoc(a, :payment),
left_join: c in assoc(a, :accompany),
where: p.status == :paid,
select: count(a.id) + count(c.id)
)

Repo.one(query) || 0
end

def available_ticket_slots do
max(ticket_capacity() - paid_people_count(), 0)
end

def ticket_capacity_available?(quantity) when is_integer(quantity) and quantity > 0 do
paid_people_count() + quantity <= ticket_capacity()
end

def ticket_capacity_available?(_quantity), do: false

@doc """
Groups attendees (and their accompanies) by `table_preference`.
"""
Expand Down Expand Up @@ -430,6 +453,9 @@ defmodule Gallium.Ticketing do
nil ->
{:error, :not_found}

%{status: :paid} = payment ->
{:ok, payment}

payment ->
payment
|> Ecto.Changeset.change(status: :paid)
Expand Down
2 changes: 1 addition & 1 deletion lib/gallium/ticketing/payment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Gallium.Ticketing.Payment do
@foreign_key_type :binary_id
schema "payments" do
field :amount, :decimal
field :status, Ecto.Enum, values: [:pending, :paid, :failed], default: :pending
field :status, Ecto.Enum, values: [:pending, :paid, :failed, :blocked], default: :pending
field :order_id, :string
belongs_to :attendee, Gallium.Ticketing.Attendee, type: :binary_id
timestamps(type: :utc_datetime)
Expand Down
4 changes: 4 additions & 0 deletions lib/gallium_web/live/backoffice/components/payment_badge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ defmodule GalliumWeb.BackOffice.Components.PaymentBadge do
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-red-50 text-red-600 font-semibold border border-red-200">
<.icon name="hero-x-mark" class="size-3" /> Falhou
</span>
<% %{status: :blocked} -> %>
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-red-50 text-red-600 font-semibold border border-red-200">
<.icon name="hero-no-symbol" class="size-3" /> Bloqueado
</span>
<% _ -> %>
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-gray-100 text-gray-400 font-semibold border border-gray-200">
Desconhecido
Expand Down
115 changes: 91 additions & 24 deletions lib/gallium_web/live/ticketing_purchase/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
assign_blocked_purchase(socket, attendee, is_cesium_member?)

{:resume, attendee} ->
assign_resumable_purchase(socket, attendee)
if Ticketing.ticket_capacity_available?(ticket_quantity(attendee)) do
assign_resumable_purchase(socket, attendee)
else
assign_capacity_blocked_purchase(socket, is_cesium_member?)
end

:new ->
assign_new_purchase(socket, is_cesium_member?)
if Ticketing.available_ticket_slots() > 0 do
assign_new_purchase(socket, is_cesium_member?)
else
assign_capacity_blocked_purchase(socket, is_cesium_member?)
end
end

{:ok, socket}
Expand Down Expand Up @@ -96,30 +104,35 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
price_per_ticket =
if is_member, do: TicketType.price_for_member(), else: TicketType.price_for_non_member()

if changeset.valid? do
amount_to_pay =
if socket.assigns.has_accompany?,
do: price_per_ticket * 2,
else: price_per_ticket
cond do
not changeset.valid? ->
changeset_com_erros = Map.put(changeset, :action, :validate)

new_data = Ecto.Changeset.apply_changes(changeset)
new_changeset = CheckoutForm.changeset_personal_data(new_data, %{})
{:noreply,
socket
|> assign(:price_per_ticket, price_per_ticket)
|> assign(:companion_price, price_per_ticket)
|> assign(:form_data, to_form(changeset_com_erros))}

{:noreply,
socket
|> assign(:price_per_ticket, price_per_ticket)
|> assign(:companion_price, price_per_ticket)
|> assign(:form_data, to_form(new_changeset))
|> assign(:amount_to_pay, amount_to_pay)
|> update(:current_step, &(&1 + 1))}
else
changeset_com_erros = Map.put(changeset, :action, :validate)
not Ticketing.ticket_capacity_available?(ticket_quantity(socket.assigns.has_accompany?)) ->
{:noreply, handle_unavailable_ticket_capacity(socket, is_member)}

{:noreply,
socket
|> assign(:price_per_ticket, price_per_ticket)
|> assign(:companion_price, price_per_ticket)
|> assign(:form_data, to_form(changeset_com_erros))}
true ->
amount_to_pay =
if socket.assigns.has_accompany?,
do: price_per_ticket * 2,
else: price_per_ticket

new_data = Ecto.Changeset.apply_changes(changeset)
new_changeset = CheckoutForm.changeset_personal_data(new_data, %{})

{:noreply,
socket
|> assign(:price_per_ticket, price_per_ticket)
|> assign(:companion_price, price_per_ticket)
|> assign(:form_data, to_form(new_changeset))
|> assign(:amount_to_pay, amount_to_pay)
|> update(:current_step, &(&1 + 1))}
end
end

Expand All @@ -143,7 +156,8 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
defp process_step3(socket, final_data) do
user = socket.assigns.current_scope.user

with {:ok, attendee} <-
with :ok <- validate_ticket_capacity(socket.assigns.has_accompany?),
{:ok, attendee} <-
get_or_create_attendee(user.id, final_data, socket.assigns.has_accompany?),
{:ok, payment} <-
Ticketing.start_payment(
Expand All @@ -170,6 +184,9 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
{:noreply,
put_flash(socket, :error, "Ocorreu um erro ao guardar o bilhete. Tenta novamente.")}

{:error, :ticket_capacity_unavailable} ->
{:noreply, handle_unavailable_ticket_capacity(socket, final_data.is_cesium_member)}

{:error, reason} ->
{:noreply,
put_flash(
Expand Down Expand Up @@ -226,6 +243,7 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
|> assign(:companion_price, price_per_ticket)
|> assign(:payment_status, :pending)
|> assign(:purchase_blocked?, false)
|> assign(:purchase_blocked_reason, nil)
|> assign(:resuming_purchase?, false)
|> assign(:user_info, nil)
end
Expand All @@ -250,6 +268,7 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
|> assign(:companion_price, price_per_ticket)
|> assign(:payment_status, payment_status(attendee))
|> assign(:purchase_blocked?, false)
|> assign(:purchase_blocked_reason, nil)
|> assign(:resuming_purchase?, true)
|> assign(:user_info, attendee)
end
Expand All @@ -266,10 +285,28 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
|> assign(:companion_price, price_per_ticket)
|> assign(:payment_status, :paid)
|> assign(:purchase_blocked?, true)
|> assign(:purchase_blocked_reason, :existing_ticket)
|> assign(:resuming_purchase?, false)
|> assign(:user_info, attendee)
end

defp assign_capacity_blocked_purchase(socket, is_cesium_member?) do
price_per_ticket = price_per_ticket(is_cesium_member?)

socket
|> assign(:current_step, 1)
|> assign(:form_data, to_form(CheckoutForm.changeset_personal_data(%CheckoutForm{}, %{})))
|> assign(:has_accompany?, false)
|> assign(:amount_to_pay, nil)
|> assign(:price_per_ticket, price_per_ticket)
|> assign(:companion_price, price_per_ticket)
|> assign(:payment_status, :pending)
|> assign(:purchase_blocked?, true)
|> assign(:purchase_blocked_reason, :capacity)
|> assign(:resuming_purchase?, false)
|> assign(:user_info, nil)
end

defp checkout_form_from_attendee(attendee) do
%CheckoutForm{
full_name: attendee.full_name,
Expand Down Expand Up @@ -305,6 +342,36 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
defp payment_status(%{payment: %{status: status}}), do: status
defp payment_status(_attendee), do: :pending

defp validate_ticket_capacity(has_accompany?) do
if Ticketing.ticket_capacity_available?(ticket_quantity(has_accompany?)) do
:ok
else
{:error, :ticket_capacity_unavailable}
end
end

defp handle_unavailable_ticket_capacity(socket, is_cesium_member?) do
case Ticketing.available_ticket_slots() do
0 ->
assign_capacity_blocked_purchase(socket, is_cesium_member?)

remaining_slots ->
put_flash(
socket,
:error,
"Já só #{remaining_ticket_slots_text(remaining_slots)} disponível. Remove o acompanhante para continuar."
)
end
end

defp remaining_ticket_slots_text(1), do: "existe 1 lugar"
defp remaining_ticket_slots_text(count), do: "existem #{count} lugares"

defp ticket_quantity(%{accompany: nil}), do: 1
defp ticket_quantity(%{accompany: _accompany}), do: 2
defp ticket_quantity(true), do: 2
defp ticket_quantity(false), do: 1

defp price_per_ticket(true), do: TicketType.price_for_member()
defp price_per_ticket(false), do: TicketType.price_for_non_member()
end
7 changes: 6 additions & 1 deletion lib/gallium_web/live/ticketing_purchase/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@
</h2>
<div class="w-12 h-0.5 bg-bronze mx-auto mb-6"></div>
<p class="text-gray-500 text-xl font-cormorant leading-relaxed mb-10">
As vendas estão encerradas ou já possuis um bilhete associado à tua conta.
<%= case @purchase_blocked_reason do %>
<% :capacity -> %>
Os bilhetes encontram-se esgotados.
<% _ -> %>
As vendas estão encerradas ou já possuis um bilhete associado à tua conta.
<% end %>
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<.primary_button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,57 @@
<div class="flex flex-col items-center max-w-lg mx-auto my-8 px-4">
<div class={[
"rounded-full p-5 mb-4 shadow-sm",
if(@payment_status == :paid,
do: "bg-olive text-olive-600",
else: "bg-amber-100 text-amber-600"
)
case @payment_status do
:paid -> "bg-olive text-olive-600"
:blocked -> "bg-red-100 text-red-600"
_ -> "bg-amber-100 text-amber-600"
end
]}>
<.icon
name={if @payment_status == :paid, do: "hero-check-circle", else: "hero-clock"}
name={
case @payment_status do
:paid -> "hero-check-circle"
:blocked -> "hero-no-symbol"
_ -> "hero-clock"
end
}
class={
if @payment_status == :paid, do: "size-10 text-white", else: "size-10 text-amber-600ç"
case @payment_status do
:paid -> "size-10 text-white"
:blocked -> "size-10 text-red-600"
_ -> "size-10 text-amber-600"
end
}
/>
</div>

<h1 class={[
"text-5xl font-amarante mb-4 text-center uppercase",
if(@payment_status == :paid, do: "text-olive", else: "text-amber-600")
case @payment_status do
:paid -> "text-olive"
:blocked -> "text-red-600"
_ -> "text-amber-600"
end
]}>
{if @payment_status == :paid, do: "Bilhete Confirmado!", else: "Pagamento Pendente"}
<%= case @payment_status do %>
<% :paid -> %>
Bilhete Confirmado!
<% :blocked -> %>
Pagamento Bloqueado
<% _ -> %>
Pagamento Pendente
<% end %>
</h1>

<p class="font-cormorant text-gray-600 text-center mb-8">
{if @payment_status == :paid,
do: "O teu pagamento foi recebido. Até já na Quinta Vinha do Cabo!",
else:
"A tua reserva foi registada. Efetua o pagamento via MBWay para confirmares o teu bilhete."}
<%= case @payment_status do %>
<% :paid -> %>
O teu pagamento foi recebido. Até já na Quinta Vinha do Cabo!
<% :blocked -> %>
O limite de 100 lugares pagos já foi atingido, incluindo acompanhantes. Contacta a organização para regularizar a situação.
<% _ -> %>
A tua reserva foi registada. Efetua o pagamento via MBWay para confirmares o teu bilhete.
<% end %>
</p>

<div class="w-full border-2 border-olive-200 p-8 bg-white rounded-box relative mb-8">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Gallium.Repo.Migrations.ChangeAttendeesWantsTransportDefault do
use Ecto.Migration

def change do
alter table(:attendees) do
modify :wants_transport, :boolean, null: false, default: true
end
end
end
37 changes: 37 additions & 0 deletions test/gallium/ticketing_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,43 @@ defmodule Gallium.TicketingTest do
end
end

describe "ticket capacity" do
import Gallium.TicketingFixtures

test "paid_people_count/0 counts paid attendees and their accompanies" do
paid_attendee = attendee_fixture()
paid_attendee_with_accompany = attendee_fixture()
pending_attendee = attendee_fixture()

accompany_fixture(%{attendee_id: paid_attendee_with_accompany.id})
accompany_fixture(%{attendee_id: pending_attendee.id})

payment_fixture(%{attendee_id: paid_attendee.id, status: :paid})
payment_fixture(%{attendee_id: paid_attendee_with_accompany.id, status: :paid})
payment_fixture(%{attendee_id: pending_attendee.id, status: :pending})

assert Ticketing.paid_people_count() == 3
assert Ticketing.available_ticket_slots() == 97
assert Ticketing.ticket_capacity_available?(97)
refute Ticketing.ticket_capacity_available?(98)
end

test "mark_payment_paid/1 accepts a confirmed payment even when it exceeds capacity" do
for index <- 1..99 do
attendee = attendee_fixture()
payment_fixture(%{attendee_id: attendee.id, status: :paid, order_id: "paid-#{index}"})
end

attendee = attendee_fixture()
accompany_fixture(%{attendee_id: attendee.id})
payment_fixture(%{attendee_id: attendee.id, status: :pending, order_id: "pending-over-cap"})

assert {:ok, payment} = Ticketing.mark_payment_paid("pending-over-cap")
assert payment.status == :paid
assert Ticketing.paid_people_count() == 101
end
end

describe "attendees" do
alias Gallium.Ticketing.Attendee
import Gallium.TicketingFixtures
Expand Down
Loading