Separated laravel app to its own folder (#540)
This commit is contained in:
92
api/app/Console/Commands/CleanDatabase.php
Normal file
92
api/app/Console/Commands/CleanDatabase.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Forms\FormStatistic;
|
||||
use App\Models\Forms\FormView;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CleanDatabase extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'forms:database-cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Database Cleanup';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->cleanFormStatistics();
|
||||
|
||||
$this->line('Database Cleanup Success.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage FormViews & FormSubmissions records
|
||||
*/
|
||||
private function cleanFormStatistics()
|
||||
{
|
||||
$this->line('Aggregating form views...');
|
||||
$now = now();
|
||||
$finalData = [];
|
||||
|
||||
// Form Views
|
||||
FormView::select('form_id', DB::raw('DATE(created_at) as date'), DB::raw('count(*) as views'))
|
||||
->where('created_at', '<', $now)
|
||||
->orderBy('date')
|
||||
->groupBy('form_id', 'date')
|
||||
->get()->each(function ($row) use (&$finalData) {
|
||||
$finalData[$row->form_id.'-'.$row->date] = [
|
||||
'form_id' => $row->form_id,
|
||||
'date' => $row->date,
|
||||
'data' => [
|
||||
'views' => $row->views,
|
||||
'submissions' => 0,
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
if ($finalData) {
|
||||
$this->line('Storing aggregated data...');
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
// Insert into Form Statistic
|
||||
foreach ($finalData as $row) {
|
||||
$found = FormStatistic::where([['form_id', $row['form_id']], ['date', $row['date']]])->first();
|
||||
if ($found !== null) { // If found update
|
||||
$newData = $found->data;
|
||||
$newData['views'] = $newData['views'] + $row['data']['views'];
|
||||
$newData['submissions'] = 0;
|
||||
$found->update(['data' => $newData]);
|
||||
$updated++;
|
||||
} else { // Otherwise create new
|
||||
FormStatistic::create($row);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line($created.' form statistics records created.');
|
||||
$this->line($updated.' form statistics records updated.');
|
||||
|
||||
// Delete Form Views those are migrated
|
||||
$formViewRemovedCount = FormView::where('created_at', '<', $now)->delete();
|
||||
$this->line($formViewRemovedCount.' form views records deleted.');
|
||||
} else {
|
||||
$this->line('No aggregate to store.');
|
||||
}
|
||||
}
|
||||
}
|
||||
34
api/app/Console/Commands/CleanIntegrationEvents.php
Normal file
34
api/app/Console/Commands/CleanIntegrationEvents.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Integration\FormIntegrationsEvent;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanIntegrationEvents extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'forms:integration-events-cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete Old Integration Events';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$response = FormIntegrationsEvent::where('created_at', '<', now()->subDays(14))->delete();
|
||||
$this->line($response . ' Events Deleted');
|
||||
}
|
||||
}
|
||||
418
api/app/Console/Commands/GenerateTemplate.php
Normal file
418
api/app/Console/Commands/GenerateTemplate.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Template;
|
||||
use App\Service\OpenAi\GptCompleter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateTemplate extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'ai:make-form-template {prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generates a new form template from a prompt';
|
||||
|
||||
public const MAX_RELATED_TEMPLATES = 8;
|
||||
|
||||
public const FORM_STRUCTURE_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to build a form for our user.
|
||||
|
||||
Forms are represented as Json objects. Here's an example form:
|
||||
```json
|
||||
{
|
||||
"title": "Contact Us",
|
||||
"properties": [
|
||||
{
|
||||
"help": null,
|
||||
"name": "What's your name?",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": "Steve Jobs"
|
||||
},
|
||||
{
|
||||
"help": "We will never share your email with anyone else.",
|
||||
"name": "Email",
|
||||
"type": "email",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": "steve@apple.com"
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "How would you rate your overall experience?",
|
||||
"type": "select",
|
||||
"hidden": false,
|
||||
"select": {
|
||||
"options": [
|
||||
{"name": 1, "value": 1},
|
||||
{"name": 2, "value": 2},
|
||||
{"name": 3, "value": 3},
|
||||
{"name": 4, "value": 4},
|
||||
{"name": 5, "value": 5}
|
||||
]
|
||||
},
|
||||
"prefill": 5,
|
||||
"required": true,
|
||||
"placeholder": null
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "Subject",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"placeholder": null
|
||||
},
|
||||
{
|
||||
"help": null,
|
||||
"name": "How can we help?",
|
||||
"type": "text",
|
||||
"hidden": false,
|
||||
"required": true,
|
||||
"multi_lines": true,
|
||||
"placeholder": null,
|
||||
"generates_uuid": false,
|
||||
"max_char_limit": "2000",
|
||||
"hide_field_name": false,
|
||||
"show_char_limit": false
|
||||
},
|
||||
{
|
||||
"help": "Upload any relevant files here.",
|
||||
"name": "Have any attachments?",
|
||||
"type": "files",
|
||||
"hidden": false,
|
||||
"placeholder": null
|
||||
}
|
||||
],
|
||||
"description": "<p>Looking for a real person to speak to?</p><p>We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.</p>",
|
||||
"re_fillable": false,
|
||||
"use_captcha": false,
|
||||
"redirect_url": null,
|
||||
"submitted_text": "<p>Great, we've received your message. We'll get back to you as soon as we can :)</p>",
|
||||
"uppercase_labels": false,
|
||||
"submit_button_text": "Submit",
|
||||
"re_fill_button_text": "Fill Again",
|
||||
"color": "#64748b"
|
||||
}
|
||||
```
|
||||
The form properties can only have one of the following types: 'text', 'number', 'rating', 'scale','slider', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number', 'signature'.
|
||||
All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'.
|
||||
The placeholder property is optional (can be "null") and is used to display a placeholder text in the input field.
|
||||
The help property is optional (can be "null") and is used to display extra information about the field.
|
||||
|
||||
For the type "select" and "multi_select", the input object must have a key "select" (or "multi_select") that's mapped to an object like this one:
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{"name": 1, "value": 1},
|
||||
{"name": 2, "value": 2},
|
||||
{"name": 3, "value": 3},
|
||||
{"name": 4, "value": 4}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For "rating" you can set the field property "rating_max_value" to set the maximum value of the rating.
|
||||
For "scale" you can set the field property "scale_min_value", "scale_max_value" and "scale_step_value" to set the minimum, maximum and step value of the scale.
|
||||
For "slider" you can set the field property "slider_min_value", "slider_max_value" and "slider_step_value" to set the minimum, maximum and step value of the slider.
|
||||
|
||||
If the form is too long, you can paginate it by adding a page break block in the list of properties:
|
||||
```json
|
||||
{
|
||||
"name":"Page Break",
|
||||
"next_btn_text":"Next",
|
||||
"previous_btn_text":"Previous",
|
||||
"type":"nf-page-break",
|
||||
}
|
||||
```
|
||||
|
||||
If you need to add more context to the form, you can add text blocks:
|
||||
```json
|
||||
{
|
||||
"name":"My Text",
|
||||
"type":"nf-text",
|
||||
"content": "<p>This is a text block.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
Give me the valid JSON object only, representing the following form: "[REPLACE]"
|
||||
Do not ask me for more information about required properties or types, only suggest me a form structure.
|
||||
EOD;
|
||||
|
||||
public const FORM_DESCRIPTION_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
Give me some valid html code (using only h2, p, ul, li html tags) for the following form template page: "[REPLACE]".
|
||||
|
||||
The html code should have the following structure:
|
||||
- A paragraph explaining what the template is about
|
||||
- A paragraph explaining why and when to use such a form
|
||||
- A paragraph explaining who is the target audience and why it's a great idea to build this form
|
||||
- A paragraph explaining that OpnForm is the best tool to build this form. They can duplicate this template in a few seconds, and integrate with many other tools through our webhook or zapier integration.
|
||||
Each paragraph (except for the first one) MUST start with with a h2 tag containing a title for this paragraph.
|
||||
EOD;
|
||||
|
||||
public const FORM_SHORT_DESCRIPTION_PROMPT = <<<'EOD'
|
||||
I own a form builder online named OpnForm. It's free to use.
|
||||
Give me a 1 sentence description for the following form template page: "[REPLACE]". It should be short and concise, but still explain what the form is about.
|
||||
EOD;
|
||||
|
||||
public const FORM_INDUSTRY_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
I am creating a form template: "[REPLACE]". You must assign the template to industries. Return a list of industries (minimum 1, maximum 3 but only if very relevant) and order them by relevance (most relevant first).
|
||||
|
||||
Here are the only industries you can choose from: [INDUSTRIES]
|
||||
Do no make up any new type, only use the ones listed above.
|
||||
|
||||
Reply only with a valid JSON array, being an array of string. Order assigned industries from the most relevant to the less relevant.
|
||||
Ex: { "industries": ["banking_forms","customer_service_forms"]}
|
||||
EOD;
|
||||
|
||||
public const FORM_TYPES_PROMPT = <<<'EOD'
|
||||
You are an AI assistant for OpnForm, a form builder and your job is to help us build form templates for our users.
|
||||
I am creating a form template: "[REPLACE]". You must assign the template to one or more types. Return a list of types (minimum 1, maximum 3 but only if very accurate) and order them by relevance (most relevant first).
|
||||
|
||||
Here are the only types you can choose from: [TYPES]
|
||||
Do no make up any new type, only use the ones listed above.
|
||||
|
||||
Reply only with a valid JSON array, being an array of string. Order assigned types from the most relevant to the less relevant.
|
||||
Ex: { "types": ["consent_forms","award_forms"]}
|
||||
EOD;
|
||||
|
||||
public const FORM_QAS_PROMPT = <<<'EOD'
|
||||
Now give me 4 to 6 question and answers to put on the form template page. The questions should be about the reasons for this template (when to use, why, target audience, goal etc.).
|
||||
The questions should also explain why OpnForm is the best option to create this form (open-source, free to use, integrations etc).
|
||||
Reply only with a valid JSON, being an array of object containing the keys "question" and "answer".
|
||||
EOD;
|
||||
|
||||
public const FORM_TITLE_PROMPT = <<<'EOD'
|
||||
Finally give me a title for the template. It must contain or end with "template". It should be short and to the point, without any quotes.
|
||||
EOD;
|
||||
|
||||
public const FORM_IMG_KEYWORDS_PROMPT = <<<'EOD'
|
||||
I want to add an image to illustrate this form template page. Give me a relevant search query for unsplash. Reply only with a valid JSON like this:
|
||||
```json
|
||||
{
|
||||
"search_query": ""
|
||||
}
|
||||
```
|
||||
EOD;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Get form structure
|
||||
$completer = (new GptCompleter(config('services.openai.api_key')))
|
||||
->useStreaming()
|
||||
->setSystemMessage('You are an assistant helping to generate forms.');
|
||||
$completer->expectsJson()->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
|
||||
]);
|
||||
$formData = $completer->getArray();
|
||||
$formData = self::cleanAiOutput($formData);
|
||||
|
||||
$completer->doesNotExpectJson();
|
||||
$formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString();
|
||||
$formShortDescription = $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_SHORT_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()],
|
||||
])->getString();
|
||||
// If description is between quotes, remove quotes
|
||||
$formShortDescription = Str::of($formShortDescription)->replaceMatches('/^"(.*)"$/', '$1')->toString();
|
||||
|
||||
// Get industry & types
|
||||
$completer->expectsJson();
|
||||
$industry = $this->getIndustries($completer, $this->argument('prompt'));
|
||||
$types = $this->getTypes($completer, $this->argument('prompt'));
|
||||
|
||||
// Get Related Templates
|
||||
$relatedTemplates = $this->getRelatedTemplates($industry, $types);
|
||||
|
||||
// Now get description and QAs
|
||||
$completer->doesNotExpectJson();
|
||||
$formDescription = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
])->getHtml();
|
||||
|
||||
$completer->expectsJson();
|
||||
$formCoverKeywords = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_IMG_KEYWORDS_PROMPT],
|
||||
])->getArray();
|
||||
$imageUrl = $this->getImageCoverUrl($formCoverKeywords['search_query']);
|
||||
|
||||
$formQAs = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_QAS_PROMPT],
|
||||
])->getArray();
|
||||
$completer->doesNotExpectJson();
|
||||
$formTitle = $completer->completeChat([
|
||||
['role' => 'user', 'content' => $formDescriptionPrompt],
|
||||
['role' => 'assistant', 'content' => $formDescription],
|
||||
['role' => 'user', 'content' => self::FORM_TITLE_PROMPT],
|
||||
])->getString();
|
||||
|
||||
$template = $this->createFormTemplate(
|
||||
$formData,
|
||||
$formTitle,
|
||||
$formDescription,
|
||||
$formShortDescription,
|
||||
$formQAs,
|
||||
$imageUrl,
|
||||
$industry,
|
||||
$types,
|
||||
$relatedTemplates
|
||||
);
|
||||
$this->info('/form-templates/' . $template->slug);
|
||||
|
||||
// Set reverse related Templates
|
||||
$this->setReverseRelatedTemplates($template);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an image cover URL for the template using unsplash API
|
||||
*/
|
||||
private function getImageCoverUrl($searchQuery): ?string
|
||||
{
|
||||
$url = 'https://api.unsplash.com/search/photos?query=' . urlencode($searchQuery) . '&client_id=' . config('services.unsplash.access_key');
|
||||
$response = Http::get($url)->json();
|
||||
$photoIndex = rand(0, max(count($response['results']) - 1, 10));
|
||||
if (isset($response['results'][$photoIndex]['urls']['regular'])) {
|
||||
return Str::of($response['results'][$photoIndex]['urls']['regular'])->replace('w=1080', 'w=600')->toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getIndustries(GptCompleter $completer, string $formPrompt): array
|
||||
{
|
||||
$industriesString = Template::getAllIndustries()->pluck('slug')->join(', ');
|
||||
|
||||
return $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_INDUSTRY_PROMPT)
|
||||
->replace('[REPLACE]', $formPrompt)
|
||||
->replace('[INDUSTRIES]', $industriesString)
|
||||
->toString()],
|
||||
])->getArray()['industries'];
|
||||
}
|
||||
|
||||
private function getTypes(GptCompleter $completer, string $formPrompt): array
|
||||
{
|
||||
$typesString = Template::getAllTypes()->pluck('slug')->join(', ');
|
||||
|
||||
return $completer->completeChat([
|
||||
['role' => 'user', 'content' => Str::of(self::FORM_TYPES_PROMPT)
|
||||
->replace('[REPLACE]', $formPrompt)
|
||||
->replace('[TYPES]', $typesString)
|
||||
->toString()],
|
||||
])->getArray()['types'];
|
||||
}
|
||||
|
||||
private function getRelatedTemplates(array $industries, array $types): array
|
||||
{
|
||||
$templateScore = [];
|
||||
Template::chunk(100, function ($otherTemplates) use ($industries, $types, &$templateScore) {
|
||||
foreach ($otherTemplates as $otherTemplate) {
|
||||
$industryOverlap = count(array_intersect($industries ?? [], $otherTemplate->industry ?? []));
|
||||
$typeOverlap = count(array_intersect($types ?? [], $otherTemplate->types ?? []));
|
||||
$score = $industryOverlap + $typeOverlap;
|
||||
if ($score > 1) {
|
||||
$templateScore[$otherTemplate->slug] = $score;
|
||||
}
|
||||
}
|
||||
});
|
||||
arsort($templateScore); // Sort by Score
|
||||
|
||||
return array_slice(array_keys($templateScore), 0, self::MAX_RELATED_TEMPLATES);
|
||||
}
|
||||
|
||||
private function createFormTemplate(
|
||||
array $formData,
|
||||
string $formTitle,
|
||||
string $formDescription,
|
||||
string $formShortDescription,
|
||||
array $formQAs,
|
||||
?string $imageUrl,
|
||||
array $industry,
|
||||
array $types,
|
||||
array $relatedTemplates
|
||||
) {
|
||||
return Template::create([
|
||||
'name' => $formTitle,
|
||||
'description' => $formDescription,
|
||||
'short_description' => $formShortDescription,
|
||||
'questions' => $formQAs,
|
||||
'structure' => $formData,
|
||||
'image_url' => $imageUrl,
|
||||
'publicly_listed' => true,
|
||||
'industries' => $industry,
|
||||
'types' => $types,
|
||||
'related_templates' => $relatedTemplates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function setReverseRelatedTemplates(Template $newTemplate)
|
||||
{
|
||||
if (!$newTemplate || count($newTemplate->related_templates) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$templates = Template::whereIn('slug', $newTemplate->related_templates)->get();
|
||||
foreach ($templates as $template) {
|
||||
if (count($template->related_templates) < self::MAX_RELATED_TEMPLATES) {
|
||||
$template->update(['related_templates' => array_merge($template->related_templates, [$newTemplate->slug])]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function cleanAiOutput(array $formData): array
|
||||
{
|
||||
// Add property uuids, improve form with options
|
||||
foreach ($formData['properties'] as &$property) {
|
||||
$property['id'] = Str::uuid()->toString(); // Column ID
|
||||
|
||||
// Fix types
|
||||
if ($property['type'] == 'rating') {
|
||||
$property['rating_max_value'] = $property['rating_max_value'] ?? 5;
|
||||
} elseif ($property['type'] == 'scale') {
|
||||
$property['scale_min_value'] = $property['scale_min_value'] ?? 1;
|
||||
$property['scale_max_value'] = $property['scale_max_value'] ?? 5;
|
||||
$property['scale_step_value'] = $property['scale_step_value'] ?? 1;
|
||||
} elseif ($property['type'] == 'slider') {
|
||||
$property['slider_min_value'] = $property['slider_min_value'] ?? 0;
|
||||
$property['slider_max_value'] = $property['slider_max_value'] ?? 100;
|
||||
$property['slider_step_value'] = $property['slider_step_value'] ?? 1;
|
||||
}
|
||||
|
||||
if (($property['type'] == 'select' && count($property['select']['options']) <= 4)
|
||||
|| ($property['type'] == 'multi_select' && count($property['multi_select']['options']) <= 4)
|
||||
) {
|
||||
$property['without_dropdown'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean data
|
||||
$formData['title'] = Str::of($formData['title'])->replace('"', '')->toString();
|
||||
|
||||
return $formData;
|
||||
}
|
||||
}
|
||||
48
api/app/Console/Commands/InitProjectCommand.php
Normal file
48
api/app/Console/Commands/InitProjectCommand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InitProjectCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:init-project';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Creates the default admin user';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!config('app.self_hosted')) {
|
||||
$this->error('This command can only be run in self-hosted mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there are any existing users or if the ID increment is not at 0
|
||||
if (User::max('id') !== null) {
|
||||
$this->error('Users already exist in the database or the User table is not empty. Aborting initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
User::create([
|
||||
'name' => 'Admin',
|
||||
'email' => 'admin@opnform.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$this->info('Admin user created with default credentials: admin@opnform.com / password');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
257
api/app/Console/Commands/Tax/GenerateTaxExport.php
Normal file
257
api/app/Console/Commands/Tax/GenerateTaxExport.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?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' => 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;
|
||||
} 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);
|
||||
|
||||
$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 = 'opnform-tax-export-per-invoice_'.$startDate.'_'.$endDate.'.xlsx';
|
||||
$this->exportAsXlsx($processedInvoices, $filePath);
|
||||
|
||||
$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)');
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
41
api/app/Console/Kernel.php
Normal file
41
api/app/Console/Kernel.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
/**
|
||||
* The Artisan commands provided by your application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $commands = [
|
||||
//
|
||||
];
|
||||
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
$schedule->command('forms:database-cleanup')->hourly();
|
||||
$schedule->command('forms:integration-events-cleanup')->daily();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function commands()
|
||||
{
|
||||
$this->load(__DIR__ . '/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user