Initial commit — Performance West telecom compliance platform

Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

98
frappe_adyen/README.md Normal file
View file

@ -0,0 +1,98 @@
# frappe_adyen
Adyen payment gateway integration for Frappe/ERPNext. Supports Cards, ACH Direct Debit, Klarna, Cash App Pay, and Amazon Pay via the Adyen Sessions API v71.
## Features
- **Cards** — Visa, Mastercard, Amex, Discover via Adyen Drop-in or Components
- **ACH Direct Debit** — US bank account payments via `ach` type code
- **Klarna** — Buy Now Pay Later (Pay Now, Pay Later, Slice It) via `klarna`, `klarna_account`, `klarna_paynow`
- **Cash App Pay**`cashapp` type code
- **Amazon Pay**`amazonpay` type code
- **HMAC webhook verification** — All incoming Adyen notifications verified against HMAC-SHA256 signature
- **Multi-instance** — One Adyen Settings doc per payment method grouping; each maps to its own Payment Gateway Account in ERPNext
- **Test/Live toggle** — Per-instance environment switch; live requires a unique URL prefix from Adyen
## Requirements
- Frappe >= 15.0.0
- `payments` app installed (`bench get-app payments`)
- Python >= 3.10
## Installation
```bash
# 1. Get the app
bench get-app frappe_adyen https://github.com/performancewest/frappe_adyen
# 2. Install payments dependency (if not already installed)
bench get-app payments
bench --site <your-site> install-app payments
# 3. Install frappe_adyen
bench --site <your-site> install-app frappe_adyen
```
## Configuration
After installation, navigate to **Adyen Settings** in the Frappe desk and create one document per payment method group.
### Gateway Instances
| Instance Name | `allowed_payment_methods` | Notes |
|---|---|---|
| Card | `scheme,applepay,googlepay` | Credit/debit + wallets |
| ACH | `ach` | US bank accounts only |
| Klarna | `klarna,klarna_account,klarna_paynow` | Set `capture_delay = manual` |
| CashApp | `cashapp` | Cash App Pay |
| AmazonPay | `amazonpay` | Amazon Pay |
### Configuration Fields
| Field | Description |
|---|---|
| Gateway Name | Instance label shown in Payment Gateway Account (e.g. `Card`, `ACH`) |
| Enabled | Toggle to activate/deactivate this gateway instance |
| Environment | `test` or `live` |
| Merchant Account | Your Adyen merchant account name from Customer Area |
| API Key | From Customer Area → Developers → API credentials |
| Client Key | Optional — for Drop-in or Components frontend integration |
| Webhook HMAC Key | From Customer Area → Developers → Webhooks → HMAC key |
| Live URL Prefix | Required for live only — your unique prefix from Adyen (e.g. `1797a841fbb37ca7`) |
| Allowed Payment Methods | Comma-separated Adyen type codes shown to shopper |
| Blocked Payment Methods | Optional comma-separated type codes to force-hide |
| Capture Delay | `immediate` (default) or `manual` (required for Klarna) |
| Channel | `Web`, `iOS`, or `Android` |
## Webhook Setup
1. Log in to **Adyen Customer Area**
2. Go to **Developers → Webhooks → Add webhook**
3. Select **Standard notification**
4. Set the URL to:
```
https://<your-frappe-site>/api/method/frappe_adyen.api.adyen_webhook
```
5. Under **Security**, enable **HMAC signature** and copy the generated key
6. Paste the HMAC key into the **Webhook HMAC Key** field of the matching Adyen Settings document
7. Save and activate the webhook in Adyen Customer Area
> The `/api/method/frappe_adyen.api.adyen_webhook` endpoint is exempt from Frappe CSRF protection. All requests are authenticated via HMAC-SHA256 signature verification instead.
## Live Environment
For live transactions, Adyen requires a unique URL prefix:
1. In Adyen Customer Area, go to **Developers → API credentials**
2. Copy your **live URL prefix** (e.g. `1797a841fbb37ca7-PerformanceWest`)
3. Paste it into the **Live URL Prefix** field in Adyen Settings
4. Set **Environment** to `live`
The app constructs the live checkout endpoint as:
```
https://<live_url_prefix>-checkout-live.adyenpayments.com/checkout/v71/sessions
```
## License
MIT License — Copyright 2026 Performance West Inc.

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -0,0 +1,480 @@
# Copyright (c) 2024, Performance West Inc. and contributors
# License: MIT. See LICENSE
"""
frappe_adyen.api
~~~~~~~~~~~~~~~~
Public API endpoints for the Adyen payment gateway integration.
Endpoints
---------
adyen_webhook Receives Adyen Standard Webhook notifications (CSRF-exempt).
get_payment_status Polls Payment Request status for the order success page.
"""
from __future__ import annotations
import json
import frappe
from frappe import _
# ---------------------------------------------------------------------------
# Webhook endpoint
# ---------------------------------------------------------------------------
@frappe.whitelist(allow_guest=True)
def adyen_webhook():
"""
Receive and process Adyen Standard Webhook notifications.
Security: HMAC-SHA256 verified per notification item (inside
adyen_settings.handle_webhook).
Idempotency: checks payment_request.status before creating Payment Entry.
Response: Must return "[accepted]" to prevent Adyen retry storms.
Adyen retries unacknowledged webhooks up to 36 times over 3 days.
We ALWAYS return "[accepted]" even on errors log failures, never
let Adyen retry-flood us. Only non-200 for malformed JSON.
"""
# --- 1. Parse JSON body -------------------------------------------------
try:
raw_data = frappe.request.data
if isinstance(raw_data, bytes):
raw_data = raw_data.decode("utf-8")
body = json.loads(raw_data)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — JSON Parse Error",
message=str(exc),
)
# Still return [accepted]: malformed payloads won't improve on retry
return "[accepted]"
notification_items = body.get("notificationItems", [])
# --- 2. Process each notification item independently --------------------
for wrapper in notification_items:
try:
item = wrapper.get("NotificationRequestItem", {})
merchant_account_code = item.get("merchantAccountCode", "")
payment_request_name = item.get("merchantReference", "")
# 2a. Find the matching enabled Adyen Settings instance
settings_list = frappe.get_all(
"Adyen Settings",
filters={"merchant_account": merchant_account_code, "enabled": 1},
limit=1,
)
if not settings_list:
frappe.log_error(
title="Adyen Webhook — Settings Not Found",
message=(
f"No enabled Adyen Settings found for merchantAccount="
f"'{merchant_account_code}', "
f"pspReference={item.get('pspReference')}"
),
)
continue
adyen_settings = frappe.get_doc("Adyen Settings", settings_list[0].name)
# 2b. HMAC verification (raises frappe.AuthenticationError on failure)
try:
adyen_settings.verify_hmac(item)
except frappe.AuthenticationError:
# Already logged inside adyen_settings; skip item, keep going
frappe.log_error(
title="Adyen Webhook — HMAC Rejected",
message=(
f"merchantAccount={merchant_account_code}, "
f"pspReference={item.get('pspReference')}, "
f"merchantReference={payment_request_name}"
),
)
continue
# 2c. Normalise the event via the settings controller
normalised = adyen_settings._normalise_event(item)
if normalised is None:
# Unknown eventCode — skip silently (no retry value)
continue
event_type = normalised.get("event_type")
# 2d. Route to the appropriate handler
if event_type == "payment.succeeded":
_handle_payment_succeeded(payment_request_name, normalised, adyen_settings)
elif event_type == "payment.failed":
_handle_payment_failed(payment_request_name)
elif event_type == "payment.cancelled":
_handle_payment_cancelled(payment_request_name)
elif event_type == "payment.refunded":
_handle_payment_refunded(payment_request_name, normalised)
elif event_type == "payment.disputed":
_handle_payment_disputed(payment_request_name, normalised)
# Other normalised types (capture, capture_failed, dispute_won) are
# logged but not acted on — operational visibility only
else:
frappe.logger("frappe_adyen").info(
f"Adyen webhook: unhandled event_type={event_type!r} "
f"pspReference={normalised.get('psp_reference')} "
f"merchantReference={payment_request_name}"
)
except Exception as exc:
# Catch-all: never let one bad item stop others or kill the response
frappe.log_error(
title="Adyen Webhook — Item Processing Error",
message=(
f"Error processing notification item: {exc}\n"
f"Item: {wrapper}"
),
)
# --- 3. Always acknowledge — Adyen requirement --------------------------
return "[accepted]"
# ---------------------------------------------------------------------------
# Payment event handlers
# ---------------------------------------------------------------------------
def _handle_payment_succeeded(
payment_request_name: str,
event: dict,
adyen_settings,
) -> None:
"""
Create a Payment Entry and mark the Payment Request as Paid.
Idempotency: returns immediately if the PR is already Paid.
Uses ERPNext's make_payment_entry to build the double-entry journal.
Stores the Adyen PSP reference as reference_no for reconciliation.
"""
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
except frappe.DoesNotExistError:
frappe.log_error(
title="Adyen Webhook — Payment Request Not Found",
message=f"payment.succeeded for unknown PR: {payment_request_name!r}",
)
return
# Idempotency guard
if pr.status == "Paid":
frappe.logger("frappe_adyen").info(
f"Adyen webhook: payment.succeeded for already-Paid PR {payment_request_name!r} — skipped"
)
return
try:
from erpnext.accounts.doctype.payment_request.payment_request import (
make_payment_entry,
)
pe = make_payment_entry(pr.name)
psp_reference = event.get("psp_reference", "")
payment_method = event.get("raw", {}).get("paymentMethod", "")
pe.reference_no = psp_reference
pe.reference_date = frappe.utils.nowdate()
pe.remarks = f"Adyen {payment_method} — PSP: {psp_reference}"
pe.flags.ignore_permissions = True
pe.submit()
# Mark Payment Request as Paid
frappe.db.set_value("Payment Request", payment_request_name, "status", "Paid")
frappe.db.commit()
frappe.logger("frappe_adyen").info(
f"Adyen webhook: Payment Entry {pe.name} created for PR {payment_request_name!r}, "
f"PSP={psp_reference}"
)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — Payment Entry Creation Failed",
message=(
f"Failed to create Payment Entry for PR {payment_request_name!r}: {exc}\n"
f"PSP reference: {event.get('psp_reference')}"
),
)
def _handle_payment_failed(payment_request_name: str) -> None:
"""
Mark the Payment Request as Failed.
Idempotency: returns immediately if PR is already Paid (do not
overwrite a successful payment with a late failure notification).
"""
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
except frappe.DoesNotExistError:
frappe.log_error(
title="Adyen Webhook — Payment Request Not Found",
message=f"payment.failed for unknown PR: {payment_request_name!r}",
)
return
if pr.status == "Paid":
frappe.logger("frappe_adyen").info(
f"Adyen webhook: payment.failed arrived for already-Paid PR {payment_request_name!r} — skipped"
)
return
try:
frappe.db.set_value("Payment Request", payment_request_name, "status", "Failed")
frappe.db.commit()
frappe.logger("frappe_adyen").info(
f"Adyen webhook: PR {payment_request_name!r} marked Failed"
)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — Failed Status Update Error",
message=f"Could not mark PR {payment_request_name!r} as Failed: {exc}",
)
def _handle_payment_cancelled(payment_request_name: str) -> None:
"""
Mark the Payment Request as Cancelled.
Idempotency: does not overwrite a Paid PR.
"""
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
except frappe.DoesNotExistError:
frappe.log_error(
title="Adyen Webhook — Payment Request Not Found",
message=f"payment.cancelled for unknown PR: {payment_request_name!r}",
)
return
if pr.status == "Paid":
frappe.logger("frappe_adyen").info(
f"Adyen webhook: payment.cancelled for already-Paid PR {payment_request_name!r} — skipped"
)
return
try:
frappe.db.set_value(
"Payment Request", payment_request_name, "status", "Cancelled"
)
frappe.db.commit()
frappe.logger("frappe_adyen").info(
f"Adyen webhook: PR {payment_request_name!r} marked Cancelled"
)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — Cancelled Status Update Error",
message=f"Could not mark PR {payment_request_name!r} as Cancelled: {exc}",
)
def _handle_payment_refunded(payment_request_name: str, event: dict) -> None:
"""
Record a refund against the Payment Entry linked to the Payment Request.
Does NOT auto-create a credit note that is handled manually or via a
separate reconciliation flow. We annotate the existing Payment Entry
remarks so the refund PSP reference is traceable.
"""
refund_psp = event.get("psp_reference", "")
original_psp = event.get("raw", {}).get("originalReference", "")
frappe.logger("frappe_adyen").info(
f"Adyen webhook: REFUND received for PR {payment_request_name!r}, "
f"refundPSP={refund_psp!r}, originalPSP={original_psp!r}"
)
try:
# Find the Payment Entry linked to this Payment Request
pe_name = frappe.db.get_value(
"Payment Entry",
{"payment_request": payment_request_name},
"name",
)
if not pe_name:
frappe.log_error(
title="Adyen Webhook — Refund: Payment Entry Not Found",
message=(
f"No Payment Entry found for PR {payment_request_name!r}; "
f"refundPSP={refund_psp!r}"
),
)
return
pe = frappe.get_doc("Payment Entry", pe_name)
# Append refund note to existing remarks (non-destructive)
existing_remarks = pe.remarks or ""
refund_note = f" | REFUND PSP: {refund_psp}"
if refund_psp and refund_note not in existing_remarks:
frappe.db.set_value(
"Payment Entry",
pe_name,
"remarks",
existing_remarks + refund_note,
)
frappe.db.commit()
frappe.logger("frappe_adyen").info(
f"Adyen webhook: Refund note appended to PE {pe_name!r} for PR {payment_request_name!r}"
)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — Refund Annotation Error",
message=(
f"Could not annotate refund on PE for PR {payment_request_name!r}: {exc}\n"
f"refundPSP={refund_psp!r}"
),
)
def _handle_payment_disputed(payment_request_name: str, event: dict) -> None:
"""
Create a high-priority ToDo for Administrator when a chargeback is received.
Does NOT automatically cancel or reverse the Payment Entry that requires
human review to determine the appropriate response to the dispute.
"""
dispute_psp = event.get("psp_reference", "")
original_psp = event.get("raw", {}).get("originalReference", "")
amount_value = event.get("amount_value", "")
amount_currency = event.get("amount_currency", "")
frappe.logger("frappe_adyen").info(
f"Adyen webhook: DISPUTE (chargeback) for PR {payment_request_name!r}, "
f"disputePSP={dispute_psp!r}, originalPSP={original_psp!r}"
)
try:
todo = frappe.get_doc(
{
"doctype": "ToDo",
"owner": "Administrator",
"assigned_by": "Administrator",
"priority": "High",
"status": "Open",
"description": (
f"<b>Adyen Chargeback Received</b><br><br>"
f"Payment Request: {payment_request_name}<br>"
f"Dispute PSP Reference: {dispute_psp}<br>"
f"Original PSP Reference: {original_psp}<br>"
f"Amount: {amount_value} {amount_currency}<br><br>"
f"Action required: Review the dispute in the Adyen Customer Area "
f"and respond within the deadline."
),
"reference_type": "Payment Request",
"reference_name": payment_request_name,
"date": frappe.utils.nowdate(),
}
)
todo.flags.ignore_permissions = True
todo.insert()
frappe.db.commit()
frappe.logger("frappe_adyen").info(
f"Adyen webhook: ToDo {todo.name!r} created for dispute on PR {payment_request_name!r}"
)
except Exception as exc:
frappe.log_error(
title="Adyen Webhook — Dispute ToDo Creation Failed",
message=(
f"Could not create ToDo for dispute on PR {payment_request_name!r}: {exc}\n"
f"disputePSP={dispute_psp!r}"
),
)
# ---------------------------------------------------------------------------
# Polling endpoint
# ---------------------------------------------------------------------------
@frappe.whitelist()
def get_payment_status(payment_request_name: str) -> dict:
"""
Poll the status of a Payment Request for the order success page.
First checks the ERPNext Payment Request status. If still in an
intermediate state (Initiated / Requested / Draft), optionally queries
the Adyen session directly to get a real-time answer without waiting
for the webhook to arrive.
Returns
-------
dict
``{ "status": "pending"|"paid"|"failed"|"expired",
"payment_request": payment_request_name }``
"""
_default = {"status": "pending", "payment_request": payment_request_name}
try:
pr = frappe.get_doc("Payment Request", payment_request_name)
except frappe.DoesNotExistError:
return _default
pr_status = pr.status
# --- Map terminal ERPNext statuses directly ------------------------------
if pr_status == "Paid":
return {"status": "paid", "payment_request": payment_request_name}
if pr_status in ("Failed", "Cancelled"):
return {"status": "failed", "payment_request": payment_request_name}
# --- Intermediate status: ask Adyen for the session result --------------
if pr_status in ("Initiated", "Requested", "Draft"):
session_id = pr.get("custom_adyen_session_id")
if not session_id:
return _default
try:
# Resolve Adyen Settings from the Payment Gateway Account
gateway_account = frappe.get_doc(
"Payment Gateway Account",
pr.payment_gateway_account,
)
settings_name = gateway_account.gateway_settings
adyen_settings = frappe.get_doc("Adyen Settings", settings_name)
if not adyen_settings.enabled:
return _default
session_status = adyen_settings.get_session_status(session_id)
# Adyen session resultCode: "Authorised", "Refused", "Cancelled",
# "Error", "Pending", "Received"
result_code = (session_status or {}).get("resultCode", "")
if result_code == "Authorised":
return {"status": "paid", "payment_request": payment_request_name}
elif result_code in ("Refused", "Error"):
return {"status": "failed", "payment_request": payment_request_name}
elif result_code == "Cancelled":
return {"status": "failed", "payment_request": payment_request_name}
else:
# Pending / Received / unknown — still in flight
return _default
except Exception as exc:
frappe.log_error(
title="Adyen get_payment_status — Session Poll Error",
message=(
f"Could not poll Adyen session for PR {payment_request_name!r}: {exc}"
),
)
return _default
# Any other unmapped status
return _default

View file

@ -0,0 +1,71 @@
// Copyright (c) 2024, Performance West Inc. and contributors
// License: MIT. See LICENSE
frappe.ui.form.on("Adyen Settings", {
refresh(frm) {
// --- Test Connection button ---
frm.add_custom_button(__("Test Connection"), function () {
frappe.call({
method:
"frappe_adyen.payment_gateways.doctype.adyen_settings.adyen_settings.validate_adyen_credentials",
args: { gateway_name: frm.doc.gateway_name },
callback(r) {
if (r.exc) {
frappe.msgprint({
title: __("Connection Failed"),
message: r.exc,
indicator: "red",
});
} else {
frappe.msgprint({
title: __("Connection Successful"),
message: __("Adyen credentials are valid."),
indicator: "green",
});
}
},
});
});
// --- Copy Webhook URL button ---
frm.add_custom_button(__("Copy Webhook URL"), function () {
const webhook_url =
window.location.origin + "/api/method/frappe_adyen.api.adyen_webhook";
navigator.clipboard
.writeText(webhook_url)
.then(function () {
frappe.show_alert({ message: __("Copied!"), indicator: "green" }, 3);
})
.catch(function () {
frappe.msgprint({
title: __("Webhook URL"),
message: webhook_url,
indicator: "blue",
});
});
});
// --- live_url_prefix visibility ---
toggle_live_url_prefix(frm);
// --- allowed_payment_methods hint ---
frm.get_field("allowed_payment_methods").set_description(
"<strong>Presets:</strong> " +
"Cards: <code>scheme,applepay,googlepay</code> &nbsp;|&nbsp; " +
"ACH: <code>ach</code> &nbsp;|&nbsp; " +
"Klarna: <code>klarna,klarna_account,klarna_paynow</code> &nbsp;|&nbsp; " +
"Cash App: <code>cashapp</code> &nbsp;|&nbsp; " +
"Amazon Pay: <code>amazonpay</code>"
);
},
environment(frm) {
toggle_live_url_prefix(frm);
},
});
function toggle_live_url_prefix(frm) {
const is_live = frm.doc.environment === "live";
frm.toggle_display("live_url_prefix", is_live);
frm.toggle_reqd("live_url_prefix", is_live);
}

View file

@ -0,0 +1,134 @@
{
"actions": [],
"autoname": "field:gateway_name",
"creation": "2026-03-28 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"gateway_name",
"enabled",
"environment",
"merchant_account",
"api_key",
"client_key",
"hmac_key",
"live_url_prefix",
"allowed_payment_methods",
"blocked_payment_methods",
"capture_delay",
"channel"
],
"fields": [
{
"description": "Instance name shown in Payment Gateway Account (e.g. Card, ACH, Klarna, CashApp, AmazonPay)",
"fieldname": "gateway_name",
"fieldtype": "Data",
"label": "Gateway Name",
"reqd": 1,
"unique": 1
},
{
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"default": "test",
"fieldname": "environment",
"fieldtype": "Select",
"label": "Environment",
"options": "test\nlive",
"reqd": 1
},
{
"description": "Your Adyen merchant account name from Customer Area",
"fieldname": "merchant_account",
"fieldtype": "Data",
"label": "Merchant Account",
"reqd": 1
},
{
"description": "From Customer Area \u2192 Developers \u2192 API credentials",
"fieldname": "api_key",
"fieldtype": "Password",
"label": "API Key",
"reqd": 1
},
{
"description": "Optional \u2014 for Drop-in or Components integration",
"fieldname": "client_key",
"fieldtype": "Data",
"label": "Client Key"
},
{
"description": "From Customer Area \u2192 Developers \u2192 Webhooks \u2192 HMAC key",
"fieldname": "hmac_key",
"fieldtype": "Password",
"label": "Webhook HMAC Key",
"reqd": 1
},
{
"description": "Required for live environment only \u2014 your unique prefix from Adyen (e.g. 1797a841fbb37ca7)",
"fieldname": "live_url_prefix",
"fieldtype": "Data",
"label": "Live URL Prefix"
},
{
"description": "Comma-separated Adyen type codes shown to shopper (e.g. scheme,applepay,googlepay or ach or klarna,klarna_account,klarna_paynow)",
"fieldname": "allowed_payment_methods",
"fieldtype": "Small Text",
"label": "Allowed Payment Methods",
"reqd": 1
},
{
"description": "Optional comma-separated type codes to hide even if otherwise allowed",
"fieldname": "blocked_payment_methods",
"fieldtype": "Small Text",
"label": "Blocked Payment Methods"
},
{
"default": "immediate",
"description": "Use 'manual' for Klarna which requires a separate capture after shipment",
"fieldname": "capture_delay",
"fieldtype": "Select",
"label": "Capture Delay",
"options": "immediate\nmanual"
},
{
"default": "Web",
"fieldname": "channel",
"fieldtype": "Select",
"label": "Channel",
"options": "Web\niOS\nAndroid"
}
],
"index_web_pages_for_search": 0,
"is_submittable": 0,
"issingle": 0,
"links": [],
"modified": "2026-03-28 00:00:00.000000",
"modified_by": "Administrator",
"module": "Payment Gateways",
"name": "Adyen Settings",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -0,0 +1,430 @@
"""
Adyen Settings controller for Frappe Payments.
Implements the payment gateway interface expected by frappe/payments.
"""
import hashlib
import hmac
import base64
import json
import requests
import frappe
from frappe import _
from frappe.model.document import Document
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CHECKOUT_API_VERSION = "v71"
SUPPORTED_CURRENCIES = [
"AED", "AUD", "BHD", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK",
"EUR", "GBP", "HKD", "HUF", "ILS", "INR", "JOD", "JPY", "KWD",
"MXN", "MYR", "NOK", "NZD", "OMR", "PLN", "QAR", "RON", "SAR",
"SEK", "SGD", "THB", "TWD", "USD", "ZAR",
]
# Currencies whose amounts are expressed in major units (no minor units)
ZERO_DECIMAL_CURRENCIES = {"JPY", "KWD", "BHD", "OMR", "QAR", "JOD", "SAR"}
# Minimum charge amounts in the currency's minor unit (e.g. USD cents)
# Values sourced from Adyen's risk rules / Stripe minimum guidance
CURRENCY_WISE_MINIMUM_CHARGE_AMOUNT = {
"USD": 0.50,
"CAD": 0.50,
"EUR": 0.50,
"GBP": 0.30,
"AUD": 0.50,
"CHF": 0.50,
"DKK": 2.50,
"NOK": 3.00,
"SEK": 3.00,
"SGD": 0.50,
"HKD": 4.00,
"MXN": 10.00,
"INR": 0.50,
"BRL": 0.50,
"PLN": 2.00,
}
# ---------------------------------------------------------------------------
# Controller
# ---------------------------------------------------------------------------
class AdyenSettings(Document):
"""Document controller for Adyen Settings."""
# -- Frappe lifecycle hooks ---------------------------------------------
def on_update(self):
"""Register with Frappe payments infrastructure on save."""
from frappe.utils import call_hook_method
from payments.utils import create_payment_gateway
create_payment_gateway(
"Adyen-" + self.gateway_name,
settings="Adyen Settings",
controller=self.gateway_name,
)
call_hook_method("payment_gateway_enabled", gateway="Adyen-" + self.gateway_name)
if not self.flags.ignore_mandatory:
self.validate_adyen_credentials()
# -- Frappe Payment Gateway interface -----------------------------------
def validate_transaction_currency(self, currency: str) -> None:
"""Raise if *currency* is not supported by Adyen."""
if currency not in SUPPORTED_CURRENCIES:
frappe.throw(
_(
"Please select another payment method. "
"Adyen does not support transactions in currency '{0}'"
).format(currency)
)
def validate_minimum_transaction_amount(self, currency: str, amount: float) -> None:
"""Raise if *amount* is below the minimum for *currency*."""
minimum = CURRENCY_WISE_MINIMUM_CHARGE_AMOUNT.get(currency)
if minimum is not None and float(amount) < minimum:
frappe.throw(
_(
"For currency {0}, the minimum transaction amount is {1}"
).format(currency, minimum)
)
def get_payment_url(self, **kwargs):
"""Return the payment URL after creating an Adyen Checkout session."""
amount_cents = kwargs.get("amount")
currency = kwargs.get("currency", "USD")
reference = kwargs.get("order_id") or kwargs.get("reference")
return_url = kwargs.get("redirect_to") or kwargs.get("return_url", "")
shopper_email = kwargs.get("payer_email", "")
shopper_reference = kwargs.get("payer_name", "")
country_code = kwargs.get("country", "US")
session = self.create_session(
amount_cents=amount_cents,
currency=currency,
reference=reference,
return_url=return_url,
shopper_email=shopper_email,
shopper_reference=shopper_reference,
country_code=country_code,
)
return session.get("url", "")
# -- Checkout Sessions --------------------------------------------------
def create_session(
self,
amount_cents: int,
currency: str,
reference: str,
return_url: str,
shopper_email: str = "",
shopper_reference: str = "",
country_code: str = "US",
line_items: list | None = None,
) -> dict:
"""POST /sessions to Adyen Checkout API and return the session dict."""
if not amount_cents:
frappe.throw(_("Payment amount must be greater than zero"))
payload = {
"merchantAccount": self.merchant_account,
"amount": {"value": amount_cents, "currency": currency},
"reference": reference,
"returnUrl": return_url,
"channel": self.channel,
"captureDelayHours": 0 if self.capture_delay == "immediate" else -1,
}
# Only send allowedPaymentMethods when the field is non-empty
allowed = [
m.strip()
for m in (self.allowed_payment_methods or "").split(",")
if m.strip()
]
if allowed:
payload["allowedPaymentMethods"] = allowed
blocked = [
m.strip()
for m in (self.blocked_payment_methods or "").split(",")
if m.strip()
]
if blocked:
payload["blockedPaymentMethods"] = blocked
if shopper_email:
payload["shopperEmail"] = shopper_email
if shopper_reference:
payload["shopperReference"] = shopper_reference
if country_code:
payload["countryCode"] = country_code
if line_items:
payload["lineItems"] = line_items
url = self._api_url("sessions")
response = requests.post(
url,
json=payload,
headers=self._headers(),
timeout=15,
)
response.raise_for_status()
return response.json()
# -- Session status polling ---------------------------------------------
def get_session_status(self, session_id: str) -> dict:
"""
GET /v71/sessions/{session_id}?merchantAccount={merchant_account}
Returns normalised dict:
{status: "pending"|"paid"|"failed"|"expired", raw: {...}}
"""
url = self._api_url(f"sessions/{session_id}")
resp = requests.get(
url,
headers=self._headers(),
params={"merchantAccount": self.merchant_account},
timeout=10,
)
if not resp.ok:
return {"status": "pending", "raw": {}}
data = resp.json()
# Adyen session statuses: active, completed, paymentPending, expired
result_code = data.get("resultCode", "")
status_map = {
"Authorised": "paid",
"Pending": "pending",
"Received": "pending",
"PresentToShopper": "pending",
"Refused": "failed",
"Error": "failed",
"Cancelled": "expired",
"Expired": "expired",
}
return {
"status": status_map.get(result_code, "pending"),
"result_code": result_code,
"psp_reference": data.get("pspReference", ""),
"raw": data,
}
# -- Credential validation ----------------------------------------------
def validate_adyen_credentials(self) -> None:
"""
Validate credentials by calling GET /v71/paymentMethods.
Raises ``frappe.ValidationError`` with a clear message on failure.
The API key is never included in error output.
"""
try:
url = self._api_url("paymentMethods")
resp = requests.get(
url,
headers=self._headers(),
params={"merchantAccount": self.merchant_account},
timeout=10,
)
if resp.status_code == 401:
frappe.throw(_("Invalid Adyen API Key — authentication failed (401)"))
if resp.status_code == 403:
frappe.throw(
_(
"Adyen API Key lacks permission for merchant account '{0}' (403)"
).format(self.merchant_account)
)
# 200 or any other status → credentials accepted / non-auth error
except requests.exceptions.ConnectionError:
frappe.throw(
_(
"Could not connect to Adyen API — "
"check your environment setting and live_url_prefix"
)
)
except requests.exceptions.Timeout:
frappe.throw(_("Adyen API connection timed out"))
# -- URL helpers --------------------------------------------------------
def _api_url(self, endpoint: str) -> str:
"""Build the Adyen Checkout API URL for *endpoint*."""
if self.environment == "live":
prefix = self.live_url_prefix
if not prefix:
frappe.throw(
_(
"live_url_prefix is required when environment is set to 'live'. "
"Find it in your Adyen Customer Area under Account → API URLs."
)
)
base = (
f"https://{prefix}-checkout-live.adyenpayments.com"
f"/checkout/{CHECKOUT_API_VERSION}"
)
else:
base = f"https://checkout-test.adyen.com/{CHECKOUT_API_VERSION}"
return f"{base}/{endpoint}"
def _headers(self) -> dict:
"""Return standard Adyen API request headers (API key never logged)."""
return {
"X-API-Key": self.get_password(fieldname="api_key", raise_exception=False) or "",
"Content-Type": "application/json",
}
# -- Webhook / HMAC verification ----------------------------------------
def verify_hmac(self, notification_item: dict) -> bool:
"""
Return True if the HMAC signature in *notification_item* is valid.
Raises ``frappe.AuthenticationError`` on invalid signature.
Algorithm (Adyen docs):
1. Build the colon-delimited signing string from 8 notification fields.
2. Binary-decode the hex HMAC key.
3. Compute HMAC-SHA256 and base64-encode the raw digest.
4. Compare to ``additionalData.hmacSignature`` (constant-time).
"""
additional_data = notification_item.get("additionalData", {})
received_sig = additional_data.get("hmacSignature", "")
signing_string = self._build_hmac_string(notification_item)
expected_sig = self._compute_hmac(signing_string)
if not hmac.compare_digest(
expected_sig.encode("utf-8"),
received_sig.encode("utf-8"),
):
frappe.throw(
_("Adyen webhook HMAC signature mismatch"),
frappe.AuthenticationError,
)
return True
def _build_hmac_string(self, item: dict) -> str:
"""
Construct the colon-delimited string to sign per Adyen HMAC spec.
Fields (in order):
pspReference : originalReference : merchantAccountCode
: merchantReference : amount.value : amount.currency
: eventCode : success
"""
amount = item.get("amount", {})
fields = [
item.get("pspReference", ""),
item.get("originalReference", ""),
item.get("merchantAccountCode", ""),
item.get("merchantReference", ""),
str(amount.get("value", "")),
amount.get("currency", ""),
item.get("eventCode", ""),
item.get("success", ""),
]
return ":".join(fields)
def _compute_hmac(self, message: str) -> str:
"""
Return base64-encoded HMAC-SHA256 signature for *message*.
Key is stored as a hex string in the ``hmac_key`` field and is
binary-decoded before use. The key value is never logged.
"""
hmac_key_hex = self.get_password(fieldname="hmac_key", raise_exception=False) or ""
binary_key = bytes.fromhex(hmac_key_hex)
digest = hmac.new(
binary_key,
message.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.b64encode(digest).decode("utf-8")
# -- Webhook event routing ----------------------------------------------
def handle_webhook(self, notification_request: dict) -> list[dict]:
"""
Process an Adyen webhook ``notificationItems`` payload.
Returns a list of normalised event dicts (unknown eventCodes are
returned as None entries in the list).
"""
items = notification_request.get("notificationItems", [])
results = []
for wrapper in items:
item = wrapper.get("NotificationRequestItem", {})
# Verify HMAC before processing — log pspReference only (not keys)
try:
self.verify_hmac(item)
except frappe.AuthenticationError:
frappe.log_error(
title="Adyen HMAC Failure",
message=f"pspReference={item.get('pspReference')}",
)
continue
normalised = self._normalise_event(item)
results.append(normalised)
return results
def _normalise_event(self, item: dict) -> dict | None:
"""Map an Adyen notification item to a canonical event dict."""
event_code = item.get("eventCode", "")
success = item.get("success", "false").lower() == "true"
psp = item.get("pspReference", "")
reference = item.get("merchantReference", "")
amount = item.get("amount", {})
mapping = {
"AUTHORISATION": "payment.succeeded" if success else "payment.failed",
"CANCELLATION": "payment.cancelled",
"REFUND": "payment.refunded",
"CHARGEBACK": "payment.disputed",
"CHARGEBACK_REVERSED": "payment.dispute_won",
"CAPTURE": "payment.captured",
"CAPTURE_FAILED": "payment.capture_failed",
}
event_type = mapping.get(event_code)
if event_type is None:
return None
return {
"event_type": event_type,
"psp_reference": psp,
"merchant_reference": reference,
"amount_value": amount.get("value"),
"amount_currency": amount.get("currency"),
"success": success,
"raw": item,
}
# ---------------------------------------------------------------------------
# Whitelisted module-level helpers (called from JS form buttons)
# ---------------------------------------------------------------------------
@frappe.whitelist()
def validate_adyen_credentials(gateway_name: str) -> dict:
"""
Called from the "Test Connection" button on the Adyen Settings form.
Returns ``{"valid": True}`` on success or ``{"valid": False, "error": "..."}``
on failure. The API key is never included in the error message.
"""
try:
doc = frappe.get_doc("Adyen Settings", gateway_name)
doc.validate_adyen_credentials()
return {"valid": True}
except Exception as e:
return {"valid": False, "error": str(e)}

View file

@ -0,0 +1,718 @@
"""
Unit tests for AdyenSettings controller methods.
All external calls (frappe DB, requests, frappe.throw) are mocked.
No real Adyen API hits. No live Frappe instance required.
"""
import base64
import hashlib
import hmac
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Frappe stub — must be installed before importing the controller
# ---------------------------------------------------------------------------
# Build a minimal frappe module so the controller can be imported standalone
_frappe_stub = types.ModuleType("frappe")
_frappe_stub._ = lambda s: s # translation passthrough
class _AuthError(Exception):
pass
class _ValidationError(Exception):
pass
_frappe_stub.AuthenticationError = _AuthError
_frappe_stub.ValidationError = _ValidationError
def _whitelist(*args, **kwargs):
def _decorator(fn):
return fn
return _decorator
_frappe_stub.whitelist = _whitelist
def _frappe_throw(msg, exc=None):
if exc is None:
exc = _ValidationError
raise exc(msg)
_frappe_stub.throw = _frappe_throw
_frappe_stub.log_error = lambda **kwargs: None
# Stub frappe.model.document.Document
_model_mod = types.ModuleType("frappe.model")
_doc_mod = types.ModuleType("frappe.model.document")
class _Document:
pass
_doc_mod.Document = _Document
_model_mod.document = _doc_mod
_frappe_stub.model = _model_mod
sys.modules["frappe"] = _frappe_stub
sys.modules["frappe.model"] = _model_mod
sys.modules["frappe.model.document"] = _doc_mod
# Now import the real controller
from frappe_adyen.gateways.doctype.adyen_settings.adyen_settings import ( # noqa: E402
CURRENCY_WISE_MINIMUM_CHARGE_AMOUNT,
SUPPORTED_CURRENCIES,
AdyenSettings,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# 64-char (32-byte) hex key — valid even-length hex for bytes.fromhex()
# (The Adyen docs example key is padded here to a proper 32-byte value)
HMAC_KEY_HEX = "44782DEF547AAA06C910C43932B1EB0C71FC68D9D0C057550C48EC2552FF0011"
def _compute_expected_hmac(signing_string: str, key_hex: str = HMAC_KEY_HEX) -> str:
"""Reference HMAC computation used by tests to build expected values."""
binary_key = bytes.fromhex(key_hex)
digest = hmac.new(
binary_key,
signing_string.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.b64encode(digest).decode("utf-8")
def _make_settings(**kwargs) -> AdyenSettings:
"""Factory that returns an AdyenSettings instance with sensible defaults."""
doc = AdyenSettings.__new__(AdyenSettings)
doc.gateway_name = kwargs.get("gateway_name", "Card")
doc.environment = kwargs.get("environment", "test")
doc.merchant_account = kwargs.get("merchant_account", "TestMerchant")
doc.live_url_prefix = kwargs.get("live_url_prefix", "")
doc.allowed_payment_methods = kwargs.get(
"allowed_payment_methods", "scheme,applepay,googlepay"
)
doc.blocked_payment_methods = kwargs.get("blocked_payment_methods", "")
doc.capture_delay = kwargs.get("capture_delay", "immediate")
doc.channel = kwargs.get("channel", "Web")
doc._api_key = kwargs.get("api_key", "test-api-key")
doc._hmac_key_hex = kwargs.get("hmac_key_hex", HMAC_KEY_HEX)
def _get_password(fieldname, raise_exception=True):
if fieldname == "api_key":
return doc._api_key
if fieldname == "hmac_key":
return doc._hmac_key_hex
return ""
doc.get_password = _get_password
return doc
def _build_notification_item(psp="8435601107183227", original_ref="12345",
merchant_account="TestMerchant",
merchant_ref="TestPayment-1407325143704",
amount_value=1199, currency="EUR",
event_code="AUTHORISATION", success="true",
hmac_signature=None):
"""Build a NotificationRequestItem dict, computing HMAC if not provided."""
item = {
"pspReference": psp,
"originalReference": original_ref,
"merchantAccountCode": merchant_account,
"merchantReference": merchant_ref,
"amount": {"value": amount_value, "currency": currency},
"eventCode": event_code,
"success": success,
"additionalData": {},
}
if hmac_signature is None:
signing = ":".join([
psp, original_ref, merchant_account, merchant_ref,
str(amount_value), currency, event_code, success,
])
hmac_signature = _compute_expected_hmac(signing)
item["additionalData"]["hmacSignature"] = hmac_signature
return item
# ===========================================================================
# 1. HMAC Signature Verification
# ===========================================================================
class TestAdyenHMAC(unittest.TestCase):
"""Tests for AdyenSettings.verify_hmac and helper methods."""
def setUp(self):
self.settings = _make_settings()
# --- _build_hmac_string ------------------------------------------------
def test_build_hmac_string_uses_adyen_test_vector(self):
"""Signing string matches the Adyen documentation test vector exactly."""
item = _build_notification_item()
result = self.settings._build_hmac_string(item)
expected = (
"8435601107183227:12345:TestMerchant:"
"TestPayment-1407325143704:1199:EUR:AUTHORISATION:true"
)
self.assertEqual(result, expected)
def test_build_hmac_string_empty_fields_produce_colons(self):
"""Missing optional fields produce empty string segments (not omitted)."""
item = {
"pspReference": "PSP1",
"merchantAccountCode": "ACCT",
"merchantReference": "REF1",
"amount": {"value": 100, "currency": "USD"},
"eventCode": "CAPTURE",
"success": "true",
# originalReference intentionally absent
}
result = self.settings._build_hmac_string(item)
# second segment should be empty string
parts = result.split(":")
self.assertEqual(len(parts), 8)
self.assertEqual(parts[1], "") # originalReference missing → ""
# --- _compute_hmac -----------------------------------------------------
def test_compute_hmac_matches_reference_implementation(self):
"""_compute_hmac output matches our independent reference calculation."""
message = (
"8435601107183227:12345:TestMerchant:"
"TestPayment-1407325143704:1199:EUR:AUTHORISATION:true"
)
expected = _compute_expected_hmac(message)
result = self.settings._compute_hmac(message)
self.assertEqual(result, expected)
def test_compute_hmac_is_base64_encoded(self):
"""Result is valid base64."""
result = self.settings._compute_hmac("test:message")
# Should not raise
decoded = base64.b64decode(result)
self.assertEqual(len(decoded), 32) # SHA-256 digest = 32 bytes
# --- verify_hmac -------------------------------------------------------
def test_verify_hmac_valid_signature_returns_true(self):
"""A correctly signed item passes verification."""
item = _build_notification_item()
self.assertTrue(self.settings.verify_hmac(item))
def test_verify_hmac_tampered_psp_reference_fails(self):
"""Changing pspReference after signing causes verification failure."""
item = _build_notification_item()
item["pspReference"] = "TAMPERED"
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_tampered_amount_fails(self):
"""Changing amount.value after signing causes verification failure."""
item = _build_notification_item(amount_value=1199)
item["amount"]["value"] = 9999
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_tampered_currency_fails(self):
"""Changing amount.currency after signing causes verification failure."""
item = _build_notification_item()
item["amount"]["currency"] = "USD"
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_wrong_key_fails(self):
"""Signature computed with a different key fails verification."""
wrong_key = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"
item = _build_notification_item()
# Recompute with wrong key to produce a different-but-valid-looking sig
msg = self.settings._build_hmac_string(item)
item["additionalData"]["hmacSignature"] = _compute_expected_hmac(msg, wrong_key)
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_empty_signature_fails(self):
"""An empty hmacSignature always fails."""
item = _build_notification_item()
item["additionalData"]["hmacSignature"] = ""
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_missing_additional_data_fails(self):
"""Missing additionalData block fails (no signature to compare)."""
item = _build_notification_item()
del item["additionalData"]
with self.assertRaises(_AuthError):
self.settings.verify_hmac(item)
def test_verify_hmac_success_false_has_different_signature(self):
"""success=false produces a different signing string than success=true."""
item_true = _build_notification_item(success="true")
item_false = _build_notification_item(success="false")
sig_true = item_true["additionalData"]["hmacSignature"]
sig_false = item_false["additionalData"]["hmacSignature"]
self.assertNotEqual(sig_true, sig_false)
def test_verify_hmac_cancellation_event(self):
"""CANCELLATION events with correct HMAC pass."""
item = _build_notification_item(event_code="CANCELLATION", success="true")
self.assertTrue(self.settings.verify_hmac(item))
def test_verify_hmac_refund_event(self):
"""REFUND events with correct HMAC pass."""
item = _build_notification_item(event_code="REFUND", success="true")
self.assertTrue(self.settings.verify_hmac(item))
# ===========================================================================
# 2. create_session — payload construction
# ===========================================================================
class TestAdyenSession(unittest.TestCase):
"""Tests for AdyenSettings.create_session."""
def setUp(self):
self.settings = _make_settings()
def _fake_response(self, body: dict, status: int = 200):
mock_resp = MagicMock()
mock_resp.status_code = status
mock_resp.json.return_value = body
mock_resp.raise_for_status = MagicMock()
return mock_resp
@patch("requests.post")
def test_merchant_account_in_payload(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.create_session(1000, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["merchantAccount"], "TestMerchant")
@patch("requests.post")
def test_amount_value_and_currency(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.create_session(4999, "CAD", "ORD-42", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["amount"]["value"], 4999)
self.assertEqual(payload["amount"]["currency"], "CAD")
@patch("requests.post")
def test_reference_in_payload(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.create_session(100, "USD", "MY-REF-001", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["reference"], "MY-REF-001")
@patch("requests.post")
def test_return_url_in_payload(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
return_url = "https://mysite.com/payment/return"
self.settings.create_session(100, "USD", "REF1", return_url)
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["returnUrl"], return_url)
@patch("requests.post")
def test_allowed_payment_methods_parsed_from_csv(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.allowed_payment_methods = "scheme, applepay, googlepay"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertIsInstance(payload["allowedPaymentMethods"], list)
self.assertEqual(sorted(payload["allowedPaymentMethods"]), ["applepay", "googlepay", "scheme"])
@patch("requests.post")
def test_single_allowed_payment_method(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.allowed_payment_methods = "ach"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["allowedPaymentMethods"], ["ach"])
@patch("requests.post")
def test_blocked_payment_methods_included_when_set(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.blocked_payment_methods = "sepadirectdebit,ideal"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertIn("blockedPaymentMethods", payload)
self.assertEqual(sorted(payload["blockedPaymentMethods"]), ["ideal", "sepadirectdebit"])
@patch("requests.post")
def test_blocked_payment_methods_omitted_when_empty(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.blocked_payment_methods = ""
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertNotIn("blockedPaymentMethods", payload)
@patch("requests.post")
def test_blocked_payment_methods_omitted_when_none(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.blocked_payment_methods = None
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertNotIn("blockedPaymentMethods", payload)
@patch("requests.post")
def test_test_environment_url(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.environment = "test"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
url = mock_post.call_args.args[0]
self.assertEqual(url, "https://checkout-test.adyen.com/v71/sessions")
@patch("requests.post")
def test_live_environment_url_uses_prefix(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.environment = "live"
self.settings.live_url_prefix = "1797a841fbb37ca7"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
url = mock_post.call_args.args[0]
self.assertEqual(
url,
"https://1797a841fbb37ca7-checkout-live.adyenpayments.com/checkout/v71/sessions",
)
@patch("requests.post")
def test_api_key_sent_in_header(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings._api_key = "AQEyhmfuXNWTK0Qc+iSah7pMkgzpMqAAM2MkBTy4m="
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
headers = mock_post.call_args.kwargs["headers"]
self.assertEqual(headers["X-API-Key"], "AQEyhmfuXNWTK0Qc+iSah7pMkgzpMqAAM2MkBTy4m=")
@patch("requests.post")
def test_returns_session_dict(self, mock_post):
fake_body = {
"id": "CS-XYZ",
"sessionData": "encoded_session_data",
"url": "https://checkoutshopper-test.adyen.com/checkoutshopper/...",
}
mock_post.return_value = self._fake_response(fake_body)
result = self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
self.assertEqual(result["id"], "CS-XYZ")
self.assertEqual(result["sessionData"], "encoded_session_data")
@patch("requests.post")
def test_immediate_capture_delay_sends_zero_hours(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.capture_delay = "immediate"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["captureDelayHours"], 0)
@patch("requests.post")
def test_manual_capture_delay_sends_negative_one(self, mock_post):
mock_post.return_value = self._fake_response({"id": "CS1", "sessionData": "x", "url": "https://example.com"})
self.settings.capture_delay = "manual"
self.settings.create_session(100, "USD", "REF1", "https://return.example.com")
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["captureDelayHours"], -1)
# ===========================================================================
# 3 & 4. validate_transaction_currency / validate_minimum_transaction_amount
# ===========================================================================
class TestAdyenConfig(unittest.TestCase):
"""Tests for currency validation and minimum amount checks."""
def setUp(self):
self.settings = _make_settings()
# --- validate_transaction_currency -------------------------------------
def test_usd_is_supported(self):
self.settings.validate_transaction_currency("USD") # must not raise
def test_cad_is_supported(self):
self.settings.validate_transaction_currency("CAD")
def test_eur_is_supported(self):
self.settings.validate_transaction_currency("EUR")
def test_gbp_is_supported(self):
self.settings.validate_transaction_currency("GBP")
def test_jpy_is_supported(self):
# JPY is in SUPPORTED_CURRENCIES (zero-decimal, but still supported)
self.assertIn("JPY", SUPPORTED_CURRENCIES)
self.settings.validate_transaction_currency("JPY")
def test_unsupported_currency_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_transaction_currency("XYZ")
def test_unsupported_currency_btc_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_transaction_currency("BTC")
def test_unsupported_currency_empty_string_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_transaction_currency("")
def test_unsupported_currency_lowercase_raises(self):
# Currency codes are case-sensitive
with self.assertRaises(_ValidationError):
self.settings.validate_transaction_currency("usd")
# --- validate_minimum_transaction_amount -------------------------------
def test_usd_fifty_cents_passes(self):
self.settings.validate_minimum_transaction_amount("USD", 0.50)
def test_usd_one_dollar_passes(self):
self.settings.validate_minimum_transaction_amount("USD", 1.00)
def test_usd_forty_nine_cents_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_minimum_transaction_amount("USD", 0.49)
def test_usd_zero_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_minimum_transaction_amount("USD", 0.00)
def test_gbp_thirty_pence_passes(self):
self.settings.validate_minimum_transaction_amount("GBP", 0.30)
def test_gbp_twenty_nine_pence_raises(self):
with self.assertRaises(_ValidationError):
self.settings.validate_minimum_transaction_amount("GBP", 0.29)
def test_cad_fifty_cents_passes(self):
self.settings.validate_minimum_transaction_amount("CAD", 0.50)
def test_eur_fifty_cents_passes(self):
self.settings.validate_minimum_transaction_amount("EUR", 0.50)
def test_currency_without_minimum_always_passes(self):
# JPY has no minimum defined — any amount should pass
self.assertNotIn("JPY", CURRENCY_WISE_MINIMUM_CHARGE_AMOUNT)
self.settings.validate_minimum_transaction_amount("JPY", 0) # must not raise
def test_dkk_minimum(self):
# DKK minimum is 2.50
with self.assertRaises(_ValidationError):
self.settings.validate_minimum_transaction_amount("DKK", 2.49)
self.settings.validate_minimum_transaction_amount("DKK", 2.50)
# ===========================================================================
# 5. _api_url routing
# ===========================================================================
class TestAdyenApiUrl(unittest.TestCase):
"""Tests for the _api_url helper."""
def test_test_environment_sessions_url(self):
settings = _make_settings(environment="test")
url = settings._api_url("sessions")
self.assertEqual(url, "https://checkout-test.adyen.com/v71/sessions")
def test_test_environment_payments_url(self):
settings = _make_settings(environment="test")
url = settings._api_url("payments")
self.assertEqual(url, "https://checkout-test.adyen.com/v71/payments")
def test_live_environment_sessions_url(self):
settings = _make_settings(environment="live", live_url_prefix="abc123")
url = settings._api_url("sessions")
self.assertEqual(
url,
"https://abc123-checkout-live.adyenpayments.com/checkout/v71/sessions",
)
def test_live_environment_payments_url(self):
settings = _make_settings(environment="live", live_url_prefix="abc123")
url = settings._api_url("payments")
self.assertEqual(
url,
"https://abc123-checkout-live.adyenpayments.com/checkout/v71/payments",
)
def test_live_url_uses_correct_prefix_format(self):
"""Live URL pattern: {prefix}-checkout-live.adyenpayments.com"""
settings = _make_settings(environment="live", live_url_prefix="1797a841fbb37ca7")
url = settings._api_url("sessions")
self.assertIn("1797a841fbb37ca7-checkout-live.adyenpayments.com", url)
self.assertNotIn("checkout-test.adyen.com", url)
def test_test_url_does_not_contain_live_domain(self):
settings = _make_settings(environment="test")
url = settings._api_url("sessions")
self.assertNotIn("adyenpayments.com", url)
self.assertIn("adyen.com", url)
# ===========================================================================
# 6. handle_webhook event routing
# ===========================================================================
class TestAdyenWebhook(unittest.TestCase):
"""Tests for handle_webhook and _normalise_event routing."""
def setUp(self):
self.settings = _make_settings()
def _make_notification_request(self, *items):
"""Wrap one or more NotificationRequestItems in the Adyen envelope."""
return {
"notificationItems": [
{"NotificationRequestItem": item} for item in items
]
}
# --- _normalise_event --------------------------------------------------
def test_authorisation_success_maps_to_payment_succeeded(self):
item = _build_notification_item(event_code="AUTHORISATION", success="true")
result = self.settings._normalise_event(item)
self.assertIsNotNone(result)
self.assertEqual(result["event_type"], "payment.succeeded")
def test_authorisation_failure_maps_to_payment_failed(self):
item = _build_notification_item(event_code="AUTHORISATION", success="false")
result = self.settings._normalise_event(item)
self.assertIsNotNone(result)
self.assertEqual(result["event_type"], "payment.failed")
def test_cancellation_maps_to_payment_cancelled(self):
item = _build_notification_item(event_code="CANCELLATION")
result = self.settings._normalise_event(item)
self.assertIsNotNone(result)
self.assertEqual(result["event_type"], "payment.cancelled")
def test_refund_maps_to_payment_refunded(self):
item = _build_notification_item(event_code="REFUND")
result = self.settings._normalise_event(item)
self.assertIsNotNone(result)
self.assertEqual(result["event_type"], "payment.refunded")
def test_chargeback_maps_to_payment_disputed(self):
item = _build_notification_item(event_code="CHARGEBACK")
result = self.settings._normalise_event(item)
self.assertIsNotNone(result)
self.assertEqual(result["event_type"], "payment.disputed")
def test_unknown_event_code_returns_none(self):
item = _build_notification_item(event_code="UNKNOWN_EVENT")
result = self.settings._normalise_event(item)
self.assertIsNone(result)
def test_report_available_returns_none(self):
item = _build_notification_item(event_code="REPORT_AVAILABLE")
result = self.settings._normalise_event(item)
self.assertIsNone(result)
def test_normalised_event_contains_psp_reference(self):
item = _build_notification_item(event_code="AUTHORISATION", psp="PSP-999")
result = self.settings._normalise_event(item)
self.assertEqual(result["psp_reference"], "PSP-999")
def test_normalised_event_contains_merchant_reference(self):
item = _build_notification_item(event_code="AUTHORISATION",
merchant_ref="ORDER-12345")
result = self.settings._normalise_event(item)
self.assertEqual(result["merchant_reference"], "ORDER-12345")
def test_normalised_event_contains_amount_info(self):
item = _build_notification_item(event_code="AUTHORISATION",
amount_value=4999, currency="CAD")
result = self.settings._normalise_event(item)
self.assertEqual(result["amount_value"], 4999)
self.assertEqual(result["amount_currency"], "CAD")
def test_normalised_event_success_flag_true(self):
item = _build_notification_item(event_code="AUTHORISATION", success="true")
result = self.settings._normalise_event(item)
self.assertTrue(result["success"])
def test_normalised_event_success_flag_false(self):
item = _build_notification_item(event_code="AUTHORISATION", success="false")
result = self.settings._normalise_event(item)
self.assertFalse(result["success"])
def test_normalised_event_contains_raw_item(self):
item = _build_notification_item(event_code="AUTHORISATION")
result = self.settings._normalise_event(item)
self.assertIn("raw", result)
self.assertIs(result["raw"], item)
# --- handle_webhook (full pipeline with HMAC) --------------------------
def test_handle_webhook_valid_item_processed(self):
item = _build_notification_item(event_code="AUTHORISATION", success="true")
request = self._make_notification_request(item)
results = self.settings.handle_webhook(request)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["event_type"], "payment.succeeded")
def test_handle_webhook_invalid_hmac_item_skipped(self):
item = _build_notification_item(event_code="AUTHORISATION",
hmac_signature="invalid-bad-signature")
request = self._make_notification_request(item)
results = self.settings.handle_webhook(request)
# Invalid HMAC items are logged and skipped, not raised
self.assertEqual(len(results), 0)
def test_handle_webhook_multiple_items(self):
item1 = _build_notification_item(event_code="AUTHORISATION", success="true",
psp="PSP-001")
item2 = _build_notification_item(event_code="REFUND", psp="PSP-002")
request = self._make_notification_request(item1, item2)
results = self.settings.handle_webhook(request)
self.assertEqual(len(results), 2)
event_types = {r["event_type"] for r in results}
self.assertIn("payment.succeeded", event_types)
self.assertIn("payment.refunded", event_types)
def test_handle_webhook_empty_items(self):
results = self.settings.handle_webhook({"notificationItems": []})
self.assertEqual(results, [])
def test_handle_webhook_missing_notification_items_key(self):
results = self.settings.handle_webhook({})
self.assertEqual(results, [])
def test_handle_webhook_unknown_event_included_as_none(self):
item = _build_notification_item(event_code="UNKNOWN_EVENT")
request = self._make_notification_request(item)
results = self.settings.handle_webhook(request)
# The item passes HMAC but normalises to None
self.assertEqual(len(results), 1)
self.assertIsNone(results[0])
def test_handle_webhook_mixed_valid_and_invalid_hmac(self):
"""Valid-HMAC items are processed; invalid-HMAC items are silently dropped."""
good_item = _build_notification_item(event_code="AUTHORISATION", psp="GOOD")
bad_item = _build_notification_item(event_code="AUTHORISATION", psp="BAD",
hmac_signature="tampered")
request = self._make_notification_request(good_item, bad_item)
results = self.settings.handle_webhook(request)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["psp_reference"], "GOOD")
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -0,0 +1,17 @@
from . import __version__ as app_version
app_name = "frappe_adyen"
app_title = "Adyen Payment Gateway"
app_publisher = "Performance West Inc."
app_description = "Adyen payment gateway integration for Frappe/ERPNext — supports Cards, ACH, Klarna, Cash App Pay, and Amazon Pay via the Adyen Sessions API v71."
app_email = "support@performancewest.net"
app_license = "MIT"
# Frappe app install hooks
before_install = "frappe_adyen.install.before_install"
after_install = "frappe_adyen.install.after_install"
# Exempt Adyen webhook from CSRF — Adyen uses HMAC-SHA256 signature verification instead
csrf_ignore_methods = [
"frappe_adyen.api.adyen_webhook",
]

View file

@ -0,0 +1,20 @@
import frappe
def before_install():
"""Verify the frappe/payments app is installed — frappe_adyen depends on it."""
installed = frappe.get_installed_apps()
if "payments" not in installed:
frappe.throw(
"The 'payments' app must be installed before frappe_adyen. "
"Run: bench get-app payments && bench --site <site> install-app payments"
)
def after_install():
frappe.msgprint(
"Adyen Payment Gateway installed successfully. "
"Go to Adyen Settings to configure your API credentials.",
title="frappe_adyen installed",
indicator="green",
)

View file

View file

@ -0,0 +1,39 @@
{% extends "templates/web.html" %}
{% block title %}{{ _("Redirecting to Payment...") }}{% endblock %}
{% block page_content %}
<div class="container" style="text-align:center; padding: 4rem 1rem;">
{% if error %}
<div class="alert alert-danger" role="alert">
<h4>{{ _("Payment Error") }}</h4>
<p>{{ error }}</p>
<a href="/" class="btn btn-secondary mt-2">{{ _("Return Home") }}</a>
</div>
{% else %}
<div id="redirect-notice">
<h3>{{ _("Redirecting to secure checkout&hellip;") }}</h3>
<p class="text-muted">{{ _("Please wait while we connect to the payment processor.") }}</p>
<div class="spinner-border text-primary mt-3" role="status" aria-label="Loading">
<span class="visually-hidden">{{ _("Loading&hellip;") }}</span>
</div>
</div>
<script>
(function () {
var url = {{ checkout_url | tojson }};
if (url) {
window.location.href = url;
} else {
document.getElementById("redirect-notice").innerHTML =
'<div class="alert alert-danger">' +
'<p>{{ _("Could not initialize payment. Please") }} <a href="mailto:support@performancewest.net">{{ _("contact support") }}</a>.</p>' +
'<a href="/" class="btn btn-secondary mt-2">{{ _("Return Home") }}</a>' +
'</div>';
}
})();
</script>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,125 @@
# Copyright (c) 2024, Performance West Inc. and contributors
# License: MIT. See LICENSE
"""
Adyen Checkout Redirect Page
URL: /adyen_checkout?<payment_request_params>
Flow:
1. Parse query params from Payment Request redirect (payment_request name, amount,
currency, reference_doctype, reference_name, payer_email, return_url, cancel_url)
2. Look up the Payment Request in ERPNext
3. Look up the Adyen Settings instance via Payment Gateway Account
4. Call adyen_settings.create_session() to POST to Adyen Sessions API v71
5. Store Adyen session ID on the Payment Request (custom_adyen_session_id)
6. Set context.checkout_url = session["url"]
7. Template auto-redirects to Adyen Hosted Checkout
Error handling:
- If Payment Request already Paid/Cancelled: show message, no redirect
- If Adyen Settings disabled: raise ValueError
- All exceptions: log to Error Log, set context.error with user-friendly message
"""
import frappe
from frappe import _
from frappe.utils import get_url
def get_context(context):
context.no_cache = 1
context.checkout_url = ""
context.error = None
form_dict = frappe.form_dict
payment_request_name = (
form_dict.get("payment_request")
or form_dict.get("reference_name")
)
reference_doctype = form_dict.get("reference_doctype", "Sales Invoice")
reference_name = form_dict.get("reference_name", "")
payer_email = form_dict.get("payer_email", "")
payer_name = form_dict.get("payer_name", "")
amount = form_dict.get("amount", "0")
currency = form_dict.get("currency", "USD")
order_type = form_dict.get("order_type", "")
try:
if not payment_request_name:
raise ValueError("payment_request parameter is required")
payment_request = frappe.get_doc("Payment Request", payment_request_name)
if payment_request.status in ("Paid", "Cancelled"):
context.error = _("This payment request has already been processed.")
return
# Get Adyen Settings instance from Payment Gateway Account
gateway_account = frappe.get_doc(
"Payment Gateway Account",
payment_request.payment_gateway_account,
)
settings_name = gateway_account.gateway_settings # e.g. "Card", "ACH", "Klarna"
adyen_settings = frappe.get_doc("Adyen Settings", settings_name)
if not adyen_settings.enabled:
raise ValueError(f"Adyen Settings '{settings_name}' is disabled")
# Build return URL pointing back to our Astro success/cancel pages
# Use site host_name from site_config for the Astro site domain
site_url = frappe.utils.get_url()
# Try to get the Astro site domain from System Settings website_baseurl
try:
pw_domain = frappe.db.get_single_value("System Settings", "website_baseurl") or site_url
except Exception:
pw_domain = site_url
return_url = (
f"{pw_domain}/order/success"
f"?session_id={payment_request_name}"
f"&order_id={frappe.utils.escape_html(reference_name)}"
f"&order_type={frappe.utils.escape_html(order_type)}"
)
# Amount in minor units (cents)
amount_cents = int(round(float(amount) * 100))
# shopper_reference — stable customer identifier for tokenization
shopper_reference = (
frappe.db.get_value("Payment Request", payment_request_name, "party")
or payer_email
or reference_name
)
# Create Adyen session
session = adyen_settings.create_session(
amount_cents=amount_cents,
currency=currency.upper(),
reference=payment_request_name,
return_url=return_url,
shopper_email=payer_email,
shopper_reference=shopper_reference,
country_code="US",
)
# Store session ID on Payment Request for status polling
frappe.db.set_value(
"Payment Request",
payment_request_name,
{
"custom_adyen_session_id": session.get("id", ""),
"status": "Initiated",
},
)
frappe.db.commit()
context.checkout_url = session.get("url", "")
except Exception as e:
frappe.log_error(
f"[adyen_checkout] Error for payment_request={payment_request_name}: {e}",
"Adyen Checkout Error",
)
context.error = _("Could not initialize payment. Please contact support.")
context.checkout_url = ""

21
frappe_adyen/license.txt Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright 2026 Performance West Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,19 @@
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "frappe_adyen"
version = "1.0.0"
description = "Adyen payment gateway integration for Frappe/ERPNext"
license = { text = "MIT" }
authors = [{ name = "Performance West Inc.", email = "support@performancewest.net" }]
requires-python = ">=3.10"
dependencies = [
"frappe>=15.0.0,<16",
"payments",
"requests",
]
[project.urls]
Repository = "https://github.com/performancewest/frappe_adyen"

13
frappe_adyen/setup.py Normal file
View file

@ -0,0 +1,13 @@
from setuptools import setup, find_packages
setup(
name="frappe_adyen",
version="1.0.0",
description="Adyen payment gateway integration for Frappe/ERPNext",
author="Performance West Inc.",
author_email="support@performancewest.net",
packages=find_packages(),
zip_safe=False,
include_package_data=True,
install_requires=["frappe", "payments", "requests"],
)