/// Hand-written client for `/api/v1/instruments/pending`. /// /// **Temporary.** These routes do not exist in `openapi/openapi.json` yet, so /// `app/packages/api_client` has no generated methods or models for them. The models and /// calls here follow `docs/ai/import-contract.md` literally and are meant to be **deleted** /// once the routes land in the spec and `just gen-client` regenerates the real client — /// at that point the providers should switch to `getInstrumentsApi()` and these classes /// should give way to the generated ones. /// /// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, the bearer header and the /// one-shot refresh on 401 are already wired there, so this layer only shapes URLs and JSON. library; import 'package:dio/dio.dart'; /// A report line whose instrument the server refused to guess. Mirrors /// `PendingInstrumentOut`; the extra fields (`firstSeenFileId`, `createdAt`) are absent /// from the copy embedded in `ImportPreview.pending_instruments`, hence nullable. class PendingInstrument { const PendingInstrument({ required this.id, required this.source, required this.sourceKey, required this.status, this.isin, this.ticker, this.board, this.name, this.currency, this.assetClassHint, this.occurrences = 0, this.sampleQuantity, this.samplePrice, this.instrumentId, this.firstSeenFileId, this.createdAt, }); final int id; final String source; final String sourceKey; final String status; final String? isin; final String? ticker; final String? board; final String? name; final String? currency; final String? assetClassHint; final int occurrences; final String? sampleQuantity; final String? samplePrice; final int? instrumentId; final int? firstSeenFileId; final DateTime? createdAt; /// The best human label available, never a guess about which instrument this is. String get title { final parts = [?ticker, ?name]; if (parts.isNotEmpty) return parts.join(' · '); return isin ?? sourceKey; } static PendingInstrument fromJson(Map json) => PendingInstrument( id: asInt(json['id'])!, source: asString(json['source']) ?? '', sourceKey: asString(json['source_key']) ?? '', status: asString(json['status']) ?? 'pending', isin: asString(json['isin']), ticker: asString(json['ticker']), board: asString(json['board']), name: asString(json['name']), currency: asString(json['currency']), assetClassHint: asString(json['asset_class_hint']), occurrences: asInt(json['occurrences']) ?? 0, sampleQuantity: asString(json['sample_quantity']), samplePrice: asString(json['sample_price']), instrumentId: asInt(json['instrument_id']), firstSeenFileId: asInt(json['first_seen_file_id']), createdAt: asDate(json['created_at']), ); } /// What `POST /instruments/pending/{id}/resolve` reports back. class PendingResolveResult { const PendingResolveResult({ required this.id, required this.status, this.instrumentId, this.eventsBound = 0, this.aliasCreated = false, this.metricsRefreshed = false, }); final int id; final String status; final int? instrumentId; final int eventsBound; final bool aliasCreated; final bool metricsRefreshed; static PendingResolveResult fromJson(Map json) => PendingResolveResult( id: asInt(json['id']) ?? 0, status: asString(json['status']) ?? 'resolved', instrumentId: asInt(json['instrument_id']), eventsBound: asInt(json['events_bound']) ?? 0, aliasCreated: json['alias_created'] == true, metricsRefreshed: json['metrics_refreshed'] == true, ); } /// The body of `action: "create"` — the user's own answer, typed in by hand. class NewInstrument { const NewInstrument({ required this.assetClass, required this.name, required this.currency, this.isin, this.ticker, this.board, this.lot, }); /// A plain string on the wire: `AssetClass` is deliberately not exposed by the API /// (`index` cannot be a Dart enum member — it collides with `Enum.index`). final String assetClass; final String name; final String currency; final String? isin; final String? ticker; final String? board; final int? lot; Map toJson() => { 'asset_class': assetClass, 'name': name, 'currency': currency, if (isin != null && isin!.isNotEmpty) 'isin': isin, if (ticker != null && ticker!.isNotEmpty) 'ticker': ticker, if (board != null && board!.isNotEmpty) 'board': board, if (lot != null) 'lot': lot, }; } /// The asset classes the contract allows, as wire strings. const assetClassKeys = [ 'share', 'bond', 'etf', 'fund', 'currency', 'index', 'deposit', 'real_estate', 'crypto', 'custom', ]; class PendingApi { const PendingApi(this._dio); final Dio _dio; static const _base = '/api/v1/instruments/pending'; Future> list({ String status = 'pending', int limit = 100, int offset = 0, }) async { final r = await _dio.get>( _base, queryParameters: {'status': status, 'limit': limit, 'offset': offset}, ); return (r.data ?? const []) .map((e) => PendingInstrument.fromJson(Map.from(e as Map))) .toList(); } Future link(int id, int instrumentId) => _resolve(id, {'action': 'link', 'instrument_id': instrumentId}); Future create(int id, NewInstrument instrument) => _resolve(id, {'action': 'create', 'instrument': instrument.toJson()}); Future ignore(int id) => _resolve(id, {'action': 'ignore'}); Future _resolve(int id, Map body) async { final r = await _dio.post>('$_base/$id/resolve', data: body); return PendingResolveResult.fromJson(r.data ?? const {}); } } // --- JSON coercion helpers, shared with the imports layer ------------------------------- // // The server sends money and quantities as strings and never as numbers; these helpers do // not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept // as its lossless string form and parsed into `Decimal` at the point of display. String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); int? asInt(Object? v) => switch (v) { null => null, final int i => i, final String s => int.tryParse(s), _ => null, }; DateTime? asDate(Object? v) { final s = asString(v); if (s == null || s.isEmpty) return null; return DateTime.tryParse(s); }