"""Tests for Payment model""" import pytest from datetime import datetime, date, timedelta from decimal import Decimal from app import db, create_app from sqlalchemy.pool import StaticPool from app.models import Payment, Invoice, User, Project, Client from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, PaymentFactory @pytest.fixture def app(): """Isolated app for payment model tests using in-memory SQLite to avoid file locks on Windows.""" app = create_app({ 'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite://', 'WTF_CSRF_ENABLED': False, 'SQLALCHEMY_ENGINE_OPTIONS': { 'connect_args': {'check_same_thread': False, 'timeout': 30}, 'poolclass': StaticPool, }, 'SQLALCHEMY_SESSION_OPTIONS': {'expire_on_commit': False}, }) with app.app_context(): db.create_all() try: # Improve SQLite concurrency behavior db.session.execute("PRAGMA journal_mode=WAL;") db.session.execute("PRAGMA synchronous=NORMAL;") db.session.execute("PRAGMA busy_timeout=30000;") db.session.commit() except Exception: db.session.rollback() try: yield app finally: db.session.remove() db.drop_all() try: db.engine.dispose() except Exception: pass @pytest.fixture def test_user(app): """Create a test user""" with app.app_context(): user = UserFactory() yield user @pytest.fixture def test_client(app): """Create a test client""" with app.app_context(): client = ClientFactory() yield client @pytest.fixture def test_project(app, test_client, test_user): """Create a test project""" with app.app_context(): project = ProjectFactory( client_id=test_client.id, billable=True, hourly_rate=Decimal('100.00') ) yield project @pytest.fixture def test_invoice(app, test_project, test_user, test_client): """Create a test invoice""" with app.app_context(): invoice = InvoiceFactory( project_id=test_project.id, client_id=test_client.id, created_by=test_user.id, client_name='Test Client', due_date=(date.today() + timedelta(days=30)), ) # Ensure non-zero totals for payment-related assertions invoice.subtotal = Decimal('1000.00') invoice.tax_rate = Decimal('21.00') invoice.tax_amount = Decimal('210.00') invoice.total_amount = Decimal('1210.00') db.session.add(invoice) db.session.commit() yield invoice class TestPaymentModel: """Test Payment model functionality""" def test_create_payment(self, app, test_invoice, test_user): """Test creating a payment""" with app.app_context(): payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), method='bank_transfer', reference='REF-12345', notes='Test payment', status='completed', received_by=test_user.id ) db.session.add(payment) db.session.commit() # Verify payment was created assert payment.id is not None assert payment.amount == Decimal('500.00') assert payment.currency == 'EUR' assert payment.method == 'bank_transfer' assert payment.status == 'completed' # Cleanup db.session.delete(payment) db.session.commit() def test_payment_calculate_net_amount_without_fee(self, app, test_invoice): """Test calculating net amount without gateway fee""" with app.app_context(): payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), status='completed' ) payment.calculate_net_amount() assert payment.net_amount == Decimal('500.00') # Cleanup (not in DB yet, so no cleanup needed) def test_payment_calculate_net_amount_with_fee(self, app, test_invoice): """Test calculating net amount with gateway fee""" with app.app_context(): payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), gateway_fee=Decimal('15.00'), status='completed' ) payment.calculate_net_amount() assert payment.net_amount == Decimal('485.00') def test_payment_to_dict(self, app, test_invoice, test_user): """Test converting payment to dictionary""" with app.app_context(): payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), method='bank_transfer', reference='REF-12345', notes='Test payment', status='completed', received_by=test_user.id, gateway_fee=Decimal('15.00'), # created_at/updated_at set by defaults; no need to override ) payment.calculate_net_amount() db.session.add(payment) db.session.commit() payment_dict = payment.to_dict() assert payment_dict['invoice_id'] == test_invoice.id assert payment_dict['amount'] == 500.0 assert payment_dict['currency'] == 'EUR' assert payment_dict['method'] == 'bank_transfer' assert payment_dict['reference'] == 'REF-12345' assert payment_dict['status'] == 'completed' assert payment_dict['gateway_fee'] == 15.0 assert payment_dict['net_amount'] == 485.0 # Cleanup db.session.delete(payment) db.session.commit() def test_payment_relationship_with_invoice(self, app, test_invoice): """Test payment relationship with invoice""" with app.app_context(): # Re-query invoice to attach to current session from app.models.invoice import Invoice invoice_in_session = Invoice.query.get(test_invoice.id) payment = PaymentFactory( invoice_id=invoice_in_session.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), status='completed' ) db.session.add(payment) db.session.commit() # Refresh invoice to get updated relationships db.session.refresh(invoice_in_session) # Verify relationship assert payment.invoice == invoice_in_session assert payment in invoice_in_session.payments # Cleanup db.session.delete(payment) db.session.commit() def test_payment_relationship_with_user(self, app, test_invoice, test_user): """Test payment relationship with user (receiver)""" with app.app_context(): # Re-query user to attach to current session from app.models.user import User user_in_session = User.query.get(test_user.id) payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), status='completed', received_by=user_in_session.id ) db.session.add(payment) db.session.commit() # Refresh user to get updated relationships db.session.refresh(user_in_session) # Verify relationship assert payment.receiver == user_in_session assert payment in user_in_session.received_payments # Cleanup db.session.delete(payment) db.session.commit() def test_payment_repr(self, app, test_invoice): """Test payment string representation""" with app.app_context(): payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('500.00'), currency='EUR', payment_date=date.today(), status='completed' ) repr_str = repr(payment) assert 'Payment' in repr_str assert '500.00' in repr_str assert 'EUR' in repr_str def test_multiple_payments_for_invoice(self, app, test_invoice): """Test multiple payments for a single invoice""" with app.app_context(): # Re-query invoice to attach to current session from app.models.invoice import Invoice invoice_in_session = Invoice.query.get(test_invoice.id) payment1 = PaymentFactory( invoice_id=invoice_in_session.id, amount=Decimal('300.00'), currency='EUR', payment_date=date.today(), status='completed' ) payment2 = PaymentFactory( invoice_id=invoice_in_session.id, amount=Decimal('200.00'), currency='EUR', payment_date=date.today() + timedelta(days=1), status='completed' ) db.session.add_all([payment1, payment2]) db.session.commit() # Refresh invoice to get updated relationships db.session.refresh(invoice_in_session) # Verify both payments are associated with invoice assert invoice_in_session.payments.count() == 2 # Cleanup db.session.delete(payment1) db.session.delete(payment2) db.session.commit() def test_payment_status_values(self, app, test_invoice): """Test different payment status values""" with app.app_context(): statuses = ['completed', 'pending', 'failed', 'refunded'] for status in statuses: payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('100.00'), currency='EUR', payment_date=date.today(), status=status ) db.session.add(payment) db.session.commit() assert payment.status == status # Cleanup db.session.delete(payment) db.session.commit() class TestPaymentIntegration: """Test Payment model integration with Invoice""" def test_invoice_updates_with_payment(self, app, test_invoice): """Test that invoice updates correctly when payment is added""" with app.app_context(): # Initial state assert test_invoice.amount_paid == Decimal('0') assert test_invoice.payment_status == 'unpaid' # Add payment payment = PaymentFactory( invoice_id=test_invoice.id, amount=Decimal('605.00'), # Half of total currency='EUR', payment_date=date.today(), status='completed' ) db.session.add(payment) # Update invoice manually (this would be done by route logic) test_invoice.amount_paid = (test_invoice.amount_paid or Decimal('0')) + payment.amount test_invoice.update_payment_status() db.session.commit() # Verify invoice was updated assert test_invoice.amount_paid == Decimal('605.00') assert test_invoice.payment_status == 'partially_paid' # Cleanup db.session.delete(payment) test_invoice.amount_paid = Decimal('0') test_invoice.update_payment_status() db.session.commit() def test_invoice_fully_paid_with_payments(self, app, test_invoice): """Test that invoice becomes fully paid when total payments equal total amount""" with app.app_context(): # Add payments that equal total amount payment = PaymentFactory( invoice_id=test_invoice.id, amount=test_invoice.total_amount, currency='EUR', payment_date=date.today(), status='completed' ) db.session.add(payment) # Update invoice manually (this would be done by route logic) test_invoice.amount_paid = payment.amount test_invoice.update_payment_status() db.session.commit() # Verify invoice is fully paid assert test_invoice.payment_status == 'fully_paid' assert test_invoice.is_paid is True # Cleanup db.session.delete(payment) test_invoice.amount_paid = Decimal('0') test_invoice.update_payment_status() db.session.commit()