diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3984fa4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +defaults: + run: + working-directory: pretex + +jobs: + precommit: + name: mix precommit + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: pretex_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + MIX_ENV: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.19" + otp-version: "27" + + - name: Restore deps cache + uses: actions/cache@v4 + id: deps-cache + with: + path: pretex/deps + key: deps-${{ runner.os }}-${{ hashFiles('pretex/mix.lock') }} + restore-keys: deps-${{ runner.os }}- + + - name: Restore build cache + uses: actions/cache@v4 + with: + path: pretex/_build + key: build-${{ runner.os }}-${{ hashFiles('pretex/mix.lock') }} + restore-keys: build-${{ runner.os }}- + + - name: Install dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + run: mix deps.get + + - name: Run precommit checks + run: mix precommit diff --git a/pretex/lib/pretex/memberships.ex b/pretex/lib/pretex/memberships.ex index 6c14407..b6a2e34 100644 --- a/pretex/lib/pretex/memberships.ex +++ b/pretex/lib/pretex/memberships.ex @@ -110,7 +110,8 @@ defmodule Pretex.Memberships do now = DateTime.utc_now(:second) Membership - |> where([m], + |> where( + [m], m.customer_id == ^customer.id and m.organization_id == ^org_id and m.status == "active" and @@ -134,7 +135,8 @@ defmodule Pretex.Memberships do memberships = Membership - |> where([m], + |> where( + [m], m.customer_id == ^customer_id and m.organization_id == ^org_id and m.status == "active" and @@ -210,7 +212,10 @@ defmodule Pretex.Memberships do min(value, subtotal_cents) end - defp compute_benefit_discount(%{benefit_type: "percentage_discount", value: basis_points}, subtotal_cents) do + defp compute_benefit_discount( + %{benefit_type: "percentage_discount", value: basis_points}, + subtotal_cents + ) do round(subtotal_cents * basis_points / 10_000) end diff --git a/pretex/lib/pretex/memberships/membership.ex b/pretex/lib/pretex/memberships/membership.ex index 934dcf5..28de786 100644 --- a/pretex/lib/pretex/memberships/membership.ex +++ b/pretex/lib/pretex/memberships/membership.ex @@ -30,7 +30,14 @@ defmodule Pretex.Memberships.Membership do :organization_id, :source_order_id ]) - |> validate_required([:starts_at, :expires_at, :status, :membership_type_id, :customer_id, :organization_id]) + |> validate_required([ + :starts_at, + :expires_at, + :status, + :membership_type_id, + :customer_id, + :organization_id + ]) |> validate_inclusion(:status, @statuses) end end diff --git a/pretex/lib/pretex/orders.ex b/pretex/lib/pretex/orders.ex index 9409548..678a49e 100644 --- a/pretex/lib/pretex/orders.ex +++ b/pretex/lib/pretex/orders.ex @@ -58,6 +58,32 @@ defmodule Pretex.Orders do |> Repo.update() end + @doc """ + Persists the attendee name and email on the cart so they survive page refreshes. + """ + def update_cart_attendee_info(%CartSession{} = cart, name, email) do + cart + |> CartSession.attendee_changeset(%{attendee_name: name, attendee_email: email}) + |> Repo.update() + end + + @doc """ + Slides the cart's expiry window 15 minutes from now. + + Called on every checkout page visit so the TTL is activity-based rather than + fixed at cart-creation time. A user who spends more than 15 minutes browsing + the summary page no longer hits a spurious "cart expired" error when they + click to place their order. + """ + def extend_cart_expiry(%CartSession{} = cart) do + new_expiry = + DateTime.utc_now() |> DateTime.add(15 * 60, :second) |> DateTime.truncate(:second) + + cart + |> Ecto.Changeset.change(expires_at: new_expiry) + |> Repo.update() + end + @doc """ Adds an item to the cart or updates quantity if already present. Options: [quantity: 1, variation_id: nil] @@ -345,11 +371,19 @@ defmodule Pretex.Orders do order_after_gift_card |> Repo.preload(order_items: [item: []]) |> Map.get(:order_items, []) - |> Enum.filter(fn oi -> oi.item.item_type == "membership" && oi.item.membership_type_id != nil end) + |> Enum.filter(fn oi -> + oi.item.item_type == "membership" && oi.item.membership_type_id != nil + end) |> Enum.each(fn oi -> mt = Pretex.Memberships.get_membership_type!(oi.item.membership_type_id) customer = Repo.get!(Pretex.Customers.Customer, customer_id) - Pretex.Memberships.activate_membership_from_order(mt, customer, org, order_after_gift_card) + + Pretex.Memberships.activate_membership_from_order( + mt, + customer, + org, + order_after_gift_card + ) end) end diff --git a/pretex/lib/pretex/orders/cart_session.ex b/pretex/lib/pretex/orders/cart_session.ex index 3cceb1f..2b49428 100644 --- a/pretex/lib/pretex/orders/cart_session.ex +++ b/pretex/lib/pretex/orders/cart_session.ex @@ -8,6 +8,8 @@ defmodule Pretex.Orders.CartSession do field(:session_token, :string) field(:expires_at, :utc_datetime) field(:status, :string, default: "active") + field(:attendee_name, :string) + field(:attendee_email, :string) belongs_to(:event, Pretex.Events.Event) has_many(:cart_items, Pretex.Orders.CartItem) @@ -24,4 +26,9 @@ defmodule Pretex.Orders.CartSession do |> validate_inclusion(:status, @statuses) |> unique_constraint(:session_token) end + + def attendee_changeset(cart, attrs) do + cart + |> cast(attrs, [:attendee_name, :attendee_email]) + end end diff --git a/pretex/lib/pretex/payments.ex b/pretex/lib/pretex/payments.ex index a93a833..64b1481 100644 --- a/pretex/lib/pretex/payments.ex +++ b/pretex/lib/pretex/payments.ex @@ -232,6 +232,15 @@ defmodule Pretex.Payments do |> Repo.one() end + @doc """ + Updates the transfer_note on a payment (customer-submitted proof for bank transfer). + """ + def update_payment_transfer_note(%Payment{} = payment, note) do + payment + |> Payment.note_changeset(%{transfer_note: note}) + |> Repo.update() + end + def list_pending_async_payments do Payment |> where([p], p.status == "pending" and p.flow == "async") diff --git a/pretex/lib/pretex/payments/payment.ex b/pretex/lib/pretex/payments/payment.ex index 1a01c9e..49fdc42 100644 --- a/pretex/lib/pretex/payments/payment.ex +++ b/pretex/lib/pretex/payments/payment.ex @@ -29,6 +29,8 @@ defmodule Pretex.Payments.Payment do field(:settled_at, :utc_datetime) # Human-readable failure reason, if any field(:failure_reason, :string) + # Customer-submitted proof/note for manual bank transfer payments + field(:transfer_note, :string) has_many(:refunds, Pretex.Payments.Refund) @@ -67,6 +69,12 @@ defmodule Pretex.Payments.Payment do ]) end + def note_changeset(payment, attrs) do + payment + |> cast(attrs, [:transfer_note]) + |> validate_length(:transfer_note, max: 1000) + end + def confirm_changeset(payment, attrs \\ %{}) do now = DateTime.utc_now() |> DateTime.truncate(:second) diff --git a/pretex/lib/pretex_web/controllers/page_controller.ex b/pretex/lib/pretex_web/controllers/page_controller.ex index 60febca..18b4212 100644 --- a/pretex/lib/pretex_web/controllers/page_controller.ex +++ b/pretex/lib/pretex_web/controllers/page_controller.ex @@ -2,6 +2,6 @@ defmodule PretexWeb.PageController do use PretexWeb, :controller def home(conn, _params) do - render(conn, :home) + render(conn, :home, current_scope: conn.assigns.current_scope) end end diff --git a/pretex/lib/pretex_web/controllers/page_html/home.html.heex b/pretex/lib/pretex_web/controllers/page_html/home.html.heex index 0e37345..e28a13a 100644 --- a/pretex/lib/pretex_web/controllers/page_html/home.html.heex +++ b/pretex/lib/pretex_web/controllers/page_html/home.html.heex @@ -34,8 +34,18 @@
- Entrar - Criar Conta + <%= if @current_scope && @current_scope.customer do %> + + Meus Pedidos + <.link href="/customers/log-out" method="delete" class="btn btn-ghost btn-sm"> + Sair + + <% else %> + Entrar + Criar Conta + <% end %>
diff --git a/pretex/lib/pretex_web/live/admin/order_live/show.ex b/pretex/lib/pretex_web/live/admin/order_live/show.ex index 781dfe7..da2a4d7 100644 --- a/pretex/lib/pretex_web/live/admin/order_live/show.ex +++ b/pretex/lib/pretex_web/live/admin/order_live/show.ex @@ -4,18 +4,21 @@ defmodule PretexWeb.Admin.OrderLive.Show do alias Pretex.Events alias Pretex.Orders alias Pretex.Organizations + alias Pretex.Payments @impl true def mount(%{"org_id" => org_id, "event_id" => event_id, "id" => id}, _session, socket) do org = Organizations.get_organization!(org_id) event = Events.get_event!(event_id) order = Orders.get_order_with_details!(id) + payment = Payments.get_payment_for_order(order) socket = socket |> assign(:org, org) |> assign(:event, event) |> assign(:order, order) + |> assign(:payment, payment) |> assign(:page_title, "Pedido ##{order.confirmation_code}") {:ok, socket} @@ -85,4 +88,27 @@ defmodule PretexWeb.Admin.OrderLive.Show do {:noreply, put_flash(socket, :error, "Não foi possível cancelar o pedido.")} end end + + @impl true + def handle_event("confirm_payment", _params, socket) do + case socket.assigns.payment do + nil -> + {:noreply, put_flash(socket, :error, "Nenhum pagamento encontrado para este pedido.")} + + payment -> + case Payments.confirm_payment(payment) do + {:ok, confirmed_payment} -> + updated_order = Orders.get_order_with_details!(socket.assigns.order.id) + + {:noreply, + socket + |> assign(:order, updated_order) + |> assign(:payment, confirmed_payment) + |> put_flash(:info, "Pagamento confirmado com sucesso.")} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Não foi possível confirmar o pagamento.")} + end + end + end end diff --git a/pretex/lib/pretex_web/live/admin/order_live/show.html.heex b/pretex/lib/pretex_web/live/admin/order_live/show.html.heex index af39632..e162a1e 100644 --- a/pretex/lib/pretex_web/live/admin/order_live/show.html.heex +++ b/pretex/lib/pretex_web/live/admin/order_live/show.html.heex @@ -40,6 +40,16 @@ <.icon name="hero-lock-open" class="size-4" /> Desbloquear + + + + + + <%!-- Card de auditoria / linha do tempo --%>
@@ -292,6 +401,15 @@ <.icon name="hero-envelope" class="size-4" /> Reenviar Ingressos + +
+ <%!-- Transfer proof / note --%> +
+
+ <.icon name="hero-check-circle" class="size-5 text-success shrink-0 mt-0.5" /> +
+

Comprovante enviado!

+

+ {@transfer_note} +

+
+
+ +
+
+

+ Envie o comprovante da transferência +

+

+ Cole o número da transação, ID do comprovante ou qualquer informação que ajude o organizador a identificar seu pagamento. +

+
+
+ + +
+
+
+
- Monitorando confirmação automática... + Aguardando confirmação do organizador...
@@ -539,6 +585,8 @@ defmodule PretexWeb.EventsLive.Checkout do |> assign(:gift_card, nil) |> assign(:gift_card_error, nil) |> assign(:gift_card_deduction, 0) + |> assign(:transfer_note, "") + |> assign(:transfer_note_submitted, false) {:ok, socket} end @@ -552,61 +600,75 @@ defmodule PretexWeb.EventsLive.Checkout do cart_token = Map.get(params, "cart_token") event = socket.assigns.event + # On the :payment step the cart is already checked_out — skip cart loading + # entirely and let load_payment_step below handle everything. socket = - if cart_token do - case Orders.get_cart_by_token(cart_token) do - nil -> - socket - |> put_flash(:error, "Carrinho não encontrado. Por favor adicione itens primeiro.") - |> push_navigate(to: ~p"/events/#{event.slug}") - - cart -> - if cart.event_id == event.id && cart.status == "active" do - payment_options = load_payment_options(event) - subtotal = Orders.cart_total(cart) - fee_preview = Pretex.Fees.compute_fees_for_cart(event, subtotal) - fee_total = Pretex.Fees.total_fees_cents(fee_preview) - - cart_items = - Enum.map(cart.cart_items, fn ci -> - %{ - item_id: ci.item_id, - item_variation_id: ci.item_variation_id, - quantity: ci.quantity, - unit_price_cents: - if(ci.item_variation && ci.item_variation.price_cents, - do: ci.item_variation.price_cents, - else: ci.item.price_cents - ) - } - end) - - discount_preview = Pretex.Discounts.compute_discount_for_cart(event.id, cart_items) - - {discount_rule_name, _} = - case Pretex.Discounts.best_discount(event.id, cart_items) do - {:ok, %{rule: r}} -> {r.name, nil} - _ -> {nil, nil} - end - - socket - |> assign(:cart, cart) - |> assign(:cart_total, subtotal) - |> assign(:payment_options, payment_options) - |> assign(:fee_preview, fee_preview) - |> assign(:fee_total, fee_total) - |> assign(:discount_preview, discount_preview) - |> assign(:applied_discount_rule_name, discount_rule_name) - else + if socket.assigns.live_action == :payment do + socket + else + if cart_token do + case Orders.get_cart_by_token(cart_token) do + nil -> socket - |> put_flash(:error, "Seu carrinho expirou. Por favor comece novamente.") + |> put_flash(:error, "Carrinho não encontrado. Por favor adicione itens primeiro.") |> push_navigate(to: ~p"/events/#{event.slug}") - end + + cart -> + if cart.event_id == event.id && cart.status == "active" do + {:ok, cart} = Orders.extend_cart_expiry(cart) + payment_options = load_payment_options(event) + subtotal = Orders.cart_total(cart) + fee_preview = Pretex.Fees.compute_fees_for_cart(event, subtotal) + fee_total = Pretex.Fees.total_fees_cents(fee_preview) + + cart_items = + Enum.map(cart.cart_items, fn ci -> + %{ + item_id: ci.item_id, + item_variation_id: ci.item_variation_id, + quantity: ci.quantity, + unit_price_cents: + if(ci.item_variation && ci.item_variation.price_cents, + do: ci.item_variation.price_cents, + else: ci.item.price_cents + ) + } + end) + + discount_preview = + Pretex.Discounts.compute_discount_for_cart(event.id, cart_items) + + {discount_rule_name, _} = + case Pretex.Discounts.best_discount(event.id, cart_items) do + {:ok, %{rule: r}} -> {r.name, nil} + _ -> {nil, nil} + end + + name = cart.attendee_name || "" + email = cart.attendee_email || "" + + socket + |> assign(:cart, cart) + |> assign(:cart_total, subtotal) + |> assign(:payment_options, payment_options) + |> assign(:fee_preview, fee_preview) + |> assign(:fee_total, fee_total) + |> assign(:discount_preview, discount_preview) + |> assign(:applied_discount_rule_name, discount_rule_name) + |> assign(:attendee_name, name) + |> assign(:attendee_email, email) + |> assign(:form, to_form(%{"name" => name, "email" => email}, as: :checkout)) + else + socket + |> put_flash(:error, "Seu carrinho expirou. Por favor comece novamente.") + |> push_navigate(to: ~p"/events/#{event.slug}") + end + end + else + socket + |> put_flash(:error, "Nenhum carrinho encontrado. Por favor adicione itens primeiro.") + |> push_navigate(to: ~p"/events/#{event.slug}") end - else - socket - |> put_flash(:error, "Nenhum carrinho encontrado. Por favor adicione itens primeiro.") - |> push_navigate(to: ~p"/events/#{event.slug}") end # On the :payment step, load the order and payment and subscribe to updates @@ -694,6 +756,7 @@ defmodule PretexWeb.EventsLive.Checkout do true -> cart = socket.assigns.cart + Orders.update_cart_attendee_info(cart, name, email) socket = socket @@ -762,6 +825,12 @@ defmodule PretexWeb.EventsLive.Checkout do name = socket.assigns.attendee_name email = socket.assigns.attendee_email + customer_id = + case socket.assigns.current_scope do + %{customer: %{id: id}} -> id + _ -> nil + end + if is_nil(payment_method) do {:noreply, put_flash(socket, :error, "Por favor selecione uma forma de pagamento.")} else @@ -770,6 +839,7 @@ defmodule PretexWeb.EventsLive.Checkout do attrs = %{ name: name, email: email, + customer_id: customer_id, payment_method: payment_method, payment_provider_id: provider_id && String.to_integer(provider_id), voucher_code: socket.assigns.voucher_code, @@ -934,6 +1004,32 @@ defmodule PretexWeb.EventsLive.Checkout do |> assign(:gift_card_deduction, 0)} end + def handle_event("submit_transfer_note", %{"note" => note}, socket) do + note = String.trim(note) + + if note == "" do + {:noreply, put_flash(socket, :error, "Por favor descreva o comprovante antes de enviar.")} + else + case socket.assigns.payment do + nil -> + {:noreply, put_flash(socket, :error, "Pagamento não encontrado.")} + + payment -> + case Payments.update_payment_transfer_note(payment, note) do + {:ok, updated_payment} -> + {:noreply, + socket + |> assign(:payment, updated_payment) + |> assign(:transfer_note, note) + |> assign(:transfer_note_submitted, true)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Não foi possível enviar o comprovante.")} + end + end + end + end + def handle_event("retry_payment", _params, socket) do cart_token = socket.assigns.cart && socket.assigns.cart.session_token diff --git a/pretex/lib/pretex_web/live/events_live/confirmation.ex b/pretex/lib/pretex_web/live/events_live/confirmation.ex index eb54077..5b80bc7 100644 --- a/pretex/lib/pretex_web/live/events_live/confirmation.ex +++ b/pretex/lib/pretex_web/live/events_live/confirmation.ex @@ -8,27 +8,51 @@ defmodule PretexWeb.EventsLive.Confirmation do ~H""" <.customer_layout current_scope={@current_scope} current_path="" flash={@flash}>
- <%!-- Success hero --%> + <%!-- Hero — adapts to order status --%>
-
-
- <.icon name="hero-check-circle" class="size-16 text-success" /> + <%= if @order.status == "confirmed" do %> +
+
+ <.icon name="hero-check-circle" class="size-16 text-success" /> +
-
-

Pedido Confirmado!

-

- Obrigado pela sua compra. Seus ingressos estão prontos. -

+

Pedido Confirmado!

+

+ Obrigado pela sua compra. Seus ingressos estão prontos. +

+ <% else %> +
+
+ <.icon name="hero-clock" class="size-16 text-warning" /> +
+
+

Aguardando Pagamento

+

+ Seu pedido foi criado. Assim que o pagamento for confirmado, seus ingressos serão emitidos. +

+ <% end %>
<%!-- Order details card --%>
<%!-- Confirmation code banner --%> -
-

- Código de Confirmação +

+

+ Código do Pedido

-

+

{@order.confirmation_code}

@@ -110,7 +134,9 @@ defmodule PretexWeb.EventsLive.Confirmation do <%!-- Total --%>
- Total Pago + + {if @order.status == "confirmed", do: "Total Pago", else: "Total"} + {format_price(@order.total_cents)}
diff --git a/pretex/mix.exs b/pretex/mix.exs index d207005..7ecd19a 100644 --- a/pretex/mix.exs +++ b/pretex/mix.exs @@ -93,7 +93,12 @@ defmodule Pretex.MixProject do "esbuild pretex --minify", "phx.digest" ], - precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + precommit: [ + "compile --warnings-as-errors", + "deps.unlock --unused", + "format", + "test --warnings-as-errors" + ] ] end end diff --git a/pretex/priv/repo/migrations/20260320300000_add_oban_jobs_table.exs b/pretex/priv/repo/migrations/20260320300000_add_oban_jobs_table.exs new file mode 100644 index 0000000..2775f81 --- /dev/null +++ b/pretex/priv/repo/migrations/20260320300000_add_oban_jobs_table.exs @@ -0,0 +1,11 @@ +defmodule Pretex.Repo.Migrations.AddObanJobsTable do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 12) + end + + def down do + Oban.Migration.down(version: 1) + end +end diff --git a/pretex/priv/repo/migrations/20260320300001_add_attendee_info_to_cart_sessions.exs b/pretex/priv/repo/migrations/20260320300001_add_attendee_info_to_cart_sessions.exs new file mode 100644 index 0000000..e1157f2 --- /dev/null +++ b/pretex/priv/repo/migrations/20260320300001_add_attendee_info_to_cart_sessions.exs @@ -0,0 +1,10 @@ +defmodule Pretex.Repo.Migrations.AddAttendeeInfoToCartSessions do + use Ecto.Migration + + def change do + alter table(:cart_sessions) do + add(:attendee_name, :string) + add(:attendee_email, :string) + end + end +end diff --git a/pretex/priv/repo/migrations/20260320300002_add_transfer_note_to_payments.exs b/pretex/priv/repo/migrations/20260320300002_add_transfer_note_to_payments.exs new file mode 100644 index 0000000..29ccedf --- /dev/null +++ b/pretex/priv/repo/migrations/20260320300002_add_transfer_note_to_payments.exs @@ -0,0 +1,9 @@ +defmodule Pretex.Repo.Migrations.AddTransferNoteToPayments do + use Ecto.Migration + + def change do + alter table(:payments) do + add(:transfer_note, :text) + end + end +end diff --git a/pretex/test/pretex/discount_order_integration_test.exs b/pretex/test/pretex/discount_order_integration_test.exs index c3e4856..6855c25 100644 --- a/pretex/test/pretex/discount_order_integration_test.exs +++ b/pretex/test/pretex/discount_order_integration_test.exs @@ -14,7 +14,7 @@ defmodule Pretex.DiscountOrderIntegrationTest do # Helpers # --------------------------------------------------------------------------- - defp cart_with_item(event, price_cents \\ 5000, quantity \\ 1) do + defp cart_with_item(event, price_cents, quantity \\ 1) do item = item_fixture(event, %{price_cents: price_cents}) {:ok, cart} = Orders.create_cart(event) {:ok, _cart_item} = Orders.add_to_cart(cart, item, quantity: quantity) @@ -32,7 +32,7 @@ defmodule Pretex.DiscountOrderIntegrationTest do ) end - defp discount_rule_fixture(event, attrs \\ %{}) do + defp discount_rule_fixture(event, attrs) do base = %{ name: "Regra Integração #{System.unique_integer([:positive])}", condition_type: "min_quantity", @@ -46,7 +46,7 @@ defmodule Pretex.DiscountOrderIntegrationTest do rule end - defp voucher_fixture(event, attrs \\ %{}) do + defp voucher_fixture(event, attrs) do base = %{ code: "VOUCHER#{System.unique_integer([:positive])}", effect: "fixed_discount", diff --git a/pretex/test/pretex/discounts_test.exs b/pretex/test/pretex/discounts_test.exs index d3c3d16..dd6b4be 100644 --- a/pretex/test/pretex/discounts_test.exs +++ b/pretex/test/pretex/discounts_test.exs @@ -6,9 +6,7 @@ defmodule Pretex.DiscountsTest do import Pretex.CatalogFixtures alias Pretex.Discounts - alias Pretex.Discounts.DiscountRule alias Pretex.Discounts.OrderDiscount - alias Pretex.Orders alias Pretex.Repo # --------------------------------------------------------------------------- @@ -29,7 +27,7 @@ defmodule Pretex.DiscountsTest do rule end - defp order_fixture(event, total_cents \\ 10_000) do + defp order_fixture(event, total_cents) do {:ok, order} = %Pretex.Orders.Order{} |> Ecto.Changeset.change(%{ @@ -47,17 +45,6 @@ defmodule Pretex.DiscountsTest do order end - defp cart_items_fixture(price_cents \\ 5000, quantity \\ 1) do - [ - %{ - item_id: System.unique_integer([:positive]), - item_variation_id: nil, - quantity: quantity, - unit_price_cents: price_cents - } - ] - end - # --------------------------------------------------------------------------- # list_discount_rules/1 # --------------------------------------------------------------------------- diff --git a/pretex/test/pretex/gift_card_order_integration_test.exs b/pretex/test/pretex/gift_card_order_integration_test.exs index e2868a9..3ca0b29 100644 --- a/pretex/test/pretex/gift_card_order_integration_test.exs +++ b/pretex/test/pretex/gift_card_order_integration_test.exs @@ -13,7 +13,7 @@ defmodule Pretex.GiftCardOrderIntegrationTest do # Helpers # --------------------------------------------------------------------------- - defp cart_with_item(event, price_cents \\ 5000) do + defp cart_with_item(event, price_cents) do item = item_fixture(event, %{price_cents: price_cents}) {:ok, cart} = Orders.create_cart(event) {:ok, _cart_item} = Orders.add_to_cart(cart, item, quantity: 1) @@ -31,7 +31,7 @@ defmodule Pretex.GiftCardOrderIntegrationTest do ) end - defp gift_card_fixture(org, attrs \\ %{}) do + defp gift_card_fixture(org, attrs) do base = %{ code: "GC-TEST#{System.unique_integer([:positive])}", balance_cents: 5000, @@ -315,7 +315,7 @@ defmodule Pretex.GiftCardOrderIntegrationTest do test "gift card applied case-insensitively" do org = org_fixture() event = published_event_fixture(org) - gc = gift_card_fixture(org, %{code: "GC-CASECI01", balance_cents: 1000}) + _gc = gift_card_fixture(org, %{code: "GC-CASECI01", balance_cents: 1000}) cart = cart_with_item(event, 5000) subtotal = Orders.cart_total(cart) diff --git a/pretex/test/pretex/membership_order_integration_test.exs b/pretex/test/pretex/membership_order_integration_test.exs index c8de3f9..24cd620 100644 --- a/pretex/test/pretex/membership_order_integration_test.exs +++ b/pretex/test/pretex/membership_order_integration_test.exs @@ -14,7 +14,7 @@ defmodule Pretex.MembershipOrderIntegrationTest do # Helpers # --------------------------------------------------------------------------- - defp cart_with_item(event, price_cents \\ 5000, quantity \\ 1) do + defp cart_with_item(event, price_cents, quantity \\ 1) do item = item_fixture(event, %{price_cents: price_cents}) {:ok, cart} = Orders.create_cart(event) {:ok, _} = Orders.add_to_cart(cart, item, quantity: quantity) @@ -39,7 +39,10 @@ defmodule Pretex.MembershipOrderIntegrationTest do event = published_event_fixture(org) {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, _m} = Memberships.grant_membership(mt, customer, org) cart = cart_with_item(event, 20_000) @@ -83,7 +86,10 @@ defmodule Pretex.MembershipOrderIntegrationTest do event = published_event_fixture(org) {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, m} = Memberships.grant_membership(mt, customer, org) {:ok, _} = Memberships.expire_membership(m) @@ -105,7 +111,10 @@ defmodule Pretex.MembershipOrderIntegrationTest do event = published_event_fixture(org2) {:ok, mt} = Memberships.create_membership_type(org1, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, _m} = Memberships.grant_membership(mt, customer, org1) cart = cart_with_item(event, 20_000) @@ -124,12 +133,20 @@ defmodule Pretex.MembershipOrderIntegrationTest do customer = customer_fixture() event = published_event_fixture(org) - {:ok, mt_gold} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt_gold, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, mt_gold} = + Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) + + {:ok, _b} = + Memberships.create_benefit(mt_gold, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, _m} = Memberships.grant_membership(mt_gold, customer, org) - {:ok, mt_silver} = Memberships.create_membership_type(org, %{name: "Silver", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt_silver, %{benefit_type: "percentage_discount", value: 1000}) + {:ok, mt_silver} = + Memberships.create_membership_type(org, %{name: "Silver", validity_days: 365}) + + {:ok, _b} = + Memberships.create_benefit(mt_silver, %{benefit_type: "percentage_discount", value: 1000}) + {:ok, _m} = Memberships.grant_membership(mt_silver, customer, org) cart = cart_with_item(event, 20_000) @@ -168,7 +185,10 @@ defmodule Pretex.MembershipOrderIntegrationTest do # Membership: 10% off {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1000}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1000}) + {:ok, _m} = Memberships.grant_membership(mt, customer, org) # Auto-discount: fixed R$5 @@ -218,7 +238,9 @@ defmodule Pretex.MembershipOrderIntegrationTest do event = published_event_fixture(org) {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) # Create a membership item in the catalog item = diff --git a/pretex/test/pretex/memberships_test.exs b/pretex/test/pretex/memberships_test.exs index 329d436..5671013 100644 --- a/pretex/test/pretex/memberships_test.exs +++ b/pretex/test/pretex/memberships_test.exs @@ -113,7 +113,10 @@ defmodule Pretex.MembershipsTest do {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) assert {:error, changeset} = - Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 10_001}) + Memberships.create_benefit(mt, %{ + benefit_type: "percentage_discount", + value: 10_001 + }) assert errors_on(changeset).value != [] end @@ -170,7 +173,10 @@ defmodule Pretex.MembershipsTest do org = org_fixture() customer = customer_fixture() {:ok, mt} = Memberships.create_membership_type(org, %{name: "Gold", validity_days: 365}) - {:ok, _b} = Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + + {:ok, _b} = + Memberships.create_benefit(mt, %{benefit_type: "percentage_discount", value: 1500}) + {:ok, _m} = Memberships.grant_membership(mt, customer, org) memberships = Memberships.active_memberships_for_checkout(customer, org) diff --git a/pretex/test/pretex/voucher_order_integration_test.exs b/pretex/test/pretex/voucher_order_integration_test.exs index c41b6c6..9dc8388 100644 --- a/pretex/test/pretex/voucher_order_integration_test.exs +++ b/pretex/test/pretex/voucher_order_integration_test.exs @@ -13,7 +13,7 @@ defmodule Pretex.VoucherOrderIntegrationTest do # Helpers # --------------------------------------------------------------------------- - defp cart_with_item(event, price_cents \\ 5000) do + defp cart_with_item(event, price_cents) do item = item_fixture(event, %{price_cents: price_cents}) {:ok, cart} = Orders.create_cart(event) {:ok, _cart_item} = Orders.add_to_cart(cart, item, quantity: 1) @@ -31,7 +31,7 @@ defmodule Pretex.VoucherOrderIntegrationTest do ) end - defp voucher_fixture(event, attrs \\ %{}) do + defp voucher_fixture(event, attrs) do base = %{ code: "TESTCODE#{System.unique_integer([:positive])}", effect: "fixed_discount", diff --git a/pretex/test/pretex_web/controllers/page_controller_test.exs b/pretex/test/pretex_web/controllers/page_controller_test.exs index c2f1ad0..7a13eb9 100644 --- a/pretex/test/pretex_web/controllers/page_controller_test.exs +++ b/pretex/test/pretex_web/controllers/page_controller_test.exs @@ -1,27 +1,91 @@ defmodule PretexWeb.PageControllerTest do use PretexWeb.ConnCase, async: true - test "GET / renders the landing page with key sections", %{conn: conn} do - conn = get(conn, ~p"/") - html = html_response(conn, 200) + describe "GET / - unauthenticated" do + test "renders the landing page with key sections", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) - # Hero section - assert html =~ "Pretex" - assert html =~ "plataforma" + assert html =~ "Pretex" + assert html =~ "plataforma" + end - # Features section - assert html =~ "Gestão de Eventos" - assert html =~ "Venda de Ingressos" + test "renders features section", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) - # CTA - assert html =~ "Começar Agora" + assert html =~ "Gestão de Eventos" + assert html =~ "Venda de Ingressos" + end + + test "renders call-to-action links", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + assert html =~ "Começar Agora" + end + + test "navbar shows login and register links when not authenticated", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + assert html =~ ~s(href="/customers/log-in") + assert html =~ ~s(href="/customers/register") + end + + test "navbar does not show logout or orders links when not authenticated", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + refute html =~ "Meus Pedidos" + refute html =~ "Sair" + end + + test "contains navigation links to events and register", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + assert html =~ ~s(href="/events") + assert html =~ ~s(href="/customers/register") + end end - test "GET / contains navigation links", %{conn: conn} do - conn = get(conn, ~p"/") - html = html_response(conn, 200) + describe "GET / - authenticated" do + setup :register_and_log_in_customer + + test "renders the landing page successfully", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Pretex" + end + + test "navbar shows customer email when authenticated", %{conn: conn, customer: customer} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + assert html =~ customer.email + end + + test "navbar shows orders and logout links when authenticated", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + assert html =~ "Meus Pedidos" + assert html =~ "Sair" + end + + test "navbar does not show login or register links when authenticated", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) + + refute html =~ "Entrar" + refute html =~ "Criar Conta" + end + + test "navbar orders link points to account orders page", %{conn: conn} do + conn = get(conn, ~p"/") + html = html_response(conn, 200) - assert html =~ ~s(href="/events") - assert html =~ ~s(href="/customers/register") + assert html =~ ~s(href="/account/orders") + end end end diff --git a/pretex/test/pretex_web/live/admin/discount_live_test.exs b/pretex/test/pretex_web/live/admin/discount_live_test.exs index 46cf4ad..9b4809b 100644 --- a/pretex/test/pretex_web/live/admin/discount_live_test.exs +++ b/pretex/test/pretex_web/live/admin/discount_live_test.exs @@ -32,7 +32,7 @@ defmodule PretexWeb.Admin.DiscountLiveTest do event end - defp discount_rule_fixture(event, attrs \\ %{}) do + defp discount_rule_fixture(event, attrs) do base = %{ name: "Regra Teste #{System.unique_integer([:positive])}", condition_type: "min_quantity", diff --git a/pretex/test/pretex_web/live/admin/gift_card_live_test.exs b/pretex/test/pretex_web/live/admin/gift_card_live_test.exs index de14f51..4649e88 100644 --- a/pretex/test/pretex_web/live/admin/gift_card_live_test.exs +++ b/pretex/test/pretex_web/live/admin/gift_card_live_test.exs @@ -19,7 +19,7 @@ defmodule PretexWeb.Admin.GiftCardLiveTest do org end - defp gift_card_fixture(org, attrs \\ %{}) do + defp gift_card_fixture(org, attrs) do base = %{ code: "GC-TEST#{System.unique_integer([:positive])}", balance_cents: 5000, diff --git a/pretex/test/pretex_web/live/admin/payment_live_test.exs b/pretex/test/pretex_web/live/admin/payment_live_test.exs index e2bd245..3580db9 100644 --- a/pretex/test/pretex_web/live/admin/payment_live_test.exs +++ b/pretex/test/pretex_web/live/admin/payment_live_test.exs @@ -88,7 +88,7 @@ defmodule PretexWeb.Admin.PaymentLiveTest do test "exibe seleção de tipos de provedores", %{conn: conn} do org = org_fixture() - {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/payments") + {:ok, _view, _html} = live(conn, ~p"/admin/organizations/#{org}/payments") # Navigate to provider selection page {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/payments/new") diff --git a/pretex/test/pretex_web/live/checkout_gift_card_test.exs b/pretex/test/pretex_web/live/checkout_gift_card_test.exs index cbc0043..8565b3b 100644 --- a/pretex/test/pretex_web/live/checkout_gift_card_test.exs +++ b/pretex/test/pretex_web/live/checkout_gift_card_test.exs @@ -57,7 +57,7 @@ defmodule PretexWeb.CheckoutGiftCardTest do Orders.get_cart_by_token(cart.session_token) end - defp gift_card_fixture(org, attrs \\ %{}) do + defp gift_card_fixture(org, attrs) do base = %{ code: "GC-TEST#{System.unique_integer([:positive])}", balance_cents: 5000, @@ -183,7 +183,7 @@ defmodule PretexWeb.CheckoutGiftCardTest do event = event_fixture(org) item = item_fixture(event, 5000) cart = cart_fixture(event, item) - gc = gift_card_fixture(org, %{code: "GC-CITEST1", balance_cents: 1000}) + _gc = gift_card_fixture(org, %{code: "GC-CITEST1", balance_cents: 1000}) {:ok, view, _html} = navigate_to_summary(conn, event, cart) diff --git a/pretex/test/pretex_web/live/checkout_voucher_test.exs b/pretex/test/pretex_web/live/checkout_voucher_test.exs index 5bb954a..1f45204 100644 --- a/pretex/test/pretex_web/live/checkout_voucher_test.exs +++ b/pretex/test/pretex_web/live/checkout_voucher_test.exs @@ -58,7 +58,7 @@ defmodule PretexWeb.CheckoutVoucherTest do Orders.get_cart_by_token(cart.session_token) end - defp voucher_fixture(event, attrs \\ %{}) do + defp voucher_fixture(event, attrs) do base = %{ code: "VOUCHER#{System.unique_integer([:positive])}", effect: "fixed_discount", diff --git a/pretex/test/pretex_web/live/events_live/checkout_e2e_test.exs b/pretex/test/pretex_web/live/events_live/checkout_e2e_test.exs new file mode 100644 index 0000000..ee851d7 --- /dev/null +++ b/pretex/test/pretex_web/live/events_live/checkout_e2e_test.exs @@ -0,0 +1,561 @@ +defmodule PretexWeb.EventsLive.CheckoutE2ETest do + use PretexWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Pretex.OrganizationsFixtures + import Pretex.EventsFixtures + import Pretex.CatalogFixtures + + alias Pretex.Orders + alias Pretex.Payments + alias Pretex.Repo + + # --------------------------------------------------------------------------- + # Shared helpers + # --------------------------------------------------------------------------- + + defp manual_provider_fixture(org) do + {:ok, provider} = + Payments.create_provider(%{ + organization_id: org.id, + type: "manual", + name: "Transferência Bancária E2E #{System.unique_integer([:positive])}", + credentials: %{"bank_info" => "Banco do Brasil Ag 0001 CC 12345-6"}, + is_active: true + }) + + {:ok, provider} = Payments.validate_provider(provider) + provider + end + + # Extracts a named query-string parameter from a URL path string. + defp query_param(path, key) do + path + |> URI.parse() + |> Map.get(:query, "") + |> URI.decode_query() + |> Map.fetch!(key) + end + + # --------------------------------------------------------------------------- + # End-to-end: events list → event page → add to cart → checkout → confirmation + # --------------------------------------------------------------------------- + + describe "full purchase flow" do + test "user browses events, adds a ticket to cart and completes purchase", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{name: "Ingresso Geral", price_cents: 5000}) + provider = manual_provider_fixture(org) + + # 1. Events list + {:ok, _view, html} = live(conn, ~p"/events") + assert html =~ event.name + + # 2. Event detail page + {:ok, view, html} = live(conn, ~p"/events/#{event.slug}") + assert html =~ event.name + assert html =~ item.name + + # 3. Add ticket to cart — the page push_patches with the cart token + view |> element("#add-#{item.id}") |> render_click() + patch_path = assert_patch(view) + assert patch_path =~ "cart_token" + + cart_token = query_param(patch_path, "cart_token") + + # 4. Checkout — info step + {:ok, view, html} = + live(conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart_token}") + + assert html =~ "Suas Informações" + + view + |> form("#checkout-info-form", %{ + checkout: %{name: "João Silva", email: "joao@test.com"} + }) + |> render_submit() + + assert_patch(view) + + # 5. Summary step + html = render(view) + assert html =~ "Resumo do Pedido" + assert html =~ item.name + + # 6. Select payment method + view + |> element("#pay-bank_transfer") + |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) + + # 7. Place order + view |> element("#place-order-btn") |> render_click() + + payment_path = assert_patch(view) + assert payment_path =~ "/checkout/payment" + + order_code = query_param(payment_path, "order_code") + {:ok, order} = Orders.get_order_by_confirmation_code(order_code) + payment = Payments.get_payment_for_order(order) + + # 8. Confirm payment (simulating admin/webhook) + {:ok, _} = Payments.confirm_payment(payment) + + assert_redirect(view, ~p"/events/#{event.slug}/orders/#{order_code}") + + # 9. Order confirmation page + {:ok, _view, html} = live(conn, ~p"/events/#{event.slug}/orders/#{order_code}") + assert html =~ "Pedido Confirmado!" + assert html =~ "João Silva" + assert html =~ "R$ 50,00" + end + + # Regression test for the "cart expired" bug: + # - extend_cart_expiry updated the DB row but the stale CartSession struct + # (with the old expires_at) was still stored in socket.assigns.cart. + # - create_order_from_cart received the stale struct and validate_cart_not_expired + # always returned {:error, :cart_expired}. + test "an already-expired cart is recoverable — visiting checkout extends the TTL", %{ + conn: conn + } do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{name: "Ingresso VIP", price_cents: 15000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + # Backdate the expiry so the cart looks already expired + expired_at = + DateTime.utc_now() |> DateTime.add(-120, :second) |> DateTime.truncate(:second) + + Repo.update!(Ecto.Changeset.change(cart, expires_at: expired_at)) + + # Opening the checkout page must extend the TTL and NOT redirect away + {:ok, view, html} = + live(conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart.session_token}") + + refute html =~ "expirou" + assert html =~ "Suas Informações" + + # Fill in info — the form must accept the submission + view + |> form("#checkout-info-form", %{ + checkout: %{name: "Maria Souza", email: "maria@test.com"} + }) + |> render_submit() + + assert_patch(view) + assert render(view) =~ "Resumo do Pedido" + + # Select payment method and place the order — must NOT raise cart_expired + view + |> element("#pay-bank_transfer") + |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) + + view |> element("#place-order-btn") |> render_click() + + # Reaching the payment step proves no cart_expired error was raised + payment_path = assert_patch(view) + assert payment_path =~ "/checkout/payment" + + order_code = query_param(payment_path, "order_code") + {:ok, order} = Orders.get_order_by_confirmation_code(order_code) + payment = Payments.get_payment_for_order(order) + {:ok, _} = Payments.confirm_payment(payment) + + assert_redirect(view, ~p"/events/#{event.slug}/orders/#{order_code}") + + {:ok, _view, html} = live(conn, ~p"/events/#{event.slug}/orders/#{order_code}") + assert html =~ "Pedido Confirmado!" + assert html =~ "Maria Souza" + assert html =~ "R$ 150,00" + end + end + + # --------------------------------------------------------------------------- + # Bank transfer note submission + # --------------------------------------------------------------------------- + + describe "bank transfer note" do + test "payment step shows the transfer note form for bank_transfer", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{name: "Ingresso TED", price_cents: 8000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Ana Lima", + email: "ana@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, view, html} = + live( + conn, + ~p"/events/#{event.slug}/checkout/payment?cart_token=#{cart.session_token}&order_code=#{order.confirmation_code}" + ) + + assert html =~ "Envie o comprovante da transferência" + assert has_element?(view, "#transfer-note-input") + end + + test "submitting a transfer note saves it and shows confirmation", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{name: "Ingresso PIX", price_cents: 3000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Carlos Melo", + email: "carlos@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, view, _html} = + live( + conn, + ~p"/events/#{event.slug}/checkout/payment?cart_token=#{cart.session_token}&order_code=#{order.confirmation_code}" + ) + + view + |> element("form[phx-submit='submit_transfer_note']") + |> render_submit(%{"note" => "TED · ID 123456 · R$ 30,00"}) + + html = render(view) + assert html =~ "Comprovante enviado!" + + payment = Payments.get_payment_for_order(order) + assert payment.transfer_note == "TED · ID 123456 · R$ 30,00" + end + + test "submitting an empty note shows an error and does not persist", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{name: "Ingresso DOC", price_cents: 4500}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Paula Dias", + email: "paula@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, view, _html} = + live( + conn, + ~p"/events/#{event.slug}/checkout/payment?cart_token=#{cart.session_token}&order_code=#{order.confirmation_code}" + ) + + view + |> element("form[phx-submit='submit_transfer_note']") + |> render_submit(%{"note" => " "}) + + html = render(view) + # Form stays visible and note was not persisted + refute html =~ "Comprovante enviado!" + + payment = Payments.get_payment_for_order(order) + assert is_nil(payment.transfer_note) + end + end + + # --------------------------------------------------------------------------- + # customer_id linking — orders appear in /account/orders + # --------------------------------------------------------------------------- + + describe "customer_id on orders" do + test "order created by a logged-in customer is linked to their account", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 5000}) + provider = manual_provider_fixture(org) + + # Log in as a customer + %{conn: authed_conn, customer: customer} = register_and_log_in_customer(%{conn: conn}) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, view, _html} = + live(authed_conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart.session_token}") + + view + |> form("#checkout-info-form", %{ + checkout: %{name: "Sofia Rocha", email: "sofia@example.com"} + }) + |> render_submit() + + assert_patch(view) + + view + |> element("#pay-bank_transfer") + |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) + + view |> element("#place-order-btn") |> render_click() + payment_path = assert_patch(view) + order_code = query_param(payment_path, "order_code") + + {:ok, order} = Orders.get_order_by_confirmation_code(order_code) + assert order.customer_id == customer.id + + # The order must appear in /account/orders + {:ok, _view, html} = live(authed_conn, ~p"/account/orders") + assert html =~ order_code + end + + test "order created by a guest has no customer_id", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 2000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + # Unauthenticated connection — no current_scope customer + {:ok, view, _html} = + live(conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart.session_token}") + + view + |> form("#checkout-info-form", %{ + checkout: %{name: "Visitante Guest", email: "guest@example.com"} + }) + |> render_submit() + + assert_patch(view) + + view + |> element("#pay-bank_transfer") + |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) + + view |> element("#place-order-btn") |> render_click() + payment_path = assert_patch(view) + order_code = query_param(payment_path, "order_code") + + {:ok, order} = Orders.get_order_by_confirmation_code(order_code) + assert is_nil(order.customer_id) + end + end + + # --------------------------------------------------------------------------- + # Admin payment confirmation + # --------------------------------------------------------------------------- + + describe "admin confirm payment" do + setup :register_and_log_in_user + + test "admin sees the payment card with transfer note on the order show page", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 7500}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Luisa Ferreira", + email: "luisa@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, payment} = Payments.create_payment(order, provider, "bank_transfer") + {:ok, _} = Payments.update_payment_transfer_note(payment, "TED confirmada · ID 998877") + + {:ok, _view, html} = + live(conn, ~p"/admin/organizations/#{org}/events/#{event}/orders/#{order.id}") + + assert html =~ "Comprovante enviado pelo cliente" + assert html =~ "TED confirmada · ID 998877" + assert html =~ "Confirmar Pagamento" + end + + test "admin sees placeholder when no transfer note was submitted", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 4000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Ricardo Nunes", + email: "ricardo@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, _view, html} = + live(conn, ~p"/admin/organizations/#{org}/events/#{event}/orders/#{order.id}") + + assert html =~ "Nenhum comprovante enviado ainda" + assert html =~ "Confirmar Pagamento" + end + + test "admin clicking confirm_payment confirms the order and updates the UI", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 6000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Beatriz Teles", + email: "beatriz@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, view, html} = + live(conn, ~p"/admin/organizations/#{org}/events/#{event}/orders/#{order.id}") + + assert html =~ "Pendente" + assert html =~ "Confirmar Pagamento" + + view |> element("#confirm-payment-card") |> render_click() + + html = render(view) + assert html =~ "Confirmado" + assert html =~ "Pagamento confirmado com sucesso" + refute has_element?(view, "#confirm-payment-card") + + # The order must be confirmed in the database + confirmed_order = Orders.get_order_with_details!(order.id) + assert confirmed_order.status == "confirmed" + end + + test "admin cannot see confirm button for an already confirmed order", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 5000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Fernanda Leal", + email: "fernanda@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, payment} = Payments.create_payment(order, provider, "bank_transfer") + {:ok, _} = Payments.confirm_payment(payment) + + {:ok, _view, html} = + live(conn, ~p"/admin/organizations/#{org}/events/#{event}/orders/#{order.id}") + + assert html =~ "Confirmado" + refute html =~ "Confirmar Pagamento" + end + end + + # --------------------------------------------------------------------------- + # Confirmation page status display + # --------------------------------------------------------------------------- + + describe "confirmation page status display" do + test "pending order shows 'Aguardando Pagamento' hero", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 2000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Guest Pendente", + email: "pending@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, _payment} = Payments.create_payment(order, provider, "bank_transfer") + + {:ok, _view, html} = + live(conn, ~p"/events/#{event.slug}/orders/#{order.confirmation_code}") + + assert html =~ "Aguardando Pagamento" + refute html =~ "Pedido Confirmado!" + assert html =~ order.confirmation_code + end + + test "confirmed order shows 'Pedido Confirmado!' hero", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 3000}) + provider = manual_provider_fixture(org) + + {:ok, raw_cart} = Orders.create_cart(event) + Orders.add_to_cart(raw_cart, item) + cart = Orders.get_cart_by_token(raw_cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Guest Confirmado", + email: "confirmed@example.com", + payment_method: "bank_transfer", + payment_provider_id: provider.id + }) + + {:ok, payment} = Payments.create_payment(order, provider, "bank_transfer") + {:ok, _} = Payments.confirm_payment(payment) + + {:ok, _view, html} = + live(conn, ~p"/events/#{event.slug}/orders/#{order.confirmation_code}") + + assert html =~ "Pedido Confirmado!" + refute html =~ "Aguardando Pagamento" + assert html =~ order.confirmation_code + end + end +end diff --git a/pretex/test/pretex_web/live/events_live/events_live_test.exs b/pretex/test/pretex_web/live/events_live/events_live_test.exs index 5ee64ed..555adad 100644 --- a/pretex/test/pretex_web/live/events_live/events_live_test.exs +++ b/pretex/test/pretex_web/live/events_live/events_live_test.exs @@ -328,6 +328,34 @@ defmodule PretexWeb.EventsLiveTest do assert html =~ "Resumo do Pedido" assert html =~ "Pagamento" end + + test "attendee name and email are restored after a page refresh", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + cart = cart_with_item_fixture(event, item) + + # Fill in the info form and submit — this persists name/email to the cart + {:ok, view, _html} = + live(conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart.session_token}") + + view + |> form("#checkout-info-form", %{ + checkout: %{name: "Maria Silva", email: "maria@example.com"} + }) + |> render_submit() + + # Simulate a full page refresh by mounting a brand-new LiveView process + # for the same URL (summary step). The attendee info must come from the DB. + {:ok, _view2, html} = + live( + conn, + ~p"/events/#{event.slug}/checkout/summary?cart_token=#{cart.session_token}" + ) + + assert html =~ "Maria Silva" + assert html =~ "maria@example.com" + end end describe "Checkout - summary step" do @@ -392,7 +420,7 @@ defmodule PretexWeb.EventsLiveTest do assert has_element?(view, "#pay-pix") end - test "place_order navigates away after checkout", %{conn: conn} do + test "place_order navigates to payment step after checkout", %{conn: conn} do org = org_fixture() event = published_event_fixture(org) item = item_fixture(event, %{price_cents: 1000}) @@ -408,25 +436,62 @@ defmodule PretexWeb.EventsLiveTest do |> form("#checkout-info-form", %{checkout: %{name: "Alice Smith", email: "alice@test.com"}}) |> render_submit() + # Drain the push_patch to the summary step before proceeding + assert_patch(view) + # Step 2: select a payment method available from the manual provider view |> element("#pay-bank_transfer") |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) - # Step 3: place order — the LiveView creates the order and navigates + # Step 3: place order — the LiveView creates the order and patch-navigates + # to the payment step (async flow for manual/bank_transfer provider) + view + |> element("#place-order-btn") + |> render_click() + + path = assert_patch(view) + assert path =~ "/checkout/payment" + end + + test "an expired cart is recoverable — page load extends the TTL", %{conn: conn} do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event, %{price_cents: 1000}) + provider = payment_provider_fixture(org, "manual") + cart = cart_with_item_fixture(event, item) + + # Backdate the cart so it looks already expired before the page opens + expired_at = + DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + + Pretex.Repo.update!(Ecto.Changeset.change(cart, expires_at: expired_at)) + + # Opening the checkout page calls extend_cart_expiry, sliding the window + # 15 minutes into the future regardless of where it was before. + {:ok, view, _html} = + live(conn, ~p"/events/#{event.slug}/checkout?cart_token=#{cart.session_token}") + + refute render(view) =~ "expirou" + + view + |> form("#checkout-info-form", %{checkout: %{name: "Alice Smith", email: "alice@test.com"}}) + |> render_submit() + + # Drain the push_patch to the summary step before proceeding + assert_patch(view) + + view + |> element("#pay-bank_transfer") + |> render_click(%{"method" => "bank_transfer", "provider-id" => to_string(provider.id)}) + view |> element("#place-order-btn") |> render_click() - # The LiveView navigates after placing an order. Depending on whether a - # payment provider is reachable (stubs may behave differently in test), - # it can go to: - # - /events/:slug/checkout/payment (push_patch for async payment step) - # - /events/:slug/orders/:code (direct confirmation for free/instant) - # - /events/:slug (fallback on payment error) - # Any navigation away from the current page is the correct behaviour here. - {path, _flash} = assert_redirect(view) - assert is_binary(path) + # The order must succeed and patch to the payment step — NOT show a "cart expired" error. + path = assert_patch(view) + assert path =~ "/checkout/payment" end end @@ -451,7 +516,7 @@ defmodule PretexWeb.EventsLiveTest do {:ok, _view, html} = live(conn, ~p"/events/#{event.slug}/orders/#{order.confirmation_code}") - assert html =~ "Pedido Confirmado" + assert html =~ "Aguardando Pagamento" assert html =~ order.confirmation_code assert html =~ "Bob Builder" assert html =~ "bob@example.com" diff --git a/pretex/test/pretex_web/live/events_live/payment_processing_live_test.exs b/pretex/test/pretex_web/live/events_live/payment_processing_live_test.exs index bb8b8fa..9f73ec9 100644 --- a/pretex/test/pretex_web/live/events_live/payment_processing_live_test.exs +++ b/pretex/test/pretex_web/live/events_live/payment_processing_live_test.exs @@ -402,7 +402,7 @@ defmodule PretexWeb.EventsLive.PaymentProcessingLiveTest do # --------------------------------------------------------------------------- describe "AC3 — redirect-based payment" do - test "payment with redirect flow redirects the browser to external URL", %{conn: conn} do + test "payment with redirect flow redirects the browser to external URL", %{conn: _conn} do org = org_fixture() event = published_event_fixture(org) item = item_fixture(event, %{price_cents: 5000})