Initial commit — Performance West telecom compliance platform

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

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

42
frappe_crypto/README.md Normal file
View 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

View file

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

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

View 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;
}
},
});

View file

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

View file

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

View 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",
]

View 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."
)

View file

View 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;">&#9888;</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 %}

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

View 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"

View file

@ -0,0 +1,3 @@
frappe
payments
requests

16
frappe_crypto/setup.py Normal file
View 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,
)