import 'dart:convert'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; /// Persisted trade order audit row. class TradeOrder { TradeOrder({ required this.id, required this.firebaseUid, required this.clientOrderId, required this.symbol, required this.side, required this.orderType, required this.status, this.alpacaOrderId, this.notionalUsd, this.qty, this.questionId, this.ruleId, this.submittedAt, this.filledAt, this.raw, this.createdAt, }); final String id; final String firebaseUid; final String clientOrderId; final String? alpacaOrderId; final String symbol; final String side; final String orderType; final num? notionalUsd; final num? qty; final String status; final String? questionId; final String? ruleId; final DateTime? submittedAt; final DateTime? filledAt; final Map? raw; final DateTime? createdAt; } /// Postgres access for [trade_orders]. class TradeOrdersDb { TradeOrdersDb(this._connection); final Connection _connection; static const Uuid _uuid = Uuid(); /// Counts non-terminal orders submitted at or after [since]. /// /// Used by guardrails for `max_orders_per_day` (pass UTC start-of-day) and /// rolling-window order-count checks. Future countOrdersSince(String firebaseUid, DateTime since) async { final Result result = await _connection.execute( Sql.named( ''' SELECT COUNT(*) FROM trade_orders WHERE firebase_uid = @uid AND submitted_at IS NOT NULL AND submitted_at >= @since AND status NOT IN ('rejected', 'canceled', 'failed') ''', ), parameters: { 'uid': firebaseUid, 'since': since.toUtc(), }, ); if (result.isEmpty) return 0; final Object? raw = result.first[0]; if (raw is num) return raw.toInt(); return num.parse(raw.toString()).toInt(); } /// Sums `notional_usd` of non-terminal orders submitted at or after [since]. /// /// Used by guardrails for the rolling 4-hour notional cap. Future notionalUsdInWindow(String firebaseUid, DateTime since) async { final Result result = await _connection.execute( Sql.named( ''' SELECT COALESCE(SUM(notional_usd), 0) FROM trade_orders WHERE firebase_uid = @uid AND submitted_at IS NOT NULL AND submitted_at >= @since AND status NOT IN ('rejected', 'canceled', 'failed') ''', ), parameters: { 'uid': firebaseUid, 'since': since.toUtc(), }, ); if (result.isEmpty) return 0; final Object? raw = result.first[0]; if (raw is num) return raw; return num.parse(raw.toString()); } Future findByClientOrderId(String clientOrderId) async { final Result result = await _connection.execute( Sql.named( ''' SELECT id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, order_type, notional_usd, qty, status, question_id, rule_id, submitted_at, filled_at, raw, created_at FROM trade_orders WHERE client_order_id = @client_order_id ''', ), parameters: {'client_order_id': clientOrderId}, ); if (result.isEmpty) { return null; } return _rowToOrder(result.first); } /// Inserts a pending order. Returns existing row if [clientOrderId] already exists. Future insertOrder({ required String firebaseUid, required String clientOrderId, required String symbol, required String side, required String orderType, required String status, String? alpacaOrderId, num? notionalUsd, num? qty, String? questionId, String? ruleId, Map? raw, }) async { final TradeOrder? existing = await findByClientOrderId(clientOrderId); if (existing != null) { return existing; } final String id = _uuid.v4(); try { final Result result = await _connection.execute( Sql.named( ''' INSERT INTO trade_orders ( id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, order_type, notional_usd, qty, status, question_id, rule_id, raw, submitted_at ) VALUES ( @id::uuid, @uid, @client_order_id, @alpaca_order_id, @symbol, @side, @order_type, @notional_usd, @qty, @status, @question_id::uuid, @rule_id, @raw::jsonb, @submitted_at ) RETURNING id, firebase_uid, client_order_id, alpaca_order_id, symbol, side, order_type, notional_usd, qty, status, question_id, rule_id, submitted_at, filled_at, raw, created_at ''', ), parameters: { 'id': id, 'uid': firebaseUid, 'client_order_id': clientOrderId, 'alpaca_order_id': alpacaOrderId, 'symbol': symbol, 'side': side, 'order_type': orderType, 'notional_usd': notionalUsd, 'qty': qty, 'status': status, 'question_id': questionId, 'rule_id': ruleId, 'raw': raw == null ? null : jsonEncode(raw), 'submitted_at': DateTime.now().toUtc(), }, ); return _rowToOrder(result.first); } on ServerException catch (e) { if (e.code == '23505') { final TradeOrder? raced = await findByClientOrderId(clientOrderId); if (raced != null) { return raced; } } rethrow; } } TradeOrder _rowToOrder(ResultRow row) { final Object idValue = row[0]!; final String id = idValue is String ? idValue : idValue.toString(); final Object? questionRaw = row[10]; final String? questionId = questionRaw == null ? null : questionRaw.toString(); final Object? rawValue = row[14]; Map? raw; if (rawValue is Map) { raw = rawValue; } else if (rawValue != null) { raw = jsonDecode(rawValue.toString()) as Map; } return TradeOrder( id: id, firebaseUid: row[1]! as String, clientOrderId: row[2]! as String, alpacaOrderId: row[3] as String?, symbol: row[4]! as String, side: row[5]! as String, orderType: row[6]! as String, notionalUsd: _readOptionalNumeric(row[7]), qty: _readOptionalNumeric(row[8]), status: row[9]! as String, questionId: questionId, ruleId: row[11] as String?, submittedAt: row[12] as DateTime?, filledAt: row[13] as DateTime?, raw: raw, createdAt: row[15] as DateTime?, ); } static num? _readOptionalNumeric(Object? value) { if (value == null) { return null; } if (value is num) { return value; } if (value is String) { return num.parse(value); } return num.parse(value.toString()); } }