diff --git a/config/runtime.exs b/config/runtime.exs index f1ea5d6..5b59361 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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"))] diff --git a/lib/gallium/ticketing.ex b/lib/gallium/ticketing.ex index 51fff4c..336b35b 100644 --- a/lib/gallium/ticketing.ex +++ b/lib/gallium/ticketing.ex @@ -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. """ @@ -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`. """ @@ -430,6 +453,9 @@ defmodule Gallium.Ticketing do nil -> {:error, :not_found} + %{status: :paid} = payment -> + {:ok, payment} + payment -> payment |> Ecto.Changeset.change(status: :paid) diff --git a/lib/gallium/ticketing/payment.ex b/lib/gallium/ticketing/payment.ex index c52fdaa..77bfa1e 100644 --- a/lib/gallium/ticketing/payment.ex +++ b/lib/gallium/ticketing/payment.ex @@ -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) diff --git a/lib/gallium_web/live/backoffice/components/payment_badge.ex b/lib/gallium_web/live/backoffice/components/payment_badge.ex index 133aebe..7ef2e15 100644 --- a/lib/gallium_web/live/backoffice/components/payment_badge.ex +++ b/lib/gallium_web/live/backoffice/components/payment_badge.ex @@ -23,6 +23,10 @@ defmodule GalliumWeb.BackOffice.Components.PaymentBadge do <.icon name="hero-x-mark" class="size-3" /> Falhou + <% %{status: :blocked} -> %> + + <.icon name="hero-no-symbol" class="size-3" /> Bloqueado + <% _ -> %> Desconhecido diff --git a/lib/gallium_web/live/ticketing_purchase/index.ex b/lib/gallium_web/live/ticketing_purchase/index.ex index fafb775..85f40d9 100644 --- a/lib/gallium_web/live/ticketing_purchase/index.ex +++ b/lib/gallium_web/live/ticketing_purchase/index.ex @@ -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} @@ -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 @@ -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( @@ -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( @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/lib/gallium_web/live/ticketing_purchase/index.html.heex b/lib/gallium_web/live/ticketing_purchase/index.html.heex index eab1ab7..211ec11 100644 --- a/lib/gallium_web/live/ticketing_purchase/index.html.heex +++ b/lib/gallium_web/live/ticketing_purchase/index.html.heex @@ -46,7 +46,12 @@

- 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 %>

<.primary_button diff --git a/lib/gallium_web/live/ticketing_purchase/steps/confirmation.html.heex b/lib/gallium_web/live/ticketing_purchase/steps/confirmation.html.heex index bb95f03..3245514 100644 --- a/lib/gallium_web/live/ticketing_purchase/steps/confirmation.html.heex +++ b/lib/gallium_web/live/ticketing_purchase/steps/confirmation.html.heex @@ -1,31 +1,57 @@
"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 } />

"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 %>

- {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 %>

diff --git a/priv/repo/migrations/20260511120000_change_attendees_wants_transport_default.exs b/priv/repo/migrations/20260511120000_change_attendees_wants_transport_default.exs new file mode 100644 index 0000000..095a80b --- /dev/null +++ b/priv/repo/migrations/20260511120000_change_attendees_wants_transport_default.exs @@ -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 diff --git a/test/gallium/ticketing_test.exs b/test/gallium/ticketing_test.exs index d65822e..42bde43 100644 --- a/test/gallium/ticketing_test.exs +++ b/test/gallium/ticketing_test.exs @@ -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