Pular para o conteúdo principal

Compartilhamento e Importação de Pedido

Última atualização: 14 de julho de 2026

Visão Geral

Esse fluxo permite transferir um rascunho de venda ainda não enviado de um dispositivo para outro. É útil quando o vendedor precisa trocar de aparelho ou repassar um pedido para outro vendedor finalizar. O rascunho é exportado como um arquivo .txt criptografado, compartilhado pelo mecanismo nativo de compartilhamento (Share do React Native), e importado de volta via seletor de documentos.

Não confunda com SaleShareSheet (features/sale/components/sale-share-sheet/): aquele componente compartilha relatórios em PDF de um pedido já finalizado, como layout padrão, relatórios do ERP e boletos. São dois recursos de "compartilhar" completamente diferentes, em partes diferentes da tela de vendas.

Arquivos-chave

ArquivoResponsabilidade
services/sale/sale-draft/sale-draft-transfer/transfer.sale-draft.service.tsExport/import: monta o payload, chama a API de compartilhamento nativa, reconstrói drafts importados
services/sale/sale-draft/sale-draft-transfer/transfer.sale-draft.crypto.tsCriptografia/descriptografia do envelope (AES-GCM com fallback)
features/sale/hooks/useSaleActions.tsexportSelectedSale / importSaleFromFile, pontos de entrada chamados pela tela de lista de vendas
features/sale/components/import-modal/import-modal.tsxModal "Importar vendas" (seleção de arquivo)

Quando a exportação está disponível

A exportação só é oferecida para uma venda que ainda é um rascunho offline não enviado. useSaleActions.exportSelectedSale bloqueia explicitamente qualquer outro caso:

const record = selectedSale as unknown as Record<string, unknown>;

if (!record.isOffline) {
feedback.onError(
"A exportação está disponível apenas para vendas offline ainda não enviadas.",
);
return;
}

Exportação

Se o rascunho fizer parte de um pedido dividido por tipo de operação, como visto em Separação por Tipo de Operação, getLinkedDraftGroup traz todos os pedaços vinculados, e todos vão dentro do mesmo arquivo exportado. O pedido é transferido como um todo, não em partes:

async exportDraftAsEncryptedTxt(saleDraftId: string) {
const drafts = await this.getLinkedDraftGroup(saleDraftId);
const payload: SaleDraftTransferPayload = {
app: "force-mobile",
version: 1,
exportedAt: nowIso(),
sourceSaleDraftId: saleDraftId,
vinculoId: drafts[0]?.vinculoId ?? null,
drafts,
};
// ... criptografa e compartilha
}

Formato do arquivo

O arquivo .txt não é o JSON puro: tem uma linha de cabeçalho fixa seguida do envelope criptografado serializado:

const SALE_DRAFT_TRANSFER_HEADER = "FORCE_SALE_DRAFT_TRANSFER_V1";

function buildSaleDraftTransferEnvelopeText(envelope) {
return `${SALE_DRAFT_TRANSFER_HEADER}\n${JSON.stringify(envelope)}`;
}

Isso permite rejeitar rapidamente um arquivo que não seja de exportação do app (parseSaleDraftTransferEnvelopeText lança "Arquivo de venda inválido." se o cabeçalho não bater), antes mesmo de tentar decodificar JSON ou descriptografar.

O nome do arquivo é derivado do cliente: venda-<referência-do-cliente>-<timestamp>.txt.

Criptografia

A chave de criptografia não é por usuário nem por dispositivo. É uma chave fixa do app, configurada em app.json (extra.SALE_DRAFT_TRANSFER_KEY), com mínimo de 16 caracteres:

function getSaleDraftTransferKey(): string {
const extra = (Constants.expoConfig?.extra ?? {}) as SaleDraftTransferExtraConfig;
const key = String(extra.SALE_DRAFT_TRANSFER_KEY ?? "").trim();
if (key.length < 16) {
throw new Error(
"Chave de exportação inválida. Configure SALE_DRAFT_TRANSFER_KEY em app.json.",
);
}
return key;
}

O algoritmo real usado depende do que a Web Crypto API do dispositivo suporta:

  1. AES-GCM, o caminho preferencial: deriva a chave da passphrase via PBKDF2, com 250.000 iterações em SHA-256, e usa crypto.subtle. É o caminho usado sempre que disponível.
  2. HASH-STREAM-1, o fallback: só é usado se crypto.subtle não existir no dispositivo. É um stream cipher artesanal, feito de XOR contra blocos derivados de SHA-256 encadeado, com uma tag de integridade também via SHA-256. Não é AES real, é um algoritmo próprio do projeto, criado para não deixar a exportação completamente inviável em ambientes sem Web Crypto.
export async function encryptSaleDraftTransferContent(
plaintext: string,
passphrase: string,
): Promise<EncryptedSaleDraftTransferEnvelope> {
try {
return await encryptWithAesGcm(plaintext, passphrase);
} catch {
return encryptWithHashStream(plaintext, passphrase);
}
}

Na descriptografia, o algoritmo usado é lido do próprio envelope (envelope.alg), então um arquivo exportado com AES-GCM sempre é importado com AES-GCM, independentemente do dispositivo de destino suportar ou não o fallback.

Cuidado ao alterar isso: HASH-STREAM-1 é criptografia simétrica simples, não uma implementação revisada ou padrão de mercado. Ela existe como fallback de compatibilidade, não como alternativa de segurança equivalente ao AES-GCM. Não recomende esse caminho como "opção" em melhorias futuras sem entender essa diferença.

Importação

Ponto crítico: a importação nunca reaproveita os ids do arquivo. Todo saleDraftId, e por consequência todo id de item, é gerado do zero no dispositivo de destino, mesmo que o arquivo tenha vindo do mesmo dispositivo que o exportou. Se o payload tinha múltiplos drafts vinculados (pedido dividido), um novo vinculoId compartilhado também é gerado para o grupo importado:

private async importDraftsFromTransferPayload(payload: SaleDraftTransferPayload) {
const drafts = sortDraftGroup(payload.drafts, payload.sourceSaleDraftId);
const timestamp = nowIso();
const newVinculoId = drafts.length > 1 ? createSaleDraftId() : null;
const importedSaleDraftIds: string[] = [];

for (const sourceDraft of drafts) {
const nextSaleDraftId = createSaleDraftId();
const nextItems = (sourceDraft.items ?? []).map((item) =>
buildImportedDraftItem({ saleDraftId: nextSaleDraftId, item, timestamp }),
);
// ... monta o novo SaleDraftRecord com status "Rascunho", log limpo, vinculoId novo
await this.draftRepo.upsert(nextDraft);
await this.cartRepo.upsertItemsBatch(nextItems);
importedSaleDraftIds.push(nextSaleDraftId);
}

return { saleDraftIds: importedSaleDraftIds, draftCount: importedSaleDraftIds.length };
}

Isso evita qualquer colisão com rascunhos que já existam localmente e garante que importar o mesmo arquivo duas vezes cria duas cópias independentes (não é idempotente por design).

Validação do conteúdo importado

Antes de tocar no banco, o payload decriptado passa por schemas Zod (saleDraftTransferPayloadSchemasaleDraftTransferDraftSchemasaleDraftTransferItemSchema) que replicam a forma de um SaleDraftRecord/SaleDraftItem válido. Qualquer campo faltando ou de tipo errado faz a importação falhar antes de criar qualquer registro. Não existe importação parcial.

Armadilhas conhecidas

  • Exportação só é permitida para rascunhos offline ainda não enviados. Não é um recurso de "compartilhar pedido finalizado", isso é o SaleShareSheet, de relatórios PDF.
  • SALE_DRAFT_TRANSFER_KEY ausente ou com menos de 16 caracteres quebra export e import imediatamente. É uma configuração de build (app.json), não algo por usuário.
  • Nunca assuma que o id de um draft/item importado é igual ao do arquivo original. Todo id é regenerado no destino.
  • Importar não é idempotente: reimportar o mesmo arquivo cria uma nova cópia do pedido, não atualiza uma existente.
  • HASH-STREAM-1 é um fallback de compatibilidade, não uma alternativa de segurança. Não o generalize para outros usos de criptografia no app sem revisão própria.