Skip to content

Commit

Permalink
Enhance Tax Export Command Date Handling
Browse files Browse the repository at this point in the history
- 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
JhumanJ committed Jan 14, 2025
1 parent 747b41d commit 9bf83d5
Show file tree
Hide file tree
Showing 2 changed files with 387 additions and 17 deletions.
357 changes: 357 additions & 0 deletions api/app/Console/Commands/Tax/GenerateDesXmlExport.php
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());
}
}
Loading

0 comments on commit 9bf83d5

Please sign in to comment.