Skip to content
OwnerPro-SoftwarePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Larafocus

Laravel package for integration with the FocusNFe API.

Manage NFSe (electronic service invoices), companies, webhooks, and search municipalities — all through a clean, type-safe API with fully validated DTOs.

CI License: MIT

Requirements

  • PHP 8.2+
  • Laravel 12 or 13
  • Extensions: json, curl

Installation

The package is not published on Packagist; install it straight from GitHub:

composer config repositories.larafocus vcs https://github.com/OwnerPro-Software/larafocus
composer require ownerpro/larafocus

The service provider is auto-discovered.

Configuration

Publish the config file:

php artisan vendor:publish --provider="Larafocus\LarafocusServiceProvider"

Add these to your .env:

LARAFOCUS_ENVIRONMENT=sandbox
LARAFOCUS_SANDBOX_TOKEN=your-sandbox-token
LARAFOCUS_PRODUCTION_TOKEN=your-production-token
LARAFOCUS_MASTER_TOKEN=your-master-token
Variable Description Default
LARAFOCUS_ENVIRONMENT sandbox or production sandbox
LARAFOCUS_SANDBOX_TOKEN Token for sandbox API —
LARAFOCUS_PRODUCTION_TOKEN Token for production API —
LARAFOCUS_MASTER_TOKEN Master token for Companies API —
LARAFOCUS_PREFIX API path prefix /v2
LARAFOCUS_SANDBOX_ENDPOINT Sandbox base URL https://homologacao.focusnfe.com.br
LARAFOCUS_PRODUCTION_ENDPOINT Production base URL https://api.focusnfe.com.br
LARAFOCUS_TIMEOUT Total request timeout in seconds (emission can be slow) 60
LARAFOCUS_CONNECT_TIMEOUT Connection timeout in seconds (a dead host fails fast) 10

Usage

All interactions go through the Focus facade, which provides four resources:

use Larafocus\Focus;

Focus::nfse();       // NFSe operations
Focus::companies();  // Company management (requires master token)
Focus::hooks();      // Webhook management
Focus::search();     // Municipality, service, and tax code search

Every method returns a FocusResponse with:

$response->statusCode;   // int — HTTP status code
$response->success;      // bool — true if status < 400
$response->data;         // array — parsed JSON response
$response->errors;       // array — extracted errors (if any)
$response->response;     // Illuminate\Http\Client\Response — raw response
$response->isNotFound(); // bool — true when Focus reported the ref as non-existent (404)

NFSe

use Larafocus\Focus;
use Larafocus\Nfse\DTO\NfseRequest;
use Larafocus\Nfse\DTO\NfseCancelRequest;
use Larafocus\Nfse\DTO\NfseEmailRequest;
use Larafocus\Nfse\DTO\Prestador;
use Larafocus\Nfse\DTO\Tomador;
use Larafocus\Nfse\DTO\Servico;
use Larafocus\Nfse\DTO\Enums\NaturezaOperacao;

// Create
$response = Focus::nfse()->create('ref-001', new NfseRequest(
    data_emissao: '2024-01-15T10:30:00-03:00',
    natureza_operacao: NaturezaOperacao::TributacaoMunicipio,
    optante_simples_nacional: true,
    prestador: new Prestador(
        cnpj: '12345678000195',
        inscricao_municipal: '12345',
    ),
    tomador: new Tomador(
        cnpj: '98765432000187',
        email: 'client@example.com',
    ),
    servico: new Servico(
        valor_servicos: 1500.00,
        iss_retido: false,
        item_lista_servico: '1.07',
        discriminacao: 'Desenvolvimento de software',
        codigo_municipio: '3550308',
    ),
));

// Get
$response = Focus::nfse()->get('ref-001');

// Cancel
$response = Focus::nfse()->cancel('ref-001', new NfseCancelRequest(
    justificativa: 'Cancelamento solicitado pelo tomador',
));

// Send by email
$response = Focus::nfse()->email('ref-001', new NfseEmailRequest(
    emails: ['recipient@example.com'],
));

// Trigger webhook
$response = Focus::nfse()->hook('ref-001');

Each email must be a valid email address (validated with FILTER_VALIDATE_EMAIL). The list accepts 1 to 10 addresses.

Arrays are also accepted — they are converted to DTOs automatically:

$response = Focus::nfse()->create('ref-002', [
    'data_emissao' => '2024-01-15T10:30:00-03:00',
    'natureza_operacao' => '1',
    'optante_simples_nacional' => true,
    'prestador' => ['cnpj' => '12345678000195', 'inscricao_municipal' => '12345'],
    'tomador' => ['cnpj' => '98765432000187'],
    'servico' => [
        'valor_servicos' => 1500.00,
        'iss_retido' => false,
        'item_lista_servico' => '1.07',
        'discriminacao' => 'Desenvolvimento de software',
        'codigo_municipio' => '3550308',
    ],
]);

Companies

Requires LARAFOCUS_MASTER_TOKEN. Always hits the production endpoint.

use Larafocus\Focus;
use Larafocus\Companies\DTO\EmpresaRequest;
use Larafocus\Companies\DTO\Enums\RegimeTributario;

// List (with optional offset for pagination)
$response = Focus::companies()->list();
$response = Focus::companies()->list(10);

// Get
$response = Focus::companies()->get('company-id');

// Create
$response = Focus::companies()->create(new EmpresaRequest(
    nome: 'Empresa LTDA',
    nome_fantasia: 'Empresa',
    cnpj: '12345678000195',
    regime_tributario: RegimeTributario::SimplesNacional,
    logradouro: 'Rua X',
    numero: '123',
    bairro: 'Centro',
    municipio: 'Sao Paulo',
    cep: '01310100',
    uf: 'SP',
    telefone: '1133334444',
    email: 'contact@empresa.com',
    habilita_nfse: true,
));

// Update
$response = Focus::companies()->update('company-id', new EmpresaRequest(
    nome: 'Novo Nome LTDA',
));

// Delete
$response = Focus::companies()->delete('company-id');

In sandbox environment, create() and update() automatically append ?dry_run=1.

Webhooks

use Larafocus\Focus;
use Larafocus\Hooks\DTO\WebhookRequest;
use Larafocus\Hooks\DTO\Enums\WebhookEvent;

// List all
$response = Focus::hooks()->list();

// Create
$response = Focus::hooks()->create(new WebhookRequest(
    event: WebhookEvent::Nfse,
    url: 'https://example.com/webhook/nfse',
));

// Get
$response = Focus::hooks()->get('hook-id');

// Delete
$response = Focus::hooks()->delete('hook-id');

The webhook url must be a valid URL using https or http scheme.

Search

use Larafocus\Focus;

// Municipalities
Focus::search()->cities()->list();
Focus::search()->cities()->get('3550308');

// Services for a city
Focus::search()->cities()->servicesFor('3550308')->list();
Focus::search()->cities()->servicesFor('3550308')->get('1.07');

// Tax codes for a city
Focus::search()->cities()->taxCodesFor('3550308')->list();
Focus::search()->cities()->taxCodesFor('3550308')->get('code-123');

File URLs

The FocusNFe API returns relative paths for file downloads (XML, cancellation XML). Use resolveFileUrl to build the full URL:

use Larafocus\Focus;

// Build download URL from a relative path
$xmlUrl = Focus::resolveFileUrl($data['caminho_xml_nota_fiscal']);
// => "https://homologacao.focusnfe.com.br/v2/nfse/abc123.xml"

$cancelXmlUrl = Focus::resolveFileUrl($data['caminho_xml_cancelamento']);
// => "https://homologacao.focusnfe.com.br/v2/nfse/abc123-cancelamento.xml"

The relativePath must start with / and must not contain path traversal sequences (..) or null bytes.

The URL respects the current environment (sandbox or production).

Switching Environments

use Larafocus\Focus;
use Larafocus\Infrastructure\Environment;

// Temporary override (returns a new instance, does not affect the singleton)
Focus::using(
    environment: Environment::Production,
    token: 'custom-token',
    timeout: 30,
    connectTimeout: 5,
)->nfse()->get('ref-001');

// Persistent change (mutates the singleton)
Focus::config(
    environment: Environment::Production,
    token: 'production-token',
);

Error Handling

DTOs validate their input on construction. Invalid data throws InvalidDtoException:

use Larafocus\Shared\InvalidDtoException;

try {
    Focus::nfse()->cancel('ref', new NfseCancelRequest(
        justificativa: 'Too short', // must be 15-255 characters
    ));
} catch (InvalidDtoException $e) {
    echo $e->getMessage();
}

API errors are captured in FocusResponse::$errors without throwing:

$response = Focus::nfse()->get('nonexistent-ref');

if (! $response->success) {
    foreach ($response->errors as $error) {
        // Handle API error
    }
}

Transport failures and timeout reconciliation

A response that arrives — including a definitive rejection such as erro_autorizacao, 404, 408, or 429 — is always returned as a FocusResponse. Only when no trustworthy answer is obtained does the client throw a typed transport exception, all extending Larafocus\Exceptions\CommunicationException:

Exception Meaning Safe action
RequestNotDeliveredException The request never reached Focus (DNS, connect, or TLS failure). Retry directly — nothing was emitted or cancelled.
IndeterminateResultException The request may have been processed (timeout, unreadable 2xx, or a 5xx on a write). Reconcile before retrying — the outcome is unknown.

Because the ref deduplicates on Focus, reconciling with get($ref) never risks a duplicate:

use Larafocus\Exceptions\IndeterminateResultException;
use Larafocus\Exceptions\RequestNotDeliveredException;

try {
    $response = Focus::nfse()->create($reference, $payload);
    // handle success / erro_autorizacao / processando_autorizacao
} catch (RequestNotDeliveredException $e) {
    // Nothing reached Focus — retrying the same call is safe.
} catch (IndeterminateResultException $e) {
    // It may have been emitted — reconcile by reference.
    $lookup = Focus::nfse()->get($reference);

    if ($lookup->isNotFound()) {
        // Not emitted — re-emitting the same ref is safe.
    } else {
        // Emitted — a webhook/verify will complete it.
    }
}

The IndeterminateResultException::$phase property (read, transfer, body, …) records where the failure happened, for logging.

Testing

./vendor/bin/pest --coverage --min=100 --parallel
./vendor/bin/pest --mutate --min=100 --parallel
./vendor/bin/pest --type-coverage --min=100
./vendor/bin/phpstan analyse
./vendor/bin/psalm --taint-analysis
./vendor/bin/rector --dry-run
./vendor/bin/pint --test

License

MIT. See LICENSE for details.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages