From 9bf83d5f89b43382a9e5859f038c7da6a114bd49 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Tue, 14 Jan 2025 14:40:26 +0100 Subject: [PATCH] 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. --- .../Commands/Tax/GenerateDesXmlExport.php | 357 ++++++++++++++++++ .../Commands/Tax/GenerateTaxExport.php | 47 ++- 2 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 api/app/Console/Commands/Tax/GenerateDesXmlExport.php diff --git a/api/app/Console/Commands/Tax/GenerateDesXmlExport.php b/api/app/Console/Commands/Tax/GenerateDesXmlExport.php new file mode 100644 index 00000000..6f2acad4 --- /dev/null +++ b/api/app/Console/Commands/Tax/GenerateDesXmlExport.php @@ -0,0 +1,357 @@ +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(''); + + // 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()); + } +} diff --git a/api/app/Console/Commands/Tax/GenerateTaxExport.php b/api/app/Console/Commands/Tax/GenerateTaxExport.php index ec90ceea..cad34469 100644 --- a/api/app/Console/Commands/Tax/GenerateTaxExport.php +++ b/api/app/Console/Commands/Tax/GenerateTaxExport.php @@ -68,31 +68,42 @@ class GenerateTaxExport extends Command $startDate = $this->option('start-date'); $endDate = $this->option('end-date'); - // Validate the date format - if ($startDate && ! Carbon::createFromFormat('Y-m-d', $startDate)) { + // 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 ($endDate && ! Carbon::createFromFormat('Y-m-d', $endDate)) { + // 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; - } elseif (! $endDate && $this->option('full-month')) { - $endDate = Carbon::parse($startDate)->endOfMonth()->endOfDay()->format('Y-m-d'); } - $this->info('Start date: '.$startDate); - $this->info('End date: '.$endDate); + $this->info('Start date: ' . $startDate); + $this->info('End date: ' . $endDate); $processedInvoices = []; // Create a progress bar $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'], + '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) { @@ -135,14 +146,14 @@ class GenerateTaxExport extends Command $aggregatedReport = $this->aggregateReport($processedInvoices); - $filePath = 'opnform-tax-export-per-invoice_'.$startDate.'_'.$endDate.'.xlsx'; + $filePath = 'opnform-tax-export-per-invoice_' . $startDate . '_' . $endDate . '.xlsx'; $this->exportAsXlsx($processedInvoices, $filePath); - $aggregatedReportFilePath = 'opnform-tax-export-aggregated_'.$startDate.'_'.$endDate.'.xlsx'; + $aggregatedReportFilePath = 'opnform-tax-export-aggregated_' . $startDate . '_' . $endDate . '.xlsx'; $this->exportAsXlsx($aggregatedReport, $aggregatedReportFilePath); // Display the results - $this->info('Total invoices: '.$totalInvoice.' (with '.$paymentNotSuccessfulCount.' payment not successful or trial free invoice)'); + $this->info('Total invoices: ' . $totalInvoice . ' (with ' . $paymentNotSuccessfulCount . ' payment not successful or trial free invoice)'); return Command::SUCCESS; } @@ -222,9 +233,11 @@ class GenerateTaxExport extends Command private function computeTaxRate($countryCode, $vatId) { // Since we're a French company, for France, always apply 20% VAT - if ($countryCode == 'FR' || + if ( + $countryCode == 'FR' || is_null($countryCode) || - empty($countryCode)) { + empty($countryCode) + ) { return self::EU_TAX_RATES['FR']; } @@ -252,6 +265,6 @@ class GenerateTaxExport extends Command } (new ArrayExport($data))->store($filename, 'local', \Maatwebsite\Excel\Excel::XLSX); - $this->line('File generated: '.storage_path('app/'.$filename)); + $this->line('File generated: ' . storage_path('app/' . $filename)); } }