diff --git a/docs/ADYEN.md b/docs/ADYEN.md index d256c9d..34b0ee4 100644 --- a/docs/ADYEN.md +++ b/docs/ADYEN.md @@ -2,7 +2,7 @@ Adyen is a payment processor that supports cards, wallets, and local payment methods. -**Status: Not yet implemented** +**Status: Implemented** ## Overview @@ -10,13 +10,26 @@ Adyen provides similar functionality to Stripe with a server-side API for proces ## Privacy/Verification Requirements -Like PayPal and Stripe, Adyen requires business verification: +Like PayPal and Stripe, Adyen requires business verification. However, Adyen's process is generally less invasive than PayPal's: -- Business registration documents -- Proof of identity for account holders -- Bank account verification +**What Adyen requires:** +- Business registration documents (company registration, articles of incorporation) +- Proof of identity for account holders (government ID photo) +- Bank account verification for payouts +- Proof of address (utility bill, bank statement) -Similar KYC (Know Your Customer) requirements as other payment processors. If privacy is a priority, use crypto payments (XMR/DOGE) instead. +**What Adyen does NOT require (unlike PayPal):** +- No face scanning / biometric capture +- No selfies or photos of your face +- Auto-verification attempted first before manual document requests + +**Onboarding process:** +1. Self-serve signup at adyen.com +2. Adyen attempts automatic verification first +3. If auto-verification fails, they request documents via dashboard +4. Once verified, you can process live payments + +Similar KYC (Know Your Customer) requirements as other payment processors, but less invasive than PayPal. If privacy is a priority, use crypto payments (XMR/DOGE) instead. ## Technical Integration diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 20323b8..2e6a6fd 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -111,6 +111,9 @@ class Invoice(RBase, Base): stripe_payment_intent_id = Column(Unicode(64), nullable=True) stripe_charge_id = Column(Unicode(64), nullable=True) + # Adyen payment tracking (nullable - only set for Adyen payments) + adyen_psp_reference = Column(Unicode(64), nullable=True) + # one to one. user = relationship(argument="User", uselist=False, lazy="joined") @@ -293,6 +296,9 @@ class Invoice(RBase, Base): # Check for PayPal payment if self.paypal_order_id: return "paypal" + # Check for Adyen payment + if self.adyen_psp_reference: + return "adyen" # Check for Stripe payment (or assume Stripe for legacy invoices) if self.stripe_payment_intent_id: return "stripe" @@ -329,6 +335,13 @@ def get_invoice_by_stripe_payment_intent_id(dbsession, payment_intent_id): ).first() +def get_invoice_by_adyen_psp_reference(dbsession, psp_reference): + """Try to get Invoice object by Adyen PSP reference or return None.""" + return dbsession.query(Invoice).filter( + Invoice.adyen_psp_reference == psp_reference + ).first() + + def delete_invoice_by_id(dbsession, invoice_id): """ Safely delete an invoice and its line items, but only if it's from a terminated/unsuccessful crypto payment. diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 4f9a0ee..e7f0bc1 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -84,6 +84,13 @@ class Shop(RBase, Base): paypal_secret = Column(Unicode(128), nullable=True) paypal_enabled = Column(Boolean, default=True) + # Adyen API credentials for accepting payments + adyen_api_key = Column(Unicode(128), nullable=True) + adyen_merchant_account = Column(Unicode(128), nullable=True) + adyen_client_key = Column(Unicode(128), nullable=True) + adyen_hmac_key = Column(Unicode(128), nullable=True) + adyen_enabled = Column(Boolean, default=True) + created_timestamp = Column(BigInteger, nullable=False) updated_timestamp = Column(BigInteger, nullable=False) @@ -246,6 +253,17 @@ class Shop(RBase, Base): def is_paypal_not_ready(self): return not self.is_paypal_ready + @property + def is_adyen_ready(self): + """Check if shop has Adyen API credentials configured.""" + if self.adyen_api_key and self.adyen_merchant_account: + return True + return False + + @property + def is_adyen_not_ready(self): + return not self.is_adyen_ready + def is_ready_for_payment(self, request): """Check if shop is ready based on enabled payment methods.""" # If Stripe is enabled, shop needs Stripe API keys @@ -258,6 +276,11 @@ class Shop(RBase, Base): if self.is_paypal_ready: return True + # If Adyen is enabled, shop needs Adyen API credentials + if getattr(request, "adyen_enabled", False): + if self.is_adyen_ready: + return True + # If Monero is enabled, check if shop has configured processor and RPC is available if request.monero_enabled and request.monero_rpc_available: from .crypto_processor import CryptoProcessor @@ -406,6 +429,38 @@ class Shop(RBase, Base): .one_or_none() ) + @property + def adyen(self): + """Return an Adyen SDK instance using this shop's credentials.""" + if hasattr(self, "_adyen") == False: + if self.adyen_api_key and self.adyen_merchant_account: + import Adyen + + adyen = Adyen.Adyen() + adyen.client.xapikey = self.adyen_api_key + + # Get test/live mode from request/config if available + # Default to test for safety + platform = "test" + if hasattr(self, "dbsession") and self.dbsession: + try: + from pyramid.threadlocal import get_current_request + request = get_current_request() + if request and hasattr(request, "app"): + test_mode = request.app.get("adyen.test_mode", True) + if isinstance(test_mode, str): + test_mode = test_mode.strip().lower() in ("1", "true", "yes", "on") + if not test_mode: + platform = "live" + except: + pass + + adyen.client.platform = platform + self._adyen = adyen + else: + self._adyen = None + return self._adyen + @property def theme_base_color(self): # return the user defined base for shop or default. diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index befc9ac..7a06047 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -222,6 +222,27 @@ def includeme(config): return val return False + def add_adyen_enabled(request): + """Check if Adyen payments are enabled globally and for the current shop.""" + # If globally disabled, return False + if not request.adyen_globally_enabled: + return False + + # Check per-shop setting if shop is available + if hasattr(request, "shop") and request.shop: + return getattr(request.shop, "adyen_enabled", True) + + return request.adyen_globally_enabled + + def add_adyen_globally_enabled(request): + """Check if Adyen payments are enabled globally (ignoring per-shop setting).""" + val = request.app.get("payments.adyen.enabled") + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes", "on") + elif isinstance(val, bool): + return val + return False + def add_monero_enabled(request): """Check if Monero payments are enabled globally.""" try: @@ -350,6 +371,10 @@ def includeme(config): config.add_request_method( add_paypal_globally_enabled, "paypal_globally_enabled", reify=True ) + config.add_request_method(add_adyen_enabled, "adyen_enabled", reify=True) + config.add_request_method( + add_adyen_globally_enabled, "adyen_globally_enabled", reify=True + ) config.add_request_method(add_monero_enabled, "monero_enabled", reify=True) config.add_request_method( add_monero_rpc_available, "monero_rpc_available", reify=True diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 7b01579..4fdb3cc 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -30,9 +30,14 @@ def includeme(config): config.add_route("paypal_create_order", "/paypal/create-order/{cart_id}") config.add_route("paypal_complete_checkout", "/paypal/complete-checkout/{cart_id}") + # Adyen routes + config.add_route("adyen_create_session", "/adyen/create-session/{cart_id}") + config.add_route("adyen_complete_checkout", "/adyen/complete-checkout/{cart_id}") + # Webhook routes config.add_route("paypal_webhook", "/webhooks/paypal") config.add_route("stripe_webhook", "/webhooks/stripe") + config.add_route("adyen_webhook", "/webhooks/adyen") # user routes. config.add_route("user_settings", "/u/settings") diff --git a/make_post_sell/scripts/alembic/versions/3734955e7379_add_adyen_payment_columns_to_shop_and_.py b/make_post_sell/scripts/alembic/versions/3734955e7379_add_adyen_payment_columns_to_shop_and_.py new file mode 100644 index 0000000..547580d --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/3734955e7379_add_adyen_payment_columns_to_shop_and_.py @@ -0,0 +1,58 @@ +"""add adyen payment columns to shop and invoice + +Revision ID: 3734955e7379 +Revises: 63d935094f97 +Create Date: 2025-12-22 16:19:40.557406 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3734955e7379' +down_revision = '63d935094f97' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add Adyen columns to mps_shop + op.add_column( + 'mps_shop', + sa.Column('adyen_api_key', sa.Unicode(128), nullable=True) + ) + op.add_column( + 'mps_shop', + sa.Column('adyen_merchant_account', sa.Unicode(128), nullable=True) + ) + op.add_column( + 'mps_shop', + sa.Column('adyen_client_key', sa.Unicode(128), nullable=True) + ) + op.add_column( + 'mps_shop', + sa.Column('adyen_hmac_key', sa.Unicode(128), nullable=True) + ) + op.add_column( + 'mps_shop', + sa.Column('adyen_enabled', sa.Boolean(), nullable=False, server_default='1') + ) + + # Add Adyen PSP reference column to mps_invoice + op.add_column( + 'mps_invoice', + sa.Column('adyen_psp_reference', sa.Unicode(64), nullable=True) + ) + + +def downgrade(): + # Remove Invoice column + op.drop_column('mps_invoice', 'adyen_psp_reference') + + # Remove Shop columns + op.drop_column('mps_shop', 'adyen_enabled') + op.drop_column('mps_shop', 'adyen_hmac_key') + op.drop_column('mps_shop', 'adyen_client_key') + op.drop_column('mps_shop', 'adyen_merchant_account') + op.drop_column('mps_shop', 'adyen_api_key') diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index b701eda..a2ca7ff 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -281,10 +281,119 @@
{% endif %} +{% if request.adyen_globally_enabled %} +
+
+ +

Adyen Settings 💳

+ +
+ + + + +
+ + + + +
+ +
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + {% if adyen_enabled %} + ✓ Adyen configured and ready to accept payments +
+
+ + + {% else %} + ✗ Adyen payments are currently disabled +
+
+ Your API keys are preserved but customers cannot select Adyen as a payment method. +
+
+ + Re-enable Adyen payments to update your API keys + {% endif %} + +
+ +
+ +
+
+ +
+ +
+
+ +
+
+{% endif %} + {% if request.monero_enabled %}
- +

Crypto Settings 🪙

Payment Risk Thresholds

@@ -765,15 +874,32 @@ document.addEventListener('DOMContentLoaded', function() { if (stripeToggle) { stripeToggle.addEventListener('change', function() { const stripeControls = document.querySelector('#toggle-stripe ~ .hidden-control'); - + // Save state to localStorage localStorage.setItem('show-stripe-keys', this.checked); - + if (stripeControls) { stripeControls.style.display = this.checked ? 'block' : 'none'; } }); } + + // Adyen toggle + const adyenToggle = document.getElementById('toggle-adyen'); + const adyenControls = document.getElementById('adyen-controls'); + + if (adyenToggle && adyenControls) { + // Restore state from localStorage + const adyenState = localStorage.getItem('show-adyen-keys') === 'true'; + adyenToggle.checked = adyenState; + adyenControls.style.display = adyenState ? 'block' : 'none'; + + adyenToggle.addEventListener('change', function() { + // Save state to localStorage + localStorage.setItem('show-adyen-keys', this.checked); + adyenControls.style.display = this.checked ? 'block' : 'none'; + }); + } }); diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 385ab23..d10b230 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -2089,3 +2089,143 @@ class AuthenticatedFunctionalTests(FunctionalTests): # Should handle gracefully self.assertEqual(response.status_int, 200) + + def test_adyen_settings_form_save_credentials(self): + """Test that Adyen credentials can be saved through settings form.""" + # Create shop using helper + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + ) + + # Save Adyen settings + adyen_settings_data = { + "form_section": "adyen-settings", + "adyen_api_key": "test_api_key_12345", + "adyen_merchant_account": "TestMerchantAccount", + "adyen_client_key": "test_client_key", + "adyen_hmac_key": "test_hmac_key", + "csrf_token": self.get_csrf_token(shop.uuid_str), + } + + settings_res = self.testapp.post( + f"/s/{shop.id}/settings", adyen_settings_data, status=302 + ) + + # Refresh shop from DB + self.dbsession.expire(shop) + + self.assertEqual(shop.adyen_api_key, "test_api_key_12345") + self.assertEqual(shop.adyen_merchant_account, "TestMerchantAccount") + self.assertEqual(shop.adyen_client_key, "test_client_key") + self.assertEqual(shop.adyen_hmac_key, "test_hmac_key") + + def test_adyen_settings_disable_and_reenable(self): + """Test that Adyen can be disabled and re-enabled.""" + # Create shop using helper + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + ) + + # Get csrf token once before modifying anything + csrf_token = self.get_csrf_token(shop.uuid_str) + + # First set Adyen credentials via form + setup_data = { + "form_section": "adyen-settings", + "adyen_api_key": "test_key", + "adyen_merchant_account": "TestMerchant", + "csrf_token": csrf_token, + } + self.testapp.post(f"/s/{shop.id}/settings", setup_data, status=302) + + # Refresh shop from DB - should have Adyen enabled by default + self.dbsession.expire(shop) + self.assertTrue(shop.adyen_enabled) + + # Disable Adyen + disable_data = { + "form_section": "adyen-settings", + "disable_adyen": "1", + "csrf_token": csrf_token, + } + self.testapp.post(f"/s/{shop.id}/settings", disable_data, status=302) + + # Refresh shop from DB + self.dbsession.expire(shop) + self.assertFalse(shop.adyen_enabled) + + # Re-enable Adyen + enable_data = { + "form_section": "adyen-settings", + "csrf_token": csrf_token, + } + self.testapp.post(f"/s/{shop.id}/settings", enable_data, status=302) + + # Refresh shop from DB + self.dbsession.expire(shop) + self.assertTrue(shop.adyen_enabled) + + @patch("smtplib.SMTP") + def test_adyen_webhook_authorisation_event(self, mock_smtp): + """Test that Adyen AUTHORISATION webhook is handled correctly.""" + # Post webhook notification (doesn't require shop setup, just tests handler) + webhook_payload = { + "notificationItems": [{ + "NotificationRequestItem": { + "eventCode": "AUTHORISATION", + "success": "true", + "pspReference": "PSP_TEST_12345", + "merchantReference": "test_reference", + "amount": {"value": 1000, "currency": "USD"}, + } + }] + } + + response = self.testapp.post_json( + "/webhooks/adyen", + webhook_payload, + status=200 + ) + + # Should return [accepted] + self.assertIn("[accepted]", response.body.decode()) + + @patch("smtplib.SMTP") + def test_adyen_webhook_chargeback_event(self, mock_smtp): + """Test that Adyen CHARGEBACK webhook is handled correctly.""" + # Post chargeback webhook notification + webhook_payload = { + "notificationItems": [{ + "NotificationRequestItem": { + "eventCode": "CHARGEBACK", + "pspReference": "PSP_CHARGEBACK_123", + "merchantReference": "test_reference", + "amount": {"value": 5000, "currency": "USD"}, + "reason": "Goods not received", + } + }] + } + + response = self.testapp.post_json( + "/webhooks/adyen", + webhook_payload, + status=200 + ) + + # Should return [accepted] + self.assertIn("[accepted]", response.body.decode()) + + @patch("smtplib.SMTP") + def test_adyen_webhook_malformed_json(self, mock_smtp): + """Test that malformed JSON in Adyen webhook is handled gracefully.""" + response = self.testapp.post( + "/webhooks/adyen", + "not valid json", + content_type="application/json", + status=200 # Returns 200 to prevent retries + ) + + # Should handle gracefully and return [accepted] + self.assertIn("[accepted]", response.body.decode()) diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 37cec35..a1c159b 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -2889,3 +2889,158 @@ class TestInvoiceStripeIntegration(DatabaseIntegrationTests): self.assertEqual(invoice_both.payment_method, "paypal") transaction.commit() + + +class TestAdyenIntegration(DatabaseIntegrationTests): + """Integration tests for Adyen payment integration.""" + + def test_shop_adyen_credentials_persistence(self): + """Test that Adyen credentials are properly persisted to the database.""" + # Create shop with Adyen credentials + shop = Shop( + name="Adyen Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Test shop for Adyen", + ) + shop.adyen_api_key = "test_api_key_12345" + shop.adyen_merchant_account = "TestMerchantAccount" + shop.adyen_client_key = "test_client_key" + shop.adyen_hmac_key = "test_hmac_key" + self.dbsession.add(shop) + self.dbsession.flush() + + # Re-query the shop to verify persistence + shop_id = shop.id + self.dbsession.expunge(shop) + + from ..models.shop import get_shop_by_id + reloaded_shop = get_shop_by_id(self.dbsession, shop_id) + + self.assertEqual(reloaded_shop.adyen_api_key, "test_api_key_12345") + self.assertEqual(reloaded_shop.adyen_merchant_account, "TestMerchantAccount") + self.assertEqual(reloaded_shop.adyen_client_key, "test_client_key") + self.assertEqual(reloaded_shop.adyen_hmac_key, "test_hmac_key") + + transaction.commit() + + def test_shop_is_adyen_ready_integration(self): + """Test is_adyen_ready with persisted shop.""" + # Shop without credentials + shop1 = Shop( + name="Shop Without Adyen", + phone_number="555-555-0001", + billing_address="123 Test St", + description="No Adyen", + ) + self.dbsession.add(shop1) + self.dbsession.flush() + + self.assertFalse(shop1.is_adyen_ready) + + # Shop with credentials + shop2 = Shop( + name="Shop With Adyen", + phone_number="555-555-0002", + billing_address="456 Test St", + description="Has Adyen", + ) + shop2.adyen_api_key = "test_api_key" + shop2.adyen_merchant_account = "TestMerchant" + self.dbsession.add(shop2) + self.dbsession.flush() + + self.assertTrue(shop2.is_adyen_ready) + + transaction.commit() + + def test_invoice_adyen_psp_reference_integration(self): + """Test that Adyen PSP reference is properly stored on invoice.""" + # Create user and shop + user = get_or_create_user_by_email(self.dbsession, "adyen@test.com") + shop = Shop( + name="Adyen Invoice Test", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Testing Adyen invoices", + ) + shop.adyen_api_key = "test_api_key" + shop.adyen_merchant_account = "TestMerchant" + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # Create invoice with Adyen payment + invoice = Invoice(user=user) + invoice.shop = shop + invoice.adyen_psp_reference = "882619391893263J" + self.dbsession.add(invoice) + self.dbsession.flush() + + # Verify payment method detection + self.assertEqual(invoice.payment_method, "adyen") + + # Verify persistence + invoice_id = invoice.id + self.dbsession.expunge(invoice) + + from ..models.invoice import get_invoice_by_adyen_psp_reference + reloaded_invoice = get_invoice_by_adyen_psp_reference( + self.dbsession, "882619391893263J" + ) + + self.assertIsNotNone(reloaded_invoice) + self.assertEqual(str(reloaded_invoice.id), str(invoice_id)) + self.assertEqual(reloaded_invoice.adyen_psp_reference, "882619391893263J") + + transaction.commit() + + def test_get_invoice_by_adyen_psp_reference_not_found(self): + """Test that get_invoice_by_adyen_psp_reference returns None for non-existent reference.""" + from ..models.invoice import get_invoice_by_adyen_psp_reference + + result = get_invoice_by_adyen_psp_reference( + self.dbsession, "nonexistent_psp_reference" + ) + self.assertIsNone(result) + + def test_invoice_payment_method_priority_with_adyen_integration(self): + """Test payment method priority when multiple payment references exist.""" + user = get_or_create_user_by_email(self.dbsession, "priority@test.com") + shop = Shop( + name="Priority Test Shop", + phone_number="555-555-5555", + billing_address="123 Test St", + description="Testing priority", + ) + self.dbsession.add(user) + self.dbsession.add(shop) + self.dbsession.flush() + + # Invoice with only Adyen payment + invoice_adyen = Invoice(user=user) + invoice_adyen.shop = shop + invoice_adyen.adyen_psp_reference = "PSP_ONLY" + self.dbsession.add(invoice_adyen) + self.dbsession.flush() + self.assertEqual(invoice_adyen.payment_method, "adyen") + + # Invoice with Adyen and Stripe - Adyen takes priority + invoice_adyen_stripe = Invoice(user=user) + invoice_adyen_stripe.shop = shop + invoice_adyen_stripe.adyen_psp_reference = "PSP_PRIORITY" + invoice_adyen_stripe.stripe_payment_intent_id = "pi_lower_priority" + self.dbsession.add(invoice_adyen_stripe) + self.dbsession.flush() + self.assertEqual(invoice_adyen_stripe.payment_method, "adyen") + + # Invoice with PayPal and Adyen - PayPal takes priority + invoice_paypal_adyen = Invoice(user=user) + invoice_paypal_adyen.shop = shop + invoice_paypal_adyen.paypal_order_id = "PP_HIGHEST" + invoice_paypal_adyen.adyen_psp_reference = "PSP_SECOND" + self.dbsession.add(invoice_paypal_adyen) + self.dbsession.flush() + self.assertEqual(invoice_paypal_adyen.payment_method, "paypal") + + transaction.commit() diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index ffcb1d7..6cef1d7 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -1762,3 +1762,93 @@ class TestInvoiceStripe(unittest.TestCase): # PayPal is checked first in payment_method self.assertEqual(invoice.payment_method, "paypal") + + def test_invoice_payment_method_adyen(self): + """Test that Adyen payment method is detected correctly.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.adyen_psp_reference = "PSP123456789" + + self.assertEqual(invoice.payment_method, "adyen") + + def test_invoice_payment_method_priority_paypal_over_adyen(self): + """Test that PayPal takes priority over Adyen (edge case).""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.paypal_order_id = "PAYPAL-ORDER-123" + invoice.adyen_psp_reference = "PSP123456789" + + # PayPal is checked first in payment_method + self.assertEqual(invoice.payment_method, "paypal") + + def test_invoice_payment_method_priority_adyen_over_stripe(self): + """Test that Adyen takes priority over Stripe (edge case).""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.adyen_psp_reference = "PSP123456789" + invoice.stripe_payment_intent_id = "pi_1234567890abcdef" + + # Adyen is checked before Stripe in payment_method + self.assertEqual(invoice.payment_method, "adyen") + + +class TestAdyen(unittest.TestCase): + """Test Adyen-related model functionality.""" + + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + # Create test shop + self.shop = Shop( + "test-shop", + "555-555-5555", + "123 Test St", + "Test shop description", + ) + # Create test user + self.user = User("test@example.com") + + def test_shop_is_adyen_ready_without_credentials(self): + """Test that shop is not Adyen-ready without credentials.""" + self.assertFalse(self.shop.is_adyen_ready) + + def test_shop_is_adyen_ready_with_api_key_only(self): + """Test that shop is not Adyen-ready with just API key.""" + self.shop.adyen_api_key = "test_api_key" + self.assertFalse(self.shop.is_adyen_ready) + + def test_shop_is_adyen_ready_with_merchant_account_only(self): + """Test that shop is not Adyen-ready with just merchant account.""" + self.shop.adyen_merchant_account = "TestMerchant" + self.assertFalse(self.shop.is_adyen_ready) + + def test_shop_is_adyen_ready_with_both_credentials(self): + """Test that shop is Adyen-ready with both API key and merchant account.""" + self.shop.adyen_api_key = "test_api_key" + self.shop.adyen_merchant_account = "TestMerchant" + self.assertTrue(self.shop.is_adyen_ready) + + def test_shop_is_adyen_not_ready(self): + """Test the inverse property for convenience.""" + self.assertTrue(self.shop.is_adyen_not_ready) + self.shop.adyen_api_key = "test_api_key" + self.shop.adyen_merchant_account = "TestMerchant" + self.assertFalse(self.shop.is_adyen_not_ready) + + def test_shop_adyen_enabled_default(self): + """Test that Adyen enabled is None before DB insert (server_default handles it).""" + # Before DB insert, the value is None (server_default of '1' applies on insert) + # When retrieved from DB after insert, it would be True + self.assertIsNone(self.shop.adyen_enabled) + + def test_invoice_adyen_psp_reference_default(self): + """Test that Adyen PSP reference is None by default.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + self.assertIsNone(invoice.adyen_psp_reference) + + def test_invoice_adyen_psp_reference_set(self): + """Test setting Adyen PSP reference.""" + invoice = Invoice(self.user) + invoice.shop = self.shop + invoice.adyen_psp_reference = "882619391893263J" + self.assertEqual(invoice.adyen_psp_reference, "882619391893263J") diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index b9bd57f..ced0322 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -943,6 +943,233 @@ def paypal_complete_checkout(request): return HTTPFound("/cart") +@view_config(route_name="adyen_create_session", request_method="POST", renderer="json") +@user_required() +@shop_is_ready_required() +def adyen_create_session(request): + """Create Adyen checkout session(s) for the cart.""" + if not getattr(request, "adyen_enabled", False): + return {"error": "Adyen payments are disabled"} + + cart = get_cart_from_matchdict(request) + + if cart is None: + return {"error": "Cart not found"} + + if cart.is_empty: + return {"error": "Cart is empty"} + + try: + sessions = [] + + invoice_map = {} + for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): + shop = cart.shops[shop_id] + + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents + + if cart.physical_products: + invoice.delivery_address = request.user.active_address.data + + for product, quantity in product_quantity_tuple: + invoice.new_line_item(product=product, quantity=quantity) + + for coupon in cart.coupons: + invoice.new_coupon_redemption(coupon) + + invoice_map[shop_id] = invoice + + for shop_id, invoice in invoice_map.items(): + shop = invoice.shop + + if not shop.adyen or not shop.is_adyen_ready: + return {"error": f"Adyen is not configured for shop: {shop.name}"} + + # Create Adyen checkout session + adyen = shop.adyen + result = adyen.checkout.payments_api.sessions({ + "amount": { + "currency": "USD", + "value": invoice.total_in_cents + }, + "reference": f"cart_{cart.uuid_str}_{shop.uuid_str}", + "merchantAccount": shop.adyen_merchant_account, + "returnUrl": f"{request.host_url}/adyen/complete-checkout/{cart.id}", + "shopperReference": request.user.uuid_str, + "shopperEmail": request.user.email, + }) + + if result.status_code == 201: + session_data = result.message + sessions.append({ + "shop_id": shop.uuid_str, + "session_id": session_data.get("id"), + "session_data": session_data.get("sessionData"), + "client_key": shop.adyen_client_key, + }) + else: + return {"error": f"Failed to create Adyen session for {shop.name}"} + + return {"sessions": sessions} + + except Exception as e: + return {"error": str(e)} + + +@view_config( + route_name="adyen_complete_checkout", request_method="POST", require_csrf=True +) +@user_required() +@shop_is_ready_required() +def adyen_complete_checkout(request): + """Complete checkout using Adyen payment.""" + if not getattr(request, "adyen_enabled", False): + request.session.flash(("Adyen payments are disabled.", "error")) + return HTTPFound("/cart") + + cart = get_cart_from_matchdict(request) + + if cart is None: + request.session.flash(("That cart_id does not exist.", "error")) + return HTTPFound(get_referer_or_home(request)) + + elif cart.is_not_public and request.user.does_not_own_cart(cart): + request.session.flash(("That cart is not public and you do not own that cart.", "error")) + return HTTPFound(get_referer_or_home(request)) + + elif cart.is_empty: + request.session.flash(("That cart is empty, you cannot checkout.", "error")) + return HTTPFound(get_referer_or_home(request)) + + error_messages = cart.validate_attached_coupons() + if error_messages: + for error_message in error_messages: + request.session.flash((error_message, "error")) + return HTTPFound(get_referer_or_home(request)) + + # Get PSP references from Adyen redirectResultCode or params + psp_references_param = request.params.get("psp_references") + if not psp_references_param: + request.session.flash(("Adyen payment reference is missing.", "error")) + return HTTPFound("/cart") + + psp_references = [ref.strip() for ref in psp_references_param.split(",")] + + successful_invoices = [] + failed_shops = [] + + try: + invoice_map = {} + for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): + shop = cart.shops[shop_id] + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents + + if cart.physical_products: + invoice.delivery_address = request.user.active_address.data + + for product, quantity in product_quantity_tuple: + invoice.new_line_item(product=product, quantity=quantity) + + for coupon in cart.coupons: + if hasattr(coupon, 'is_active') and not coupon.is_active: + continue + invoice.new_coupon_redemption(coupon) + + invoice_map[shop_id] = invoice + + invoices_requiring_payment = [inv for inv in invoice_map.values() if inv.requires_payment] + + if len(psp_references) != len(invoices_requiring_payment): + request.session.flash((f"Adyen payment reference count mismatch.", "error")) + return HTTPFound("/cart") + + for idx, invoice in enumerate(invoices_requiring_payment): + shop = invoice.shop + psp_reference = psp_references[idx] + + try: + # Verify the payment with Adyen + adyen = shop.adyen + payment_details = adyen.checkout.payments_api.get_result_of_payment_session({ + "sessionId": psp_reference + }) + + if payment_details.status_code != 200: + failed_shops.append((shop, "Payment verification failed.")) + continue + + result = payment_details.message + result_code = result.get("resultCode", "") + + if result_code not in ["Authorised", "Received"]: + failed_shops.append((shop, f"Payment not authorized: {result_code}")) + continue + + # Store the PSP reference + invoice.adyen_psp_reference = result.get("pspReference", psp_reference) + + for line_item in invoice.line_items: + line_item.product.unlock_for_user(request.user) + request.dbsession.add(line_item.product) + + request.dbsession.add(invoice) + successful_invoices.append(invoice) + + except Exception as e: + failed_shops.append((shop, f"Unexpected error: {str(e)}")) + continue + + if successful_invoices: + for invoice in successful_invoices: + for line_item in invoice.line_items: + cart.remove_product(line_item.product, line_item.quantity) + cart.update_inventory(request.shop_location) + + for invoice in successful_invoices: + send_purchase_email( + request, + request.user.email, + [item.product for item in invoice.line_items], + invoice.total, + ) + send_sale_email( + request, + invoice.shop, + [item.product for item in invoice.line_items], + invoice.total, + ) + + if successful_invoices and not failed_shops: + request.session.flash(("Success! You have completed the purchase.", "success")) + elif successful_invoices and failed_shops: + request.session.flash(("Partial success. Some payments failed.", "warning")) + for shop, error_msg in failed_shops: + request.session.flash((f"{shop.name}: {error_msg}", "error")) + else: + request.session.flash(("All payments failed.", "error")) + for shop, error_msg in failed_shops: + request.session.flash((f"{shop.name}: {error_msg}", "error")) + + save_cart(request) + + if successful_invoices: + return HTTPFound(get_smart_purchase_redirect_url(successful_invoices)) + return HTTPFound("/cart") + + except Exception as e: + request.tm.abort() + request.session.flash((f"Payment processing failed: {str(e)}", "error")) + return HTTPFound("/cart") + + @view_config(route_name="paypal_create_order", request_method="POST", renderer="json") @user_required() @shop_is_ready_required() diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index c19440a..e9991de 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -648,6 +648,38 @@ def shop_settings(request): ) request.session.flash(msg) + # Handle Adyen settings form + if form_section == "adyen-settings": + adyen_api_key = request.params.get("adyen_api_key", "").strip() + adyen_merchant_account = request.params.get("adyen_merchant_account", "").strip() + adyen_client_key = request.params.get("adyen_client_key", "").strip() + adyen_hmac_key = request.params.get("adyen_hmac_key", "").strip() + + # Handle disable action + if request.params.get("disable_adyen"): + shop.adyen_enabled = False + request.session.flash(("Adyen payments disabled", "success")) + # Handle re-enable action + elif not shop.adyen_enabled: + shop.adyen_enabled = True + request.session.flash(("Adyen payments re-enabled", "success")) + else: + if adyen_api_key and adyen_api_key != shop.adyen_api_key: + shop.adyen_api_key = adyen_api_key + request.session.flash(("You set the shop's Adyen API Key.", "success")) + + if adyen_merchant_account and adyen_merchant_account != shop.adyen_merchant_account: + shop.adyen_merchant_account = adyen_merchant_account + request.session.flash(("You set the shop's Adyen Merchant Account.", "success")) + + if adyen_client_key and adyen_client_key != shop.adyen_client_key: + shop.adyen_client_key = adyen_client_key + request.session.flash(("You set the shop's Adyen Client Key.", "success")) + + if adyen_hmac_key and adyen_hmac_key != shop.adyen_hmac_key: + shop.adyen_hmac_key = adyen_hmac_key + request.session.flash(("You set the shop's Adyen HMAC Key.", "success")) + # Handle maintenance settings form if form_section == "maintenance-settings": if shop.maint_mode != maint_mode: @@ -911,6 +943,11 @@ def shop_settings(request): "plausible_domain_name": shop.plausible_domain_name or "", "stripe_public_api_key": shop.stripe_public_api_key or "", "stripe_secret_api_key": shop.stripe_secret_api_key or "", + "adyen_api_key": shop.adyen_api_key or "", + "adyen_merchant_account": shop.adyen_merchant_account or "", + "adyen_client_key": shop.adyen_client_key or "", + "adyen_hmac_key": shop.adyen_hmac_key or "", + "adyen_enabled": shop.adyen_enabled, "crypto_quote_expiry_seconds": shop.crypto_quote_expiry_seconds, "payment_risk_threshold_mid_dollars": cents_to_dollars( shop.payment_risk_threshold_mid_cents diff --git a/make_post_sell/views/webhooks.py b/make_post_sell/views/webhooks.py index 6e7b2fe..249cad3 100644 --- a/make_post_sell/views/webhooks.py +++ b/make_post_sell/views/webhooks.py @@ -495,3 +495,216 @@ def _handle_stripe_dispute(request, dispute): f"Stripe DISPUTE created - Dispute ID: {dispute_id}, " f"Charge: {charge_id}, Amount: ${amount_dollars:.2f}, Reason: {reason}" ) + + +# ============================================================================= +# Adyen Webhooks +# ============================================================================= + + +@view_config(route_name="adyen_webhook", request_method="POST") +def adyen_webhook(request): + """ + Handle Adyen webhook notifications. + + Key events: + - AUTHORISATION - Payment authorized + - CAPTURE - Payment captured + - REFUND - Refund processed + - CHARGEBACK - Dispute/chargeback created + + Adyen uses HMAC-SHA256 for webhook verification. + """ + import hashlib + import hmac + import base64 + import binascii + + try: + payload = request.body.decode("utf-8") + notification = json.loads(payload) + + # Adyen sends notifications in a wrapper + notification_items = notification.get("notificationItems", []) + + for item in notification_items: + notification_request = item.get("NotificationRequestItem", {}) + event_code = notification_request.get("eventCode") + psp_reference = notification_request.get("pspReference") + merchant_reference = notification_request.get("merchantReference") + + log.info(f"Adyen webhook received: {event_code} for PSP ref {psp_reference}") + + # Find the invoice by PSP reference + from ..models.invoice import get_invoice_by_adyen_psp_reference + + invoice = get_invoice_by_adyen_psp_reference(request.dbsession, psp_reference) + + if not invoice: + # Try to find by merchant reference (which includes cart/shop info) + log.info(f"Adyen webhook: No invoice found for PSP ref {psp_reference}, trying merchant ref") + # We can't verify the signature without the shop, so just acknowledge + continue + + shop = invoice.shop + if not shop: + log.error(f"Adyen webhook: Invoice {invoice.id} has no shop") + continue + + # Verify HMAC signature + if shop.adyen_hmac_key: + hmac_signature = request.headers.get("X-Adyen-Hmac-Signature") + if hmac_signature: + if not _verify_adyen_hmac(shop.adyen_hmac_key, hmac_signature, notification_request): + log.warning(f"Adyen webhook: HMAC verification failed for PSP ref {psp_reference}") + continue + + # Process the event + if event_code == "AUTHORISATION": + success = notification_request.get("success") == "true" + if success: + _handle_adyen_authorisation(request, invoice, notification_request) + else: + reason = notification_request.get("reason", "Unknown") + log.warning(f"Adyen AUTHORISATION failed for PSP ref {psp_reference}: {reason}") + + elif event_code == "CAPTURE": + _handle_adyen_capture(request, invoice, notification_request) + + elif event_code == "REFUND": + _handle_adyen_refund(notification_request, psp_reference) + + elif event_code == "CHARGEBACK": + _handle_adyen_chargeback(notification_request, psp_reference) + + # Adyen expects [accepted] as response + return Response("[accepted]", content_type="text/plain; charset=utf-8", status=200) + + except Exception as e: + log.exception(f"Adyen webhook error: {str(e)}") + # Return accepted to prevent infinite retries + return Response("[accepted]", content_type="text/plain; charset=utf-8", status=200) + + +def _verify_adyen_hmac(hmac_key, hmac_signature, notification_request): + """ + Verify Adyen webhook HMAC signature. + + Adyen's HMAC is computed from a specific concatenation of fields. + """ + import hashlib + import hmac + import base64 + import binascii + + try: + # Build the signing string according to Adyen's specification + # Fields: pspReference, originalReference, merchantAccountCode, merchantReference, + # amount.value, amount.currency, eventCode, success + psp_reference = notification_request.get("pspReference", "") + original_reference = notification_request.get("originalReference", "") + merchant_account = notification_request.get("merchantAccountCode", "") + merchant_reference = notification_request.get("merchantReference", "") + amount = notification_request.get("amount", {}) + amount_value = str(amount.get("value", "")) + amount_currency = amount.get("currency", "") + event_code = notification_request.get("eventCode", "") + success = notification_request.get("success", "") + + # Concatenate with colons + signing_string = ":".join([ + psp_reference, + original_reference, + merchant_account, + merchant_reference, + amount_value, + amount_currency, + event_code, + success + ]) + + # Compute HMAC-SHA256 + expected = hmac.new( + binascii.unhexlify(hmac_key), + signing_string.encode('utf-8'), + hashlib.sha256 + ).digest() + expected_signature = base64.b64encode(expected).decode('utf-8') + + return hmac.compare_digest(hmac_signature, expected_signature) + + except Exception as e: + log.exception(f"Adyen HMAC verification error: {e}") + return False + + +def _handle_adyen_authorisation(request, invoice, notification): + """ + Handle AUTHORISATION event. + + This confirms the payment was authorized successfully. + """ + psp_reference = notification.get("pspReference") + + # Idempotency check + if invoice.adyen_psp_reference == psp_reference: + log.info(f"Adyen webhook: Invoice {invoice.id} already has PSP ref, checking products") + + # Update invoice if needed + if not invoice.adyen_psp_reference: + invoice.adyen_psp_reference = psp_reference + request.dbsession.add(invoice) + + # Unlock products for the user + user = invoice.user + if user: + for line_item in invoice.line_items: + if not line_item.product.is_unlocked_for_user(user): + line_item.product.unlock_for_user(user) + request.dbsession.add(line_item.product) + log.info(f"Adyen webhook: Unlocked product {line_item.product.id} for user {user.id}") + + # Send confirmation emails + try: + products = [item.product for item in invoice.line_items] + send_purchase_email(request, user.email, products, invoice.total) + send_sale_email(request, invoice.shop, products, invoice.total) + log.info(f"Adyen webhook: Sent confirmation emails for invoice {invoice.id}") + except Exception as e: + log.exception(f"Adyen webhook: Failed to send emails for invoice {invoice.id}: {e}") + + request.dbsession.flush() + + +def _handle_adyen_capture(request, invoice, notification): + """Handle CAPTURE event - payment was captured.""" + psp_reference = notification.get("pspReference") + log.info(f"Adyen CAPTURE processed for invoice {invoice.id}, PSP ref {psp_reference}") + + +def _handle_adyen_refund(notification, psp_reference): + """Handle REFUND event.""" + amount = notification.get("amount", {}) + amount_value = amount.get("value", 0) + currency = amount.get("currency", "USD") + + # Convert from minor units + amount_dollars = amount_value / 100 + + log.info(f"Adyen REFUND processed - PSP ref: {psp_reference}, Amount: {amount_dollars:.2f} {currency}") + + +def _handle_adyen_chargeback(notification, psp_reference): + """Handle CHARGEBACK event.""" + amount = notification.get("amount", {}) + amount_value = amount.get("value", 0) + currency = amount.get("currency", "USD") + reason = notification.get("reason", "unknown") + + # Convert from minor units + amount_dollars = amount_value / 100 + + log.critical( + f"Adyen CHARGEBACK created - PSP ref: {psp_reference}, " + f"Amount: {amount_dollars:.2f} {currency}, Reason: {reason}" + )