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
21 changes: 16 additions & 5 deletions integrations/stripe/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
)


def create_checkout_session(payment: Payment, request) -> str:
def create_checkout_session(
payment: Payment, request, *, success_url: str | None = None, cancel_url: str | None = None
) -> str:
if payment.status == 'paid':
raise ValueError('Pagamento ja consta como PAGO no banco de dados.')

Check warning on line 34 in integrations/stripe/services.py

View workflow job for this annotation

GitHub Actions / full-test-suite

Missing coverage

Missing coverage on line 34

idem_key = generate_idempotency_key(payment, 'checkout')
metadata = {
Expand All @@ -46,10 +48,19 @@

product_name = 'Fatura Avulsa'
if payment.enrollment and payment.enrollment.plan:
product_name = f'Plano {payment.enrollment.plan.name} - {payment.installment_number}/{payment.installment_total}'

Check warning on line 51 in integrations/stripe/services.py

View workflow job for this annotation

GitHub Actions / full-test-suite

Missing coverage

Missing coverage on line 51

success_url = request.build_absolute_uri(reverse('checkout_success', args=[payment.id])) + '?session_id={CHECKOUT_SESSION_ID}'
cancel_url = request.build_absolute_uri(reverse('checkout_cancel', args=[payment.id]))
# URLs de retorno parametrizaveis: o staff usa as rotas do catalogo (default),
# o app do aluno passa as proprias rotas do /aluno/. Default preserva o
# comportamento atual.
if success_url is None:
success_url = request.build_absolute_uri(reverse('checkout_success', args=[payment.id])) + '?session_id={CHECKOUT_SESSION_ID}'
if cancel_url is None:
cancel_url = request.build_absolute_uri(reverse('checkout_cancel', args=[payment.id]))

# O aluno nao e um request.user autenticado do Django (auth por cookie proprio):
# nesse caso o ator do audit e None. Staff continua com o proprio user.
actor = request.user if getattr(getattr(request, 'user', None), 'is_authenticated', False) else None

try:
session = stripe.checkout.Session.create(
Expand All @@ -74,16 +85,16 @@
)

log_audit_event(
actor=request.user,
actor=actor,
action='stripe_checkout_initiated',
target=payment,
description='Redirecionando para portal seguro da Stripe',
metadata={'session_id': session.id, 'idempotency_key': idem_key},
)
return session.url
except stripe.StripeError as exc:
log_audit_event(

Check warning on line 96 in integrations/stripe/services.py

View workflow job for this annotation

GitHub Actions / full-test-suite

Missing coverage

Missing coverage on lines 95-96
actor=request.user,
actor=actor,
action='stripe_checkout_failed',
target=payment,
description=f'Falha na malha da Stripe: {str(exc)}',
Expand Down
26 changes: 25 additions & 1 deletion student_app/student_payments_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ def build_student_payment_rows(student, *, limit: int = 24, today=None) -> list[
and payment.due_date < today
)
rows.append({
'id': payment.id,
'due_date': payment.due_date,
'amount': payment.amount,
'status': 'overdue' if is_overdue else payment.status,
'status_label': 'Atrasado' if is_overdue else payment.get_status_display(),
'is_open': (is_overdue or payment.status == PaymentStatus.PENDING),
'paid_at': payment.paid_at,
})
return rows
Expand All @@ -47,4 +49,26 @@ def count_open_payments(rows: list[dict]) -> int:
return sum(1 for row in rows if row['status'] in OPEN_STATUSES)


__all__ = ['build_student_payment_rows', 'count_open_payments', 'OPEN_STATUSES']
def resolve_payable_student_invoice(student, payment_id):
"""Resolve a fatura que o aluno pode pagar, ou None.

Seguranca: so retorna a cobranca se ela for DO PROPRIO aluno e estiver EM
ABERTO (pendente/atrasada). Qualquer outro caso (fatura de outro aluno, paga,
cancelada, estornada, inexistente) retorna None — a view trata como negado.
"""
from finance.models import Payment, PaymentStatus

payment = Payment.objects.filter(pk=payment_id, student=student).first()
if payment is None:
return None
if payment.status not in (PaymentStatus.PENDING, PaymentStatus.OVERDUE):
return None
return payment


__all__ = [
'build_student_payment_rows',
'count_open_payments',
'resolve_payable_student_invoice',
'OPEN_STATUSES',
]
6 changes: 6 additions & 0 deletions student_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
StudentNoActiveBoxView,
StudentOnboardingWizardView,
StudentOfflineView,
StudentPayCancelView,
StudentPayInvoiceView,
StudentPaySuccessView,
StudentPushSubscribeView,
StudentPushUnsubscribeView,
StudentRmView,
Expand Down Expand Up @@ -46,6 +49,9 @@
path('rm/<int:pk>/atualizar/', StudentUpdateRmView.as_view(), name='student-app-rm-update'),
path('aula/<int:session_id>/turma/', StudentSessionAttendeesView.as_view(), name='student-app-session-attendees'),
path('configuracoes/', StudentSettingsView.as_view(), name='student-app-settings'),
path('pagamentos/<int:payment_id>/pagar/', StudentPayInvoiceView.as_view(), name='student-app-pay-invoice'),
path('pagamentos/sucesso/', StudentPaySuccessView.as_view(), name='student-app-pay-success'),
path('pagamentos/cancelado/', StudentPayCancelView.as_view(), name='student-app-pay-cancel'),
path('manifest.webmanifest', StudentManifestView.as_view(), name='student-app-manifest'),
path('sw.js', StudentServiceWorkerView.as_view(), name='student-app-sw'),
path('offline/', StudentOfflineView.as_view(), name='student-app-offline'),
Expand Down
8 changes: 8 additions & 0 deletions student_app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
StudentSwitchBoxView,
)
from .onboarding_views import StudentOnboardingWizardView
from .payment_views import (
StudentPayCancelView,
StudentPayInvoiceView,
StudentPaySuccessView,
)
from .public_workout_views import (
PublicWorkoutDetailView,
PublicWorkoutManifestView,
Expand Down Expand Up @@ -59,6 +64,9 @@
'StudentMembershipPendingView',
'StudentNoActiveBoxView',
'StudentOfflineView',
'StudentPayCancelView',
'StudentPayInvoiceView',
'StudentPaySuccessView',
'StudentRequestFreezeView',
'StudentOnboardingWizardView',
'StudentPushSubscribeView',
Expand Down
78 changes: 78 additions & 0 deletions student_app/views/payment_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
ARQUIVO: views de pagamento do aluno (self-service).

POR QUE ELE EXISTE:
- Permite o aluno pagar a PROPRIA fatura em aberto, iniciando um checkout Stripe
a partir do app. Roda no schema do box ativo, entao a baixa segue pelo webhook
ja existente (router._handle_student_payment).

PONTOS CRITICOS:
- Seguranca: a fatura precisa ser do proprio aluno e estar em aberto
(resolve_payable_student_invoice). Nunca confiar so no payment_id da URL.
- O aluno nao e request.user do Django; create_checkout_session ja trata ator
anonimo e recebe as rotas de retorno do /aluno/.
"""

import logging

from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from django.views.generic import TemplateView

from integrations.stripe.services import create_checkout_session
from shared_support.security.fintech_throttles import checkout_rate_limit_exceeded
from student_app.student_payments_presentation import resolve_payable_student_invoice

from .base import StudentIdentityRequiredMixin

logger = logging.getLogger(__name__)


class StudentPayInvoiceView(StudentIdentityRequiredMixin, View):
def post(self, request, payment_id, *args, **kwargs):
if checkout_rate_limit_exceeded(request):
messages.error(request, 'Muitas tentativas em pouco tempo. Tente de novo em instantes.')
return redirect('student-app-settings')

student = request.student_identity.student
payment = resolve_payable_student_invoice(student, payment_id)
if payment is None:
messages.error(request, 'Esta cobranca nao esta disponivel para pagamento.')
return redirect('student-app-settings')

try:
success_url = request.build_absolute_uri(reverse('student-app-pay-success'))
cancel_url = request.build_absolute_uri(reverse('student-app-pay-cancel'))
checkout_url = create_checkout_session(
payment, request, success_url=success_url, cancel_url=cancel_url
)
return redirect(checkout_url)
except Exception:
logger.exception('StudentPayInvoiceView: falha ao abrir checkout. payment=%s', payment_id)
messages.error(request, 'Nao foi possivel abrir o pagamento agora. Tente de novo em instantes.')
return redirect('student-app-settings')

Check warning on line 55 in student_app/views/payment_views.py

View workflow job for this annotation

GitHub Actions / full-test-suite

Missing coverage

Missing coverage on lines 35-55


class StudentPaySuccessView(StudentIdentityRequiredMixin, TemplateView):
template_name = 'student_app/pay_success.html'

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['student_shell_nav'] = 'settings'
context['student_shell_title'] = 'Pagamento'
return self._attach_student_shell_context(context)


class StudentPayCancelView(StudentIdentityRequiredMixin, TemplateView):
template_name = 'student_app/pay_cancel.html'

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['student_shell_nav'] = 'settings'
context['student_shell_title'] = 'Pagamento'
return self._attach_student_shell_context(context)


__all__ = ['StudentPayInvoiceView', 'StudentPaySuccessView', 'StudentPayCancelView']
14 changes: 14 additions & 0 deletions templates/student_app/pay_cancel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends "student_app/layout.html" %}

{% block title %}Pagamento | OctoBox Aluno{% endblock %}

{% block body %}
<section class="student-card">
<p class="student-eyebrow">Pagamento</p>
<h1>Pagamento nao concluido.</h1>
<p class="student-copy">
Voce saiu antes de finalizar e nenhum valor foi cobrado. Quando quiser, e so tentar de novo pela secao Pagamentos do Perfil.
</p>
<a class="student-primary-action" href="{% url 'student-app-settings' %}">Voltar ao Perfil</a>
</section>
{% endblock %}
14 changes: 14 additions & 0 deletions templates/student_app/pay_success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends "student_app/layout.html" %}

{% block title %}Pagamento | OctoBox Aluno{% endblock %}

{% block body %}
<section class="student-card">
<p class="student-eyebrow">Pagamento</p>
<h1>Pagamento recebido.</h1>
<p class="student-copy">
Estamos confirmando com a operadora. Em instantes sua cobranca aparece como paga aqui no app. Se nao atualizar em alguns minutos, recarregue o Perfil.
</p>
<a class="student-primary-action" href="{% url 'student-app-settings' %}">Voltar ao Perfil</a>
</section>
{% endblock %}
10 changes: 9 additions & 1 deletion templates/student_app/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,15 @@
{% for payment in student_payments %}
<div>
<dt>{{ payment.due_date|date:"d/m/Y" }}</dt>
<dd>R$ {{ payment.amount|floatformat:2 }} — {{ payment.status_label }}</dd>
<dd>
R$ {{ payment.amount|floatformat:2 }} — {{ payment.status_label }}
{% if payment.is_open %}
<form method="post" action="{% url 'student-app-pay-invoice' payment.id %}" style="margin-top:8px;">
{% csrf_token %}
<button type="submit" class="student-primary-action">Pagar</button>
</form>
{% endif %}
</dd>
</div>
{% endfor %}
</dl>
Expand Down
91 changes: 91 additions & 0 deletions tests/test_student_payment_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
ARQUIVO: testes do fluxo de pagamento iniciado pelo aluno (P3).

POR QUE EXISTE:
- Protege a logica de seguranca (a fatura precisa ser do PROPRIO aluno e estar em
aberto) e a adaptacao do create_checkout_session para o app do aluno (URLs de
retorno proprias + ator anonimo).
"""

from __future__ import annotations

from datetime import date
from types import SimpleNamespace
from unittest.mock import patch

from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, SimpleTestCase, TestCase

from finance.models import PaymentStatus
from integrations.stripe.services import create_checkout_session
from student_app.student_payments_presentation import (
build_student_payment_rows,
resolve_payable_student_invoice,
)
from tests.factories import PaymentFactory, StudentFactory


class ResolvePayableInvoiceTests(TestCase):
def test_returns_open_invoice_of_own_student(self):
student = StudentFactory()
payment = PaymentFactory(student=student, status=PaymentStatus.PENDING, amount='100.00')

self.assertEqual(resolve_payable_student_invoice(student, payment.id), payment)

def test_none_for_other_students_invoice(self):
owner = StudentFactory()
other = StudentFactory()
payment = PaymentFactory(student=other, status=PaymentStatus.PENDING, amount='100.00')

self.assertIsNone(resolve_payable_student_invoice(owner, payment.id))

def test_none_for_paid_invoice(self):
student = StudentFactory()
payment = PaymentFactory(student=student, status=PaymentStatus.PAID, amount='100.00')

self.assertIsNone(resolve_payable_student_invoice(student, payment.id))

def test_none_for_nonexistent_invoice(self):
student = StudentFactory()
self.assertIsNone(resolve_payable_student_invoice(student, 999_999))

def test_rows_carry_id_and_is_open(self):
student = StudentFactory()
PaymentFactory(student=student, status=PaymentStatus.PENDING, due_date=date(2026, 5, 1), amount='100.00')

rows = build_student_payment_rows(student, today=date(2026, 6, 22))

self.assertIn('id', rows[0])
self.assertTrue(rows[0]['is_open']) # PENDING vencida -> em aberto


class StudentCheckoutAdaptationTests(SimpleTestCase):
def _request(self):
request = RequestFactory().post('/aluno/pagamentos/5/pagar/')
request.user = AnonymousUser()
return request

def _payment(self):
return SimpleNamespace(
id=5, version=0, status='pending', amount=100.0, notes='',
student=SimpleNamespace(id=3, full_name='Aluno Teste'),
enrollment=None, installment_number=1, installment_total=1,
)

@patch('integrations.stripe.services.log_audit_event')
@patch('integrations.stripe.services.stripe.checkout.Session.create')
def test_custom_return_urls_and_anonymous_actor(self, session_mock, audit_mock):
session_mock.return_value = SimpleNamespace(id='cs_1', url='https://stripe.test/cs_1')

url = create_checkout_session(
self._payment(), self._request(),
success_url='https://app.test/aluno/pagamentos/sucesso/',
cancel_url='https://app.test/aluno/pagamentos/cancelado/',
)

self.assertEqual(url, 'https://stripe.test/cs_1')
kwargs = session_mock.call_args.kwargs
self.assertEqual(kwargs['success_url'], 'https://app.test/aluno/pagamentos/sucesso/')
self.assertEqual(kwargs['cancel_url'], 'https://app.test/aluno/pagamentos/cancelado/')
# Aluno anonimo (sem request.user autenticado) -> ator do audit e None.
self.assertIsNone(audit_mock.call_args.kwargs['actor'])
Loading