414 lines
15 KiB
PHP
414 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Tax;
|
|
|
|
use App\Exports\Tax\ArrayExport;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Console\Command;
|
|
use Laravel\Cashier\Cashier;
|
|
use Stripe\Invoice;
|
|
|
|
class GenerateTaxExport extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'stripe:generate-stripe-export
|
|
{--start-date= : Start date (YYYY-MM-DD)}
|
|
{--end-date= : End date (YYYY-MM-DD)}
|
|
{--full-month : Use the full month of the start date}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Compute Stripe VAT per country';
|
|
|
|
public const EU_TAX_RATES = [
|
|
'AT' => 20,
|
|
'BE' => 21,
|
|
'BG' => 20,
|
|
'HR' => 25,
|
|
'CY' => 19,
|
|
'CZ' => 21,
|
|
'DK' => 25,
|
|
'EE' => 22,
|
|
"FI" => 25.5,
|
|
'FR' => 20,
|
|
'DE' => 19,
|
|
'GR' => 24,
|
|
'HU' => 27,
|
|
'IE' => 23,
|
|
'IT' => 22,
|
|
'LV' => 21,
|
|
'LT' => 21,
|
|
'LU' => 17,
|
|
'MT' => 18,
|
|
'NL' => 21,
|
|
'PL' => 23,
|
|
'PT' => 23,
|
|
'RO' => 19,
|
|
'SK' => 20,
|
|
'SI' => 22,
|
|
'ES' => 21,
|
|
'SE' => 25,
|
|
];
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function handle()
|
|
{
|
|
// Start the processing timer
|
|
$startTime = microtime(true);
|
|
|
|
// iterate through all Stripe invoices
|
|
$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);
|
|
|
|
$processedInvoices = [];
|
|
|
|
// Create a progress bar
|
|
$queryOptions = [
|
|
'limit' => 100, // Maximum allowed by Stripe API
|
|
'expand' => [
|
|
'data.customer',
|
|
'data.customer.address',
|
|
'data.customer.tax_ids',
|
|
'data.payment_intent',
|
|
'data.payment_intent.payment_method',
|
|
'data.charge.balance_transaction',
|
|
'data.automatic_tax',
|
|
'data.total_tax_amounts'
|
|
],
|
|
'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();
|
|
|
|
// Improved counters for better tracking
|
|
$paymentNotSuccessfulCount = 0;
|
|
$refundedInvoicesCount = 0;
|
|
$missingDataInvoicesCount = 0;
|
|
$totalInvoice = 0;
|
|
$processedInvoiceCount = 0;
|
|
$defaultedToFranceCount = 0;
|
|
$totalResults = 0;
|
|
|
|
// Volume metrics
|
|
$grossVolumeUsd = 0;
|
|
$netVolumeUsd = 0;
|
|
$taxTotalUsd = 0;
|
|
$grossVolumeEur = 0;
|
|
$netVolumeEur = 0;
|
|
$taxTotalEur = 0;
|
|
|
|
do {
|
|
$batchSize = count($invoices->data);
|
|
$totalResults += $batchSize;
|
|
|
|
foreach ($invoices as $invoice) {
|
|
$totalInvoice++;
|
|
|
|
// Ignore if payment was refunded or not successful
|
|
if (($invoice->payment_intent->status ?? null) !== 'succeeded') {
|
|
$paymentNotSuccessfulCount++;
|
|
continue;
|
|
}
|
|
|
|
// Check if invoice was refunded
|
|
if (isset($invoice->charge) && isset($invoice->charge->refunded) && $invoice->charge->refunded) {
|
|
$refundedInvoicesCount++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$formattedInvoice = $this->formatInvoice($invoice);
|
|
|
|
// Check if we defaulted to France
|
|
if ($formattedInvoice['cust_country'] === 'FR' && $formattedInvoice['_defaulted_to_fr'] === true) {
|
|
$defaultedToFranceCount++;
|
|
}
|
|
|
|
// Remove the internal tracking field
|
|
unset($formattedInvoice['_defaulted_to_fr']);
|
|
|
|
$processedInvoices[] = $formattedInvoice;
|
|
$processedInvoiceCount++;
|
|
|
|
// Track volume metrics
|
|
$grossVolumeUsd += $formattedInvoice['total_usd'];
|
|
$netVolumeUsd += $formattedInvoice['total_after_tax_usd'];
|
|
$taxTotalUsd += $formattedInvoice['tax_total_usd'];
|
|
$grossVolumeEur += $formattedInvoice['total_eur'];
|
|
$netVolumeEur += $formattedInvoice['total_after_tax_eur'];
|
|
$taxTotalEur += $formattedInvoice['tax_total_eur'];
|
|
} catch (\Exception $e) {
|
|
$this->warn("Error processing invoice {$invoice->id}: {$e->getMessage()}");
|
|
$missingDataInvoicesCount++;
|
|
continue;
|
|
}
|
|
|
|
// Advance the progress bar
|
|
$bar->advance();
|
|
}
|
|
|
|
// Safe pagination
|
|
try {
|
|
$lastInvoice = end($invoices->data);
|
|
if ($lastInvoice) {
|
|
$queryOptions['starting_after'] = $lastInvoice->id;
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
// No need for sleep - Stripe API can handle the request rate
|
|
$invoices = Cashier::stripe()->invoices->all($queryOptions);
|
|
} catch (\Exception $e) {
|
|
$this->error("Error fetching next batch of invoices: {$e->getMessage()}");
|
|
break;
|
|
}
|
|
} while ($invoices->has_more);
|
|
|
|
$bar->finish();
|
|
$this->line('');
|
|
|
|
$aggregatedReport = $this->aggregateReport($processedInvoices);
|
|
|
|
$filePath = 'opnform-tax-export-per-invoice_' . $startDate . '_' . $endDate . '.xlsx';
|
|
$this->exportAsXlsx($processedInvoices, $filePath);
|
|
|
|
$aggregatedReportFilePath = 'opnform-tax-export-aggregated_' . $startDate . '_' . $endDate . '.xlsx';
|
|
$this->exportAsXlsx($aggregatedReport, $aggregatedReportFilePath);
|
|
|
|
// Calculate processing time
|
|
$endTime = microtime(true);
|
|
$executionTime = round($endTime - $startTime, 2);
|
|
|
|
// Display the results with improved statistics
|
|
$this->info('Processing completed in ' . $executionTime . ' seconds');
|
|
$this->info('Total invoices found: ' . $totalInvoice);
|
|
$this->info('Processed invoices: ' . $processedInvoiceCount);
|
|
$this->info('Excluded invoices:');
|
|
$this->info(' - Payment not successful: ' . $paymentNotSuccessfulCount);
|
|
$this->info(' - Refunded: ' . $refundedInvoicesCount);
|
|
$this->info(' - Missing required data: ' . $missingDataInvoicesCount);
|
|
$this->info(' - Defaulted to France: ' . $defaultedToFranceCount);
|
|
|
|
// Display volume metrics
|
|
$this->line('');
|
|
$this->info('Volume Metrics (USD):');
|
|
$this->info(' - Gross volume: $' . number_format($grossVolumeUsd, 2));
|
|
$this->info(' - Tax collected: $' . number_format($taxTotalUsd, 2));
|
|
$this->info(' - Net volume: $' . number_format($netVolumeUsd, 2));
|
|
|
|
$this->line('');
|
|
$this->info('Volume Metrics (EUR):');
|
|
$this->info(' - Gross volume: €' . number_format($grossVolumeEur, 2));
|
|
$this->info(' - Tax collected: €' . number_format($taxTotalEur, 2));
|
|
$this->info(' - Net volume: €' . number_format($netVolumeEur, 2));
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
private function aggregateReport($invoices): array
|
|
{
|
|
// Sum invoices per country
|
|
$aggregatedReport = [];
|
|
foreach ($invoices as $invoice) {
|
|
$country = $invoice['cust_country'];
|
|
$customerType = is_null($invoice['cust_vat_id']) && $this->isEuropeanCountry($country) ? 'individual' : 'business';
|
|
if (! isset($aggregatedReport[$country])) {
|
|
$defaultVal = [
|
|
'count' => 0,
|
|
'total_usd' => 0,
|
|
'tax_total_usd' => 0,
|
|
'total_after_tax_usd' => 0,
|
|
'total_eur' => 0,
|
|
'tax_total_eur' => 0,
|
|
'total_after_tax_eur' => 0,
|
|
];
|
|
$aggregatedReport[$country] = [
|
|
'individual' => $defaultVal,
|
|
'business' => $defaultVal,
|
|
];
|
|
}
|
|
$aggregatedReport[$country][$customerType]['count']++;
|
|
$aggregatedReport[$country][$customerType]['total_usd'] = ($aggregatedReport[$country][$customerType]['total_usd'] ?? 0) + $invoice['total_usd'];
|
|
$aggregatedReport[$country][$customerType]['tax_total_usd'] = ($aggregatedReport[$country][$customerType]['tax_total_usd'] ?? 0) + $invoice['tax_total_usd'];
|
|
$aggregatedReport[$country][$customerType]['total_after_tax_usd'] = ($aggregatedReport[$country][$customerType]['total_after_tax_usd'] ?? 0) + $invoice['total_after_tax_usd'];
|
|
$aggregatedReport[$country][$customerType]['total_eur'] = ($aggregatedReport[$country][$customerType]['total_eur'] ?? 0) + $invoice['total_eur'];
|
|
$aggregatedReport[$country][$customerType]['tax_total_eur'] = ($aggregatedReport[$country][$customerType]['tax_total_eur'] ?? 0) + $invoice['tax_total_eur'];
|
|
$aggregatedReport[$country][$customerType]['total_after_tax_eur'] = ($aggregatedReport[$country][$customerType]['total_after_tax_eur'] ?? 0) + $invoice['total_after_tax_eur'];
|
|
}
|
|
|
|
$finalReport = [];
|
|
foreach ($aggregatedReport as $country => $data) {
|
|
foreach ($data as $customerType => $aggData) {
|
|
$finalReport[] = [
|
|
'country' => $country,
|
|
'customer_type' => $customerType,
|
|
...$aggData,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $finalReport;
|
|
}
|
|
|
|
private function formatInvoice(Invoice $invoice): array
|
|
{
|
|
// Enhanced country detection logic with multiple fallbacks
|
|
$country = null;
|
|
$taxLocationFound = false;
|
|
$defaultedToFrance = false;
|
|
|
|
// Try to get country from customer's billing address
|
|
if (isset($invoice->customer->address) && !empty($invoice->customer->address->country)) {
|
|
$country = $invoice->customer->address->country;
|
|
$taxLocationFound = true;
|
|
}
|
|
// Try to get country from payment method
|
|
elseif (
|
|
isset($invoice->payment_intent) && isset($invoice->payment_intent->payment_method) &&
|
|
isset($invoice->payment_intent->payment_method->card) &&
|
|
!empty($invoice->payment_intent->payment_method->card->country)
|
|
) {
|
|
$country = $invoice->payment_intent->payment_method->card->country;
|
|
$taxLocationFound = true;
|
|
}
|
|
// Try to get country from automatic tax calculation
|
|
elseif (
|
|
isset($invoice->automatic_tax) && isset($invoice->automatic_tax->tax_location) &&
|
|
!empty($invoice->automatic_tax->tax_location->country)
|
|
) {
|
|
$country = $invoice->automatic_tax->tax_location->country;
|
|
$taxLocationFound = true;
|
|
}
|
|
// Try to get country from tax breakdown
|
|
elseif (isset($invoice->total_tax_amounts) && !empty($invoice->total_tax_amounts->data)) {
|
|
foreach ($invoice->total_tax_amounts->data as $taxAmount) {
|
|
if (isset($taxAmount->tax_rate) && isset($taxAmount->tax_rate->country)) {
|
|
$country = $taxAmount->tax_rate->country;
|
|
$taxLocationFound = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to France if no country found
|
|
if (!$taxLocationFound || is_null($country) || empty($country)) {
|
|
$country = 'FR';
|
|
$defaultedToFrance = true;
|
|
}
|
|
|
|
$vatId = null;
|
|
if (isset($invoice->customer->tax_ids) && !empty($invoice->customer->tax_ids->data)) {
|
|
$vatId = $invoice->customer->tax_ids->data[0]->value ?? null;
|
|
}
|
|
|
|
$taxRate = $this->computeTaxRate($country, $vatId);
|
|
|
|
// Safely calculate tax amounts
|
|
$total = $invoice->total ?? 0;
|
|
$taxAmountCollectedUsd = $taxRate > 0 ? $total * $taxRate / ($taxRate + 100) : 0;
|
|
|
|
$totalEur = 0;
|
|
if (isset($invoice->charge) && isset($invoice->charge->balance_transaction)) {
|
|
$totalEur = $invoice->charge->balance_transaction->amount ?? 0;
|
|
}
|
|
|
|
$taxAmountCollectedEur = $taxRate > 0 ? $totalEur * $taxRate / ($taxRate + 100) : 0;
|
|
|
|
return [
|
|
'invoice_id' => $invoice->id,
|
|
'created_at' => Carbon::createFromTimestamp($invoice->created)->format('Y-m-d H:i:s'),
|
|
'cust_id' => $invoice->customer->id ?? 'unknown',
|
|
'cust_vat_id' => $vatId,
|
|
'cust_country' => $country,
|
|
'tax_rate' => $taxRate,
|
|
'total_usd' => $total / 100,
|
|
'tax_total_usd' => $taxAmountCollectedUsd / 100,
|
|
'total_after_tax_usd' => ($total - $taxAmountCollectedUsd) / 100,
|
|
'total_eur' => $totalEur / 100,
|
|
'tax_total_eur' => $taxAmountCollectedEur / 100,
|
|
'total_after_tax_eur' => ($totalEur - $taxAmountCollectedEur) / 100,
|
|
'_defaulted_to_fr' => $defaultedToFrance,
|
|
];
|
|
}
|
|
|
|
private function computeTaxRate($countryCode, $vatId)
|
|
{
|
|
// Since we're a French company, for France, always apply 20% VAT
|
|
if (
|
|
$countryCode == 'FR' ||
|
|
is_null($countryCode) ||
|
|
empty($countryCode)
|
|
) {
|
|
return self::EU_TAX_RATES['FR'];
|
|
}
|
|
|
|
if ($taxRate = (self::EU_TAX_RATES[$countryCode] ?? null)) {
|
|
// If VAT ID is provided, then TAX is 0%
|
|
if (! $vatId) {
|
|
return $taxRate;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private function isEuropeanCountry($countryCode)
|
|
{
|
|
return isset(self::EU_TAX_RATES[$countryCode]);
|
|
}
|
|
|
|
private function exportAsXlsx($data, $filename)
|
|
{
|
|
if (count($data) == 0) {
|
|
$this->info('Empty data. No file generated.');
|
|
|
|
return;
|
|
}
|
|
|
|
(new ArrayExport($data))->store($filename, 'local', \Maatwebsite\Excel\Excel::XLSX);
|
|
$this->line('File generated: ' . storage_path('app/' . $filename));
|
|
}
|
|
}
|