-
Notifications
You must be signed in to change notification settings - Fork 323
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Enhance Tax Export Command Date Handling
- Improved date validation logic in the GenerateTaxExport command. - Added default start date as the first day of the previous month if not specified, with user confirmation. - Set default end date to the end of the month based on the start date if not provided. - Refactored date formatting for better readability and consistency in log messages. - Enhanced the clarity of the progress bar query options by formatting the expand array. These changes improve user experience by providing sensible defaults for date inputs and ensuring clear communication of the command's actions.
- Loading branch information
Showing
2 changed files
with
387 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,357 @@ | ||
<?php | ||
|
||
namespace App\Console\Commands\Tax; | ||
|
||
use Carbon\Carbon; | ||
use Illuminate\Console\Command; | ||
use Laravel\Cashier\Cashier; | ||
use Stripe\Invoice; | ||
|
||
/** | ||
* This command generates an XML file for DES (Déclaration Européenne de Services) reporting | ||
* to French customs authorities. It processes Stripe invoices within a given date range | ||
* and creates an XML file following the official DES schema. | ||
* | ||
* The XML file includes: | ||
* - Company VAT number | ||
* - Declaration period (month/year) | ||
* - Declaration type (DES) | ||
* - Flow type (INTRODUCTION) | ||
* - Line items for each EU customer with: | ||
* - Line number | ||
* - Value | ||
* - Partner VAT number | ||
* - Country code | ||
* | ||
* Usage: | ||
* php artisan stripe:generate-des-xml --vat=FR12345678900 --start-date=2024-01-01 --end-date=2024-01-31 | ||
* | ||
* Options: | ||
* --start-date : Start date in YYYY-MM-DD format (defaults to first day of previous month) | ||
* --end-date : End date in YYYY-MM-DD format (defaults to last day of start date's month) | ||
* --full-month : Use the full month of the start date | ||
* --vat : Company VAT number (required, must start with FR) | ||
*/ | ||
|
||
class GenerateDesXmlExport extends Command | ||
{ | ||
/** | ||
* The name and signature of the console command. | ||
* | ||
* @var string | ||
*/ | ||
protected $signature = 'stripe:generate-des-xml | ||
{--start-date= : Start date (YYYY-MM-DD)} | ||
{--end-date= : End date (YYYY-MM-DD)} | ||
{--full-month : Use the full month of the start date} | ||
{--vat= : French VAT number (must start with FR)}'; | ||
|
||
/** | ||
* The console command description. | ||
* | ||
* @var string | ||
*/ | ||
protected $description = 'Generate DES XML file for French customs'; | ||
|
||
private const DECLARATION_TYPE = 'DES'; | ||
private const FLUX_TYPE = 'INTRODUCTION'; | ||
|
||
public function handle() | ||
{ | ||
// Validate required options | ||
$vatNumber = $this->option('vat'); | ||
if (!$vatNumber) { | ||
$this->error('VAT number is required'); | ||
return Command::FAILURE; | ||
} | ||
|
||
// Validate VAT number format | ||
if (!preg_match('/^FR[0-9A-Z]{11}$/', $vatNumber)) { | ||
$this->error('Invalid French VAT number format. Must start with FR followed by 11 characters.'); | ||
return Command::FAILURE; | ||
} | ||
|
||
// Get dates | ||
$startDate = $this->option('start-date'); | ||
$endDate = $this->option('end-date'); | ||
|
||
// If no start date, use first day of previous month | ||
if (!$startDate) { | ||
$startDate = Carbon::now()->subMonth()->startOfMonth()->format('Y-m-d'); | ||
if (!$this->confirm("No start date specified. Use {$startDate}?", true)) { | ||
return Command::FAILURE; | ||
} | ||
} elseif (!Carbon::createFromFormat('Y-m-d', $startDate)) { | ||
$this->error('Invalid start date format. Use YYYY-MM-DD.'); | ||
return Command::FAILURE; | ||
} | ||
|
||
// If no end date, use end of the month from start date | ||
if (!$endDate) { | ||
$endDate = Carbon::parse($startDate)->endOfMonth()->format('Y-m-d'); | ||
$this->info("Using end date: {$endDate}"); | ||
} elseif (!Carbon::createFromFormat('Y-m-d', $endDate)) { | ||
$this->error('Invalid end date format. Use YYYY-MM-DD.'); | ||
return Command::FAILURE; | ||
} | ||
|
||
$this->info('Start date: ' . $startDate); | ||
$this->info('End date: ' . $endDate); | ||
|
||
// Get invoices | ||
$invoices = $this->getInvoices($startDate, $endDate); | ||
|
||
// Generate XML | ||
$xml = $this->generateXml($invoices); | ||
|
||
// Save XML file with .xml extension | ||
$period = Carbon::parse($startDate)->format('Ym'); | ||
$filename = "DES_{$period}.xml"; | ||
file_put_contents(storage_path("app/{$filename}"), $xml->asXML()); | ||
|
||
$this->info("XML file generated: " . storage_path("app/{$filename}")); | ||
|
||
return Command::SUCCESS; | ||
} | ||
|
||
private function getInvoices($startDate, $endDate) | ||
{ | ||
$processedInvoices = []; | ||
|
||
$queryOptions = [ | ||
'limit' => 100, | ||
'expand' => [ | ||
'data.customer', | ||
'data.customer.address', | ||
'data.customer.tax_ids', | ||
'data.payment_intent', | ||
'data.payment_intent.payment_method', | ||
'data.charge.balance_transaction' | ||
], | ||
'status' => 'paid', | ||
]; | ||
|
||
if ($startDate) { | ||
$queryOptions['created']['gte'] = Carbon::parse($startDate)->startOfDay()->timestamp; | ||
} | ||
if ($endDate) { | ||
$queryOptions['created']['lte'] = Carbon::parse($endDate)->endOfDay()->timestamp; | ||
} | ||
|
||
$invoices = Cashier::stripe()->invoices->all($queryOptions); | ||
$bar = $this->output->createProgressBar(); | ||
$bar->start(); | ||
|
||
do { | ||
foreach ($invoices as $invoice) { | ||
// Skip cancelled or uncollectible invoices | ||
if ($invoice->status === 'void' || $invoice->status === 'uncollectible' || $invoice->amount_remaining === $invoice->total) { | ||
continue; | ||
} | ||
|
||
// Ignore if payment was not successful | ||
if (($invoice->payment_intent->status ?? null) !== 'succeeded') { | ||
continue; | ||
} | ||
|
||
// Only process EU B2B invoices with VAT number | ||
if (!$this->isEligibleForDes($invoice)) { | ||
continue; | ||
} | ||
|
||
try { | ||
$processedInvoices[] = $this->formatInvoiceForDes($invoice); | ||
$bar->advance(); | ||
} catch (\Exception $e) { | ||
$this->warn("Skipping invoice {$invoice->id}: {$e->getMessage()}"); | ||
} | ||
} | ||
|
||
if (!empty($invoices->data)) { | ||
$queryOptions['starting_after'] = end($invoices->data)->id; | ||
} | ||
sleep(1); | ||
$invoices = Cashier::stripe()->invoices->all($queryOptions); | ||
} while ($invoices->has_more); | ||
|
||
$bar->finish(); | ||
$this->line(''); | ||
|
||
return $processedInvoices; | ||
} | ||
|
||
private function isEligibleForDes(Invoice $invoice): bool | ||
{ | ||
$country = $invoice->customer->address->country ?? null; | ||
|
||
// Find VAT ID among tax IDs | ||
$vatId = null; | ||
if (!empty($invoice->customer->tax_ids->data)) { | ||
foreach ($invoice->customer->tax_ids->data as $taxId) { | ||
if ($taxId->type === 'eu_vat') { | ||
$vatId = $taxId->value; | ||
break; | ||
} | ||
} | ||
} | ||
|
||
// Only include EU B2B transactions (with VAT number) | ||
if (!$country || $country === 'FR' || !isset(GenerateTaxExport::EU_TAX_RATES[$country]) || !$vatId) { | ||
return false; | ||
} | ||
|
||
// Validate VAT number format: should start with 2 letters and contain at least one number | ||
$vatId = $this->cleanVatNumber($vatId); | ||
if (!preg_match('/^[A-Z]{2}[A-Z0-9]+$/', $vatId)) { | ||
$this->warn("Invalid VAT number format for invoice {$invoice->id}: {$vatId} (country: {$country})"); | ||
return false; | ||
} | ||
|
||
// Also verify that the country code matches | ||
if (substr($vatId, 0, 2) !== $country) { | ||
$this->warn("VAT number country code doesn't match address for invoice {$invoice->id}: {$vatId} (country: {$country})"); | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
private function formatInvoiceForDes(Invoice $invoice): array | ||
{ | ||
$country = $invoice->customer->address->country; | ||
|
||
// Find VAT ID | ||
$vatId = null; | ||
foreach ($invoice->customer->tax_ids->data as $taxId) { | ||
if ($taxId->type === 'eu_vat') { | ||
$vatId = $taxId->value; | ||
break; | ||
} | ||
} | ||
|
||
if (!$vatId) { | ||
throw new \InvalidArgumentException("No EU VAT number found for invoice {$invoice->id}"); | ||
} | ||
|
||
// Get amount in EUR | ||
$amount = $this->getInvoiceAmountInEur($invoice); | ||
if ($amount === null) { | ||
throw new \RuntimeException("Could not determine EUR amount for invoice {$invoice->id}"); | ||
} | ||
|
||
return [ | ||
'country_code' => $country, | ||
'vat_number' => $this->cleanVatNumber($vatId), | ||
'amount_eur' => $amount, | ||
'created_at' => $invoice->created, | ||
]; | ||
} | ||
|
||
private function getInvoiceAmountInEur(Invoice $invoice): ?float | ||
{ | ||
// If the invoice is already in EUR, just convert from cents | ||
if ($invoice->currency === 'eur') { | ||
return $invoice->amount_paid / 100; | ||
} | ||
|
||
// Try to get the converted amount from the balance transaction | ||
if ($invoice->charge && $invoice->charge->balance_transaction) { | ||
return $invoice->charge->balance_transaction->amount / 100; | ||
} | ||
|
||
// Try to get the amount from payment intent | ||
if ($invoice->payment_intent && $invoice->payment_intent->charges->data) { | ||
foreach ($invoice->payment_intent->charges->data as $charge) { | ||
if ($charge->balance_transaction) { | ||
return $charge->balance_transaction->amount / 100; | ||
} | ||
} | ||
} | ||
|
||
// If we can't find the EUR amount, log it and return null | ||
$this->warn("Could not find EUR amount for invoice {$invoice->id} in {$invoice->currency}"); | ||
return null; | ||
} | ||
|
||
/** | ||
* Get exchange rate for a currency to EUR | ||
* You might want to use a more sophisticated exchange rate service in production | ||
*/ | ||
private function getExchangeRate(string $currency): float | ||
{ | ||
// For now, we'll throw an exception if we can't get the exchange rate from Stripe | ||
throw new \RuntimeException("Unable to convert {$currency} to EUR. No exchange rate available."); | ||
} | ||
|
||
private function cleanVatNumber(string $vatId): string | ||
{ | ||
// Clean any special characters and convert to uppercase | ||
return strtoupper(str_replace(['.', '-', ' '], '', $vatId)); | ||
} | ||
|
||
private function generateXml(array $invoices): \SimpleXMLElement | ||
{ | ||
$this->info('Generating XML file...'); | ||
$bar = $this->output->createProgressBar(3); | ||
|
||
// Create XML without namespace (as per example) | ||
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><fichier_des></fichier_des>'); | ||
|
||
// Group invoices by month first, then by VAT number | ||
$invoicesByMonth = []; | ||
foreach ($invoices as $invoice) { | ||
$month = Carbon::createFromTimestamp($invoice['created_at'])->format('Y-m'); | ||
if (!isset($invoicesByMonth[$month])) { | ||
$invoicesByMonth[$month] = []; | ||
} | ||
|
||
$key = $invoice['vat_number']; // Use full VAT number as key since it includes country code | ||
if (!isset($invoicesByMonth[$month][$key])) { | ||
$invoicesByMonth[$month][$key] = [ | ||
'country_code' => $invoice['country_code'], | ||
'vat_number' => $invoice['vat_number'], | ||
'amount_eur' => 0, | ||
]; | ||
} | ||
$invoicesByMonth[$month][$key]['amount_eur'] += $invoice['amount_eur']; | ||
} | ||
|
||
$bar->advance(); | ||
|
||
// Sort months chronologically | ||
ksort($invoicesByMonth); | ||
|
||
// Create a declaration for each month | ||
foreach ($invoicesByMonth as $month => $groupedInvoices) { | ||
$monthDate = Carbon::createFromFormat('Y-m', $month); | ||
|
||
// Add declaration header for this month | ||
$declaration = $xml->addChild('declaration_des'); | ||
$declaration->addChild('num_des', '00001'); | ||
$declaration->addChild('num_tvaFr', $this->option('vat')); | ||
$declaration->addChild('mois_des', $monthDate->format('m')); | ||
$declaration->addChild('an_des', $monthDate->format('Y')); | ||
|
||
// Add line items for this month | ||
$lineNumber = 1; | ||
foreach ($groupedInvoices as $line) { | ||
$item = $declaration->addChild('ligne_des'); | ||
$item->addChild('numlin_des', str_pad($lineNumber++, 6, '0', STR_PAD_LEFT)); // 6 digits as per example | ||
$item->addChild('valeur', round($line['amount_eur'])); // Round to nearest euro as per DES requirements | ||
$item->addChild('partner_des', $line['vat_number']); // Use full VAT number from Stripe | ||
} | ||
} | ||
|
||
$bar->advance(); | ||
$bar->finish(); | ||
$this->line(''); | ||
|
||
// Format XML with proper indentation | ||
$dom = new \DOMDocument('1.0'); | ||
$dom->preserveWhiteSpace = false; | ||
$dom->formatOutput = true; | ||
$dom->loadXML($xml->asXML()); | ||
|
||
return new \SimpleXMLElement($dom->saveXML()); | ||
} | ||
} |
Oops, something went wrong.