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
| Arquivo | Responsabilidade |
|---|---|
services/sale/sale-draft/sale-draft-transfer/transfer.sale-draft.service.ts | Export/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.ts | Criptografia/descriptografia do envelope (AES-GCM com fallback) |
features/sale/hooks/useSaleActions.ts | exportSelectedSale / importSaleFromFile, pontos de entrada chamados pela tela de lista de vendas |
features/sale/components/import-modal/import-modal.tsx | Modal "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:
- 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. HASH-STREAM-1, o fallback: só é usado secrypto.subtlenã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 (saleDraftTransferPayloadSchema → saleDraftTransferDraftSchema → saleDraftTransferItemSchema) 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_KEYausente 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.