Merge main
This commit is contained in:
252
app/Console/Commands/Tax/GenerateTaxExport.php
Normal file
252
app/Console/Commands/Tax/GenerateTaxExport.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?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';
|
||||
|
||||
const EU_TAX_RATES = [
|
||||
"AT" => 20,
|
||||
"BE" => 21,
|
||||
"BG" => 20,
|
||||
"HR" => 25,
|
||||
"CY" => 19,
|
||||
"CZ" => 21,
|
||||
"DK" => 25,
|
||||
"EE" => 20,
|
||||
"FI" => 24,
|
||||
"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()
|
||||
{
|
||||
// iterate through all Stripe invoices
|
||||
$startDate = $this->option('start-date');
|
||||
$endDate = $this->option('end-date');
|
||||
|
||||
// Validate the date format
|
||||
if ($startDate && !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)) {
|
||||
$this->error('Invalid end date format. Use YYYY-MM-DD.');
|
||||
return Command::FAILURE;
|
||||
} else if (!$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);
|
||||
|
||||
$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'],
|
||||
'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();
|
||||
$paymentNotSuccessfulCount = 0;
|
||||
$totalInvoice = 0;
|
||||
|
||||
do {
|
||||
foreach ($invoices as $invoice) {
|
||||
// Ignore if payment was refunded
|
||||
if (($invoice->payment_intent->status ?? null) !== 'succeeded') {
|
||||
$paymentNotSuccessfulCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$processedInvoices[] = $this->formatInvoice($invoice);
|
||||
$totalInvoice++;
|
||||
|
||||
// Advance the progress bar
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$queryOptions['starting_after'] = end($invoices->data)->id;
|
||||
|
||||
sleep(5);
|
||||
$invoices = $invoices->all($queryOptions);
|
||||
} while ($invoices->has_more);
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
|
||||
$aggregatedReport = $this->aggregateReport($processedInvoices);
|
||||
|
||||
$filePath = 'tax-export-per-invoice_' . $startDate . '_' . $endDate . '.xlsx';
|
||||
$this->exportAsXlsx($processedInvoices, $filePath);
|
||||
|
||||
$aggregatedReportFilePath = '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)');
|
||||
|
||||
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
|
||||
{
|
||||
$country = $invoice->customer->address->country ?? $invoice->payment_intent->payment_method->card->country ?? null;
|
||||
|
||||
$vatId = $invoice->customer->tax_ids->data[0]->value ?? null;
|
||||
$taxRate = $this->computeTaxRate($country, $vatId);
|
||||
|
||||
$taxAmountCollectedUsd = $taxRate > 0 ? $invoice->total * $taxRate / ($taxRate + 100) : 0;
|
||||
$totalEur = $invoice->charge->balance_transaction->amount;
|
||||
$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,
|
||||
'cust_vat_id' => $vatId,
|
||||
'cust_country' => $country,
|
||||
'tax_rate' => $taxRate,
|
||||
'total_usd' => $invoice->total / 100,
|
||||
'tax_total_usd' => $taxAmountCollectedUsd / 100,
|
||||
'total_after_tax_usd' => ($invoice->total - $taxAmountCollectedUsd) / 100,
|
||||
'total_eur' => $totalEur / 100,
|
||||
'tax_total_eur' => $taxAmountCollectedEur / 100,
|
||||
'total_after_tax_eur' => ($totalEur - $taxAmountCollectedEur) / 100,
|
||||
];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
27
app/Exports/Tax/ArrayExport.php
Normal file
27
app/Exports/Tax/ArrayExport.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports\Tax;
|
||||
|
||||
use Maatwebsite\Excel\Concerns\Exportable;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
|
||||
class ArrayExport implements FromArray, WithHeadings
|
||||
{
|
||||
use Exportable;
|
||||
|
||||
public function __construct(public array $data)
|
||||
{
|
||||
}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return array_keys($this->data[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\AuthenticateJWT;
|
||||
use App\Http\Middleware\CustomDomainRestriction;
|
||||
use App\Http\Middleware\EmbeddableForms;
|
||||
use App\Http\Middleware\IsAdmin;
|
||||
@@ -27,6 +28,7 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\TrimStrings::class,
|
||||
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
AuthenticateJWT::class,
|
||||
CustomDomainRestriction::class,
|
||||
];
|
||||
|
||||
|
||||
46
app/Http/Middleware/AuthenticateJWT.php
Normal file
46
app/Http/Middleware/AuthenticateJWT.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Tymon\JWTAuth\Exceptions\JWTException;
|
||||
|
||||
class AuthenticateJWT
|
||||
{
|
||||
|
||||
/**
|
||||
* Verifies the JWT token and validates the IP and User Agent
|
||||
* Invalidates token otherwise
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Parse JWT Payload
|
||||
try {
|
||||
$payload = \JWTAuth::parseToken()->getPayload();
|
||||
} catch (JWTException $e) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Validate IP and User Agent
|
||||
if ($payload) {
|
||||
$error = null;
|
||||
if (!\Hash::check($request->ip(), $payload->get('ip'))) {
|
||||
$error = 'Origin IP is invalid';
|
||||
}
|
||||
|
||||
if (!\Hash::check($request->userAgent(), $payload->get('ua'))) {
|
||||
$error = 'Origin User Agent is invalid';
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
auth()->invalidate();
|
||||
return response()->json([
|
||||
'message' => $error
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,9 @@ class FormResource extends JsonResource
|
||||
'removed_properties' => $this->removed_properties,
|
||||
'last_edited_human' => $this->updated_at?->diffForHumans(),
|
||||
'seo_meta' => $this->seo_meta,
|
||||
'max_file_size' => $this->max_file_size / 1000000,
|
||||
] : [];
|
||||
|
||||
$baseData = $this->getFilteredFormData(parent::toArray($request), $this->userIsFormOwner());
|
||||
|
||||
return array_merge($baseData, $ownerData, [
|
||||
return array_merge(parent::toArray($request), $ownerData, [
|
||||
'is_pro' => $this->workspaceIsPro(),
|
||||
'workspace_id' => $this->workspace_id,
|
||||
'workspace' => new WorkspaceResource($this->getWorkspace()),
|
||||
@@ -64,32 +61,11 @@ class FormResource extends JsonResource
|
||||
'is_password_protected' => false,
|
||||
'has_password' => $this->has_password,
|
||||
'max_number_of_submissions_reached' => $this->max_number_of_submissions_reached,
|
||||
'form_pending_submission_key' => $this->form_pending_submission_key
|
||||
'form_pending_submission_key' => $this->form_pending_submission_key,
|
||||
'max_file_size' => $this->max_file_size / 1000000,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter form data to hide properties from users.
|
||||
* - For relation fields, hides the relation information
|
||||
*/
|
||||
private function getFilteredFormData(array $data, bool $userIsFormOwner)
|
||||
{
|
||||
if ($userIsFormOwner) return $data;
|
||||
|
||||
$properties = collect($data['properties'])->map(function($property){
|
||||
// Remove database details from relation
|
||||
if ($property['type'] === 'relation') {
|
||||
if (isset($property['relation'])) {
|
||||
unset($property['relation']);
|
||||
}
|
||||
}
|
||||
return $property;
|
||||
});
|
||||
|
||||
$data['properties'] = $properties->toArray();
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function setCleanings(array $cleanings)
|
||||
{
|
||||
$this->cleanings = $cleanings;
|
||||
|
||||
@@ -194,7 +194,10 @@ class User extends Authenticatable implements JWTSubject
|
||||
*/
|
||||
public function getJWTCustomClaims()
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
'ip' => \Hash::make(request()->ip()),
|
||||
'ua' => \Hash::make(request()->userAgent()),
|
||||
];
|
||||
}
|
||||
|
||||
public function getIsRiskyAttribute()
|
||||
|
||||
Reference in New Issue
Block a user