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
42
frappe_crypto/README.md
Normal file
42
frappe_crypto/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Crypto Payment Gateway (frappe_crypto)
|
||||
|
||||
SHKeeper-based cryptocurrency payment gateway for ERPNext.
|
||||
|
||||
Accepts BTC, ETH, USDC, USDT, MATIC, TRX, BNB, LTC, DOGE, and any coin supported by your SHKeeper instance.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Frappe >= 15.0.0
|
||||
- [payments](https://github.com/frappe/payments) app installed
|
||||
- A running [SHKeeper](https://shkeeper.io/) instance
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bench get-app https://github.com/performancewest/frappe_crypto.git
|
||||
bench --site your-site install-app frappe_crypto
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Go to **Crypto Payment Settings** in the sidebar
|
||||
2. Set **Gateway Name** (e.g. "Crypto")
|
||||
3. Enter your **SHKeeper URL** (e.g. `https://pay.performancewest.net`)
|
||||
4. Enter your **API Key** from SHKeeper
|
||||
5. Optionally set a **Default Crypto** (e.g. "BTC")
|
||||
6. Click **Test Connection** to verify
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Customer selects crypto payment at checkout
|
||||
2. ERPNext creates a Payment Request and redirects to `/crypto_checkout`
|
||||
3. The checkout page calls SHKeeper to create a crypto invoice
|
||||
4. Customer sees the wallet address, QR code, and amount in crypto
|
||||
5. Page auto-polls for payment confirmation every 5 seconds
|
||||
6. SHKeeper sends a webhook when payment is received
|
||||
7. Webhook handler creates a Payment Entry and marks the Payment Request as Paid
|
||||
8. Customer sees a success message and is redirected
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1
frappe_crypto/frappe_crypto/__init__.py
Normal file
1
frappe_crypto/frappe_crypto/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "1.0.0"
|
||||
207
frappe_crypto/frappe_crypto/api.py
Normal file
207
frappe_crypto/frappe_crypto/api.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""
|
||||
API endpoints for frappe_crypto.
|
||||
|
||||
- crypto_webhook: receives SHKeeper payment callbacks (allow_guest, CSRF-exempt)
|
||||
- get_payment_status: polled by the checkout page to check if payment is confirmed
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def crypto_webhook():
|
||||
"""
|
||||
Receive SHKeeper payment callbacks.
|
||||
|
||||
SHKeeper POSTs when an invoice receives a transaction.
|
||||
Body includes: external_id (Payment Request name), paid (bool), status (1/2/3),
|
||||
balance_fiat, balance_crypto, crypto, transactions[].
|
||||
|
||||
Auth: verify X-Shkeeper-Api-Key header matches our stored api_key.
|
||||
Must return HTTP 202 to acknowledge. Any other response = SHKeeper retries every 60s.
|
||||
|
||||
Status codes from SHKeeper:
|
||||
1 = pending (partial payment received)
|
||||
2 = paid (exact amount received)
|
||||
3 = overpaid (more than expected received)
|
||||
"""
|
||||
try:
|
||||
data = json.loads(frappe.request.data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
frappe.local.response.http_status_code = 202
|
||||
return {"status": "accepted", "message": "Invalid JSON body"}
|
||||
|
||||
external_id = data.get("external_id", "")
|
||||
is_paid = data.get("paid", False)
|
||||
status_code = data.get("status", 0)
|
||||
crypto = data.get("crypto", "")
|
||||
balance_fiat = data.get("balance_fiat", "0")
|
||||
balance_crypto = data.get("balance_crypto", "0")
|
||||
transactions = data.get("transactions", [])
|
||||
|
||||
frappe.logger("frappe_crypto").info(
|
||||
f"Webhook received: external_id={external_id}, paid={is_paid}, "
|
||||
f"status={status_code}, crypto={crypto}, balance_fiat={balance_fiat}"
|
||||
)
|
||||
|
||||
# Verify API key
|
||||
incoming_key = frappe.request.headers.get("X-Shkeeper-Api-Key", "")
|
||||
if not _verify_webhook_key(incoming_key):
|
||||
frappe.logger("frappe_crypto").warning(
|
||||
f"Webhook auth failed for external_id={external_id}"
|
||||
)
|
||||
frappe.local.response.http_status_code = 202
|
||||
return {"status": "accepted", "message": "Auth failed"}
|
||||
|
||||
# Validate that the Payment Request exists
|
||||
if not external_id or not frappe.db.exists("Payment Request", external_id):
|
||||
frappe.logger("frappe_crypto").warning(
|
||||
f"Payment Request not found: {external_id}"
|
||||
)
|
||||
frappe.local.response.http_status_code = 202
|
||||
return {"status": "accepted", "message": "Payment Request not found"}
|
||||
|
||||
# Process payment if paid or overpaid
|
||||
if is_paid or status_code in (2, 3):
|
||||
_process_payment(
|
||||
payment_request_name=external_id,
|
||||
amount_fiat=balance_fiat,
|
||||
amount_crypto=balance_crypto,
|
||||
crypto=crypto,
|
||||
transactions=transactions,
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
frappe.local.response.http_status_code = 202
|
||||
return {"status": "accepted"}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_payment_status(payment_request_name: str = ""):
|
||||
"""
|
||||
Check the status of a Payment Request.
|
||||
Polled by the crypto_checkout page every 5 seconds.
|
||||
"""
|
||||
if not payment_request_name:
|
||||
return {"status": "error", "message": "Missing payment_request_name"}
|
||||
|
||||
if not frappe.db.exists("Payment Request", payment_request_name):
|
||||
return {"status": "error", "message": "Payment Request not found"}
|
||||
|
||||
pr = frappe.get_doc("Payment Request", payment_request_name)
|
||||
return {
|
||||
"status": "ok",
|
||||
"payment_status": pr.status,
|
||||
"paid": pr.status == "Paid",
|
||||
"amount": pr.grand_total,
|
||||
"currency": pr.currency,
|
||||
}
|
||||
|
||||
|
||||
def _verify_webhook_key(incoming_key: str) -> bool:
|
||||
"""
|
||||
Verify the incoming X-Shkeeper-Api-Key header against stored API keys.
|
||||
Checks all Crypto Payment Settings documents for a matching key.
|
||||
"""
|
||||
if not incoming_key:
|
||||
return False
|
||||
|
||||
settings_list = frappe.get_all(
|
||||
"Crypto Payment Settings",
|
||||
filters={"enabled": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for settings_name in settings_list:
|
||||
doc = frappe.get_doc("Crypto Payment Settings", settings_name)
|
||||
stored_key = doc.get_password(fieldname="api_key", raise_exception=False) or ""
|
||||
if stored_key and stored_key == incoming_key:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _process_payment(
|
||||
payment_request_name: str,
|
||||
amount_fiat: str,
|
||||
amount_crypto: str,
|
||||
crypto: str,
|
||||
transactions: list,
|
||||
status_code: int,
|
||||
):
|
||||
"""
|
||||
Create a Payment Entry and mark the Payment Request as Paid.
|
||||
Idempotent: skips if PR is already Paid.
|
||||
"""
|
||||
pr = frappe.get_doc("Payment Request", payment_request_name)
|
||||
|
||||
# Idempotency check — don't create duplicate Payment Entry
|
||||
if pr.status == "Paid":
|
||||
frappe.logger("frappe_crypto").info(
|
||||
f"Payment Request {payment_request_name} already Paid, skipping"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
amount = float(amount_fiat)
|
||||
except (ValueError, TypeError):
|
||||
amount = pr.grand_total
|
||||
|
||||
# Build remarks with crypto transaction details
|
||||
remarks_parts = [
|
||||
f"Crypto payment via SHKeeper",
|
||||
f"Crypto: {crypto}",
|
||||
f"Amount (crypto): {amount_crypto}",
|
||||
f"Amount (fiat): ${amount_fiat} USD",
|
||||
f"SHKeeper status: {status_code}",
|
||||
]
|
||||
if transactions:
|
||||
tx_hashes = [t.get("txid", t.get("tx_hash", "unknown")) for t in transactions[:5]]
|
||||
remarks_parts.append(f"TX hashes: {', '.join(tx_hashes)}")
|
||||
|
||||
remarks = " | ".join(remarks_parts)
|
||||
|
||||
try:
|
||||
# Use the Payment Request's built-in method to create Payment Entry
|
||||
# This handles all the GL entry creation and reference linking
|
||||
pr.flags.ignore_permissions = True
|
||||
pr.run_method("set_as_paid")
|
||||
|
||||
# Update the Payment Entry with crypto-specific remarks
|
||||
payment_entries = frappe.get_all(
|
||||
"Payment Entry",
|
||||
filters={
|
||||
"reference_no": payment_request_name,
|
||||
"docstatus": ["in", [0, 1]],
|
||||
},
|
||||
pluck="name",
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if payment_entries:
|
||||
pe = frappe.get_doc("Payment Entry", payment_entries[0])
|
||||
pe.remarks = remarks
|
||||
pe.reference_no = payment_request_name
|
||||
pe.save(ignore_permissions=True)
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
frappe.logger("frappe_crypto").info(
|
||||
f"Payment processed for {payment_request_name}: "
|
||||
f"${amount_fiat} USD in {crypto}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
frappe.log_error(
|
||||
title=f"Crypto Payment Error: {payment_request_name}",
|
||||
message=f"Error processing crypto payment:\n{str(e)}\n\n"
|
||||
f"Data: crypto={crypto}, amount_fiat={amount_fiat}, "
|
||||
f"amount_crypto={amount_crypto}, status={status_code}",
|
||||
)
|
||||
frappe.logger("frappe_crypto").error(
|
||||
f"Error processing payment for {payment_request_name}: {e}"
|
||||
)
|
||||
0
frappe_crypto/frappe_crypto/gateways/__init__.py
Normal file
0
frappe_crypto/frappe_crypto/gateways/__init__.py
Normal file
0
frappe_crypto/frappe_crypto/gateways/doctype/__init__.py
Normal file
0
frappe_crypto/frappe_crypto/gateways/doctype/__init__.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Copyright (c) 2026, Performance West Inc. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Crypto Payment Settings", {
|
||||
refresh(frm) {
|
||||
// Add "Test Connection" button
|
||||
frm.add_custom_button(__("Test Connection"), function () {
|
||||
if (!frm.doc.shkeeper_url || !frm.doc.api_key) {
|
||||
frappe.msgprint(
|
||||
__("Please enter SHKeeper URL and API Key before testing."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "test_connection",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Testing connection to SHKeeper..."),
|
||||
callback: function (r) {
|
||||
if (r.message && r.message.success) {
|
||||
frappe.msgprint({
|
||||
title: __("Connection Successful"),
|
||||
indicator: "green",
|
||||
message: r.message.message,
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: __("Connection Failed"),
|
||||
indicator: "red",
|
||||
message: r.message
|
||||
? r.message.message
|
||||
: __("Unknown error"),
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
frappe.msgprint({
|
||||
title: __("Error"),
|
||||
indicator: "red",
|
||||
message: __(
|
||||
"Failed to test connection. Please check your settings.",
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Show gateway name info
|
||||
if (frm.doc.gateway_name) {
|
||||
frm.dashboard.set_headline(
|
||||
__("Payment Gateway: Crypto-{0}", [frm.doc.gateway_name]),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
validate(frm) {
|
||||
// Ensure SHKeeper URL doesn't have trailing slash
|
||||
if (frm.doc.shkeeper_url) {
|
||||
frm.doc.shkeeper_url = frm.doc.shkeeper_url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if (
|
||||
frm.doc.shkeeper_url &&
|
||||
!frm.doc.shkeeper_url.startsWith("http")
|
||||
) {
|
||||
frappe.msgprint(
|
||||
__("SHKeeper URL must start with http:// or https://"),
|
||||
);
|
||||
frappe.validated = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 0,
|
||||
"creation": "2026-03-29 00:00:00.000000",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"gateway_name",
|
||||
"enabled",
|
||||
"section_shkeeper",
|
||||
"shkeeper_url",
|
||||
"api_key",
|
||||
"section_defaults",
|
||||
"default_crypto",
|
||||
"section_security",
|
||||
"webhook_secret"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "gateway_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Gateway Name",
|
||||
"reqd": 1,
|
||||
"unique": 1,
|
||||
"in_list_view": 1,
|
||||
"description": "Unique name for this gateway instance, e.g. 'Crypto'. Used as Payment Gateway name with 'Crypto-' prefix."
|
||||
},
|
||||
{
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled",
|
||||
"default": "1",
|
||||
"in_list_view": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_shkeeper",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "SHKeeper Configuration"
|
||||
},
|
||||
{
|
||||
"fieldname": "shkeeper_url",
|
||||
"fieldtype": "Data",
|
||||
"label": "SHKeeper URL",
|
||||
"reqd": 1,
|
||||
"description": "Base URL of your SHKeeper instance, e.g. https://pay.performancewest.net"
|
||||
},
|
||||
{
|
||||
"fieldname": "api_key",
|
||||
"fieldtype": "Password",
|
||||
"label": "API Key",
|
||||
"reqd": 1,
|
||||
"description": "SHKeeper API key (used for both outgoing requests and webhook verification)"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_defaults",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Defaults"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_crypto",
|
||||
"fieldtype": "Data",
|
||||
"label": "Default Cryptocurrency",
|
||||
"description": "Default crypto for invoices, e.g. 'BTC', 'ETH', 'LTC'. If blank, the checkout page uses BTC."
|
||||
},
|
||||
{
|
||||
"fieldname": "section_security",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Security"
|
||||
},
|
||||
{
|
||||
"fieldname": "webhook_secret",
|
||||
"fieldtype": "Password",
|
||||
"label": "Webhook Secret",
|
||||
"description": "Optional shared secret for additional webhook verification (not currently used by SHKeeper)"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 0,
|
||||
"issingle": 0,
|
||||
"links": [],
|
||||
"modified": "2026-03-29 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Payment Gateways",
|
||||
"name": "Crypto Payment Settings",
|
||||
"naming_rule": "By fieldname",
|
||||
"autoname": "field:gateway_name",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 0,
|
||||
"email": 0,
|
||||
"export": 0,
|
||||
"print": 0,
|
||||
"read": 1,
|
||||
"report": 0,
|
||||
"role": "Accounts Manager",
|
||||
"share": 0,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1,
|
||||
"custom": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""Controller for Crypto Payment Settings DocType."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import get_url
|
||||
from payments.utils import create_payment_gateway
|
||||
|
||||
|
||||
class CryptoPaymentSettings(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
api_key: DF.Password
|
||||
default_crypto: DF.Data | None
|
||||
enabled: DF.Check
|
||||
gateway_name: DF.Data
|
||||
shkeeper_url: DF.Data
|
||||
webhook_secret: DF.Password | None
|
||||
# end: auto-generated types
|
||||
|
||||
def on_update(self):
|
||||
"""Register this as a Payment Gateway in ERPNext."""
|
||||
gateway_name = "Crypto-" + self.gateway_name
|
||||
create_payment_gateway(
|
||||
gateway_name,
|
||||
settings="Crypto Payment Settings",
|
||||
controller=self.gateway_name,
|
||||
)
|
||||
call_hook = frappe.get_hooks("payment_gateway_enabled")
|
||||
if call_hook:
|
||||
frappe.get_attr(call_hook[0])(gateway=gateway_name)
|
||||
|
||||
def validate_transaction_currency(self, currency):
|
||||
"""SHKeeper only supports USD fiat currently."""
|
||||
if currency.upper() != "USD":
|
||||
frappe.throw(_("SHKeeper only supports USD-denominated invoices"))
|
||||
|
||||
def validate_minimum_transaction_amount(self, currency, amount):
|
||||
"""Minimum transaction amount is $1.00."""
|
||||
if float(amount) < 1.0:
|
||||
frappe.throw(_("Minimum transaction amount is $1.00"))
|
||||
|
||||
def get_payment_url(self, **kwargs):
|
||||
"""
|
||||
Build the URL for the crypto checkout page.
|
||||
Called by Payment Request to get the payment gateway URL.
|
||||
"""
|
||||
kwargs["crypto_settings"] = self.name
|
||||
return get_url(f"./crypto_checkout?{urlencode(kwargs)}")
|
||||
|
||||
def get_available_cryptos(self) -> list:
|
||||
"""
|
||||
GET /api/v1/crypto - returns list of available cryptocurrencies.
|
||||
|
||||
Returns:
|
||||
list of dicts: [{"display_name": "Bitcoin", "name": "BTC"}, ...]
|
||||
"""
|
||||
resp = requests.get(
|
||||
f"{self.shkeeper_url.rstrip('/')}/api/v1/crypto",
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("crypto_list", [])
|
||||
|
||||
def create_invoice(
|
||||
self,
|
||||
crypto: str,
|
||||
amount_usd: float,
|
||||
order_id: str,
|
||||
callback_url: str,
|
||||
) -> dict:
|
||||
"""
|
||||
POST /api/v1/<crypto>/payment_request
|
||||
|
||||
Creates a payment invoice in SHKeeper for the specified cryptocurrency.
|
||||
|
||||
Args:
|
||||
crypto: Cryptocurrency symbol, e.g. "BTC", "ETH"
|
||||
amount_usd: Amount in USD
|
||||
order_id: External ID (Payment Request name)
|
||||
callback_url: URL for SHKeeper to POST payment updates to
|
||||
|
||||
Returns:
|
||||
dict: {amount, display_name, exchange_rate, id, status, wallet}
|
||||
"""
|
||||
resp = requests.post(
|
||||
f"{self.shkeeper_url.rstrip('/')}/api/v1/{crypto}/payment_request",
|
||||
headers=self._headers(),
|
||||
json={
|
||||
"external_id": order_id,
|
||||
"fiat": "USD",
|
||||
"amount": str(amount_usd),
|
||||
"callback_url": callback_url,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if not resp.ok:
|
||||
body_preview = resp.text[:300] if resp.text else "(empty)"
|
||||
frappe.throw(
|
||||
_(
|
||||
"SHKeeper invoice creation failed: {0} {1}"
|
||||
).format(resp.status_code, body_preview)
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
def validate_credentials(self):
|
||||
"""Test connection by fetching available cryptos."""
|
||||
try:
|
||||
cryptos = self.get_available_cryptos()
|
||||
if not cryptos:
|
||||
frappe.throw(_("SHKeeper returned no available cryptocurrencies"))
|
||||
except requests.exceptions.ConnectionError:
|
||||
frappe.throw(
|
||||
_("Cannot connect to SHKeeper at '{0}'").format(self.shkeeper_url)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def test_connection(self):
|
||||
"""
|
||||
Whitelisted method for the 'Test Connection' button in the form.
|
||||
Returns a dict with connection status and available cryptos.
|
||||
"""
|
||||
try:
|
||||
cryptos = self.get_available_cryptos()
|
||||
crypto_names = [c.get("display_name", c.get("name", "?")) for c in cryptos]
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connected successfully. {len(cryptos)} cryptocurrencies available: {', '.join(crypto_names)}",
|
||||
}
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Cannot connect to SHKeeper at '{self.shkeeper_url}'. Check the URL and ensure the server is running.",
|
||||
}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SHKeeper returned an error: {e}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Connection test failed: {str(e)}",
|
||||
}
|
||||
|
||||
def _headers(self) -> dict:
|
||||
"""Build headers for SHKeeper API requests."""
|
||||
return {
|
||||
"X-Shkeeper-API-Key": self.get_password(
|
||||
fieldname="api_key", raise_exception=False
|
||||
)
|
||||
or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
21
frappe_crypto/frappe_crypto/hooks.py
Normal file
21
frappe_crypto/frappe_crypto/hooks.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
app_name = "frappe_crypto"
|
||||
app_title = "Crypto Payment Gateway"
|
||||
app_publisher = "Performance West Inc."
|
||||
app_description = "SHKeeper-based cryptocurrency payment gateway for ERPNext. Accepts BTC, ETH, USDC, USDT, MATIC, TRX, BNB, LTC, DOGE, and more."
|
||||
app_email = "support@performancewest.net"
|
||||
app_license = "MIT"
|
||||
app_icon = "octicon octicon-shield-lock"
|
||||
app_color = "#f7931a"
|
||||
|
||||
before_install = "frappe_crypto.install.before_install"
|
||||
after_install = "frappe_crypto.install.after_install"
|
||||
|
||||
# SHKeeper webhook callback must bypass CSRF
|
||||
website_route_rules = []
|
||||
|
||||
override_whitelisted_methods = {}
|
||||
|
||||
# Exempt the webhook endpoint from CSRF verification
|
||||
csrf_ignore = [
|
||||
"frappe_crypto.api.crypto_webhook",
|
||||
]
|
||||
23
frappe_crypto/frappe_crypto/install.py
Normal file
23
frappe_crypto/frappe_crypto/install.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Installation hooks for frappe_crypto."""
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def before_install():
|
||||
"""Verify that the payments app is installed before proceeding."""
|
||||
try:
|
||||
import payments # noqa: F401
|
||||
except ImportError:
|
||||
frappe.throw(
|
||||
"The <b>payments</b> app is required. "
|
||||
"Install it first: <code>bench get-app payments</code> && "
|
||||
"<code>bench --site {site} install-app payments</code>"
|
||||
)
|
||||
|
||||
|
||||
def after_install():
|
||||
"""Post-installation setup."""
|
||||
frappe.logger("frappe_crypto").info(
|
||||
"frappe_crypto installed successfully. "
|
||||
"Configure your SHKeeper connection at Crypto Payment Settings."
|
||||
)
|
||||
0
frappe_crypto/frappe_crypto/modules.txt
Normal file
0
frappe_crypto/frappe_crypto/modules.txt
Normal file
195
frappe_crypto/frappe_crypto/www/crypto_checkout.html
Normal file
195
frappe_crypto/frappe_crypto/www/crypto_checkout.html
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
{% extends "templates/web.html" %}
|
||||
|
||||
{% block page_content %}
|
||||
<div class="crypto-checkout-container" style="max-width: 520px; margin: 40px auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
|
||||
{% if error %}
|
||||
<!-- Error State -->
|
||||
<div style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">⚠</div>
|
||||
<h2 style="color: #dc3545; margin-bottom: 12px;">Payment Error</h2>
|
||||
<p style="color: #666; margin-bottom: 24px;">{{ error_message }}</p>
|
||||
<a href="/" class="btn btn-primary">Return Home</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Payment Pending State -->
|
||||
<div id="payment-pending">
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<h2 style="margin-bottom: 4px;">Pay with {{ crypto_name }}</h2>
|
||||
<p style="color: #888; margin: 0;">Invoice for ${{ amount_usd }} USD</p>
|
||||
</div>
|
||||
|
||||
<!-- Amount in Crypto -->
|
||||
<div style="background: #f8f9fa; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; color: #666; margin-bottom: 4px;">Send exactly</div>
|
||||
<div style="font-size: 28px; font-weight: 700; color: #1a1a2e; letter-spacing: 0.5px;">
|
||||
{{ amount_crypto }} {{ crypto_symbol }}
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #888; margin-top: 4px;">
|
||||
1 {{ crypto_symbol }} = ${{ exchange_rate }} USD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div id="qrcode" style="display: inline-block; padding: 16px; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Address -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-size: 13px; color: #666; margin-bottom: 6px;">{{ crypto_name }} Address</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="wallet-address" value="{{ wallet_address }}" readonly
|
||||
style="flex: 1; padding: 10px 12px; border: 1px solid #d0d0d0; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; background: #fafafa; color: #333;">
|
||||
<button id="copy-btn" onclick="copyAddress()" type="button"
|
||||
style="padding: 10px 16px; border: 1px solid #d0d0d0; border-radius: 6px; background: #fff; cursor: pointer; font-size: 13px; white-space: nowrap; transition: all 0.2s;">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="status-area" style="text-align: center; padding: 16px; background: #fff8e1; border-radius: 8px; margin-bottom: 20px;">
|
||||
<div style="display: inline-flex; align-items: center; gap: 8px;">
|
||||
<div class="spinner" style="width: 18px; height: 18px; border: 2px solid #f0c040; border-top-color: #e6a800; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
||||
<span style="color: #8a6d00; font-size: 14px;" id="status-text">Waiting for payment confirmation...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div style="font-size: 12px; color: #999; text-align: center; line-height: 1.6;">
|
||||
<p>Send the exact amount shown above to the address provided.</p>
|
||||
<p>This page will automatically update when your payment is confirmed.</p>
|
||||
<p style="margin-top: 8px;">Payment Request: <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{{ payment_request_name }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Success State (hidden initially) -->
|
||||
<div id="payment-success" style="display: none; text-align: center; padding: 40px 20px;">
|
||||
<div style="width: 64px; height: 64px; background: #28a745; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px;">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 style="color: #28a745; margin-bottom: 8px;">Payment Confirmed!</h2>
|
||||
<p style="color: #666; margin-bottom: 24px;">
|
||||
Your payment of {{ amount_crypto }} {{ crypto_symbol }} (${{ amount_usd }} USD) has been received.
|
||||
</p>
|
||||
<a href="{{ redirect_to }}" class="btn btn-primary" style="padding: 10px 32px; font-size: 15px;">Continue</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.crypto-checkout-container .btn-primary {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.crypto-checkout-container .btn-primary:hover {
|
||||
background: #2d2d4e;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if not error %}
|
||||
<script>
|
||||
// QR Code generation using a lightweight inline generator
|
||||
// Generates QR code as an HTML table (no external dependencies)
|
||||
(function() {
|
||||
// Use the Google Charts API for QR code (simple, reliable, no JS dependency)
|
||||
var qrData = {{ frappe.utils.jinja_globals.frappe.as_json(qr_data) }};
|
||||
var qrContainer = document.getElementById('qrcode');
|
||||
if (qrContainer && qrData) {
|
||||
var img = document.createElement('img');
|
||||
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(qrData);
|
||||
img.alt = 'QR Code';
|
||||
img.width = 200;
|
||||
img.height = 200;
|
||||
img.style.display = 'block';
|
||||
qrContainer.appendChild(img);
|
||||
}
|
||||
})();
|
||||
|
||||
// Copy address to clipboard
|
||||
function copyAddress() {
|
||||
var input = document.getElementById('wallet-address');
|
||||
var btn = document.getElementById('copy-btn');
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(input.value).then(function() {
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.background = '#e8f5e9';
|
||||
btn.style.borderColor = '#4caf50';
|
||||
btn.style.color = '#2e7d32';
|
||||
setTimeout(function() {
|
||||
btn.textContent = 'Copy';
|
||||
btn.style.background = '#fff';
|
||||
btn.style.borderColor = '#d0d0d0';
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for payment status every 5 seconds
|
||||
(function() {
|
||||
var pollUrl = {{ frappe.utils.jinja_globals.frappe.as_json(poll_url) }};
|
||||
var redirectTo = {{ frappe.utils.jinja_globals.frappe.as_json(redirect_to) }};
|
||||
var pollInterval = null;
|
||||
var pollCount = 0;
|
||||
var maxPolls = 720; // 720 * 5s = 1 hour max polling
|
||||
|
||||
function checkStatus() {
|
||||
pollCount++;
|
||||
if (pollCount > maxPolls) {
|
||||
clearInterval(pollInterval);
|
||||
document.getElementById('status-text').textContent = 'Payment session timed out. Please refresh the page.';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(pollUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
var result = data.message || data;
|
||||
if (result.paid) {
|
||||
clearInterval(pollInterval);
|
||||
document.getElementById('payment-pending').style.display = 'none';
|
||||
document.getElementById('payment-success').style.display = 'block';
|
||||
// Auto-redirect after 5 seconds
|
||||
setTimeout(function() {
|
||||
window.location.href = redirectTo;
|
||||
}, 5000);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('Payment status poll error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
pollInterval = setInterval(checkStatus, 5000);
|
||||
// Also check immediately after a short delay
|
||||
setTimeout(checkStatus, 2000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
131
frappe_crypto/frappe_crypto/www/crypto_checkout.py
Normal file
131
frappe_crypto/frappe_crypto/www/crypto_checkout.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
Crypto Checkout page controller.
|
||||
|
||||
This is a www page that displays the crypto payment address and QR code.
|
||||
Unlike Adyen/Stripe which redirect to a hosted checkout, SHKeeper returns
|
||||
a wallet address that we display directly.
|
||||
|
||||
Query params:
|
||||
amount: Amount in USD
|
||||
currency: Currency code (must be USD)
|
||||
payment_request: Payment Request name (e.g. PAY-REQ-2026-00001)
|
||||
crypto_settings: Crypto Payment Settings document name
|
||||
payer_email: Customer email (optional)
|
||||
reference_doctype: Reference document type (optional)
|
||||
reference_docname: Reference document name (optional)
|
||||
crypto: Specific crypto to use (optional, overrides default_crypto)
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_url
|
||||
|
||||
no_cache = 1
|
||||
|
||||
|
||||
def get_context(context):
|
||||
"""Build context for the crypto checkout template."""
|
||||
# Parse query parameters
|
||||
amount = frappe.form_dict.get("amount", "0")
|
||||
currency = frappe.form_dict.get("currency", "USD")
|
||||
payment_request = frappe.form_dict.get("payment_request", "")
|
||||
crypto_settings_name = frappe.form_dict.get("crypto_settings", "")
|
||||
payer_email = frappe.form_dict.get("payer_email", "")
|
||||
crypto_override = frappe.form_dict.get("crypto", "")
|
||||
redirect_to = frappe.form_dict.get("redirect_to", "")
|
||||
|
||||
# Validate required params
|
||||
if not payment_request:
|
||||
frappe.throw(_("Missing payment_request parameter"))
|
||||
if not crypto_settings_name:
|
||||
frappe.throw(_("Missing crypto_settings parameter"))
|
||||
|
||||
try:
|
||||
amount_usd = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
frappe.throw(_("Invalid amount: {0}").format(amount))
|
||||
return
|
||||
|
||||
if amount_usd < 1.0:
|
||||
frappe.throw(_("Minimum payment amount is $1.00"))
|
||||
|
||||
# Load Crypto Payment Settings
|
||||
if not frappe.db.exists("Crypto Payment Settings", crypto_settings_name):
|
||||
frappe.throw(
|
||||
_("Crypto Payment Settings '{0}' not found").format(crypto_settings_name)
|
||||
)
|
||||
|
||||
settings = frappe.get_doc("Crypto Payment Settings", crypto_settings_name)
|
||||
|
||||
if not settings.enabled:
|
||||
frappe.throw(_("This crypto payment gateway is currently disabled"))
|
||||
|
||||
# Determine which crypto to use
|
||||
crypto = crypto_override or settings.default_crypto or "BTC"
|
||||
|
||||
# Build the webhook callback URL
|
||||
callback_url = get_url(
|
||||
"/api/method/frappe_crypto.api.crypto_webhook"
|
||||
)
|
||||
|
||||
# Create the invoice in SHKeeper
|
||||
try:
|
||||
invoice = settings.create_invoice(
|
||||
crypto=crypto,
|
||||
amount_usd=amount_usd,
|
||||
order_id=payment_request,
|
||||
callback_url=callback_url,
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.log_error(
|
||||
title=f"Crypto Checkout Error: {payment_request}",
|
||||
message=str(e),
|
||||
)
|
||||
context.error = True
|
||||
context.error_message = str(e)
|
||||
context.title = _("Payment Error")
|
||||
return
|
||||
|
||||
# Extract invoice data
|
||||
wallet_address = invoice.get("wallet", "")
|
||||
amount_crypto = invoice.get("amount", "0")
|
||||
display_name = invoice.get("display_name", crypto)
|
||||
exchange_rate = invoice.get("exchange_rate", "0")
|
||||
shkeeper_id = invoice.get("id", "")
|
||||
|
||||
# Build QR code data
|
||||
# Standard URI formats: bitcoin:addr?amount=x, ethereum:addr?value=x
|
||||
crypto_upper = crypto.upper()
|
||||
if crypto_upper == "BTC":
|
||||
qr_data = f"bitcoin:{wallet_address}?amount={amount_crypto}"
|
||||
elif crypto_upper == "ETH":
|
||||
qr_data = f"ethereum:{wallet_address}?value={amount_crypto}"
|
||||
elif crypto_upper == "LTC":
|
||||
qr_data = f"litecoin:{wallet_address}?amount={amount_crypto}"
|
||||
elif crypto_upper == "DOGE":
|
||||
qr_data = f"dogecoin:{wallet_address}?amount={amount_crypto}"
|
||||
else:
|
||||
# Generic: just use the address for QR
|
||||
qr_data = wallet_address
|
||||
|
||||
# Set context for the template
|
||||
context.title = _("Crypto Payment")
|
||||
context.error = False
|
||||
context.error_message = ""
|
||||
context.wallet_address = wallet_address
|
||||
context.amount_crypto = amount_crypto
|
||||
context.amount_usd = f"{amount_usd:.2f}"
|
||||
context.crypto_symbol = crypto_upper
|
||||
context.crypto_name = display_name
|
||||
context.exchange_rate = exchange_rate
|
||||
context.qr_data = qr_data
|
||||
context.payment_request_name = payment_request
|
||||
context.payer_email = payer_email
|
||||
context.shkeeper_invoice_id = shkeeper_id
|
||||
context.redirect_to = redirect_to or get_url("/")
|
||||
context.poll_url = get_url(
|
||||
f"/api/method/frappe_crypto.api.get_payment_status?payment_request_name={payment_request}"
|
||||
)
|
||||
context.no_cache = 1
|
||||
context.show_sidebar = False
|
||||
context.parents = []
|
||||
21
frappe_crypto/license.txt
Normal file
21
frappe_crypto/license.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 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.
|
||||
17
frappe_crypto/pyproject.toml
Normal file
17
frappe_crypto/pyproject.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[project]
|
||||
name = "frappe-crypto"
|
||||
authors = [
|
||||
{ name = "Performance West Inc.", email = "support@performancewest.net" }
|
||||
]
|
||||
description = "SHKeeper-based cryptocurrency payment gateway for ERPNext"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
dynamic = ["version"]
|
||||
|
||||
[build-system]
|
||||
requires = ["flit_core >=3.4,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[tool.bench.frappe-dependencies]
|
||||
frappe = ">=15.0.0"
|
||||
payments = ">=0.0.1"
|
||||
3
frappe_crypto/requirements.txt
Normal file
3
frappe_crypto/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
frappe
|
||||
payments
|
||||
requests
|
||||
16
frappe_crypto/setup.py
Normal file
16
frappe_crypto/setup.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from setuptools import setup, find_packages
|
||||
|
||||
with open("requirements.txt") as f:
|
||||
install_requires = f.read().strip().splitlines()
|
||||
|
||||
setup(
|
||||
name="frappe_crypto",
|
||||
version="1.0.0",
|
||||
description="SHKeeper-based cryptocurrency payment gateway for ERPNext",
|
||||
author="Performance West Inc.",
|
||||
author_email="support@performancewest.net",
|
||||
packages=find_packages(),
|
||||
zip_safe=False,
|
||||
include_package_data=True,
|
||||
install_requires=install_requires,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue