diff --git a/composer.json b/composer.json index 1efb0e0..fbb798f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.37", + "version": "1.6.38", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -55,7 +55,9 @@ "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", - "xantios/mimey": "^2.2.0" + "xantios/mimey": "^2.2.0", + "spatie/laravel-pdf": "^1.9", + "mossadal/math-parser": "^1.3" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", @@ -105,4 +107,4 @@ "@test:unit" ] } -} +} \ No newline at end of file diff --git a/migrations/2024_01_01_000001_improve_transactions_table.php b/migrations/2024_01_01_000001_improve_transactions_table.php new file mode 100644 index 0000000..1570c3e --- /dev/null +++ b/migrations/2024_01_01_000001_improve_transactions_table.php @@ -0,0 +1,205 @@ +char('subject_uuid', 36)->nullable()->after('owner_type'); + $table->string('subject_type')->nullable()->after('subject_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: payer (funds flow from) + // ---------------------------------------------------------------- + $table->char('payer_uuid', 36)->nullable()->after('subject_type'); + $table->string('payer_type')->nullable()->after('payer_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: payee (funds flow to) + // ---------------------------------------------------------------- + $table->char('payee_uuid', 36)->nullable()->after('payer_type'); + $table->string('payee_type')->nullable()->after('payee_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: initiator (what triggered the transaction) + // ---------------------------------------------------------------- + $table->char('initiator_uuid', 36)->nullable()->after('payee_type'); + $table->string('initiator_type')->nullable()->after('initiator_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: context (related business object) + // ---------------------------------------------------------------- + $table->char('context_uuid', 36)->nullable()->after('initiator_type'); + $table->string('context_type')->nullable()->after('context_uuid'); + + // ---------------------------------------------------------------- + // Direction and balance + // ---------------------------------------------------------------- + $table->string('direction')->nullable()->after('status'); // credit | debit + $table->integer('balance_after')->nullable()->after('direction'); + + // ---------------------------------------------------------------- + // Monetary breakdown (all in smallest currency unit / cents) + // ---------------------------------------------------------------- + $table->integer('fee_amount')->default(0)->after('amount'); + $table->integer('tax_amount')->default(0)->after('fee_amount'); + $table->integer('net_amount')->default(0)->after('tax_amount'); + + // ---------------------------------------------------------------- + // Multi-currency settlement + // ---------------------------------------------------------------- + $table->decimal('exchange_rate', 18, 8)->default(1)->after('currency'); + $table->string('settled_currency', 3)->nullable()->after('exchange_rate'); + $table->integer('settled_amount')->nullable()->after('settled_currency'); + + // ---------------------------------------------------------------- + // Idempotency and linkage + // ---------------------------------------------------------------- + $table->string('reference', 191)->nullable()->unique()->after('description'); + $table->char('parent_transaction_uuid', 36)->nullable()->after('reference'); + + // ---------------------------------------------------------------- + // Gateway enrichment + // ---------------------------------------------------------------- + $table->json('gateway_response')->nullable()->after('gateway_transaction_id'); + $table->string('payment_method', 50)->nullable()->after('gateway_response'); + $table->string('payment_method_last4', 4)->nullable()->after('payment_method'); + $table->string('payment_method_brand', 50)->nullable()->after('payment_method_last4'); + + // ---------------------------------------------------------------- + // Traceability and compliance + // ---------------------------------------------------------------- + $table->string('ip_address', 45)->nullable()->after('meta'); + $table->text('notes')->nullable()->after('ip_address'); + $table->string('failure_reason', 191)->nullable()->after('notes'); + $table->string('failure_code', 50)->nullable()->after('failure_reason'); + + // ---------------------------------------------------------------- + // Reporting + // ---------------------------------------------------------------- + $table->string('period', 7)->nullable()->after('failure_code'); // YYYY-MM + $table->json('tags')->nullable()->after('period'); + + // ---------------------------------------------------------------- + // Lifecycle timestamps + // ---------------------------------------------------------------- + $table->timestamp('settled_at')->nullable()->after('updated_at'); + $table->timestamp('voided_at')->nullable()->after('settled_at'); + $table->timestamp('reversed_at')->nullable()->after('voided_at'); + $table->timestamp('expires_at')->nullable()->after('reversed_at'); + }); + + // -------------------------------------------------------------------- + // Backfill subject_* from owner_* (preserve existing data) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET subject_uuid = owner_uuid, subject_type = owner_type WHERE owner_uuid IS NOT NULL'); + + // -------------------------------------------------------------------- + // Backfill payer_* from customer_* (customer was semantically the payer) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET payer_uuid = customer_uuid, payer_type = customer_type WHERE customer_uuid IS NOT NULL'); + + // -------------------------------------------------------------------- + // Backfill period from created_at + // -------------------------------------------------------------------- + DB::statement("UPDATE transactions SET period = DATE_FORMAT(created_at, '%Y-%m') WHERE created_at IS NOT NULL"); + + // -------------------------------------------------------------------- + // Backfill net_amount = amount (no fees/tax in legacy records) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET net_amount = COALESCE(amount, 0) WHERE net_amount = 0'); + + // -------------------------------------------------------------------- + // Add indexes on new columns + // -------------------------------------------------------------------- + Schema::table('transactions', function (Blueprint $table) { + $table->index(['subject_uuid', 'subject_type'], 'transactions_subject_index'); + $table->index(['payer_uuid', 'payer_type'], 'transactions_payer_index'); + $table->index(['payee_uuid', 'payee_type'], 'transactions_payee_index'); + $table->index(['initiator_uuid', 'initiator_type'], 'transactions_initiator_index'); + $table->index(['context_uuid', 'context_type'], 'transactions_context_index'); + $table->index('direction', 'transactions_direction_index'); + $table->index('period', 'transactions_period_index'); + $table->index('parent_transaction_uuid', 'transactions_parent_index'); + $table->index('payment_method', 'transactions_payment_method_index'); + $table->index('settled_at', 'transactions_settled_at_index'); + $table->index(['company_uuid', 'type'], 'transactions_company_type_index'); + $table->index(['company_uuid', 'status'], 'transactions_company_status_index'); + $table->index(['company_uuid', 'period'], 'transactions_company_period_index'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + // Drop indexes + $table->dropIndex('transactions_subject_index'); + $table->dropIndex('transactions_payer_index'); + $table->dropIndex('transactions_payee_index'); + $table->dropIndex('transactions_initiator_index'); + $table->dropIndex('transactions_context_index'); + $table->dropIndex('transactions_direction_index'); + $table->dropIndex('transactions_period_index'); + $table->dropIndex('transactions_parent_index'); + $table->dropIndex('transactions_payment_method_index'); + $table->dropIndex('transactions_settled_at_index'); + $table->dropIndex('transactions_company_type_index'); + $table->dropIndex('transactions_company_status_index'); + $table->dropIndex('transactions_company_period_index'); + $table->dropUnique(['reference']); + + // Drop new columns + $table->dropColumn([ + 'subject_uuid', 'subject_type', + 'payer_uuid', 'payer_type', + 'payee_uuid', 'payee_type', + 'initiator_uuid', 'initiator_type', + 'context_uuid', 'context_type', + 'direction', 'balance_after', + 'fee_amount', 'tax_amount', 'net_amount', + 'exchange_rate', 'settled_currency', 'settled_amount', + 'reference', 'parent_transaction_uuid', + 'gateway_response', + 'payment_method', 'payment_method_last4', 'payment_method_brand', + 'ip_address', 'notes', 'failure_reason', 'failure_code', + 'period', 'tags', + 'settled_at', 'voided_at', 'reversed_at', 'expires_at', + ]); + }); + } +}; diff --git a/migrations/2024_01_01_000002_improve_transaction_items_table.php b/migrations/2024_01_01_000002_improve_transaction_items_table.php new file mode 100644 index 0000000..8066f19 --- /dev/null +++ b/migrations/2024_01_01_000002_improve_transaction_items_table.php @@ -0,0 +1,70 @@ +string('public_id', 191)->nullable()->unique()->after('uuid'); + + // Add quantity and unit price for proper line-item accounting + $table->integer('quantity')->default(1)->after('transaction_uuid'); + $table->integer('unit_price')->default(0)->after('quantity'); + + // Add tax columns + $table->decimal('tax_rate', 5, 2)->default(0.00)->after('currency'); + $table->integer('tax_amount')->default(0)->after('tax_rate'); + + // Add description as a longer alternative to details (TEXT vs VARCHAR) + $table->text('description')->nullable()->after('details'); + + // Add sort order for ordered display of line items + $table->unsignedSmallInteger('sort_order')->default(0)->after('description'); + }); + + // Fix amount column: string → integer + // First copy to a temp column, then drop and re-add as integer + DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0'); + + // Backfill unit_price = amount for existing records (single-unit assumption) + DB::statement('UPDATE transaction_items SET unit_price = amount WHERE unit_price = 0 AND amount > 0'); + } + + public function down(): void + { + // Revert amount back to string (original type) + DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount VARCHAR(191) NULL'); + + Schema::table('transaction_items', function (Blueprint $table) { + $table->dropUnique(['public_id']); + $table->dropColumn([ + 'public_id', + 'quantity', + 'unit_price', + 'tax_rate', + 'tax_amount', + 'description', + 'sort_order', + ]); + }); + } +}; diff --git a/migrations/2026_03_03_000001_create_templates_table.php b/migrations/2026_03_03_000001_create_templates_table.php new file mode 100644 index 0000000..e922de6 --- /dev/null +++ b/migrations/2026_03_03_000001_create_templates_table.php @@ -0,0 +1,72 @@ +bigIncrements('id'); + $table->string('uuid', 191)->unique()->nullable(); + $table->string('public_id', 191)->unique()->nullable(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('created_by_uuid', 191)->nullable()->index(); + $table->string('updated_by_uuid', 191)->nullable()->index(); + + // Identity + $table->string('name'); + $table->text('description')->nullable(); + + // Context type — defines which Fleetbase model this template is designed for. + // e.g. 'order', 'invoice', 'transaction', 'shipping_label', 'receipt', 'report' + $table->string('context_type')->default('generic')->index(); + + // Canvas dimensions (in mm by default, configurable via unit) + $table->string('unit')->default('mm'); // mm, px, in + $table->decimal('width', 10, 4)->default(210); // A4 width + $table->decimal('height', 10, 4)->default(297); // A4 height + $table->string('orientation')->default('portrait'); // portrait | landscape + + // Page settings + $table->json('margins')->nullable(); // { top, right, bottom, left } + $table->string('background_color')->nullable(); + $table->string('background_image_uuid')->nullable(); + + // The full template content — array of element objects + $table->longText('content')->nullable(); // JSON array of TemplateElement objects + + // Element type definitions / schema overrides (optional per-template customisation) + $table->json('element_schemas')->nullable(); + + // Status + $table->boolean('is_default')->default(false); + $table->boolean('is_system')->default(false); + $table->boolean('is_public')->default(false); + + $table->timestamps(); + $table->softDeletes(); + + // Foreign keys + $table->foreign('company_uuid')->references('uuid')->on('companies')->onDelete('cascade'); + $table->foreign('created_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + $table->foreign('updated_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + + // Composite indexes + $table->index(['company_uuid', 'context_type']); + $table->index(['company_uuid', 'is_default']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('templates'); + } +}; diff --git a/migrations/2026_03_03_000002_create_template_queries_table.php b/migrations/2026_03_03_000002_create_template_queries_table.php new file mode 100644 index 0000000..9ad6106 --- /dev/null +++ b/migrations/2026_03_03_000002_create_template_queries_table.php @@ -0,0 +1,61 @@ +bigIncrements('id'); + $table->string('uuid', 191)->unique()->nullable(); + $table->string('public_id', 191)->unique()->nullable(); + $table->string('company_uuid', 191)->nullable()->index(); + $table->string('template_uuid', 191)->nullable()->index(); + $table->string('created_by_uuid', 191)->nullable()->index(); + + // The fully-qualified model class this query targets + // e.g. 'Fleetbase\Models\Order', 'Fleetbase\FleetOps\Models\Order' + $table->string('model_type'); + + // The variable name used in the template to access this collection + // e.g. 'orders', 'transactions', 'invoices' + $table->string('variable_name'); + + // Human-readable label shown in the variable picker + $table->string('label')->nullable(); + + // JSON array of filter condition groups + // Each condition: { field, operator, value, type } + $table->json('conditions')->nullable(); + + // JSON array of sort directives: [{ field, direction }] + $table->json('sort')->nullable(); + + // Maximum number of records to return (null = no limit) + $table->unsignedInteger('limit')->nullable(); + + // Which relationships to eager-load on the result set + $table->json('with')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('company_uuid')->references('uuid')->on('companies')->onDelete('cascade'); + $table->foreign('template_uuid')->references('uuid')->on('templates')->onDelete('cascade'); + $table->foreign('created_by_uuid')->references('uuid')->on('users')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('template_queries'); + } +}; diff --git a/src/Http/Controllers/Internal/v1/TemplateController.php b/src/Http/Controllers/Internal/v1/TemplateController.php new file mode 100644 index 0000000..aefc7e5 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TemplateController.php @@ -0,0 +1,295 @@ +renderService = $renderService; + } + + // ------------------------------------------------------------------------- + // Lifecycle hooks — called automatically by HasApiControllerBehavior + // ------------------------------------------------------------------------- + + /** + * Called by HasApiControllerBehavior::createRecord() after the template + * record has been persisted. Syncs the nested queries array included in + * the request payload. + * + * Signature expected by getControllerCallback(): ($request, $record, $input) + */ + public function onAfterCreate(Request $request, Template $record, array $input): void + { + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); + } + + /** + * Called by HasApiControllerBehavior::updateRecord() after the template + * record has been updated. Syncs the nested queries array included in + * the request payload. + * + * Signature expected by getControllerCallback(): ($request, $record, $input) + */ + public function onAfterUpdate(Request $request, Template $record, array $input): void + { + $this->_syncQueries($record, $request->input('queries', [])); + $record->load('queries'); + } + + // ------------------------------------------------------------------------- + // Custom endpoints + // ------------------------------------------------------------------------- + + /** + * Render an unsaved template payload to HTML for preview. + * + * POST /templates/preview (no {id} — template has not been persisted yet) + * + * Body: + * template (object) — the full template payload from the builder + * .name, .content, .context_type, .width, .height, .unit, + * .orientation, .margins, .styles, .queries (array, optional) + * subject_type (string, optional) — fully-qualified model class + * subject_id (string, optional) — UUID or public_id of the subject record + */ + public function previewUnsaved(Request $request): JsonResponse + { + $payload = $request->input('template', []); + + // Hydrate a transient (non-persisted) Template model from the payload. + // fill() respects $fillable so unknown keys are silently ignored. + $template = new Template(); + $template->fill([ + 'name' => data_get($payload, 'name', 'Preview'), + 'content' => data_get($payload, 'content', []), + 'context_type' => data_get($payload, 'context_type', 'generic'), + 'width' => data_get($payload, 'width'), + 'height' => data_get($payload, 'height'), + 'unit' => data_get($payload, 'unit', 'mm'), + 'orientation' => data_get($payload, 'orientation', 'portrait'), + 'margins' => data_get($payload, 'margins', []), + 'styles' => data_get($payload, 'styles', []), + ]); + + // Hydrate transient TemplateQuery objects so the render pipeline can + // execute them without any DB records existing yet. + $rawQueries = data_get($payload, 'queries', []); + $queryModels = collect($rawQueries)->map(function ($q) { + $tq = new TemplateQuery(); + $tq->fill([ + 'label' => data_get($q, 'label'), + 'variable_name' => data_get($q, 'variable_name'), + 'model_type' => data_get($q, 'model_type'), + 'conditions' => data_get($q, 'conditions', []), + 'sort' => data_get($q, 'sort', []), + 'limit' => data_get($q, 'limit'), + 'with' => data_get($q, 'with', []), + ]); + + return $tq; + }); + + // Set the queries relation directly so buildContext() can iterate them + // without calling loadMissing() against the database. + $template->setRelation('queries', $queryModels); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $html = $this->renderService->renderToHtml($template, $subject); + + return response()->json(['html' => $html]); + } + + /** + * Render a template to HTML for preview. + * + * POST /templates/{id}/preview + * + * Body: + * subject_type (string, optional) — fully-qualified model class + * subject_id (string, optional) — UUID or public_id of the subject record + */ + public function preview(string $id, Request $request): JsonResponse + { + $template = Template::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $html = $this->renderService->renderToHtml($template, $subject); + + return response()->json(['html' => $html]); + } + + /** + * Render a template to a PDF and stream it as a download. + * + * POST /templates/{id}/render + * + * Body: + * subject_type (string, optional) + * subject_id (string, optional) + * filename (string, optional) — defaults to template name + */ + public function render(string $id, Request $request): Response + { + $template = Template::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $subjectType = $request->input('subject_type'); + $subjectId = $request->input('subject_id'); + $filename = $request->input('filename', $template->name); + $subject = null; + + if ($subjectType && $subjectId && class_exists($subjectType)) { + $subject = $subjectType::where('uuid', $subjectId) + ->orWhere('public_id', $subjectId) + ->first(); + } + + $pdf = $this->renderService->renderToPdf($template, $subject); + + return $pdf->download($filename . '.pdf'); + } + + /** + * Return the available context types and their variable schemas. + * Used by the frontend variable picker. + * + * GET /templates/context-schemas + */ + public function contextSchemas(): JsonResponse + { + $schemas = $this->renderService->getContextSchemas(); + + return response()->json(['schemas' => $schemas]); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Upsert the nested queries array onto the given template. + * + * Each item in $queries may have: + * - uuid (string|null) — present for existing queries, absent for new ones + * - All other TemplateQuery fillable fields + * + * Strategy: + * 1. Collect the UUIDs present in the incoming payload. + * 2. Soft-delete any existing queries NOT in that set (removed by the user). + * 3. For each incoming query: update if UUID exists, create if not. + */ + protected function _syncQueries(Template $template, array $queries): void + { + if (empty($queries) && !is_array($queries)) { + return; + } + + $companyUuid = session('company'); + $createdByUuid = session('user'); + $incomingUuids = []; + + foreach ($queries as $queryData) { + $uuid = data_get($queryData, 'uuid'); + + // Skip client-side temporary IDs (prefixed with _new_ or _unsaved_) + if ($uuid && (Str::startsWith($uuid, '_new_') || Str::startsWith($uuid, '_unsaved_'))) { + $uuid = null; + } + + if ($uuid) { + // Update existing query + $existing = TemplateQuery::where('uuid', $uuid) + ->where('template_uuid', $template->uuid) + ->first(); + + if ($existing) { + $existing->fill([ + 'label' => data_get($queryData, 'label'), + 'variable_name' => data_get($queryData, 'variable_name'), + 'description' => data_get($queryData, 'description'), + 'model_type' => data_get($queryData, 'model_type'), + 'conditions' => data_get($queryData, 'conditions', []), + 'sort' => data_get($queryData, 'sort', []), + 'limit' => data_get($queryData, 'limit'), + 'with' => data_get($queryData, 'with', []), + ])->save(); + + $incomingUuids[] = $uuid; + } + } else { + // Create new query + $newQuery = TemplateQuery::create([ + 'template_uuid' => $template->uuid, + 'company_uuid' => $companyUuid, + 'created_by_uuid' => $createdByUuid, + 'label' => data_get($queryData, 'label'), + 'variable_name' => data_get($queryData, 'variable_name'), + 'description' => data_get($queryData, 'description'), + 'model_type' => data_get($queryData, 'model_type'), + 'conditions' => data_get($queryData, 'conditions', []), + 'sort' => data_get($queryData, 'sort', []), + 'limit' => data_get($queryData, 'limit'), + 'with' => data_get($queryData, 'with', []), + ]); + + $incomingUuids[] = $newQuery->uuid; + } + } + + // Remove queries that were deleted in the builder + // (only when the caller explicitly sent a queries array — even if empty) + $template->queries() + ->whereNotIn('uuid', $incomingUuids) + ->delete(); + } +} diff --git a/src/Http/Controllers/Internal/v1/TemplateQueryController.php b/src/Http/Controllers/Internal/v1/TemplateQueryController.php new file mode 100644 index 0000000..dc1c246 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TemplateQueryController.php @@ -0,0 +1,13 @@ +session()->has('company'); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'name' => 'required|min:2|max:191', + 'context_type' => 'required|string|max:191', + 'orientation' => 'nullable|in:portrait,landscape', + 'unit' => 'nullable|in:mm,px,in', + 'width' => 'nullable|numeric|min:1', + 'height' => 'nullable|numeric|min:1', + ]; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'name.required' => 'A template name is required.', + 'name.min' => 'The template name must be at least 2 characters.', + 'context_type.required' => 'A context type is required to determine which variables are available.', + ]; + } +} diff --git a/src/Http/Resources/Template.php b/src/Http/Resources/Template.php new file mode 100644 index 0000000..1ae0747 --- /dev/null +++ b/src/Http/Resources/Template.php @@ -0,0 +1,46 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'created_by_uuid' => $this->when(Http::isInternalRequest(), $this->created_by_uuid), + 'updated_by_uuid' => $this->when(Http::isInternalRequest(), $this->updated_by_uuid), + 'background_image_uuid' => $this->when(Http::isInternalRequest(), $this->background_image_uuid), + 'name' => $this->name, + 'description' => $this->description, + 'context_type' => $this->context_type, + 'unit' => $this->unit, + 'width' => $this->width, + 'height' => $this->height, + 'orientation' => $this->orientation, + 'margins' => $this->margins, + 'background_color' => $this->background_color, + 'background_image' => $this->whenLoaded('backgroundImage', fn () => new File($this->backgroundImage)), + 'content' => $this->content ?? [], + 'element_schemas' => $this->element_schemas ?? [], + 'queries' => TemplateQuery::collection($this->whenLoaded('queries')), + 'is_default' => $this->is_default, + 'is_system' => $this->is_system, + 'is_public' => $this->is_public, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/src/Http/Resources/TemplateQuery.php b/src/Http/Resources/TemplateQuery.php new file mode 100644 index 0000000..a0d13fd --- /dev/null +++ b/src/Http/Resources/TemplateQuery.php @@ -0,0 +1,35 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'template_uuid' => $this->when(Http::isInternalRequest(), $this->template_uuid), + 'model_type' => $this->model_type, + 'variable_name' => $this->variable_name, + 'label' => $this->label, + 'conditions' => $this->conditions ?? [], + 'sort' => $this->sort ?? [], + 'limit' => $this->limit, + 'with' => $this->with ?? [], + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/src/Models/Template.php b/src/Models/Template.php new file mode 100644 index 0000000..97481cf --- /dev/null +++ b/src/Models/Template.php @@ -0,0 +1,153 @@ + Json::class, + 'content' => Json::class, + 'element_schemas' => Json::class, + 'is_default' => 'boolean', + 'is_system' => 'boolean', + 'is_public' => 'boolean', + 'width' => 'float', + 'height' => 'float', + ]; + + /** + * The attributes that should be hidden for serialization. + */ + protected $hidden = ['id']; + + /** + * Dynamic attributes appended to the model. + */ + protected $appends = []; + + /** + * The company this template belongs to. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * The user who created this template. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + /** + * The user who last updated this template. + */ + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid'); + } + + /** + * The background image file for this template. + */ + public function backgroundImage(): BelongsTo + { + return $this->belongsTo(File::class, 'background_image_uuid', 'uuid'); + } + + /** + * The query data sources attached to this template. + */ + public function queries(): HasMany + { + return $this->hasMany(TemplateQuery::class, 'template_uuid', 'uuid'); + } + + /** + * Scope to filter templates by context type. + */ + public function scopeForContext($query, string $contextType) + { + return $query->where('context_type', $contextType); + } + + /** + * Scope to include system and company templates. + */ + public function scopeAvailableFor($query, string $companyUuid) + { + return $query->where(function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid) + ->orWhere('is_system', true) + ->orWhere('is_public', true); + }); + } +} diff --git a/src/Models/TemplateQuery.php b/src/Models/TemplateQuery.php new file mode 100644 index 0000000..fdbc8d8 --- /dev/null +++ b/src/Models/TemplateQuery.php @@ -0,0 +1,172 @@ + Json::class, + 'sort' => Json::class, + 'with' => Json::class, + 'limit' => 'integer', + ]; + + /** + * The attributes that should be hidden for serialization. + */ + protected $hidden = ['id']; + + /** + * The template this query belongs to. + */ + public function template(): BelongsTo + { + return $this->belongsTo(Template::class, 'template_uuid', 'uuid'); + } + + /** + * The company this query belongs to. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * The user who created this query. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + /** + * Execute this query and return the result collection. + * + * Conditions are evaluated against the model class. Each condition object: + * { field, operator, value, type } + * + * Supported operators: =, !=, >, >=, <, <=, like, not like, in, not in, null, not null + */ + public function execute(): \Illuminate\Support\Collection + { + $modelClass = $this->model_type; + + if (!class_exists($modelClass)) { + return collect(); + } + + $query = $modelClass::query(); + + // Apply company scope if the model supports it + if (isset((new $modelClass())->fillable) && in_array('company_uuid', (new $modelClass())->getFillable())) { + $query->where('company_uuid', $this->company_uuid); + } + + // Apply filter conditions + foreach ($this->conditions ?? [] as $condition) { + $field = data_get($condition, 'field'); + $operator = data_get($condition, 'operator', '='); + $value = data_get($condition, 'value'); + + if (!$field) { + continue; + } + + switch (strtolower($operator)) { + case 'in': + $query->whereIn($field, (array) $value); + break; + case 'not in': + $query->whereNotIn($field, (array) $value); + break; + case 'null': + $query->whereNull($field); + break; + case 'not null': + $query->whereNotNull($field); + break; + case 'like': + $query->where($field, 'LIKE', '%' . $value . '%'); + break; + case 'not like': + $query->where($field, 'NOT LIKE', '%' . $value . '%'); + break; + default: + $query->where($field, $operator, $value); + break; + } + } + + // Apply sort + foreach ($this->sort ?? [] as $sortDirective) { + $field = data_get($sortDirective, 'field'); + $direction = data_get($sortDirective, 'direction', 'asc'); + if ($field) { + $query->orderBy($field, $direction); + } + } + + // Apply limit + if ($this->limit) { + $query->limit($this->limit); + } + + // Eager-load relationships + if (!empty($this->with)) { + $query->with($this->with); + } + + return $query->get(); + } +} diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php index b10e169..9956340 100644 --- a/src/Models/Transaction.php +++ b/src/Models/Transaction.php @@ -3,13 +3,38 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\Money; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\SoftDeletes; +/** + * Transaction. + * + * The platform-wide financial transaction primitive. Every monetary movement + * on the Fleetbase platform — dispatch charges, wallet operations, gateway + * payments, refunds, earnings, adjustments — is recorded as a Transaction. + * + * Extensions (e.g. Ledger) extend this model to add domain-specific + * relationships (journal entries, invoices) without altering this schema. + * + * Monetary values are always stored as integers in the smallest currency unit + * (cents). For example, USD 10.50 is stored as 1050. + * + * Five polymorphic roles capture the full context of any transaction: + * - subject: the primary owner of the transaction record + * - payer: the entity funds flow FROM + * - payee: the entity funds flow TO + * - initiator: what triggered or authorised the transaction + * - context: the related business object (Order, Invoice, etc.) + */ class Transaction extends Model { use HasUuid; @@ -17,85 +42,470 @@ class Transaction extends Model use HasApiModelBehavior; use HasApiModelCache; use HasMetaAttributes; + use SoftDeletes; /** * The database table used by the model. - * - * @var string */ protected $table = 'transactions'; /** - * The type of public Id to generate. - * - * @var string + * The type of public ID to generate. */ protected $publicIdType = 'transaction'; /** - * The attributes that can be queried. - * - * @var array + * The attributes that can be queried via search. */ - protected $searchableColumns = []; + protected $searchableColumns = ['description', 'reference', 'gateway_transaction_id', 'type', 'status']; /** * The attributes that are mass assignable. - * - * @var array */ - protected $fillable = ['public_id', 'owner_uuid', 'owner_type', 'customer_uuid', 'customer_type', 'company_uuid', 'gateway_transaction_id', 'gateway', 'gateway_uuid', 'amount', 'currency', 'description', 'meta', 'type', 'status']; + protected $fillable = [ + 'public_id', + 'company_uuid', + + // Five polymorphic roles + 'subject_uuid', + 'subject_type', + 'payer_uuid', + 'payer_type', + 'payee_uuid', + 'payee_type', + 'initiator_uuid', + 'initiator_type', + 'context_uuid', + 'context_type', + + // Classification + 'type', + 'direction', + 'status', + + // Monetary (all in smallest currency unit / cents) + 'amount', + 'fee_amount', + 'tax_amount', + 'net_amount', + 'currency', + 'exchange_rate', + 'settled_currency', + 'settled_amount', + 'balance_after', + + // Gateway + 'gateway', + 'gateway_uuid', + 'gateway_transaction_id', + 'gateway_response', + 'payment_method', + 'payment_method_last4', + 'payment_method_brand', + + // Idempotency and linkage + 'reference', + 'parent_transaction_uuid', + + // Descriptive + 'description', + 'notes', + + // Failure info + 'failure_reason', + 'failure_code', + + // Reporting + 'period', + 'tags', + + // Traceability + 'ip_address', + + // Misc + 'meta', + + // Deprecated aliases (kept for backward compatibility) + 'owner_uuid', + 'owner_type', + 'customer_uuid', + 'customer_type', + ]; /** - * Dynamic attributes that are appended to object. - * - * @var array + * The attributes that should be cast to native types. + */ + protected $casts = [ + 'amount' => Money::class, + 'fee_amount' => Money::class, + 'tax_amount' => Money::class, + 'net_amount' => Money::class, + 'balance_after' => Money::class, + 'settled_amount' => Money::class, + 'exchange_rate' => 'decimal:8', + 'gateway_response' => Json::class, + 'tags' => Json::class, + 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, + 'payer_type' => PolymorphicType::class, + 'payee_type' => PolymorphicType::class, + 'initiator_type' => PolymorphicType::class, + 'context_type' => PolymorphicType::class, + 'customer_type' => PolymorphicType::class, + 'settled_at' => 'datetime', + 'voided_at' => 'datetime', + 'reversed_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * Dynamic attributes appended to the model's JSON form. */ protected $appends = []; /** * The attributes excluded from the model's JSON form. - * - * @var array */ - protected $hidden = []; + protected $hidden = ['gateway_response']; + + // ========================================================================= + // Direction Constants + // ========================================================================= + + /** Money flowing into the subject's account. */ + public const DIRECTION_CREDIT = 'credit'; + + /** Money flowing out of the subject's account. */ + public const DIRECTION_DEBIT = 'debit'; + + // ========================================================================= + // Status Constants + // ========================================================================= + + public const STATUS_PENDING = 'pending'; + public const STATUS_SUCCESS = 'success'; + public const STATUS_FAILED = 'failed'; + public const STATUS_REVERSED = 'reversed'; + public const STATUS_CANCELLED = 'cancelled'; + public const STATUS_VOIDED = 'voided'; + public const STATUS_EXPIRED = 'expired'; + + // ========================================================================= + // Type Constants — Platform-wide taxonomy + // ========================================================================= + + // FleetOps + public const TYPE_DISPATCH = 'dispatch'; + public const TYPE_SERVICE_QUOTE = 'service_quote'; + + // Wallet operations + public const TYPE_WALLET_DEPOSIT = 'wallet_deposit'; + public const TYPE_WALLET_WITHDRAWAL = 'wallet_withdrawal'; + public const TYPE_WALLET_TRANSFER_IN = 'wallet_transfer_in'; + public const TYPE_WALLET_TRANSFER_OUT = 'wallet_transfer_out'; + public const TYPE_WALLET_EARNING = 'wallet_earning'; + public const TYPE_WALLET_PAYOUT = 'wallet_payout'; + public const TYPE_WALLET_FEE = 'wallet_fee'; + public const TYPE_WALLET_ADJUSTMENT = 'wallet_adjustment'; + public const TYPE_WALLET_REFUND = 'wallet_refund'; + + // Gateway payments + public const TYPE_GATEWAY_CHARGE = 'gateway_charge'; + public const TYPE_GATEWAY_REFUND = 'gateway_refund'; + public const TYPE_GATEWAY_SETUP_INTENT = 'gateway_setup_intent'; + + // Invoice + public const TYPE_INVOICE_PAYMENT = 'invoice_payment'; + public const TYPE_INVOICE_REFUND = 'invoice_refund'; + + // System + public const TYPE_ADJUSTMENT = 'adjustment'; + public const TYPE_REVERSAL = 'reversal'; + public const TYPE_CORRECTION = 'correction'; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * The primary owner of this transaction record. + * Replaces the deprecated owner() relationship. + */ + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid')->withoutGlobalScopes(); + } /** - * The attributes that should be cast to native types. - * - * @var array + * The entity funds flow FROM. */ - protected $casts = [ - 'meta' => Json::class, - 'customer_type' => PolymorphicType::class, - ]; + public function payer(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'payer_type', 'payer_uuid')->withoutGlobalScopes(); + } + + /** + * The entity funds flow TO. + */ + public function payee(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'payee_type', 'payee_uuid')->withoutGlobalScopes(); + } + + /** + * What triggered or authorised this transaction. + */ + public function initiator(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'initiator_type', 'initiator_uuid')->withoutGlobalScopes(); + } + + /** + * The related business object (Order, Invoice, PurchaseRate, etc.). + */ + public function context(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'context_type', 'context_uuid')->withoutGlobalScopes(); + } + + /** + * The parent transaction this record is a refund, reversal, or split of. + */ + public function parentTransaction(): BelongsTo + { + return $this->belongsTo(static::class, 'parent_transaction_uuid', 'uuid'); + } + + /** + * Child transactions (refunds, reversals, splits) of this transaction. + */ + public function childTransactions(): HasMany + { + return $this->hasMany(static::class, 'parent_transaction_uuid', 'uuid'); + } + + /** + * Line items belonging to this transaction. + */ + public function items(): HasMany + { + return $this->hasMany(TransactionItem::class, 'transaction_uuid', 'uuid'); + } /** - * Transaction items. - * - * @var array + * @deprecated use subject() instead */ - public function items() + public function owner(): MorphTo { - return $this->hasMany(TransactionItem::class); + return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); } /** - * The customer if any for this place. - * - * @var Model + * @deprecated Use payer() instead. The customer was semantically the payer. */ - public function customer() + public function customer(): MorphTo { return $this->morphTo(__FUNCTION__, 'customer_type', 'customer_uuid')->withoutGlobalScopes(); } + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * Scope to credit transactions (money in). + */ + public function scopeCredits($query) + { + return $query->where('direction', self::DIRECTION_CREDIT); + } + + /** + * Scope to debit transactions (money out). + */ + public function scopeDebits($query) + { + return $query->where('direction', self::DIRECTION_DEBIT); + } + + /** + * Scope to successful transactions. + */ + public function scopeSuccessful($query) + { + return $query->where('status', self::STATUS_SUCCESS); + } + + /** + * Scope to pending transactions. + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * Scope to failed transactions. + */ + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + /** + * Scope to a specific transaction type. + */ + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + /** + * Scope to transactions for a specific accounting period (YYYY-MM). + */ + public function scopeForPeriod($query, string $period) + { + return $query->where('period', $period); + } + + /** + * Scope to transactions where the given model is the subject. + */ + public function scopeForSubject($query, \Illuminate\Database\Eloquent\Model $subject) + { + return $query->where('subject_uuid', $subject->uuid) + ->where('subject_type', get_class($subject)); + } + + /** + * Scope to transactions where the given model is the payer. + */ + public function scopeForPayer($query, \Illuminate\Database\Eloquent\Model $payer) + { + return $query->where('payer_uuid', $payer->uuid) + ->where('payer_type', get_class($payer)); + } + + /** + * Scope to transactions where the given model is the payee. + */ + public function scopeForPayee($query, \Illuminate\Database\Eloquent\Model $payee) + { + return $query->where('payee_uuid', $payee->uuid) + ->where('payee_type', get_class($payee)); + } + + /** + * Scope to transactions related to a specific business context object. + */ + public function scopeForContext($query, \Illuminate\Database\Eloquent\Model $context) + { + return $query->where('context_uuid', $context->uuid) + ->where('context_type', get_class($context)); + } + + /** + * Scope to refund/reversal transactions (children of another transaction). + */ + public function scopeRefunds($query) + { + return $query->whereNotNull('parent_transaction_uuid'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Whether this transaction is a credit (money in to subject). + */ + public function isCredit(): bool + { + return $this->direction === self::DIRECTION_CREDIT; + } + + /** + * Whether this transaction is a debit (money out from subject). + */ + public function isDebit(): bool + { + return $this->direction === self::DIRECTION_DEBIT; + } + + /** + * Whether this transaction completed successfully. + */ + public function isSuccessful(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + /** + * Whether this transaction is still pending. + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * Whether this transaction failed. + */ + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + /** + * Whether this transaction is a refund or reversal of another transaction. + */ + public function isRefund(): bool + { + return $this->parent_transaction_uuid !== null; + } + + /** + * Whether this transaction has been voided. + */ + public function isVoided(): bool + { + return $this->status === self::STATUS_VOIDED || $this->voided_at !== null; + } + /** - * Generates a fleetbase transaction number. - * - * @var array + * Whether this transaction has been reversed. */ - public static function generateInternalNumber($length = 10) + public function isReversed(): bool + { + return $this->status === self::STATUS_REVERSED || $this->reversed_at !== null; + } + + /** + * Whether this transaction has settled. + */ + public function isSettled(): bool + { + return $this->settled_at !== null; + } + + /** + * Whether this transaction has expired (e.g. an uncaptured pre-auth). + */ + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED + || ($this->expires_at !== null && $this->expires_at->isPast()); + } + + // ========================================================================= + // Static Helpers + // ========================================================================= + + /** + * Generate an internal transaction reference number. + * Format: TR + N random digits. + */ + public static function generateInternalNumber(int $length = 10): string { $number = 'TR'; for ($i = 0; $i < $length; $i++) { @@ -106,18 +516,17 @@ public static function generateInternalNumber($length = 10) } /** - * Generates a unique transaction number. - * - * @var array + * Generate a unique transaction reference number. + * Ensures uniqueness against the gateway_transaction_id column. */ - public static function generateNumber($length = 10) + public static function generateNumber(int $length = 10): string { $n = self::generateInternalNumber($length); - $tr = self::where('gateway_transaction_id', $n) - ->withTrashed() - ->first(); - while (is_object($tr) && $n == $tr->gateway_transaction_id) { - $n = self::generateInternalNumber($length); + $tr = self::where('gateway_transaction_id', $n)->withTrashed()->first(); + + while ($tr !== null && $n === $tr->gateway_transaction_id) { + $n = self::generateInternalNumber($length); + $tr = self::where('gateway_transaction_id', $n)->withTrashed()->first(); } return $n; diff --git a/src/Models/TransactionItem.php b/src/Models/TransactionItem.php index 0b8e957..6821160 100644 --- a/src/Models/TransactionItem.php +++ b/src/Models/TransactionItem.php @@ -3,48 +3,122 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\Money; +use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; +use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; +/** + * TransactionItem. + * + * A line item belonging to a Transaction. Stores the individual components + * of a transaction's total — e.g. base fare, surcharges, taxes, discounts. + * + * All monetary values (amount, unit_price, tax_amount) are stored as integers + * in the smallest currency unit (cents) and cast via Fleetbase\Casts\Money. + */ class TransactionItem extends Model { use HasUuid; + use HasPublicId; + use HasApiModelBehavior; use HasMetaAttributes; + use SoftDeletes; /** * The database table used by the model. - * - * @var string */ protected $table = 'transaction_items'; + /** + * The type of public ID to generate. + */ + protected $publicIdType = 'transaction_item'; + /** * The attributes that are mass assignable. - * - * @var array */ - protected $fillable = ['transaction_uuid', 'amount', 'currency', 'details', 'code', 'meta']; + protected $fillable = [ + 'public_id', + 'transaction_uuid', + 'quantity', + 'unit_price', + 'amount', + 'currency', + 'tax_rate', + 'tax_amount', + 'details', + 'description', + 'code', + 'sort_order', + 'meta', + ]; /** * The attributes that should be cast to native types. - * - * @var array */ protected $casts = [ - 'meta' => Json::class, + 'amount' => Money::class, + 'unit_price' => Money::class, + 'tax_amount' => Money::class, + 'quantity' => 'integer', + 'tax_rate' => 'decimal:2', + 'sort_order' => 'integer', + 'meta' => Json::class, ]; /** - * Dynamic attributes that are appended to object. - * - * @var array + * Dynamic attributes appended to the model's JSON form. */ protected $appends = []; /** * The attributes excluded from the model's JSON form. - * - * @var array */ protected $hidden = []; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * The transaction this item belongs to. + */ + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'transaction_uuid', 'uuid'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Calculate the line total: quantity * unit_price (in cents). + * Returns the stored amount if unit_price is not set. + */ + public function getLineTotal(): int + { + if ($this->unit_price > 0 && $this->quantity > 0) { + return $this->unit_price * $this->quantity; + } + + return $this->amount; + } + + /** + * Calculate the tax amount for this line item based on tax_rate and line total. + * Returns the stored tax_amount if tax_rate is not set. + */ + public function calculateTax(): int + { + if ($this->tax_rate > 0) { + return (int) round($this->getLineTotal() * ($this->tax_rate / 100)); + } + + return $this->tax_amount; + } } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 817da67..9e3ed4b 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -140,6 +140,11 @@ public function register() $this->app->singleton(\Fleetbase\Services\FileResolverService::class, function ($app) { return new \Fleetbase\Services\FileResolverService(); }); + + // register template render service + $this->app->singleton(\Fleetbase\Services\TemplateRenderService::class, function ($app) { + return new \Fleetbase\Services\TemplateRenderService(); + }); } /** diff --git a/src/Services/TemplateRenderService.php b/src/Services/TemplateRenderService.php new file mode 100644 index 0000000..d8462e5 --- /dev/null +++ b/src/Services/TemplateRenderService.php @@ -0,0 +1,642 @@ + [ + 'label' => 'Generic', + 'description' => 'No primary subject — only global variables are available.', + 'model' => null, + 'variables' => [], + ], + ]; + + /** + * Register a context type so the frontend variable picker knows about it. + * + * @param string $slug e.g. 'invoice', 'order', 'shipping_label' + * @param array $definition Keys: label, description, model (FQCN), variables (array of { name, path, type, description }) + */ + public static function registerContextType(string $slug, array $definition): void + { + static::$contextTypes[$slug] = $definition; + } + + /** + * Return all registered context type schemas. + */ + public function getContextSchemas(): array + { + // Always prepend the global variables that are available in every context + $globalVariables = $this->getGlobalVariableSchema(); + + return array_map(function ($definition) use ($globalVariables) { + $definition['global_variables'] = $globalVariables; + + return $definition; + }, static::$contextTypes); + } + + /** + * Render a template to an HTML string. + * + * @param Template $template the template to render + * @param Model|null $subject The primary data subject (e.g. an Invoice or Order model instance). + */ + public function renderToHtml(Template $template, ?Model $subject = null): string + { + $context = $this->buildContext($template, $subject); + $html = $this->buildHtmlFromContent($template); + $html = $this->processIterationBlocks($html, $context); + $html = $this->evaluateFormulas($html, $context); + $html = $this->substituteVariables($html, $context); + + return $this->wrapInDocument($html, $template); + } + + /** + * Render a template to a PDF response using spatie/laravel-pdf. + * + * @return \Spatie\LaravelPdf\PdfBuilder + */ + public function renderToPdf(Template $template, ?Model $subject = null) + { + $html = $this->renderToHtml($template, $subject); + + // spatie/laravel-pdf — driver resolved from config (dompdf by default) + return \Spatie\LaravelPdf\Facades\Pdf::html($html) + ->paperSize($template->width, $template->height, $template->unit) + ->margins( + data_get($template->margins, 'top', 10), + data_get($template->margins, 'right', 10), + data_get($template->margins, 'bottom', 10), + data_get($template->margins, 'left', 10), + $template->unit + ); + } + + // ------------------------------------------------------------------------- + // Context building + // ------------------------------------------------------------------------- + + /** + * Build the full merged variable context for a render operation. + * + * Context tiers (later tiers override earlier on conflict): + * 1. Global / ambient (company, user, date) + * 2. Primary subject (the passed $subject model) + * 3. Query-based collections (from TemplateQuery records) + */ + protected function buildContext(Template $template, ?Model $subject = null): array + { + $context = []; + + // Tier 1: Global ambient context + $context = array_merge($context, $this->buildGlobalContext()); + + // Tier 2: Primary subject context + if ($subject !== null) { + $subjectKey = $template->context_type !== 'generic' ? $template->context_type : $this->guessContextKey($subject); + $context[$subjectKey] = $this->modelToArray($subject); + } + + // Tier 3: Query-based collections + // Skip loadMissing when the relation has already been set in-memory + // (e.g. for a transient/unsaved template hydrated from a request payload). + if (!$template->relationLoaded('queries')) { + $template->loadMissing('queries'); + } + foreach ($template->queries as $templateQuery) { + $results = $templateQuery->execute(); + $context[$templateQuery->variable_name] = $results->map(fn ($item) => $this->modelToArray($item))->toArray(); + } + + return $context; + } + + /** + * Build the global/ambient context that is always available in every template. + * + * Variables: + * {now} — current ISO 8601 datetime + * {today} — current date (Y-m-d) + * {time} — current time (H:i:s) + * {year} — current 4-digit year + * {company.*} — current session company attributes + * {user.*} — current authenticated user attributes + */ + protected function buildGlobalContext(): array + { + $now = Carbon::now(); + + $context = [ + 'now' => $now->toIso8601String(), + 'today' => $now->toDateString(), + 'time' => $now->toTimeString(), + 'year' => $now->year, + ]; + + // Company context from session + $companyUuid = Session::get('company'); + if ($companyUuid) { + $company = \Fleetbase\Models\Company::where('uuid', $companyUuid)->first(); + if ($company) { + $context['company'] = $this->modelToArray($company); + } + } + + // Authenticated user context + $user = Auth::user(); + if ($user) { + $context['user'] = $this->modelToArray($user); + } + + return $context; + } + + /** + * Return the schema of global variables for the frontend variable picker. + */ + protected function getGlobalVariableSchema(): array + { + return [ + ['name' => 'Current Date & Time', 'path' => 'now', 'type' => 'string', 'description' => 'ISO 8601 timestamp of the render time.'], + ['name' => 'Today\'s Date', 'path' => 'today', 'type' => 'string', 'description' => 'Current date in Y-m-d format.'], + ['name' => 'Current Time', 'path' => 'time', 'type' => 'string', 'description' => 'Current time in H:i:s format.'], + ['name' => 'Current Year', 'path' => 'year', 'type' => 'integer', 'description' => 'Current 4-digit year.'], + ['name' => 'Company Name', 'path' => 'company.name', 'type' => 'string', 'description' => 'Name of the current organisation.'], + ['name' => 'Company Email', 'path' => 'company.email', 'type' => 'string', 'description' => 'Primary email of the current organisation.'], + ['name' => 'Company Phone', 'path' => 'company.phone', 'type' => 'string', 'description' => 'Phone number of the current organisation.'], + ['name' => 'Company Address', 'path' => 'company.address', 'type' => 'string', 'description' => 'Street address of the current organisation.'], + ['name' => 'User Name', 'path' => 'user.name', 'type' => 'string', 'description' => 'Full name of the authenticated user.'], + ['name' => 'User Email', 'path' => 'user.email', 'type' => 'string', 'description' => 'Email address of the authenticated user.'], + ]; + } + + // ------------------------------------------------------------------------- + // HTML generation from template content + // ------------------------------------------------------------------------- + + /** + * Convert the template's content (array of element objects) into an HTML string. + * Each element is absolutely positioned within the canvas. + */ + protected function buildHtmlFromContent(Template $template): string + { + $elements = $template->content ?? []; + $html = ''; + + foreach ($elements as $element) { + $html .= $this->renderElement($element); + } + + return $html; + } + + /** + * Render a single element object to its HTML representation. + * + * Element object shape: + * { + * id, type, x, y, width, height, rotation, + * content, styles, attributes + * } + */ + protected function renderElement(array $element): string + { + $type = data_get($element, 'type', 'text'); + $x = data_get($element, 'x', 0); + $y = data_get($element, 'y', 0); + $width = data_get($element, 'width', 100); + $height = data_get($element, 'height', 'auto'); + $rotation = data_get($element, 'rotation', 0); + $styles = data_get($element, 'styles', []); + $content = data_get($element, 'content', ''); + + // Build inline style string + $styleStr = $this->buildInlineStyle(array_merge([ + 'position' => 'absolute', + 'left' => $x . 'px', + 'top' => $y . 'px', + 'width' => is_numeric($width) ? $width . 'px' : $width, + 'height' => is_numeric($height) ? $height . 'px' : $height, + 'transform' => $rotation ? "rotate({$rotation}deg)" : null, + ], $styles)); + + switch ($type) { + case 'text': + case 'heading': + case 'paragraph': + return "
| {$label} | \n"; + } + + $html .= "
|---|
| {this.{$key}} | \n"; + } + $html .= "
| {$value} | \n"; + } + $html .= "