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:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
98
frappe_adyen/README.md
Normal file
98
frappe_adyen/README.md
Normal 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.
|
||||
1
frappe_adyen/frappe_adyen/__init__.py
Normal file
1
frappe_adyen/frappe_adyen/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "1.0.0"
|
||||
480
frappe_adyen/frappe_adyen/api.py
Normal file
480
frappe_adyen/frappe_adyen/api.py
Normal 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
|
||||
0
frappe_adyen/frappe_adyen/gateways/__init__.py
Normal file
0
frappe_adyen/frappe_adyen/gateways/__init__.py
Normal file
0
frappe_adyen/frappe_adyen/gateways/doctype/__init__.py
Normal file
0
frappe_adyen/frappe_adyen/gateways/doctype/__init__.py
Normal 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> | " +
|
||||
"ACH: <code>ach</code> | " +
|
||||
"Klarna: <code>klarna,klarna_account,klarna_paynow</code> | " +
|
||||
"Cash App: <code>cashapp</code> | " +
|
||||
"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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)
|
||||
17
frappe_adyen/frappe_adyen/hooks.py
Normal file
17
frappe_adyen/frappe_adyen/hooks.py
Normal 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",
|
||||
]
|
||||
20
frappe_adyen/frappe_adyen/install.py
Normal file
20
frappe_adyen/frappe_adyen/install.py
Normal 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",
|
||||
)
|
||||
0
frappe_adyen/frappe_adyen/modules.txt
Normal file
0
frappe_adyen/frappe_adyen/modules.txt
Normal file
39
frappe_adyen/frappe_adyen/www/adyen_checkout.html
Normal file
39
frappe_adyen/frappe_adyen/www/adyen_checkout.html
Normal 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…") }}</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…") }}</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 %}
|
||||
125
frappe_adyen/frappe_adyen/www/adyen_checkout.py
Normal file
125
frappe_adyen/frappe_adyen/www/adyen_checkout.py
Normal 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
21
frappe_adyen/license.txt
Normal 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.
|
||||
19
frappe_adyen/pyproject.toml
Normal file
19
frappe_adyen/pyproject.toml
Normal 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
13
frappe_adyen/setup.py
Normal 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"],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue