Skip to content

Commit 5c1c45f

Browse files
committed
feat: ticket limit
1 parent 041ab31 commit 5c1c45f

9 files changed

Lines changed: 215 additions & 40 deletions

File tree

config/runtime.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ end
2222

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

2728
config :gallium, GalliumWeb.Endpoint,
2829
http: [port: String.to_integer(System.get_env("PORT", "4000"))]

lib/gallium/ticketing.ex

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ defmodule Gallium.Ticketing do
99
alias Gallium.Ticketing.{Accompany, Attendee, Payment, Ticket, TicketType}
1010

1111
@pubsub Gallium.PubSub
12-
1312
@doc """
1413
Returns all attendees with their payment and accompany preloaded.
1514
"""
@@ -20,6 +19,30 @@ defmodule Gallium.Ticketing do
2019
|> Repo.preload([:payment, :accompany, user: :ticket])
2120
end
2221

22+
def ticket_capacity, do: Application.get_env(:gallium, :ticket_capacity, 100)
23+
24+
def paid_people_count do
25+
query =
26+
from(a in Attendee,
27+
join: p in assoc(a, :payment),
28+
left_join: c in assoc(a, :accompany),
29+
where: p.status == :paid,
30+
select: count(a.id) + count(c.id)
31+
)
32+
33+
Repo.one(query) || 0
34+
end
35+
36+
def available_ticket_slots do
37+
max(ticket_capacity() - paid_people_count(), 0)
38+
end
39+
40+
def ticket_capacity_available?(quantity) when is_integer(quantity) and quantity > 0 do
41+
paid_people_count() + quantity <= ticket_capacity()
42+
end
43+
44+
def ticket_capacity_available?(_quantity), do: false
45+
2346
@doc """
2447
Groups attendees (and their accompanies) by `table_preference`.
2548
"""
@@ -430,6 +453,9 @@ defmodule Gallium.Ticketing do
430453
nil ->
431454
{:error, :not_found}
432455

456+
%{status: :paid} = payment ->
457+
{:ok, payment}
458+
433459
payment ->
434460
payment
435461
|> Ecto.Changeset.change(status: :paid)

lib/gallium/ticketing/payment.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule Gallium.Ticketing.Payment do
88
@foreign_key_type :binary_id
99
schema "payments" do
1010
field :amount, :decimal
11-
field :status, Ecto.Enum, values: [:pending, :paid, :failed], default: :pending
11+
field :status, Ecto.Enum, values: [:pending, :paid, :failed, :blocked], default: :pending
1212
field :order_id, :string
1313
belongs_to :attendee, Gallium.Ticketing.Attendee, type: :binary_id
1414
timestamps(type: :utc_datetime)

lib/gallium_web/live/backoffice/components/payment_badge.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ defmodule GalliumWeb.BackOffice.Components.PaymentBadge do
2323
<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">
2424
<.icon name="hero-x-mark" class="size-3" /> Falhou
2525
</span>
26+
<% %{status: :blocked} -> %>
27+
<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">
28+
<.icon name="hero-no-symbol" class="size-3" /> Bloqueado
29+
</span>
2630
<% _ -> %>
2731
<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">
2832
Desconhecido

lib/gallium_web/live/ticketing_purchase/index.ex

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
2121
assign_blocked_purchase(socket, attendee, is_cesium_member?)
2222

2323
{:resume, attendee} ->
24-
assign_resumable_purchase(socket, attendee)
24+
if Ticketing.ticket_capacity_available?(ticket_quantity(attendee)) do
25+
assign_resumable_purchase(socket, attendee)
26+
else
27+
assign_capacity_blocked_purchase(socket, is_cesium_member?)
28+
end
2529

2630
:new ->
27-
assign_new_purchase(socket, is_cesium_member?)
31+
if Ticketing.available_ticket_slots() > 0 do
32+
assign_new_purchase(socket, is_cesium_member?)
33+
else
34+
assign_capacity_blocked_purchase(socket, is_cesium_member?)
35+
end
2836
end
2937

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

99-
if changeset.valid? do
100-
amount_to_pay =
101-
if socket.assigns.has_accompany?,
102-
do: price_per_ticket * 2,
103-
else: price_per_ticket
107+
cond do
108+
not changeset.valid? ->
109+
changeset_com_erros = Map.put(changeset, :action, :validate)
104110

105-
new_data = Ecto.Changeset.apply_changes(changeset)
106-
new_changeset = CheckoutForm.changeset_personal_data(new_data, %{})
111+
{:noreply,
112+
socket
113+
|> assign(:price_per_ticket, price_per_ticket)
114+
|> assign(:companion_price, price_per_ticket)
115+
|> assign(:form_data, to_form(changeset_com_erros))}
107116

108-
{:noreply,
109-
socket
110-
|> assign(:price_per_ticket, price_per_ticket)
111-
|> assign(:companion_price, price_per_ticket)
112-
|> assign(:form_data, to_form(new_changeset))
113-
|> assign(:amount_to_pay, amount_to_pay)
114-
|> update(:current_step, &(&1 + 1))}
115-
else
116-
changeset_com_erros = Map.put(changeset, :action, :validate)
117+
not Ticketing.ticket_capacity_available?(ticket_quantity(socket.assigns.has_accompany?)) ->
118+
{:noreply, handle_unavailable_ticket_capacity(socket, is_member)}
117119

118-
{:noreply,
119-
socket
120-
|> assign(:price_per_ticket, price_per_ticket)
121-
|> assign(:companion_price, price_per_ticket)
122-
|> assign(:form_data, to_form(changeset_com_erros))}
120+
true ->
121+
amount_to_pay =
122+
if socket.assigns.has_accompany?,
123+
do: price_per_ticket * 2,
124+
else: price_per_ticket
125+
126+
new_data = Ecto.Changeset.apply_changes(changeset)
127+
new_changeset = CheckoutForm.changeset_personal_data(new_data, %{})
128+
129+
{:noreply,
130+
socket
131+
|> assign(:price_per_ticket, price_per_ticket)
132+
|> assign(:companion_price, price_per_ticket)
133+
|> assign(:form_data, to_form(new_changeset))
134+
|> assign(:amount_to_pay, amount_to_pay)
135+
|> update(:current_step, &(&1 + 1))}
123136
end
124137
end
125138

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

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

187+
{:error, :ticket_capacity_unavailable} ->
188+
{:noreply, handle_unavailable_ticket_capacity(socket, final_data.is_cesium_member)}
189+
173190
{:error, reason} ->
174191
{:noreply,
175192
put_flash(
@@ -226,6 +243,7 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
226243
|> assign(:companion_price, price_per_ticket)
227244
|> assign(:payment_status, :pending)
228245
|> assign(:purchase_blocked?, false)
246+
|> assign(:purchase_blocked_reason, nil)
229247
|> assign(:resuming_purchase?, false)
230248
|> assign(:user_info, nil)
231249
end
@@ -250,6 +268,7 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
250268
|> assign(:companion_price, price_per_ticket)
251269
|> assign(:payment_status, payment_status(attendee))
252270
|> assign(:purchase_blocked?, false)
271+
|> assign(:purchase_blocked_reason, nil)
253272
|> assign(:resuming_purchase?, true)
254273
|> assign(:user_info, attendee)
255274
end
@@ -266,10 +285,28 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
266285
|> assign(:companion_price, price_per_ticket)
267286
|> assign(:payment_status, :paid)
268287
|> assign(:purchase_blocked?, true)
288+
|> assign(:purchase_blocked_reason, :existing_ticket)
269289
|> assign(:resuming_purchase?, false)
270290
|> assign(:user_info, attendee)
271291
end
272292

293+
defp assign_capacity_blocked_purchase(socket, is_cesium_member?) do
294+
price_per_ticket = price_per_ticket(is_cesium_member?)
295+
296+
socket
297+
|> assign(:current_step, 1)
298+
|> assign(:form_data, to_form(CheckoutForm.changeset_personal_data(%CheckoutForm{}, %{})))
299+
|> assign(:has_accompany?, false)
300+
|> assign(:amount_to_pay, nil)
301+
|> assign(:price_per_ticket, price_per_ticket)
302+
|> assign(:companion_price, price_per_ticket)
303+
|> assign(:payment_status, :pending)
304+
|> assign(:purchase_blocked?, true)
305+
|> assign(:purchase_blocked_reason, :capacity)
306+
|> assign(:resuming_purchase?, false)
307+
|> assign(:user_info, nil)
308+
end
309+
273310
defp checkout_form_from_attendee(attendee) do
274311
%CheckoutForm{
275312
full_name: attendee.full_name,
@@ -305,6 +342,36 @@ defmodule GalliumWeb.TicketingPurchaseLive.Index do
305342
defp payment_status(%{payment: %{status: status}}), do: status
306343
defp payment_status(_attendee), do: :pending
307344

345+
defp validate_ticket_capacity(has_accompany?) do
346+
if Ticketing.ticket_capacity_available?(ticket_quantity(has_accompany?)) do
347+
:ok
348+
else
349+
{:error, :ticket_capacity_unavailable}
350+
end
351+
end
352+
353+
defp handle_unavailable_ticket_capacity(socket, is_cesium_member?) do
354+
case Ticketing.available_ticket_slots() do
355+
0 ->
356+
assign_capacity_blocked_purchase(socket, is_cesium_member?)
357+
358+
remaining_slots ->
359+
put_flash(
360+
socket,
361+
:error,
362+
"Já só #{remaining_ticket_slots_text(remaining_slots)} disponível. Remove o acompanhante para continuar."
363+
)
364+
end
365+
end
366+
367+
defp remaining_ticket_slots_text(1), do: "existe 1 lugar"
368+
defp remaining_ticket_slots_text(count), do: "existem #{count} lugares"
369+
370+
defp ticket_quantity(%{accompany: nil}), do: 1
371+
defp ticket_quantity(%{accompany: _accompany}), do: 2
372+
defp ticket_quantity(true), do: 2
373+
defp ticket_quantity(false), do: 1
374+
308375
defp price_per_ticket(true), do: TicketType.price_for_member()
309376
defp price_per_ticket(false), do: TicketType.price_for_non_member()
310377
end

lib/gallium_web/live/ticketing_purchase/index.html.heex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@
4646
</h2>
4747
<div class="w-12 h-0.5 bg-bronze mx-auto mb-6"></div>
4848
<p class="text-gray-500 text-xl font-cormorant leading-relaxed mb-10">
49-
As vendas estão encerradas ou já possuis um bilhete associado à tua conta.
49+
<%= case @purchase_blocked_reason do %>
50+
<% :capacity -> %>
51+
Os bilhetes encontram-se esgotados.
52+
<% _ -> %>
53+
As vendas estão encerradas ou já possuis um bilhete associado à tua conta.
54+
<% end %>
5055
</p>
5156
<div class="flex flex-col sm:flex-row justify-center gap-4">
5257
<.primary_button

lib/gallium_web/live/ticketing_purchase/steps/confirmation.html.heex

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,57 @@
11
<div class="flex flex-col items-center max-w-lg mx-auto my-8 px-4">
22
<div class={[
33
"rounded-full p-5 mb-4 shadow-sm",
4-
if(@payment_status == :paid,
5-
do: "bg-olive text-olive-600",
6-
else: "bg-amber-100 text-amber-600"
7-
)
4+
case @payment_status do
5+
:paid -> "bg-olive text-olive-600"
6+
:blocked -> "bg-red-100 text-red-600"
7+
_ -> "bg-amber-100 text-amber-600"
8+
end
89
]}>
910
<.icon
10-
name={if @payment_status == :paid, do: "hero-check-circle", else: "hero-clock"}
11+
name={
12+
case @payment_status do
13+
:paid -> "hero-check-circle"
14+
:blocked -> "hero-no-symbol"
15+
_ -> "hero-clock"
16+
end
17+
}
1118
class={
12-
if @payment_status == :paid, do: "size-10 text-white", else: "size-10 text-amber-600ç"
19+
case @payment_status do
20+
:paid -> "size-10 text-white"
21+
:blocked -> "size-10 text-red-600"
22+
_ -> "size-10 text-amber-600"
23+
end
1324
}
1425
/>
1526
</div>
1627

1728
<h1 class={[
1829
"text-5xl font-amarante mb-4 text-center uppercase",
19-
if(@payment_status == :paid, do: "text-olive", else: "text-amber-600")
30+
case @payment_status do
31+
:paid -> "text-olive"
32+
:blocked -> "text-red-600"
33+
_ -> "text-amber-600"
34+
end
2035
]}>
21-
{if @payment_status == :paid, do: "Bilhete Confirmado!", else: "Pagamento Pendente"}
36+
<%= case @payment_status do %>
37+
<% :paid -> %>
38+
Bilhete Confirmado!
39+
<% :blocked -> %>
40+
Pagamento Bloqueado
41+
<% _ -> %>
42+
Pagamento Pendente
43+
<% end %>
2244
</h1>
2345

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

3157
<div class="w-full border-2 border-olive-200 p-8 bg-white rounded-box relative mb-8">
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defmodule Gallium.Repo.Migrations.ChangeAttendeesWantsTransportDefault do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:attendees) do
6+
modify :wants_transport, :boolean, null: false, default: true
7+
end
8+
end
9+
end

test/gallium/ticketing_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,43 @@ defmodule Gallium.TicketingTest do
144144
end
145145
end
146146

147+
describe "ticket capacity" do
148+
import Gallium.TicketingFixtures
149+
150+
test "paid_people_count/0 counts paid attendees and their accompanies" do
151+
paid_attendee = attendee_fixture()
152+
paid_attendee_with_accompany = attendee_fixture()
153+
pending_attendee = attendee_fixture()
154+
155+
accompany_fixture(%{attendee_id: paid_attendee_with_accompany.id})
156+
accompany_fixture(%{attendee_id: pending_attendee.id})
157+
158+
payment_fixture(%{attendee_id: paid_attendee.id, status: :paid})
159+
payment_fixture(%{attendee_id: paid_attendee_with_accompany.id, status: :paid})
160+
payment_fixture(%{attendee_id: pending_attendee.id, status: :pending})
161+
162+
assert Ticketing.paid_people_count() == 3
163+
assert Ticketing.available_ticket_slots() == 97
164+
assert Ticketing.ticket_capacity_available?(97)
165+
refute Ticketing.ticket_capacity_available?(98)
166+
end
167+
168+
test "mark_payment_paid/1 accepts a confirmed payment even when it exceeds capacity" do
169+
for index <- 1..99 do
170+
attendee = attendee_fixture()
171+
payment_fixture(%{attendee_id: attendee.id, status: :paid, order_id: "paid-#{index}"})
172+
end
173+
174+
attendee = attendee_fixture()
175+
accompany_fixture(%{attendee_id: attendee.id})
176+
payment_fixture(%{attendee_id: attendee.id, status: :pending, order_id: "pending-over-cap"})
177+
178+
assert {:ok, payment} = Ticketing.mark_payment_paid("pending-over-cap")
179+
assert payment.status == :paid
180+
assert Ticketing.paid_people_count() == 101
181+
end
182+
end
183+
147184
describe "attendees" do
148185
alias Gallium.Ticketing.Attendee
149186
import Gallium.TicketingFixtures

0 commit comments

Comments
 (0)