358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
|
|
<?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());
|
||
|
|
}
|
||
|
|
}
|