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:
parent
a2e2092a31
commit
4210bc1421
13 changed files with 1166 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue