@@ -1477,6 +1477,32 @@ def get_system_strategies():
14771477
14781478# ==================== Admin Orders ====================
14791479
1480+
1481+ def _ensure_usdt_admin_columns ():
1482+ """Best-effort: extend qd_usdt_orders with admin-audit columns introduced
1483+ by the manual-confirm flow. ``ADD COLUMN IF NOT EXISTS`` is idempotent
1484+ on PostgreSQL, so this is effectively a no-op after the first hit.
1485+
1486+ Failures are swallowed (logged at debug level) so a running DB user
1487+ without DDL privileges doesn't block the read paths — the SELECTs
1488+ further down use ``information_schema`` checks or COALESCE to tolerate
1489+ the columns being absent.
1490+ """
1491+ try :
1492+ with get_db_connection () as db :
1493+ cur = db .cursor ()
1494+ cur .execute (
1495+ "ALTER TABLE qd_usdt_orders ADD COLUMN IF NOT EXISTS admin_note TEXT DEFAULT NULL"
1496+ )
1497+ cur .execute (
1498+ "ALTER TABLE qd_usdt_orders ADD COLUMN IF NOT EXISTS manual_confirmed_by INTEGER DEFAULT NULL"
1499+ )
1500+ db .commit ()
1501+ cur .close ()
1502+ except Exception as exc :
1503+ logger .debug ("ensure_usdt_admin_columns skipped: %s" , exc )
1504+
1505+
14801506@user_bp .route ('/admin-orders' , methods = ['GET' ])
14811507@login_required
14821508@admin_required
@@ -1499,6 +1525,8 @@ def get_admin_orders():
14991525 page_size = min (100 , max (1 , page_size ))
15001526 offset = (page - 1 ) * page_size
15011527
1528+ _ensure_usdt_admin_columns ()
1529+
15021530 with get_db_connection () as db :
15031531 cur = db .cursor ()
15041532
@@ -1539,6 +1567,9 @@ def get_admin_orders():
15391567 o.address,
15401568 o.tx_hash,
15411569 o.status,
1570+ o.matched_via,
1571+ o.admin_note,
1572+ o.manual_confirmed_by,
15421573 o.created_at,
15431574 o.paid_at,
15441575 o.confirmed_at,
@@ -1590,6 +1621,9 @@ def get_admin_orders():
15901621 'address' : row .get ('address' ) or '' ,
15911622 'tx_hash' : row .get ('tx_hash' ) or '' ,
15921623 'status' : row .get ('status' ) or '' ,
1624+ 'matched_via' : row .get ('matched_via' ) or '' ,
1625+ 'admin_note' : row .get ('admin_note' ) or '' ,
1626+ 'manual_confirmed_by' : row .get ('manual_confirmed_by' ),
15931627 'created_at' : created_at ,
15941628 'paid_at' : paid_at ,
15951629 'confirmed_at' : confirmed_at ,
@@ -1620,6 +1654,167 @@ def get_admin_orders():
16201654 return jsonify ({'code' : 0 , 'msg' : str (e ), 'data' : None }), 500
16211655
16221656
1657+ @user_bp .route ('/admin-orders/<int:order_id>/manual-confirm' , methods = ['POST' ])
1658+ @login_required
1659+ @admin_required
1660+ def manual_confirm_order (order_id : int ):
1661+ """
1662+ Admin-only "rescue" lever for USDT orders.
1663+
1664+ Use case: the buyer paid the correct amount to the correct receiving
1665+ address, but the on-chain reconciler missed the transaction (RPC
1666+ outage, exotic wallet, chain-specific edge case, off-chain mistake
1667+ where the customer used a slightly different amount than the order
1668+ suffix demanded, etc.). Without this endpoint the admin's only option
1669+ is to ``UPDATE qd_usdt_orders ...`` by hand and then somehow trigger
1670+ ``purchase_membership``; this surface does both atomically and leaves
1671+ an audit trail.
1672+
1673+ Body:
1674+ {
1675+ "tx_hash": "<on-chain tx hash>", # required
1676+ "note": "<free-form audit note>" # optional
1677+ }
1678+
1679+ Behavior:
1680+ - Flips the order to 'confirmed'.
1681+ - Stamps tx_hash + paid_at (if empty) + confirmed_at + admin_note
1682+ + manual_confirmed_by + matched_via='manual_admin'.
1683+ - Calls ``purchase_membership`` exactly once per order (idempotent
1684+ on re-submit — already-confirmed orders only refresh the audit
1685+ fields, no double-grant).
1686+ - Refuses ``status='cancelled'`` orders so the admin doesn't
1687+ accidentally resurrect a deliberately-cancelled refund.
1688+ """
1689+ try :
1690+ admin_user_id = getattr (g , 'user_id' , None )
1691+ body = request .get_json (silent = True ) or {}
1692+ tx_hash = (body .get ('tx_hash' ) or '' ).strip ()
1693+ note = (body .get ('note' ) or '' ).strip ()
1694+
1695+ if not tx_hash :
1696+ return jsonify ({'code' : 0 , 'msg' : 'missing_tx_hash' , 'data' : None }), 400
1697+ if len (tx_hash ) > 120 :
1698+ return jsonify ({'code' : 0 , 'msg' : 'tx_hash_too_long' , 'data' : None }), 400
1699+ if len (note ) > 1000 :
1700+ return jsonify ({'code' : 0 , 'msg' : 'note_too_long' , 'data' : None }), 400
1701+
1702+ _ensure_usdt_admin_columns ()
1703+
1704+ # Load order in a short read txn (don't hold a lock across the
1705+ # billing call below — purchase_membership opens its own conn).
1706+ with get_db_connection () as db :
1707+ cur = db .cursor ()
1708+ cur .execute (
1709+ """
1710+ SELECT id, user_id, plan, status, chain
1711+ FROM qd_usdt_orders WHERE id = ?
1712+ """ ,
1713+ (order_id ,),
1714+ )
1715+ order = cur .fetchone ()
1716+ cur .close ()
1717+
1718+ if not order :
1719+ return jsonify ({'code' : 0 , 'msg' : 'order_not_found' , 'data' : None }), 404
1720+
1721+ current_status = (order .get ('status' ) or '' ).lower ()
1722+ user_id = order .get ('user_id' )
1723+ plan = order .get ('plan' )
1724+
1725+ if current_status == 'cancelled' :
1726+ # Cancelled orders are deliberately retired — surfacing this
1727+ # as an error forces the admin to recreate the order instead
1728+ # of silently rescuing a refunded one.
1729+ return jsonify ({
1730+ 'code' : 0 ,
1731+ 'msg' : 'order_cancelled' ,
1732+ 'data' : {'order_id' : order_id , 'status' : current_status },
1733+ }), 400
1734+
1735+ already_confirmed = current_status == 'confirmed'
1736+
1737+ # Stamp confirmation + audit fields. COALESCE on paid_at /
1738+ # confirmed_at means re-running this for amendments (e.g. fix a
1739+ # typo in the tx hash) preserves the original timestamps.
1740+ with get_db_connection () as db :
1741+ cur = db .cursor ()
1742+ cur .execute (
1743+ """
1744+ UPDATE qd_usdt_orders
1745+ SET status = 'confirmed',
1746+ tx_hash = ?,
1747+ paid_at = COALESCE(paid_at, NOW()),
1748+ confirmed_at = COALESCE(confirmed_at, NOW()),
1749+ admin_note = ?,
1750+ manual_confirmed_by = ?,
1751+ matched_via = 'manual_admin',
1752+ updated_at = NOW()
1753+ WHERE id = ?
1754+ """ ,
1755+ (tx_hash , note or None , admin_user_id , order_id ),
1756+ )
1757+ db .commit ()
1758+ cur .close ()
1759+
1760+ # Grant membership only when transitioning into 'confirmed' for
1761+ # the first time — re-submits (already confirmed) should only
1762+ # update the audit fields above, never grant another membership.
1763+ billing_msg = ''
1764+ if not already_confirmed :
1765+ try :
1766+ from app .services .billing_service import get_billing_service
1767+ billing = get_billing_service ()
1768+ ok , billing_msg , _ = billing .purchase_membership (
1769+ int (user_id ),
1770+ str (plan ),
1771+ record_membership_order = False ,
1772+ fulfillment_ref = f"manual_usdt:{ order_id } :by_{ admin_user_id } " ,
1773+ )
1774+ logger .info (
1775+ "[ManualConfirm] order=%s user=%s plan=%s admin=%s ok=%s msg=%s" ,
1776+ order_id , user_id , plan , admin_user_id , ok , billing_msg ,
1777+ )
1778+ if not ok :
1779+ # Order row is already 'confirmed' at this point;
1780+ # surface the billing error so the admin knows to
1781+ # retry / dig in. We deliberately don't roll back the
1782+ # status because the on-chain payment IS real.
1783+ return jsonify ({
1784+ 'code' : 0 ,
1785+ 'msg' : f'order_confirmed_but_billing_failed:{ billing_msg } ' ,
1786+ 'data' : {'order_id' : order_id , 'billing_error' : billing_msg },
1787+ }), 500
1788+ except Exception as exc :
1789+ logger .error (
1790+ "[ManualConfirm] billing exception order=%s err=%s" ,
1791+ order_id , exc , exc_info = True ,
1792+ )
1793+ return jsonify ({
1794+ 'code' : 0 ,
1795+ 'msg' : f'order_confirmed_but_billing_exception:{ exc } ' ,
1796+ 'data' : {'order_id' : order_id },
1797+ }), 500
1798+
1799+ return jsonify ({
1800+ 'code' : 1 ,
1801+ 'msg' : 'success' ,
1802+ 'data' : {
1803+ 'order_id' : order_id ,
1804+ 'user_id' : user_id ,
1805+ 'plan' : plan ,
1806+ 'status' : 'confirmed' ,
1807+ 'tx_hash' : tx_hash ,
1808+ 'admin_note' : note ,
1809+ 'manual_confirmed_by' : admin_user_id ,
1810+ 'already_confirmed' : already_confirmed ,
1811+ },
1812+ })
1813+ except Exception as e :
1814+ logger .error (f"manual_confirm_order failed: { e } " , exc_info = True )
1815+ return jsonify ({'code' : 0 , 'msg' : str (e ), 'data' : None }), 500
1816+
1817+
16231818# ==================== Admin AI Analysis Stats ====================
16241819
16251820@user_bp .route ('/admin-ai-stats' , methods = ['GET' ])
0 commit comments