import axios from "axios"; import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp"; export type BancoPontosEstaAtivoFiltro = "all" | "true" | "false"; export type BancoPontosSaldoItem = { parceiroId: string; nome: string; codinome: string | null; estaAtivo: boolean; logoUrl: string | null; /** URL pública da foto (Commander pode enviar junto com `logoUrl`). */ fotoUrl?: string | null; saldo: number; }; export type BancoPontosExtratoItem = { id: string; parceiroId: string; fechamentoId: string | null; tipo: "credito" | "debito"; quantidade: number; descricao: string; criadoEm: string; /** Mês da competência do fechamento (1–12), se a linha estiver vinculada a um fechamento. */ competenciaMes: number | null; /** Ano da competência do fechamento, se houver. */ competenciaAno: number | null; }; export type BancoPontosParceiroExtrato = { parceiroId: string; nome: string; codinome: string | null; logoUrl: string | null; fotoUrl?: string | null; saldo: number; }; type MetaResponse = { total: number; paginaAtual: number; totalPaginas: number; }; export type ListarBancoPontosSaldosResponse = { data: BancoPontosSaldoItem[]; meta: MetaResponse; }; export type ListarBancoPontosExtratoResponse = { parceiro: BancoPontosParceiroExtrato; data: BancoPontosExtratoItem[]; meta: MetaResponse; }; type ApiErrorShape = { error?: { message?: string; }; }; function pickLogoUrl(row: { logoUrl?: unknown; logo_url?: unknown }): string | null { const v = row.logoUrl ?? row.logo_url; if (v == null) return null; const s = String(v).trim(); return s.length > 0 ? s : null; } function pickSaldo(row: { saldo?: unknown; saldo_atual?: unknown }): number { const v = row.saldo ?? row.saldo_atual; if (v == null || v === "") return 0; const n = Number(v); return Number.isFinite(n) ? n : 0; } function pickFotoUrl(raw: { fotoUrl?: unknown; foto_url?: unknown }): string | null { const v = raw.fotoUrl ?? raw.foto_url; if (v == null) return null; const s = String(v).trim(); return s.length > 0 ? s : null; } function normalizeSaldoItem(raw: Record): BancoPontosSaldoItem { const logo = pickLogoUrl(raw); const foto = pickFotoUrl(raw) ?? logo; return { parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""), nome: String(raw.nome ?? ""), codinome: raw.codinome != null ? String(raw.codinome) : null, estaAtivo: Boolean(raw.estaAtivo ?? raw.esta_ativo), logoUrl: foto, fotoUrl: foto, saldo: pickSaldo(raw), }; } function normalizeParceiroExtrato(raw: Record): BancoPontosParceiroExtrato { const logo = pickLogoUrl(raw); const foto = pickFotoUrl(raw) ?? logo; return { parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""), nome: String(raw.nome ?? ""), codinome: raw.codinome != null ? String(raw.codinome) : null, logoUrl: foto, fotoUrl: foto, saldo: pickSaldo(raw), }; } function pickNullableInt(v: unknown): number | null { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; } export function normalizeBancoPontosExtratoItem(raw: Record): BancoPontosExtratoItem { const fechamentoRaw = raw.fechamentoId ?? raw.fechamento_id; return { id: String(raw.id ?? ""), parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""), fechamentoId: fechamentoRaw != null && String(fechamentoRaw).trim() !== "" ? String(fechamentoRaw) : null, tipo: raw.tipo === "debito" ? "debito" : "credito", quantidade: Number(raw.quantidade ?? 0), descricao: String(raw.descricao ?? ""), criadoEm: String(raw.criadoEm ?? raw.criado_em ?? ""), competenciaMes: pickNullableInt(raw.competenciaMes ?? raw.competencia_mes), competenciaAno: pickNullableInt(raw.competenciaAno ?? raw.competencia_ano), }; } export type ListarBancoPontosSaldosParams = { estaAtivo?: BancoPontosEstaAtivoFiltro; busca?: string; page: number; perPage: number; }; export type ListarBancoPontosExtratoParams = { page: number; perPage: number; }; export type ExportarBancoPontosExtratoResponse = { buffer: ArrayBuffer; filename: string | null; }; class FechamentoBancoPontosService { private extractFilenameFromContentDisposition(value: string | undefined): string | null { if (!value) return null; const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { try { return decodeURIComponent(utf8Match[1]); } catch { return utf8Match[1]; } } const regularMatch = value.match(/filename="?([^";]+)"?/i); return regularMatch?.[1] ?? null; } private messageFromResponseData(data: unknown): string | null { if (data == null) return null; if (typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) { const msg = (data as ApiErrorShape).error?.message; return typeof msg === "string" && msg.trim() ? msg : null; } if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); if (buf.byteLength === 0) return null; try { const text = new TextDecoder().decode(buf); const trimmed = text.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { const json = JSON.parse(trimmed) as ApiErrorShape; if (typeof json.error?.message === "string" && json.error.message.trim()) { return json.error.message; } } } catch { return null; } } return null; } private handleAxiosError(error: unknown, fallback: string): never { if (axios.isAxiosError(error)) { if (!error.response) { throw new Error( "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.", ); } const status = error.response.status; const fromBody = this.messageFromResponseData(error.response.data) ?? (status === 404 ? "Recurso não encontrado. Confirme se o backend está atualizado (rota de exportação)." : null); const message = (fromBody ?? error.message ?? fallback) as string; throw new Error(message); } throw new Error(fallback); } async listarSaldos(params: ListarBancoPontosSaldosParams): Promise { try { const headers = await buildCommanderHeaders(); const baseUrl = resolveCommanderBaseUrl(); const response = await axios.get(`${baseUrl}/banco-pontos`, { headers, params: { estaAtivo: params.estaAtivo ?? "all", busca: params.busca?.trim() || undefined, page: params.page, perPage: params.perPage, }, }); const body = response.data; return { ...body, data: (body.data ?? []).map((row) => normalizeSaldoItem(row as unknown as Record)), }; } catch (error) { this.handleAxiosError(error, "Erro ao listar saldos do banco de pontos."); } } async listarExtrato( parceiroId: string, params: ListarBancoPontosExtratoParams, ): Promise { try { const headers = await buildCommanderHeaders(); const baseUrl = resolveCommanderBaseUrl(); const response = await axios.get( `${baseUrl}/banco-pontos/${parceiroId}/extrato`, { headers, params: { page: params.page, perPage: params.perPage, }, }, ); const body = response.data; const parceiroRaw = body.parceiro as unknown; const parceiroNorm = parceiroRaw != null && typeof parceiroRaw === "object" ? normalizeParceiroExtrato(parceiroRaw as Record) : body.parceiro; return { ...body, parceiro: parceiroNorm, data: (body.data ?? []).map((row) => normalizeBancoPontosExtratoItem(row as unknown as Record)), }; } catch (error) { this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos."); } } async exportarExtrato(parceiroId: string): Promise { try { const headers = await buildCommanderHeaders(); const baseUrl = resolveCommanderBaseUrl(); const response = await axios.get( `${baseUrl}/banco-pontos/${parceiroId}/extrato/exportar`, { headers, responseType: "arraybuffer", }, ); const buf = response.data; if (buf.byteLength < 4) { throw new Error("Resposta vazia ao exportar planilha."); } const bytes = new Uint8Array(buf); const headText = new TextDecoder().decode(bytes.subarray(0, Math.min(256, bytes.length))).trim(); if (headText.startsWith("{")) { let json: ApiErrorShape; try { json = JSON.parse(new TextDecoder().decode(bytes)) as ApiErrorShape; } catch { throw new Error("A API retornou uma resposta inválida ao exportar."); } const msg = json.error?.message?.trim(); throw new Error(msg || "Falha ao exportar extrato (resposta JSON). Atualize o Commander."); } if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) { throw new Error("A API não retornou um arquivo XLSX válido (assinatura inválida)."); } return { buffer: buf, filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]), }; } catch (error) { if (axios.isAxiosError(error)) { this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos."); } throw error instanceof Error ? error : new Error("Erro ao exportar extrato do banco de pontos."); } } } export const fechamentoBancoPontosService = new FechamentoBancoPontosService();