diff --git a/integrations/stripe/services.py b/integrations/stripe/services.py index bee87d25..9e7ebcd5 100644 --- a/integrations/stripe/services.py +++ b/integrations/stripe/services.py @@ -27,7 +27,9 @@ def generate_idempotency_key(payment: Payment, action: str) -> str: ) -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.') @@ -48,8 +50,17 @@ def create_checkout_session(payment: Payment, request) -> str: if payment.enrollment and payment.enrollment.plan: product_name = f'Plano {payment.enrollment.plan.name} - {payment.installment_number}/{payment.installment_total}' - 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( @@ -74,7 +85,7 @@ def create_checkout_session(payment: Payment, request) -> str: ) log_audit_event( - actor=request.user, + actor=actor, action='stripe_checkout_initiated', target=payment, description='Redirecionando para portal seguro da Stripe', @@ -83,7 +94,7 @@ def create_checkout_session(payment: Payment, request) -> str: return session.url except stripe.StripeError as exc: log_audit_event( - actor=request.user, + actor=actor, action='stripe_checkout_failed', target=payment, description=f'Falha na malha da Stripe: {str(exc)}', diff --git a/student_app/student_payments_presentation.py b/student_app/student_payments_presentation.py index 2c516082..a8b05159 100644 --- a/student_app/student_payments_presentation.py +++ b/student_app/student_payments_presentation.py @@ -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 @@ -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', +] diff --git a/student_app/urls.py b/student_app/urls.py index f23a06c4..7997e339 100644 --- a/student_app/urls.py +++ b/student_app/urls.py @@ -11,6 +11,9 @@ StudentNoActiveBoxView, StudentOnboardingWizardView, StudentOfflineView, + StudentPayCancelView, + StudentPayInvoiceView, + StudentPaySuccessView, StudentPushSubscribeView, StudentPushUnsubscribeView, StudentRmView, @@ -46,6 +49,9 @@ path('rm//atualizar/', StudentUpdateRmView.as_view(), name='student-app-rm-update'), path('aula//turma/', StudentSessionAttendeesView.as_view(), name='student-app-session-attendees'), path('configuracoes/', StudentSettingsView.as_view(), name='student-app-settings'), + path('pagamentos//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'), diff --git a/student_app/views/__init__.py b/student_app/views/__init__.py index 42f9a032..404e6c43 100644 --- a/student_app/views/__init__.py +++ b/student_app/views/__init__.py @@ -18,6 +18,11 @@ StudentSwitchBoxView, ) from .onboarding_views import StudentOnboardingWizardView +from .payment_views import ( + StudentPayCancelView, + StudentPayInvoiceView, + StudentPaySuccessView, +) from .public_workout_views import ( PublicWorkoutDetailView, PublicWorkoutManifestView, @@ -59,6 +64,9 @@ 'StudentMembershipPendingView', 'StudentNoActiveBoxView', 'StudentOfflineView', + 'StudentPayCancelView', + 'StudentPayInvoiceView', + 'StudentPaySuccessView', 'StudentRequestFreezeView', 'StudentOnboardingWizardView', 'StudentPushSubscribeView', diff --git a/student_app/views/payment_views.py b/student_app/views/payment_views.py new file mode 100644 index 00000000..84d6205a --- /dev/null +++ b/student_app/views/payment_views.py @@ -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') + + +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'] diff --git a/templates/student_app/pay_cancel.html b/templates/student_app/pay_cancel.html new file mode 100644 index 00000000..9da0d3b3 --- /dev/null +++ b/templates/student_app/pay_cancel.html @@ -0,0 +1,14 @@ +{% extends "student_app/layout.html" %} + +{% block title %}Pagamento | OctoBox Aluno{% endblock %} + +{% block body %} +
+

Pagamento

+

Pagamento nao concluido.

+

+ Voce saiu antes de finalizar e nenhum valor foi cobrado. Quando quiser, e so tentar de novo pela secao Pagamentos do Perfil. +

+ Voltar ao Perfil +
+{% endblock %} diff --git a/templates/student_app/pay_success.html b/templates/student_app/pay_success.html new file mode 100644 index 00000000..dccf3793 --- /dev/null +++ b/templates/student_app/pay_success.html @@ -0,0 +1,14 @@ +{% extends "student_app/layout.html" %} + +{% block title %}Pagamento | OctoBox Aluno{% endblock %} + +{% block body %} +
+

Pagamento

+

Pagamento recebido.

+

+ Estamos confirmando com a operadora. Em instantes sua cobranca aparece como paga aqui no app. Se nao atualizar em alguns minutos, recarregue o Perfil. +

+ Voltar ao Perfil +
+{% endblock %} diff --git a/templates/student_app/settings.html b/templates/student_app/settings.html index 76c22df2..c25ddefd 100644 --- a/templates/student_app/settings.html +++ b/templates/student_app/settings.html @@ -110,7 +110,15 @@ {% for payment in student_payments %}
{{ payment.due_date|date:"d/m/Y" }}
-
R$ {{ payment.amount|floatformat:2 }} — {{ payment.status_label }}
+
+ R$ {{ payment.amount|floatformat:2 }} — {{ payment.status_label }} + {% if payment.is_open %} +
+ {% csrf_token %} + +
+ {% endif %} +
{% endfor %} diff --git a/tests/test_student_payment_flow.py b/tests/test_student_payment_flow.py new file mode 100644 index 00000000..10ebf04b --- /dev/null +++ b/tests/test_student_payment_flow.py @@ -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'])