Overview
The NCF Generation system provides automated management of tax document numbers required by the Dominican Republic’s DGII (Dirección General de Impuestos Internos). The system handles both physical and electronic NCF formats with full sequence management and audit trails.
NCF generation is automatically integrated with the Point of Sale system and invoice generation.
Architecture
The NCF system is built on a contract-based architecture using dependency injection:
interface NcfGeneratorInterface
{
public function generate ( Sale $sale , int $ncfTypeId ) : string ;
public function hasAvailability ( int $ncfTypeId ) : bool ;
}
The LocalNcfGenerator implementation is automatically injected into SaleService and handles all NCF generation logic.
Core Components
NCF Types
The system supports multiple NCF types as defined by DGII regulations:
class NcfType extends Model
{
protected $fillable = [
'name' , // e.g., "Factura de Crédito Fiscal"
'prefix' , // e.g., "B" or "E"
'code' , // e.g., "01", "02", "31"
'is_electronic' , // Boolean: e-NCF vs physical
'requires_rnc' , // Boolean: requires client tax ID
'is_active'
];
}
Common NCF Types:
B01 : Crédito Fiscal (Tax Credit Invoice)
B02 : Consumidor Final (Consumer Invoice)
B14 : Nota de Crédito (Credit Note)
B15 : Nota de Débito (Debit Note)
E31 : Factura Electrónica (e-NCF)
NCF Sequences
Sequences are authorized ranges of NCF numbers loaded from DGII:
class NcfSequence extends Model
{
const STATUS_ACTIVE = 'active' ;
const STATUS_EXHAUSTED = 'exhausted' ;
const STATUS_EXPIRED = 'expired' ;
protected $fillable = [
'ncf_type_id' ,
'series' , // e.g., "B01" or "E31001"
'from' , // Starting sequence number
'to' , // Ending sequence number
'current' , // Current counter
'expiry_date' , // DGII authorization expiry
'alert_threshold' , // Low stock warning
'status'
];
public function isLow () : bool
{
$remaining = $this -> to - $this -> current ;
return $remaining <= $this -> alert_threshold ;
}
}
Sequences have expiration dates set by DGII. The system automatically marks expired sequences as STATUS_EXPIRED.
NCF Log (Audit Trail)
Every generated NCF is logged for DGII compliance:
class NcfLog extends Model
{
const STATUS_USED = 'used' ;
const STATUS_VOIDED = 'voided' ;
protected $fillable = [
'full_ncf' , // Complete NCF: "B0100000123"
'sale_id' , // Associated sale
'ncf_type_id' ,
'ncf_sequence_id' ,
'status' ,
'cancellation_reason' , // Required for 608 report
'user_id'
];
}
NCF Generation Process
Step 1: Request NCF During Sale
When creating a sale through the POS, optionally specify an NCF type:
public function create ( array $data ) : Sale
{
return DB :: transaction ( function () use ( $data ) {
// ... create sale ...
if ( isset ( $data [ 'ncf_type_id' ])) {
$fullNcf = $this -> ncfGenerator -> generate ( $sale , $data [ 'ncf_type_id' ]);
$sale -> update ([ 'ncf' => $fullNcf ]);
}
// ... continue processing ...
});
}
Step 2: Generate Sequential Number
The generator finds an active sequence and atomically increments the counter:
public function generate ( Sale $sale , int $ncfTypeId ) : string
{
return DB :: transaction ( function () use ( $sale , $ncfTypeId ) {
// 1. Lock the active sequence (prevents race conditions)
$sequence = NcfSequence :: where ( 'ncf_type_id' , $ncfTypeId )
-> where ( 'status' , NcfSequence :: STATUS_ACTIVE )
-> where ( 'expiry_date' , '>=' , now ())
-> lockForUpdate ()
-> first ();
if ( ! $sequence ) {
throw new Exception ( "No active sequences available." );
}
// 2. Increment counter
$nextNumber = $sequence -> current + 1 ;
if ( $nextNumber > $sequence -> to ) {
$sequence -> update ([ 'status' => NcfSequence :: STATUS_EXHAUSTED ]);
throw new Exception ( "Sequence exhausted." );
}
// 3. Determine padding (8 digits for physical, 10 for e-NCF)
$padding = $sequence -> type -> is_electronic ? 10 : 8 ;
// 4. Format complete NCF
// Structure: Series + Type Code + Padded Number
// Example: "B01" + "00000123" = "B0100000123"
$fullNcf = $sequence -> series .
str_pad ( $sequence -> type -> code , 2 , '0' , STR_PAD_LEFT ) .
str_pad ( $nextNumber , $padding , '0' , STR_PAD_LEFT );
// 5. Update sequence
$sequence -> update ([ 'current' => $nextNumber ]);
// 6. Create audit log
NcfLog :: create ([
'full_ncf' => $fullNcf ,
'sale_id' => $sale -> id ,
'ncf_type_id' => $ncfTypeId ,
'ncf_sequence_id' => $sequence -> id ,
'status' => NcfLog :: STATUS_USED ,
'user_id' => Auth :: id () ?? $sale -> user_id ,
]);
return $fullNcf ;
});
}
Acquire Lock
Uses lockForUpdate() to prevent concurrent transactions from generating duplicate NCFs
Validate Availability
Checks sequence status, expiration date, and remaining numbers
Format NCF
Combines series, type code, and padded sequence number
Update & Log
Atomically increments counter and creates audit trail
NCF Cancellation
When a sale is voided, the associated NCF must be marked as cancelled:
public function cancel ( Sale $sale , ? string $reason = null ) : bool
{
return DB :: transaction ( function () use ( $sale , $reason ) {
// ... other cancellation logic ...
// Update NCF log with cancellation reason
NcfLog :: where ( 'sale_id' , $sale -> id )
-> update ([
'status' => NcfLog :: STATUS_VOIDED ,
'cancellation_reason' => $reason ?? 'Manual sale cancellation'
]);
// ... continue ...
});
}
A cancellation reason is required for the DGII 608 report (Comprobantes Anulados).
Sequence Management
Loading New Sequences
When you receive new NCF authorization from DGII:
NcfSequence :: create ([
'ncf_type_id' => 1 , // B01 - Crédito Fiscal
'series' => 'B01' ,
'from' => 1 ,
'to' => 10000 , // 10,000 authorized NCFs
'current' => 0 ,
'expiry_date' => '2026-12-31' , // DGII expiration date
'alert_threshold' => 500 , // Warn when 500 remaining
'status' => NcfSequence :: STATUS_ACTIVE
]);
Monitoring Availability
public function hasAvailability ( int $ncfTypeId ) : bool
{
return NcfSequence :: where ( 'ncf_type_id' , $ncfTypeId )
-> where ( 'status' , NcfSequence :: STATUS_ACTIVE )
-> where ( 'expiry_date' , '>=' , now ())
-> whereColumn ( 'current' , '<' , 'to' )
-> exists ();
}
Low Stock Alerts
The system automatically checks if sequences are running low:
$lowSequences = NcfSequence :: where ( 'status' , NcfSequence :: STATUS_ACTIVE )
-> get ()
-> filter ( function ( $sequence ) {
return $sequence -> isLow ();
});
foreach ( $lowSequences as $sequence ) {
$remaining = $sequence -> to - $sequence -> current ;
// Alert: "Sequence {$sequence->series} has only {$remaining} NCFs remaining"
}
DGII Compliance Reports
607 Report (Sales)
Export all used NCFs for tax reporting:
class NcfLogsExport implements FromQuery , WithHeadings , WithMapping
{
public function map ( $log ) : array
{
return [
$log -> created_at -> format ( 'd/m/Y' ),
$log -> full_ncf ,
$log -> type -> name ,
$log -> sale -> number ,
$log -> sale -> client -> tax_id ?? 'N/A' ,
$log -> sale -> client -> name ,
$log -> sale -> total_amount ,
$log -> sale -> total_amount * 0.18 , // ITBIS calculation
$log -> status == 'used' ? 'Utilizado' : 'Anulado'
];
}
}
608 Report (Cancellations)
Export all voided NCFs with reasons:
$voidedNcfs = NcfLog :: where ( 'status' , NcfLog :: STATUS_VOIDED )
-> whereBetween ( 'created_at' , [ $startDate , $endDate ])
-> with ([ 'sale.client' , 'type' ])
-> get ();
Usage in POS
The POS form allows NCF type selection:
< select name = "ncf_type_id" >
< option value = "" > Sin NCF </ option >
@foreach($ncfTypes as $type)
< option value = "{{ $type->id }}"
@if(!$ncfGenerator- > hasAvailability($type->id)) disabled @endif>
{{ $type->display_name }}
@if(!$ncfGenerator->hasAvailability($type->id))
(Agotado)
@endif
</ option >
@endforeach
</ select >
Best Practices
Always use database transactions when generating NCFs to prevent duplicate numbers or orphaned sequences.
Set appropriate alert_threshold values (typically 10-20% of total capacity) to request new sequences before exhaustion.
For NCF types with requires_rnc = true, validate that the client has a valid tax ID before allowing the transaction.
Cancellation Documentation
Always require and store detailed cancellation reasons for audit compliance. Generic reasons may be rejected by DGII.
Troubleshooting
”No active sequences available”
Cause: All sequences are exhausted, expired, or inactive.
Solution:
Check sequence status: NcfSequence::where('ncf_type_id', $id)->latest()->first()
Load new sequences from DGII authorization
Verify expiration dates
Duplicate NCF Generated
Cause: Transaction not properly isolated or lock not acquired.
Solution:
Verify lockForUpdate() is used
Check database transaction isolation level
Ensure no direct SQL queries bypass the service layer
Sequence Marked Exhausted Prematurely
Cause: current value doesn’t match actual usage.
Solution:
$sequence = NcfSequence :: find ( $id );
$actualUsed = NcfLog :: where ( 'ncf_sequence_id' , $id ) -> count ();
$sequence -> update ([ 'current' => $actualUsed ]);