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)); } }