Add Adyen payment integration

- Add Adyen API credentials to Shop model (api_key, merchant_account,
  client_key, hmac_key, enabled)
- Add adyen_psp_reference to Invoice model for payment tracking
- Add Adyen checkout views (create-session, complete-checkout)
- Add Adyen webhook handler with HMAC verification
- Add shop settings UI for Adyen credentials
- Add request.adyen_enabled and request.adyen_globally_enabled
- Update ADYEN.md with verification details and implementation status
- Add 21 tests (8 unit, 5 integration, 5 functional + 3 invoice)
This commit is contained in:
Russell Ballestrini 2025-12-22 17:16:54 -05:00
parent a2e2092a31
commit 4210bc1421
13 changed files with 1166 additions and 9 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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")

View file

@ -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')

View file

@ -281,10 +281,119 @@
<br />
{% endif %}
{% if request.adyen_globally_enabled %}
<section class="one-column">
<section class="shop-settings well">
<h3>Adyen Settings 💳</h3>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="adyen-settings" />
<label for="adyen_api_key_input">Adyen API Keys</label>
<br />
<input type="checkbox" id="toggle-adyen">
<label for="toggle-adyen" class="inline-label">Show Adyen API Keys</label>
<div class="hidden-control" id="adyen-controls">
<br />
<label for="adyen_api_key_input">Adyen API Key</label>
<input
name = "adyen_api_key"
type = "text"
id = "adyen_api_key"
class = "mps-adyen-api-key{% if not adyen_enabled %} disabled-input{% endif %}"
value = "{% if adyen_api_key %}{{ adyen_api_key }}{% endif %}"
placeholder = "Adyen API Key (from Adyen dashboard)"
{% if not adyen_enabled %}readonly{% endif %}
/>
<br />
<br />
<label for="adyen_merchant_account_input">Adyen Merchant Account</label>
<input
name = "adyen_merchant_account"
type = "text"
id = "adyen_merchant_account"
class = "mps-adyen-merchant-account{% if not adyen_enabled %} disabled-input{% endif %}"
value = "{% if adyen_merchant_account %}{{ adyen_merchant_account }}{% endif %}"
placeholder = "Adyen Merchant Account"
{% if not adyen_enabled %}readonly{% endif %}
/>
<br />
<br />
<label for="adyen_client_key_input">Adyen Client Key</label>
<input
name = "adyen_client_key"
type = "text"
id = "adyen_client_key"
class = "mps-adyen-client-key{% if not adyen_enabled %} disabled-input{% endif %}"
value = "{% if adyen_client_key %}{{ adyen_client_key }}{% endif %}"
placeholder = "Adyen Client Key (for Drop-in/Components)"
{% if not adyen_enabled %}readonly{% endif %}
/>
<br />
<br />
<label for="adyen_hmac_key_input">Adyen HMAC Key (for webhooks)</label>
<input
name = "adyen_hmac_key"
type = "text"
id = "adyen_hmac_key"
class = "mps-adyen-hmac-key{% if not adyen_enabled %} disabled-input{% endif %}"
value = "{% if adyen_hmac_key %}{{ adyen_hmac_key }}{% endif %}"
placeholder = "Adyen HMAC Key (for webhook verification)"
{% if not adyen_enabled %}readonly{% endif %}
/>
<br />
<br />
{% if adyen_enabled %}
<small class="success-indicator">✓ Adyen configured and ready to accept payments</small>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Adyen Settings" />
<input type="submit" name="disable_adyen" class="payment-toggle-button disable" value="Disable Adyen" />
{% else %}
<small class="error-indicator">✗ Adyen payments are currently disabled</small>
<br />
<br />
<small class="status-message">Your API keys are preserved but customers cannot select Adyen as a payment method.</small>
<br />
<br />
<input type="submit" name="submit" class="payment-toggle-button enable" value="Re-enable Adyen" />
<small class="status-message">Re-enable Adyen payments to update your API keys</small>
{% endif %}
</div>
<br />
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% endif %}
{% if request.monero_enabled %}
<section class="one-column">
<section class="shop-settings well">
<h3>Crypto Settings 🪙</h3>
<h4>Payment Risk Thresholds</h4>
@ -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';
});
}
});
</script>

View file

@ -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())

View file

@ -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()

View file

@ -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")

View file

@ -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()

View file

@ -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

View file

@ -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}"
)