@ -24,6 +24,8 @@ import math
import zipfile
import zipfile
import smtplib
import smtplib
import secrets
import secrets
import threading
import time
from reportlab . lib . pagesizes import letter
from reportlab . lib . pagesizes import letter
from reportlab . pdfgen import canvas
from reportlab . pdfgen import canvas
from reportlab . lib . utils import ImageReader
from reportlab . lib . utils import ImageReader
@ -58,6 +60,11 @@ RPC_ARBITRUM_URL = os.getenv("OTB_BILLING_RPC_ARBITRUM", "https://arbitrum-one-r
RPC_ARBITRUM_URL_2 = os . getenv ( " OTB_BILLING_RPC_ARBITRUM_2 " , " https://rpc.ankr.com/arbitrum " )
RPC_ARBITRUM_URL_2 = os . getenv ( " OTB_BILLING_RPC_ARBITRUM_2 " , " https://rpc.ankr.com/arbitrum " )
RPC_ARBITRUM_URL_3 = os . getenv ( " OTB_BILLING_RPC_ARBITRUM_3 " , " https://arb1.arbitrum.io/rpc " )
RPC_ARBITRUM_URL_3 = os . getenv ( " OTB_BILLING_RPC_ARBITRUM_3 " , " https://arb1.arbitrum.io/rpc " )
CRYPTO_PROCESSING_TIMEOUT_SECONDS = int ( os . getenv ( " OTB_BILLING_CRYPTO_PROCESSING_TIMEOUT_SECONDS " , " 900 " ) )
CRYPTO_WATCH_INTERVAL_SECONDS = int ( os . getenv ( " OTB_BILLING_CRYPTO_WATCH_INTERVAL_SECONDS " , " 30 " ) )
CRYPTO_WATCHER_STARTED = False
def load_version ( ) :
def load_version ( ) :
@ -388,6 +395,222 @@ def verify_wallet_transaction(option, tx_hash):
return seen_result
return seen_result
def get_processing_crypto_option ( payment_row ) :
currency = str ( payment_row . get ( " payment_currency " ) or " " ) . upper ( )
amount_text = str ( payment_row . get ( " payment_amount " ) or " 0 " )
wallet_address = payment_row . get ( " wallet_address " ) or CRYPTO_EVM_PAYMENT_ADDRESS
mapping = {
" USDC " : {
" symbol " : " USDC " ,
" chain " : " arbitrum " ,
" wallet_address " : wallet_address ,
" asset_type " : " token " ,
" decimals " : 6 ,
" token_contract " : " 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 " ,
" display_amount " : amount_text ,
} ,
" ETH " : {
" symbol " : " ETH " ,
" chain " : " ethereum " ,
" wallet_address " : wallet_address ,
" asset_type " : " native " ,
" decimals " : 18 ,
" token_contract " : None ,
" display_amount " : amount_text ,
} ,
" ETHO " : {
" symbol " : " ETHO " ,
" chain " : " etho " ,
" wallet_address " : wallet_address ,
" asset_type " : " native " ,
" decimals " : 18 ,
" token_contract " : None ,
" display_amount " : amount_text ,
} ,
" ETI " : {
" symbol " : " ETI " ,
" chain " : " etica " ,
" wallet_address " : wallet_address ,
" asset_type " : " token " ,
" decimals " : 18 ,
" token_contract " : " 0x34c61EA91bAcdA647269d4e310A86b875c09946f " ,
" display_amount " : amount_text ,
} ,
}
return mapping . get ( currency )
def append_payment_note ( existing_notes , extra_line ) :
base = ( existing_notes or " " ) . rstrip ( )
if not base :
return extra_line . strip ( )
return base + " \n " + extra_line . strip ( )
def mark_crypto_payment_failed ( payment_id , reason ) :
conn = get_db_connection ( )
cursor = conn . cursor ( dictionary = True )
cursor . execute ( """
SELECT invoice_id , notes
FROM payments
WHERE id = % s
LIMIT 1
""" , (payment_id,))
row = cursor . fetchone ( )
if not row :
conn . close ( )
return
new_notes = append_payment_note (
row . get ( " notes " ) ,
f " [crypto watcher] failed: { reason } "
)
update_cursor = conn . cursor ( )
update_cursor . execute ( """
UPDATE payments
SET payment_status = ' failed ' ,
notes = % s
WHERE id = % s
""" , (new_notes, payment_id))
conn . commit ( )
conn . close ( )
try :
recalc_invoice_totals ( row [ " invoice_id " ] )
except Exception :
pass
def mark_crypto_payment_confirmed ( payment_id , invoice_id , rpc_url ) :
conn = get_db_connection ( )
cursor = conn . cursor ( dictionary = True )
cursor . execute ( """
SELECT notes
FROM payments
WHERE id = % s
LIMIT 1
""" , (payment_id,))
row = cursor . fetchone ( )
if not row :
conn . close ( )
return
new_notes = append_payment_note (
row . get ( " notes " ) ,
f " [crypto watcher] confirmed via { rpc_url } "
)
update_cursor = conn . cursor ( )
update_cursor . execute ( """
UPDATE payments
SET payment_status = ' confirmed ' ,
confirmations = COALESCE ( confirmations , 1 ) ,
confirmation_required = COALESCE ( confirmation_required , 1 ) ,
received_at = COALESCE ( received_at , UTC_TIMESTAMP ( ) ) ,
notes = % s
WHERE id = % s
""" , (new_notes, payment_id))
conn . commit ( )
conn . close ( )
try :
recalc_invoice_totals ( invoice_id )
except Exception :
pass
def watch_pending_crypto_payments_once ( ) :
conn = get_db_connection ( )
cursor = conn . cursor ( dictionary = True )
cursor . execute ( """
SELECT
id ,
invoice_id ,
client_id ,
payment_currency ,
payment_amount ,
cad_value_at_payment ,
wallet_address ,
txid ,
payment_status ,
created_at ,
updated_at ,
notes
FROM payments
WHERE payment_status = ' pending '
AND txid IS NOT NULL
AND txid < > ' '
AND notes LIKE ' %% portal_crypto_intent: %% '
ORDER BY id ASC
""" )
pending_rows = cursor . fetchall ( )
conn . close ( )
now_utc = datetime . now ( timezone . utc )
for row in pending_rows :
option = get_processing_crypto_option ( row )
if not option :
continue
submitted_at = row . get ( " updated_at " ) or row . get ( " created_at " )
if submitted_at and submitted_at . tzinfo is None :
submitted_at = submitted_at . replace ( tzinfo = timezone . utc )
if not submitted_at :
submitted_at = now_utc
age_seconds = ( now_utc - submitted_at ) . total_seconds ( )
if age_seconds > CRYPTO_PROCESSING_TIMEOUT_SECONDS :
mark_crypto_payment_failed ( row [ " id " ] , " processing timeout exceeded " )
continue
try :
verified = verify_wallet_transaction ( option , str ( row . get ( " txid " ) or " " ) . strip ( ) )
mark_crypto_payment_confirmed ( row [ " id " ] , row [ " invoice_id " ] , verified . get ( " rpc_url " ) or " rpc " )
except Exception as err :
msg = str ( err or " " )
lower = msg . lower ( )
retryable = (
" not found on any configured rpc " in lower
or " not found on rpc " in lower
or " unable to verify transaction on configured rpc pool " in lower
)
if retryable :
continue
# non-retryable verification problems get marked failed immediately
mark_crypto_payment_failed ( row [ " id " ] , msg )
def crypto_payment_watcher_loop ( ) :
while True :
try :
watch_pending_crypto_payments_once ( )
except Exception as err :
try :
print ( f " [crypto-watcher] { err } " )
except Exception :
pass
time . sleep ( max ( 5 , CRYPTO_WATCH_INTERVAL_SECONDS ) )
def start_crypto_payment_watcher ( ) :
global CRYPTO_WATCHER_STARTED
if CRYPTO_WATCHER_STARTED :
return
t = threading . Thread (
target = crypto_payment_watcher_loop ,
name = " crypto-payment-watcher " ,
daemon = True ,
)
t . start ( )
CRYPTO_WATCHER_STARTED = True
def square_amount_to_cents ( value ) :
def square_amount_to_cents ( value ) :
return int ( ( to_decimal ( value ) * 100 ) . quantize ( Decimal ( " 1 " ) ) )
return int ( ( to_decimal ( value ) * 100 ) . quantize ( Decimal ( " 1 " ) ) )
@ -4622,22 +4845,27 @@ def portal_submit_crypto_tx(invoice_id):
ensure_invoice_quote_columns ( )
ensure_invoice_quote_columns ( )
payload = request . get_json ( silent = True ) or { }
try :
payload = request . get_json ( force = True ) or { }
except Exception :
payload = { }
payment_id = str ( payload . get ( " payment_id " ) or " " ) . strip ( )
payment_id = str ( payload . get ( " payment_id " ) or " " ) . strip ( )
asset_symbol = str ( payload . get ( " asset " ) or " " ) . strip ( ) . upper ( )
asset = str ( payload . get ( " asset " ) or " " ) . strip ( ) . upper ( )
tx_hash = str ( payload . get ( " tx_hash " ) or " " ) . strip ( )
tx_hash = str ( payload . get ( " tx_hash " ) or " " ) . strip ( )
if not payment_id . isdigit ( ) :
if not payment_id . isdigit ( ) :
return jsonify ( { " ok " : False , " error " : " invalid_payment_id " } ) , 400
return jsonify ( { " ok " : False , " error " : " invalid_payment_id " } ) , 400
if not re . fullmatch ( r " 0x[a-fA-F0-9] {64} " , tx_hash ) :
if not asset :
return jsonify ( { " ok " : False , " error " : " invalid_tx_hash " } ) , 400
return jsonify ( { " ok " : False , " error " : " missing_asset " } ) , 400
if not tx_hash or not tx_hash . startswith ( " 0x " ) :
return jsonify ( { " ok " : False , " error " : " missing_tx_hash " } ) , 400
conn = get_db_connection ( )
conn = get_db_connection ( )
cursor = conn . cursor ( dictionary = True )
cursor = conn . cursor ( dictionary = True )
cursor . execute ( """
cursor . execute ( """
SELECT id , client_id , invoice_number , status , total_amount , amount_paid ,
SELECT id , client_id , invoice_number , oracle_snapshot
quote_fiat_amount , quote_fiat_currency , quote_expires_at , oracle_snapshot
FROM invoices
FROM invoices
WHERE id = % s AND client_id = % s
WHERE id = % s AND client_id = % s
LIMIT 1
LIMIT 1
@ -4655,50 +4883,50 @@ def portal_submit_crypto_tx(invoice_id):
except Exception :
except Exception :
invoice [ " oracle_quote " ] = None
invoice [ " oracle_quote " ] = None
options = get_invoice_crypto_options ( invoice )
crypto_options = get_invoice_crypto_options ( invoice )
selected_option = next ( ( o for o in options if o [ " symbol " ] == asset_symbol ) , None )
selected_option = next ( ( o for o in crypto_options if o [ " symbol " ] == asset ) , None )
if not selected_option :
if not selected_option :
conn . close ( )
conn . close ( )
return jsonify ( { " ok " : False , " error " : " asset_not_allowed " } ) , 400
return jsonify ( { " ok " : False , " error " : " invalid_asset " } ) , 400
if not selected_option . get ( " wallet_capable " ) :
conn . close ( )
return jsonify ( { " ok " : False , " error " : " asset_not_wallet_capable " } ) , 400
cursor . execute ( """
cursor . execute ( """
SELECT id , invoice_id , client_id , payment_currency , payment_amount , wallet_address , reference ,
SELECT id , invoice_id , client_id , payment_currency , payment_status , txid , notes
payment_status , created_at , received_at , txid , notes
FROM payments
FROM payments
WHERE id = % s
WHERE id = % s
AND invoice_id = % s
AND invoice_id = % s
AND client_id = % s
AND client_id = % s
LIMIT 1
LIMIT 1
""" , (payment_id, invoice_id, client[ " id " ]))
""" , (int( payment_id) , invoice_id, client[ " id " ]))
payment = cursor . fetchone ( )
payment = cursor . fetchone ( )
if not payment :
if not payment :
conn . close ( )
conn . close ( )
return jsonify ( { " ok " : False , " error " : " payment_not_found " } ) , 404
return jsonify ( { " ok " : False , " error " : " payment_not_found " } ) , 404
if str ( payment . get ( " payment_currency " ) or " " ) . upper ( ) != str ( selected_option . get ( " payment_currency " ) or " " ) . upper ( ) :
if str ( payment . get ( " payment_currency " ) or " " ) . upper ( ) != selected_option [ " payment_currency " ] :
conn . close ( )
conn . close ( )
return jsonify ( { " ok " : False , " error " : " payment_currency_mismatch " } ) , 400
return jsonify ( { " ok " : False , " error " : " payment_currency_mismatch " } ) , 400
try :
if str ( payment . get ( " payment_status " ) or " " ) . lower ( ) != " pending " :
verify_wallet_transaction ( selected_option , tx_hash )
except Exception as err :
conn . close ( )
conn . close ( )
return jsonify ( { " ok " : False , " error " : " tx_verification_failed " , " detail " : str ( err ) } ) , 400
return jsonify ( { " ok " : False , " error " : " payment_not_pending " } ) , 400
new_notes = append_payment_note (
payment . get ( " notes " ) ,
f " [portal wallet submit] tx hash accepted: { tx_hash } "
)
update_cursor = conn . cursor ( )
update_cursor = conn . cursor ( )
update_cursor . execute ( """
update_cursor . execute ( """
UPDATE payments
UPDATE payments
SET txid = % s ,
SET txid = % s ,
received_at = UTC_TIMESTAMP ( ) ,
payment_status = ' pending ' ,
notes = CONCAT ( COALESCE ( notes , ' ' ) , % s )
notes = % s
WHERE id = % s
WHERE id = % s
""" , (
""" , (
tx_hash ,
tx_hash ,
f " | tx_submitted | rpc_seen | chain: { selected_option [ ' chain ' ] } | asset: { selected_option [ ' symbol ' ] } " ,
new_notes ,
payment [ " id " ]
payment [ " id " ]
) )
) )
conn . commit ( )
conn . commit ( )
@ -4706,14 +4934,10 @@ def portal_submit_crypto_tx(invoice_id):
return jsonify ( {
return jsonify ( {
" ok " : True ,
" ok " : True ,
" payment_id " : payment [ " id " ] ,
" redirect_url " : f " /portal/invoice/ { invoice_id } ?pay=crypto&asset= { asset } &payment_id= { payment [ ' id ' ] } "
" tx_hash " : tx_hash ,
" chain " : selected_option [ " chain " ] ,
" asset " : selected_option [ " symbol " ] ,
" state " : " submitted " ,
" redirect_url " : f " /portal/invoice/ { invoice_id } ?pay=crypto&asset= { selected_option [ ' symbol ' ] } &payment_id= { payment [ ' id ' ] } "
} )
} )
@app . route ( " /portal/invoice/<int:invoice_id>/pay-square " , methods = [ " GET " ] )
@app . route ( " /portal/invoice/<int:invoice_id>/pay-square " , methods = [ " GET " ] )
def portal_invoice_pay_square ( invoice_id ) :
def portal_invoice_pay_square ( invoice_id ) :
client = _portal_current_client ( )
client = _portal_current_client ( )