From eec7add5a59e01397c66bb5be708ee59ca6d5853 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:20:34 -0700 Subject: [PATCH 01/22] feat: add GL assignment rules engine and export batch migrations Co-Authored-By: Claude Sonnet 4.6 --- ...d_parent_and_active_to_ledger_accounts.php | 32 ++++++++++++++++ ...00026_create_gl_assignment_rules_table.php | 36 ++++++++++++++++++ ..._create_gl_assignment_conditions_table.php | 33 +++++++++++++++++ ..._01_000028_create_gl_assignments_table.php | 36 ++++++++++++++++++ ..._000029_create_gl_export_batches_table.php | 37 +++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 server/migrations/2024_01_01_000025_add_parent_and_active_to_ledger_accounts.php create mode 100644 server/migrations/2024_01_01_000026_create_gl_assignment_rules_table.php create mode 100644 server/migrations/2024_01_01_000027_create_gl_assignment_conditions_table.php create mode 100644 server/migrations/2024_01_01_000028_create_gl_assignments_table.php create mode 100644 server/migrations/2024_01_01_000029_create_gl_export_batches_table.php diff --git a/server/migrations/2024_01_01_000025_add_parent_and_active_to_ledger_accounts.php b/server/migrations/2024_01_01_000025_add_parent_and_active_to_ledger_accounts.php new file mode 100644 index 0000000..219cf43 --- /dev/null +++ b/server/migrations/2024_01_01_000025_add_parent_and_active_to_ledger_accounts.php @@ -0,0 +1,32 @@ +char('parent_account_uuid', 36)->nullable()->after('status'); + $table->boolean('is_active')->default(true)->after('parent_account_uuid'); + + $table->foreign('parent_account_uuid') + ->references('uuid') + ->on('ledger_accounts') + ->nullOnDelete(); + + $table->index('parent_account_uuid'); + }); + } + + public function down() + { + Schema::table('ledger_accounts', function (Blueprint $table) { + $table->dropForeign(['parent_account_uuid']); + $table->dropIndex(['parent_account_uuid']); + $table->dropColumn(['parent_account_uuid', 'is_active']); + }); + } +}; diff --git a/server/migrations/2024_01_01_000026_create_gl_assignment_rules_table.php b/server/migrations/2024_01_01_000026_create_gl_assignment_rules_table.php new file mode 100644 index 0000000..d9504d5 --- /dev/null +++ b/server/migrations/2024_01_01_000026_create_gl_assignment_rules_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('name'); + $table->integer('priority')->default(0); + $table->string('match_type', 20)->default('all'); + $table->char('gl_account_uuid', 36); + $table->string('target', 50); + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('gl_account_uuid')->references('uuid')->on('ledger_accounts'); + }); + } + + public function down() + { + Schema::dropIfExists('gl_assignment_rules'); + } +}; diff --git a/server/migrations/2024_01_01_000027_create_gl_assignment_conditions_table.php b/server/migrations/2024_01_01_000027_create_gl_assignment_conditions_table.php new file mode 100644 index 0000000..54ca5d2 --- /dev/null +++ b/server/migrations/2024_01_01_000027_create_gl_assignment_conditions_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('gl_assignment_rule_uuid', 36); + $table->string('field', 50); + $table->string('operator', 20); + $table->text('value'); + $table->json('meta')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('gl_assignment_rule_uuid') + ->references('uuid') + ->on('gl_assignment_rules') + ->cascadeOnDelete(); + }); + } + + public function down() + { + Schema::dropIfExists('gl_assignment_conditions'); + } +}; diff --git a/server/migrations/2024_01_01_000028_create_gl_assignments_table.php b/server/migrations/2024_01_01_000028_create_gl_assignments_table.php new file mode 100644 index 0000000..7d6a5cb --- /dev/null +++ b/server/migrations/2024_01_01_000028_create_gl_assignments_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('gl_account_uuid', 36); + $table->char('gl_assignment_rule_uuid', 36)->nullable(); + $table->string('assignable_type'); + $table->char('assignable_uuid', 36); + $table->decimal('amount', 12, 2); + $table->string('assignment_type', 20)->default('auto'); + $table->json('meta')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('gl_account_uuid')->references('uuid')->on('ledger_accounts'); + $table->foreign('gl_assignment_rule_uuid')->references('uuid')->on('gl_assignment_rules')->nullOnDelete(); + $table->index(['assignable_type', 'assignable_uuid']); + }); + } + + public function down() + { + Schema::dropIfExists('gl_assignments'); + } +}; diff --git a/server/migrations/2024_01_01_000029_create_gl_export_batches_table.php b/server/migrations/2024_01_01_000029_create_gl_export_batches_table.php new file mode 100644 index 0000000..fc06c96 --- /dev/null +++ b/server/migrations/2024_01_01_000029_create_gl_export_batches_table.php @@ -0,0 +1,37 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('format', 30); + $table->string('status', 20)->default('pending'); + $table->date('period_start'); + $table->date('period_end'); + $table->char('file_uuid', 36)->nullable(); + $table->integer('record_count')->default(0); + $table->decimal('total_amount', 14, 2)->default(0); + $table->timestamp('exported_at')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + + public function down() + { + Schema::dropIfExists('gl_export_batches'); + } +}; From 8285afb4578637a898de7024149ca43069194dac Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:23:50 -0700 Subject: [PATCH 02/22] feat: add GL assignment models, update Account with hierarchy, add HasGlAssignments trait Co-Authored-By: Claude Sonnet 4.6 --- server/src/Models/Account.php | 39 +++++++++++ server/src/Models/GlAssignment.php | 48 +++++++++++++ server/src/Models/GlAssignmentCondition.php | 53 +++++++++++++++ server/src/Models/GlAssignmentRule.php | 74 +++++++++++++++++++++ server/src/Models/GlExportBatch.php | 40 +++++++++++ server/src/Traits/HasGlAssignments.php | 18 +++++ 6 files changed, 272 insertions(+) create mode 100644 server/src/Models/GlAssignment.php create mode 100644 server/src/Models/GlAssignmentCondition.php create mode 100644 server/src/Models/GlAssignmentRule.php create mode 100644 server/src/Models/GlExportBatch.php create mode 100644 server/src/Traits/HasGlAssignments.php diff --git a/server/src/Models/Account.php b/server/src/Models/Account.php index a2241e9..57d3db0 100644 --- a/server/src/Models/Account.php +++ b/server/src/Models/Account.php @@ -74,6 +74,8 @@ class Account extends Model 'type', 'description', 'is_system_account', + 'parent_account_uuid', + 'is_active', 'balance', 'currency', 'status', @@ -87,6 +89,7 @@ class Account extends Model */ protected $casts = [ 'is_system_account' => 'boolean', + 'is_active' => 'boolean', 'balance' => 'integer', 'meta' => Json::class, ]; @@ -195,4 +198,40 @@ public function isExpense(): bool { return $this->type === 'expense'; } + + public function parent() + { + return $this->belongsTo(Account::class, 'parent_account_uuid', 'uuid'); + } + + public function children() + { + return $this->hasMany(Account::class, 'parent_account_uuid', 'uuid'); + } + + public function glAssignmentRules() + { + return $this->hasMany(GlAssignmentRule::class, 'gl_account_uuid', 'uuid'); + } + + public function glAssignments() + { + return $this->hasMany(GlAssignment::class, 'gl_account_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function getPathAttribute(): string + { + $parts = [$this->code ?? $this->name]; + $current = $this; + while ($current->parent) { + $current = $current->parent; + array_unshift($parts, $current->code ?? $current->name); + } + return implode(' > ', $parts); + } } diff --git a/server/src/Models/GlAssignment.php b/server/src/Models/GlAssignment.php new file mode 100644 index 0000000..dcf4a71 --- /dev/null +++ b/server/src/Models/GlAssignment.php @@ -0,0 +1,48 @@ + 'decimal:2', + 'assignable_type' => PolymorphicType::class, + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function glAccount() + { + return $this->belongsTo(Account::class, 'gl_account_uuid', 'uuid'); + } + + public function rule() + { + return $this->belongsTo(GlAssignmentRule::class, 'gl_assignment_rule_uuid', 'uuid'); + } + + public function assignable() + { + return $this->morphTo(__FUNCTION__, 'assignable_type', 'assignable_uuid'); + } +} diff --git a/server/src/Models/GlAssignmentCondition.php b/server/src/Models/GlAssignmentCondition.php new file mode 100644 index 0000000..d0b56ec --- /dev/null +++ b/server/src/Models/GlAssignmentCondition.php @@ -0,0 +1,53 @@ + Json::class, + ]; + + public function rule() + { + return $this->belongsTo(GlAssignmentRule::class, 'gl_assignment_rule_uuid', 'uuid'); + } + + public function evaluate(array $context): bool + { + $fieldValue = $context[$this->field] ?? null; + + if ($fieldValue === null) { + return false; + } + + $conditionValue = $this->value; + + if (in_array($this->operator, ['in', 'not_in'])) { + $conditionValue = json_decode($conditionValue, true) ?: [$conditionValue]; + } + + return match ($this->operator) { + 'equals' => (string) $fieldValue === (string) $conditionValue, + 'not_equals' => (string) $fieldValue !== (string) $conditionValue, + 'in' => in_array((string) $fieldValue, $conditionValue), + 'not_in' => !in_array((string) $fieldValue, $conditionValue), + 'contains' => str_contains(strtolower($fieldValue), strtolower($conditionValue)), + default => false, + }; + } +} diff --git a/server/src/Models/GlAssignmentRule.php b/server/src/Models/GlAssignmentRule.php new file mode 100644 index 0000000..57e3607 --- /dev/null +++ b/server/src/Models/GlAssignmentRule.php @@ -0,0 +1,74 @@ + 'boolean', + 'priority' => 'integer', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function glAccount() + { + return $this->belongsTo(Account::class, 'gl_account_uuid', 'uuid'); + } + + public function conditions() + { + return $this->hasMany(GlAssignmentCondition::class, 'gl_assignment_rule_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('priority', 'asc'); + } + + public function matches(array $context): bool + { + $conditions = $this->conditions; + + if ($conditions->isEmpty()) { + return false; + } + + $results = $conditions->map(function ($condition) use ($context) { + return $condition->evaluate($context); + }); + + return $this->match_type === 'all' + ? $results->every(fn ($r) => $r === true) + : $results->contains(true); + } +} diff --git a/server/src/Models/GlExportBatch.php b/server/src/Models/GlExportBatch.php new file mode 100644 index 0000000..85c5def --- /dev/null +++ b/server/src/Models/GlExportBatch.php @@ -0,0 +1,40 @@ + 'date', + 'period_end' => 'date', + 'exported_at' => 'datetime', + 'total_amount' => 'decimal:2', + 'record_count' => 'integer', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } +} diff --git a/server/src/Traits/HasGlAssignments.php b/server/src/Traits/HasGlAssignments.php new file mode 100644 index 0000000..09e8b7f --- /dev/null +++ b/server/src/Traits/HasGlAssignments.php @@ -0,0 +1,18 @@ +morphMany(GlAssignment::class, 'assignable', 'assignable_type', 'assignable_uuid'); + } + + public function primaryGlAccount() + { + return $this->glAssignments()->first()?->glAccount; + } +} From 5404c62efc23b4c679ecb295615e4a48e66d0c27 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:26:39 -0700 Subject: [PATCH 03/22] feat: add GL auto-assignment service with rules evaluation engine --- .../src/Services/GlAutoAssignmentService.php | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 server/src/Services/GlAutoAssignmentService.php diff --git a/server/src/Services/GlAutoAssignmentService.php b/server/src/Services/GlAutoAssignmentService.php new file mode 100644 index 0000000..47c0701 --- /dev/null +++ b/server/src/Services/GlAutoAssignmentService.php @@ -0,0 +1,96 @@ +company_uuid; + + $context = $this->buildContext($record); + + $rules = GlAssignmentRule::where('company_uuid', $companyUuid) + ->where('target', $target) + ->active() + ->ordered() + ->with('conditions') + ->get(); + + foreach ($rules as $rule) { + if ($rule->matches($context)) { + return GlAssignment::create([ + 'company_uuid' => $companyUuid, + 'gl_account_uuid' => $rule->gl_account_uuid, + 'gl_assignment_rule_uuid' => $rule->uuid, + 'assignable_type' => get_class($record), + 'assignable_uuid' => $record->uuid, + 'amount' => $amount, + 'assignment_type' => 'auto', + ]); + } + } + + // Fallback to default GL account + $defaultGl = Account::where('company_uuid', $companyUuid) + ->where('meta->is_default', true) + ->active() + ->first(); + + if ($defaultGl) { + return GlAssignment::create([ + 'company_uuid' => $companyUuid, + 'gl_account_uuid' => $defaultGl->uuid, + 'gl_assignment_rule_uuid' => null, + 'assignable_type' => get_class($record), + 'assignable_uuid' => $record->uuid, + 'amount' => $amount, + 'assignment_type' => 'auto', + 'meta' => ['note' => 'Assigned via default GL — no rule matched'], + ]); + } + + return null; + } + + protected function buildContext(Model $record): array + { + $context = []; + + // If model implements getGlContext(), use it + if (method_exists($record, 'getGlContext')) { + $context = array_merge($context, $record->getGlContext()); + } + + // Handle Order (by class check to avoid hard dependency) + if ($record instanceof \Fleetbase\FleetOps\Models\Order) { + $context['customer'] = $record->customer_uuid ?? null; + $context['mode'] = $record->meta['mode'] ?? null; + $context['equipment_type'] = $record->meta['equipment_type'] ?? null; + $context['carrier'] = $record->facilitator_uuid ?? null; + $context['department'] = $record->meta['department'] ?? null; + $context['cost_center'] = $record->meta['cost_center'] ?? null; + + $pickup = $record->payload?->pickup; + $dropoff = $record->payload?->dropoff; + if ($pickup) { + $context['origin_state'] = $pickup->state ?? null; + $context['origin_zip'] = $pickup->postal_code ?? null; + } + if ($dropoff) { + $context['dest_state'] = $dropoff->state ?? null; + $context['dest_zip'] = $dropoff->postal_code ?? null; + } + } + + return $context; + } +} From d0b60cd5e94de5113c8f7afe18c3d76944cf85f0 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:26:43 -0700 Subject: [PATCH 04/22] feat: add GL export service with CSV, QuickBooks IIF, and JSON formatters --- server/src/Services/GlExportService.php | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 server/src/Services/GlExportService.php diff --git a/server/src/Services/GlExportService.php b/server/src/Services/GlExportService.php new file mode 100644 index 0000000..107bf95 --- /dev/null +++ b/server/src/Services/GlExportService.php @@ -0,0 +1,112 @@ +whereBetween('created_at', [$start, $end]) + ->with('glAccount', 'assignable') + ->get(); + + $batch = GlExportBatch::create([ + 'company_uuid' => $companyUuid, + 'format' => $format, + 'status' => 'pending', + 'period_start' => $start->toDateString(), + 'period_end' => $end->toDateString(), + 'record_count' => $assignments->count(), + 'total_amount' => $assignments->sum('amount'), + ]); + + $content = match ($format) { + 'csv' => $this->formatCsv($assignments), + 'quickbooks_iif' => $this->formatQuickBooksIIF($assignments), + 'json' => $this->formatJson($assignments), + default => $this->formatCsv($assignments), + }; + + $extension = match ($format) { + 'quickbooks_iif' => 'iif', + 'json' => 'json', + default => 'csv', + }; + $filename = "gl-export-{$start->format('Y-m-d')}-{$end->format('Y-m-d')}.{$extension}"; + $path = "gl-exports/{$companyUuid}/{$filename}"; + + Storage::put($path, $content); + + $file = File::create([ + 'company_uuid' => $companyUuid, + 'path' => $path, + 'original_name' => $filename, + 'content_type' => $extension === 'csv' ? 'text/csv' : 'application/' . $extension, + ]); + + $batch->update([ + 'file_uuid' => $file->uuid, + 'status' => 'generated', + 'exported_at' => now(), + ]); + + return $batch; + } + + protected function formatCsv($assignments): string + { + $lines = ['GL Code,GL Name,Amount,Record Type,Record ID,Date,Assignment Type,Rule']; + + foreach ($assignments as $a) { + $lines[] = implode(',', [ + $a->glAccount->code ?? '', + '"' . str_replace('"', '""', $a->glAccount->name ?? '') . '"', + $a->amount, + class_basename($a->assignable_type), + $a->assignable_uuid, + $a->created_at->format('Y-m-d'), + $a->assignment_type, + $a->rule?->name ?? 'Default', + ]); + } + + return implode("\n", $lines); + } + + protected function formatQuickBooksIIF($assignments): string + { + $lines = []; + $lines[] = "!TRNS\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tMEMO"; + $lines[] = "!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tMEMO"; + $lines[] = "!ENDTRNS"; + + foreach ($assignments as $a) { + $date = $a->created_at->format('m/d/Y'); + $lines[] = "TRNS\tGENERAL JOURNAL\t{$date}\t{$a->glAccount->code}\t{$a->amount}\t" . class_basename($a->assignable_type); + $lines[] = "SPL\tGENERAL JOURNAL\t{$date}\tAccounts Payable\t-{$a->amount}\t"; + $lines[] = "ENDTRNS"; + } + + return implode("\n", $lines); + } + + protected function formatJson($assignments): string + { + return $assignments->map(fn ($a) => [ + 'gl_code' => $a->glAccount->code, + 'gl_name' => $a->glAccount->name, + 'amount' => $a->amount, + 'record_type' => class_basename($a->assignable_type), + 'record_id' => $a->assignable_uuid, + 'date' => $a->created_at->toIso8601String(), + 'assignment_type' => $a->assignment_type, + ])->toJson(JSON_PRETTY_PRINT); + } +} From 23fb4a22899d7220280528928c91c9f1cd461613 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:28:33 -0700 Subject: [PATCH 05/22] feat: add carrier invoice, line items, and audit rules table migrations Co-Authored-By: Claude Opus 4.6 (1M context) --- ...1_000030_create_carrier_invoices_table.php | 61 +++++++++++++++++++ ...031_create_carrier_invoice_items_table.php | 43 +++++++++++++ ...eate_carrier_invoice_audit_rules_table.php | 36 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 server/migrations/2024_01_01_000030_create_carrier_invoices_table.php create mode 100644 server/migrations/2024_01_01_000031_create_carrier_invoice_items_table.php create mode 100644 server/migrations/2024_01_01_000032_create_carrier_invoice_audit_rules_table.php diff --git a/server/migrations/2024_01_01_000030_create_carrier_invoices_table.php b/server/migrations/2024_01_01_000030_create_carrier_invoices_table.php new file mode 100644 index 0000000..ede8e1e --- /dev/null +++ b/server/migrations/2024_01_01_000030_create_carrier_invoices_table.php @@ -0,0 +1,61 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('vendor_uuid', 36)->index(); + $table->char('order_uuid', 36)->nullable()->index(); + $table->char('shipment_uuid', 36)->nullable()->index(); + $table->string('invoice_number')->nullable(); + $table->string('pro_number')->nullable()->index(); + $table->string('bol_number')->nullable()->index(); + + $table->string('source', 20)->default('manual'); + $table->string('status', 20)->default('pending'); + + $table->decimal('invoiced_amount', 12, 2); + $table->decimal('planned_amount', 12, 2)->nullable(); + $table->decimal('approved_amount', 12, 2)->nullable(); + $table->decimal('discrepancy_amount', 12, 2)->nullable(); + $table->decimal('discrepancy_percent', 5, 2)->nullable(); + + $table->string('discrepancy_type', 20)->nullable(); + $table->string('resolution', 20)->nullable(); + $table->text('resolution_notes')->nullable(); + $table->char('resolved_by', 36)->nullable(); + $table->timestamp('resolved_at')->nullable(); + + $table->date('invoice_date')->nullable(); + $table->date('due_date')->nullable(); + $table->timestamp('received_at')->nullable(); + $table->date('pickup_date')->nullable(); + $table->date('delivery_date')->nullable(); + + $table->string('currency', 3)->default('USD'); + $table->char('file_uuid', 36)->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('vendor_uuid')->references('uuid')->on('vendors'); + $table->foreign('resolved_by')->references('uuid')->on('users')->nullOnDelete(); + }); + } + + public function down() + { + Schema::dropIfExists('carrier_invoices'); + } +}; diff --git a/server/migrations/2024_01_01_000031_create_carrier_invoice_items_table.php b/server/migrations/2024_01_01_000031_create_carrier_invoice_items_table.php new file mode 100644 index 0000000..09fd2ff --- /dev/null +++ b/server/migrations/2024_01_01_000031_create_carrier_invoice_items_table.php @@ -0,0 +1,43 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('carrier_invoice_uuid', 36)->index(); + $table->string('charge_type', 30); + $table->string('description')->nullable(); + $table->string('accessorial_code', 20)->nullable(); + + $table->decimal('invoiced_amount', 10, 2); + $table->decimal('planned_amount', 10, 2)->nullable(); + $table->decimal('approved_amount', 10, 2)->nullable(); + $table->decimal('discrepancy_amount', 10, 2)->nullable(); + + $table->decimal('quantity', 10, 2)->nullable(); + $table->decimal('rate', 10, 4)->nullable(); + $table->string('rate_type', 20)->nullable(); + + $table->json('meta')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('carrier_invoice_uuid') + ->references('uuid') + ->on('carrier_invoices') + ->cascadeOnDelete(); + }); + } + + public function down() + { + Schema::dropIfExists('carrier_invoice_items'); + } +}; diff --git a/server/migrations/2024_01_01_000032_create_carrier_invoice_audit_rules_table.php b/server/migrations/2024_01_01_000032_create_carrier_invoice_audit_rules_table.php new file mode 100644 index 0000000..df5586d --- /dev/null +++ b/server/migrations/2024_01_01_000032_create_carrier_invoice_audit_rules_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('name'); + $table->string('rule_type', 30); + $table->decimal('tolerance_percent', 5, 2)->nullable(); + $table->decimal('tolerance_amount', 10, 2)->nullable(); + $table->string('charge_type', 30)->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('priority')->default(0); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + + public function down() + { + Schema::dropIfExists('carrier_invoice_audit_rules'); + } +}; From fffb1768acb607013bef1b5f3475c955f08ada9f Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:28:51 -0700 Subject: [PATCH 06/22] feat: add GL assignment controllers, resources, routes, and service registration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Internal/v1/GlAssignmentController.php | 11 +++++ .../v1/GlAssignmentRuleController.php | 11 +++++ .../Internal/v1/GlExportBatchController.php | 48 +++++++++++++++++++ server/src/Http/Resources/v1/GlAssignment.php | 25 ++++++++++ .../Http/Resources/v1/GlAssignmentRule.php | 29 +++++++++++ .../src/Http/Resources/v1/GlExportBatch.php | 27 +++++++++++ .../src/Providers/LedgerServiceProvider.php | 3 ++ server/src/routes.php | 29 +++++++++++ 8 files changed, 183 insertions(+) create mode 100644 server/src/Http/Controllers/Internal/v1/GlAssignmentController.php create mode 100644 server/src/Http/Controllers/Internal/v1/GlAssignmentRuleController.php create mode 100644 server/src/Http/Controllers/Internal/v1/GlExportBatchController.php create mode 100644 server/src/Http/Resources/v1/GlAssignment.php create mode 100644 server/src/Http/Resources/v1/GlAssignmentRule.php create mode 100644 server/src/Http/Resources/v1/GlExportBatch.php diff --git a/server/src/Http/Controllers/Internal/v1/GlAssignmentController.php b/server/src/Http/Controllers/Internal/v1/GlAssignmentController.php new file mode 100644 index 0000000..9112dab --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/GlAssignmentController.php @@ -0,0 +1,11 @@ +validate([ + 'format' => 'required|string|in:csv,json,quickbooks_iif', + 'period_start' => 'required|date', + 'period_end' => 'required|date|after_or_equal:period_start', + ]); + + $batch = app(GlExportService::class)->generateExport( + session('company'), + $validated['format'], + Carbon::parse($validated['period_start']), + Carbon::parse($validated['period_end']) + ); + + return response()->json(['data' => $batch]); + } + + public function download(string $id) + { + $batch = GlExportBatch::findRecordOrFail($id); + + if (!$batch->file_uuid) { + return response()->apiError('Export file not generated yet.', 404); + } + + $file = \Fleetbase\Models\File::where('uuid', $batch->file_uuid)->firstOrFail(); + + return response()->download( + storage_path('app/' . $file->path), + $file->original_name + ); + } +} diff --git a/server/src/Http/Resources/v1/GlAssignment.php b/server/src/Http/Resources/v1/GlAssignment.php new file mode 100644 index 0000000..0fb460a --- /dev/null +++ b/server/src/Http/Resources/v1/GlAssignment.php @@ -0,0 +1,25 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->uuid), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'gl_account' => $this->whenLoaded('glAccount'), + 'rule' => $this->whenLoaded('rule'), + 'assignable_type' => class_basename($this->assignable_type), + 'assignable_uuid' => $this->when(Http::isInternalRequest(), $this->assignable_uuid), + 'amount' => $this->amount, + 'assignment_type' => $this->assignment_type, + 'meta' => $this->meta, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/GlAssignmentRule.php b/server/src/Http/Resources/v1/GlAssignmentRule.php new file mode 100644 index 0000000..3186292 --- /dev/null +++ b/server/src/Http/Resources/v1/GlAssignmentRule.php @@ -0,0 +1,29 @@ + $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), + 'name' => $this->name, + 'priority' => $this->priority, + 'match_type' => $this->match_type, + 'gl_account_uuid' => $this->when(Http::isInternalRequest(), $this->gl_account_uuid), + 'gl_account' => $this->whenLoaded('glAccount'), + 'target' => $this->target, + 'is_active' => $this->is_active, + 'conditions' => $this->whenLoaded('conditions'), + 'meta' => $this->meta, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/GlExportBatch.php b/server/src/Http/Resources/v1/GlExportBatch.php new file mode 100644 index 0000000..f5c8c9a --- /dev/null +++ b/server/src/Http/Resources/v1/GlExportBatch.php @@ -0,0 +1,27 @@ + $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), + 'format' => $this->format, + 'status' => $this->status, + 'period_start' => $this->period_start, + 'period_end' => $this->period_end, + 'record_count' => $this->record_count, + 'total_amount' => $this->total_amount, + 'exported_at' => $this->exported_at, + 'meta' => $this->meta, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index deaf4eb..2c34e04 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -79,6 +79,9 @@ public function register() $this->app->singleton(PaymentService::class, function ($app) { return new PaymentService($app->make(PaymentGatewayManager::class)); }); + + $this->app->singleton(\Fleetbase\Ledger\Services\GlAutoAssignmentService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\GlExportService::class); } /** diff --git a/server/src/routes.php b/server/src/routes.php index 81e7893..916826c 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -179,6 +179,35 @@ function ($router, $controller) { $router->get('reports/cash-flow', 'ReportController@cashFlow'); $router->get('reports/ar-aging', 'ReportController@arAging'); $router->get('reports/wallet-summary', 'ReportController@walletSummary'); + + // ---------------------------------------------------------------- + // GL Assignment Rules + // ---------------------------------------------------------------- + $router->group(['prefix' => 'gl-assignment-rules'], function () use ($router) { + $router->get('/', 'GlAssignmentRuleController@queryRecord'); + $router->get('{id}', 'GlAssignmentRuleController@findRecord'); + $router->post('/', 'GlAssignmentRuleController@createRecord'); + $router->put('{id}', 'GlAssignmentRuleController@updateRecord'); + $router->delete('{id}', 'GlAssignmentRuleController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // GL Assignments + // ---------------------------------------------------------------- + $router->group(['prefix' => 'gl-assignments'], function () use ($router) { + $router->get('/', 'GlAssignmentController@queryRecord'); + $router->post('/', 'GlAssignmentController@createRecord'); + $router->delete('{id}', 'GlAssignmentController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // GL Exports + // ---------------------------------------------------------------- + $router->group(['prefix' => 'gl-exports'], function () use ($router) { + $router->get('/', 'GlExportBatchController@queryRecord'); + $router->post('generate', 'GlExportBatchController@generate'); + $router->get('{id}/download', 'GlExportBatchController@download'); + }); } ); } From efae3094483e9c737f429011f2c52a4f45b2b3f2 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:42:07 -0700 Subject: [PATCH 07/22] feat: add CarrierInvoice, CarrierInvoiceItem, and CarrierInvoiceAuditRule models Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/Models/CarrierInvoice.php | 112 ++++++++++++++++++ server/src/Models/CarrierInvoiceAuditRule.php | 50 ++++++++ server/src/Models/CarrierInvoiceItem.php | 37 ++++++ 3 files changed, 199 insertions(+) create mode 100644 server/src/Models/CarrierInvoice.php create mode 100644 server/src/Models/CarrierInvoiceAuditRule.php create mode 100644 server/src/Models/CarrierInvoiceItem.php diff --git a/server/src/Models/CarrierInvoice.php b/server/src/Models/CarrierInvoice.php new file mode 100644 index 0000000..a23c69d --- /dev/null +++ b/server/src/Models/CarrierInvoice.php @@ -0,0 +1,112 @@ + 'decimal:2', + 'planned_amount' => 'decimal:2', + 'approved_amount' => 'decimal:2', + 'discrepancy_amount' => 'decimal:2', + 'discrepancy_percent' => 'decimal:2', + 'invoice_date' => 'date', + 'due_date' => 'date', + 'pickup_date' => 'date', + 'delivery_date' => 'date', + 'received_at' => 'datetime', + 'resolved_at' => 'datetime', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function vendor() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Vendor::class, 'vendor_uuid', 'uuid'); + } + + public function order() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Order::class, 'order_uuid', 'uuid'); + } + + public function items() + { + return $this->hasMany(CarrierInvoiceItem::class, 'carrier_invoice_uuid', 'uuid'); + } + + public function resolvedBy() + { + return $this->belongsTo(\Fleetbase\Models\User::class, 'resolved_by', 'uuid'); + } + + public function file() + { + return $this->belongsTo(\Fleetbase\Models\File::class, 'file_uuid', 'uuid'); + } + + public function getGlContext(): array + { + $context = [ + 'carrier' => $this->vendor_uuid, + ]; + + if ($this->order) { + $context['customer'] = $this->order->customer_uuid ?? null; + $context['mode'] = $this->order->meta['mode'] ?? null; + $context['equipment_type'] = $this->order->meta['equipment_type'] ?? null; + + $pickup = $this->order->payload?->pickup; + $dropoff = $this->order->payload?->dropoff; + if ($pickup) { + $context['origin_state'] = $pickup->state ?? null; + $context['origin_zip'] = $pickup->postal_code ?? null; + } + if ($dropoff) { + $context['dest_state'] = $dropoff->state ?? null; + $context['dest_zip'] = $dropoff->postal_code ?? null; + } + } + + return $context; + } +} diff --git a/server/src/Models/CarrierInvoiceAuditRule.php b/server/src/Models/CarrierInvoiceAuditRule.php new file mode 100644 index 0000000..095c6be --- /dev/null +++ b/server/src/Models/CarrierInvoiceAuditRule.php @@ -0,0 +1,50 @@ + 'decimal:2', + 'tolerance_amount' => 'decimal:2', + 'is_active' => 'boolean', + 'priority' => 'integer', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('priority', 'asc'); + } +} diff --git a/server/src/Models/CarrierInvoiceItem.php b/server/src/Models/CarrierInvoiceItem.php new file mode 100644 index 0000000..89b2f8a --- /dev/null +++ b/server/src/Models/CarrierInvoiceItem.php @@ -0,0 +1,37 @@ + 'decimal:2', + 'planned_amount' => 'decimal:2', + 'approved_amount' => 'decimal:2', + 'discrepancy_amount' => 'decimal:2', + 'quantity' => 'decimal:2', + 'rate' => 'decimal:4', + 'meta' => Json::class, + ]; + + public function carrierInvoice() + { + return $this->belongsTo(CarrierInvoice::class, 'carrier_invoice_uuid', 'uuid'); + } +} From 75e90818668f463e9fddeb2cbb47c56e355446a2 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:48:12 -0700 Subject: [PATCH 08/22] feat: add carrier invoice audit service with discrepancy detection and auto-approve rules Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/CarrierInvoiceAuditService.php | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 server/src/Services/CarrierInvoiceAuditService.php diff --git a/server/src/Services/CarrierInvoiceAuditService.php b/server/src/Services/CarrierInvoiceAuditService.php new file mode 100644 index 0000000..6a7327a --- /dev/null +++ b/server/src/Services/CarrierInvoiceAuditService.php @@ -0,0 +1,107 @@ +load('items'); + + if ($invoice->planned_amount !== null) { + $discrepancy = $invoice->invoiced_amount - $invoice->planned_amount; + $invoice->discrepancy_amount = $discrepancy; + $invoice->discrepancy_percent = $invoice->planned_amount > 0 + ? round(abs($discrepancy) / $invoice->planned_amount * 100, 2) + : null; + $invoice->discrepancy_type = match (true) { + $discrepancy > 0.01 => 'overcharge', + $discrepancy < -0.01 => 'undercharge', + default => 'none', + }; + } + + foreach ($invoice->items as $item) { + if ($item->planned_amount !== null) { + $item->discrepancy_amount = $item->invoiced_amount - $item->planned_amount; + $item->save(); + } + } + + $rules = CarrierInvoiceAuditRule::where('company_uuid', $invoice->company_uuid) + ->active() + ->ordered() + ->get(); + + $autoApprove = $this->evaluateRules($invoice, $rules); + + if ($autoApprove) { + $invoice->status = 'audited'; + $invoice->approved_amount = $invoice->invoiced_amount; + } else { + $invoice->status = 'in_review'; + } + + $invoice->save(); + + return $invoice; + } + + protected function evaluateRules(CarrierInvoice $invoice, $rules): bool + { + if ($invoice->discrepancy_type === 'none') { + return true; + } + + foreach ($rules as $rule) { + if ($rule->charge_type && $rule->charge_type !== 'all') { + continue; + } + + if ($rule->rule_type === 'tolerance') { + $withinPercent = $rule->tolerance_percent === null + || ($invoice->discrepancy_percent !== null && $invoice->discrepancy_percent <= $rule->tolerance_percent); + + $withinAmount = $rule->tolerance_amount === null + || (abs($invoice->discrepancy_amount) <= $rule->tolerance_amount); + + if ($withinPercent && $withinAmount) { + return true; + } + } + + if ($rule->rule_type === 'auto_approve' && $invoice->invoiced_amount <= ($rule->tolerance_amount ?? PHP_FLOAT_MAX)) { + return true; + } + } + + return false; + } + + public function resolve(CarrierInvoice $invoice, string $resolution, ?float $customAmount = null, ?string $notes = null): CarrierInvoice + { + $invoice->resolution = $resolution; + $invoice->resolution_notes = $notes; + $invoice->resolved_by = session('user'); + $invoice->resolved_at = now(); + + $invoice->approved_amount = match ($resolution) { + 'pay_invoiced' => $invoice->invoiced_amount, + 'pay_planned' => $invoice->planned_amount, + 'pay_custom' => $customAmount, + 'disputed' => null, + }; + + $invoice->status = $resolution === 'disputed' ? 'disputed' : 'approved'; + $invoice->save(); + + if ($invoice->status === 'approved') { + event(new \Fleetbase\Ledger\Events\CarrierInvoiceApproved($invoice)); + } + + return $invoice; + } +} From d37420c40142ce18879399a37971cefe3ef99e8e Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:48:13 -0700 Subject: [PATCH 09/22] feat: add CarrierInvoiceReceived and CarrierInvoiceApproved events Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/Events/CarrierInvoiceApproved.php | 15 +++++++++++++++ server/src/Events/CarrierInvoiceReceived.php | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 server/src/Events/CarrierInvoiceApproved.php create mode 100644 server/src/Events/CarrierInvoiceReceived.php diff --git a/server/src/Events/CarrierInvoiceApproved.php b/server/src/Events/CarrierInvoiceApproved.php new file mode 100644 index 0000000..664fbcb --- /dev/null +++ b/server/src/Events/CarrierInvoiceApproved.php @@ -0,0 +1,15 @@ +carrierInvoice = $carrierInvoice; + } +} diff --git a/server/src/Events/CarrierInvoiceReceived.php b/server/src/Events/CarrierInvoiceReceived.php new file mode 100644 index 0000000..487c8b4 --- /dev/null +++ b/server/src/Events/CarrierInvoiceReceived.php @@ -0,0 +1,15 @@ +carrierInvoice = $carrierInvoice; + } +} From 9544347adfe686f80995bbeb8e42244c8f001fc3 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 09:55:46 -0700 Subject: [PATCH 10/22] feat: add carrier invoice controller, resources, routes, and GL event wiring --- .../v1/CarrierInvoiceAuditRuleController.php | 11 +++++ .../Internal/v1/CarrierInvoiceController.php | 40 ++++++++++++++++ .../src/Http/Resources/v1/CarrierInvoice.php | 48 +++++++++++++++++++ .../Http/Resources/v1/CarrierInvoiceItem.php | 29 +++++++++++ .../src/Providers/LedgerServiceProvider.php | 13 +++++ server/src/routes.php | 24 ++++++++++ 6 files changed, 165 insertions(+) create mode 100644 server/src/Http/Controllers/Internal/v1/CarrierInvoiceAuditRuleController.php create mode 100644 server/src/Http/Controllers/Internal/v1/CarrierInvoiceController.php create mode 100644 server/src/Http/Resources/v1/CarrierInvoice.php create mode 100644 server/src/Http/Resources/v1/CarrierInvoiceItem.php diff --git a/server/src/Http/Controllers/Internal/v1/CarrierInvoiceAuditRuleController.php b/server/src/Http/Controllers/Internal/v1/CarrierInvoiceAuditRuleController.php new file mode 100644 index 0000000..c27ee56 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/CarrierInvoiceAuditRuleController.php @@ -0,0 +1,11 @@ +audit($invoice); + return response()->json(['data' => $audited->load('items')]); + } + + public function resolve(string $id, Request $request) + { + $invoice = CarrierInvoice::findRecordOrFail($id); + + $validated = $request->validate([ + 'resolution' => 'required|string|in:pay_invoiced,pay_planned,pay_custom,disputed', + 'custom_amount' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string|max:5000', + ]); + + $resolved = app(CarrierInvoiceAuditService::class)->resolve( + $invoice, + $validated['resolution'], + $validated['custom_amount'] ?? null, + $validated['notes'] ?? null + ); + + return response()->json(['data' => $resolved->load('items')]); + } +} diff --git a/server/src/Http/Resources/v1/CarrierInvoice.php b/server/src/Http/Resources/v1/CarrierInvoice.php new file mode 100644 index 0000000..80a3bc2 --- /dev/null +++ b/server/src/Http/Resources/v1/CarrierInvoice.php @@ -0,0 +1,48 @@ + $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), + 'vendor_uuid' => $this->when(Http::isInternalRequest(), $this->vendor_uuid), + 'order_uuid' => $this->when(Http::isInternalRequest(), $this->order_uuid), + 'shipment_uuid' => $this->when(Http::isInternalRequest(), $this->shipment_uuid), + 'invoice_number' => $this->invoice_number, + 'pro_number' => $this->pro_number, + 'bol_number' => $this->bol_number, + 'source' => $this->source, + 'status' => $this->status, + 'invoiced_amount' => $this->invoiced_amount, + 'planned_amount' => $this->planned_amount, + 'approved_amount' => $this->approved_amount, + 'discrepancy_amount' => $this->discrepancy_amount, + 'discrepancy_percent' => $this->discrepancy_percent, + 'discrepancy_type' => $this->discrepancy_type, + 'resolution' => $this->resolution, + 'resolution_notes' => $this->resolution_notes, + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'pickup_date' => $this->pickup_date, + 'delivery_date' => $this->delivery_date, + 'currency' => $this->currency, + 'vendor' => $this->whenLoaded('vendor'), + 'order' => $this->whenLoaded('order'), + 'items' => CarrierInvoiceItem::collection($this->whenLoaded('items')), + 'resolved_by' => $this->whenLoaded('resolvedBy'), + 'meta' => $this->meta, + 'received_at' => $this->received_at, + 'resolved_at' => $this->resolved_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/CarrierInvoiceItem.php b/server/src/Http/Resources/v1/CarrierInvoiceItem.php new file mode 100644 index 0000000..11d7972 --- /dev/null +++ b/server/src/Http/Resources/v1/CarrierInvoiceItem.php @@ -0,0 +1,29 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->uuid), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'charge_type' => $this->charge_type, + 'description' => $this->description, + 'accessorial_code' => $this->accessorial_code, + 'invoiced_amount' => $this->invoiced_amount, + 'planned_amount' => $this->planned_amount, + 'approved_amount' => $this->approved_amount, + 'discrepancy_amount' => $this->discrepancy_amount, + 'quantity' => $this->quantity, + 'rate' => $this->rate, + 'rate_type' => $this->rate_type, + 'meta' => $this->meta, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index 2c34e04..4d04730 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -82,6 +82,7 @@ public function register() $this->app->singleton(\Fleetbase\Ledger\Services\GlAutoAssignmentService::class); $this->app->singleton(\Fleetbase\Ledger\Services\GlExportService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\CarrierInvoiceAuditService::class); } /** @@ -107,6 +108,18 @@ public function boot() // registry at render time to resolve {namespace.field} placeholders. $this->registerInvoiceTemplateContext(); + // GL auto-assignment on carrier invoice approval + \Illuminate\Support\Facades\Event::listen( + \Fleetbase\Ledger\Events\CarrierInvoiceApproved::class, + function ($event) { + $invoice = $event->carrierInvoice; + if ($invoice->approved_amount) { + app(\Fleetbase\Ledger\Services\GlAutoAssignmentService::class) + ->assignForRecord($invoice, 'carrier_invoice', $invoice->approved_amount); + } + } + ); + // Register Artisan commands if ($this->app->runningInConsole()) { $this->commands([ diff --git a/server/src/routes.php b/server/src/routes.php index 916826c..00be223 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -208,6 +208,30 @@ function ($router, $controller) { $router->post('generate', 'GlExportBatchController@generate'); $router->get('{id}/download', 'GlExportBatchController@download'); }); + + // ---------------------------------------------------------------- + // Carrier Invoices + // ---------------------------------------------------------------- + $router->group(['prefix' => 'carrier-invoices'], function () use ($router) { + $router->get('/', 'CarrierInvoiceController@queryRecord'); + $router->get('{id}', 'CarrierInvoiceController@findRecord'); + $router->post('/', 'CarrierInvoiceController@createRecord'); + $router->put('{id}', 'CarrierInvoiceController@updateRecord'); + $router->delete('{id}', 'CarrierInvoiceController@deleteRecord'); + $router->post('{id}/audit', 'CarrierInvoiceController@audit'); + $router->post('{id}/resolve', 'CarrierInvoiceController@resolve'); + }); + + // ---------------------------------------------------------------- + // Carrier Invoice Audit Rules + // ---------------------------------------------------------------- + $router->group(['prefix' => 'carrier-invoice-audit-rules'], function () use ($router) { + $router->get('/', 'CarrierInvoiceAuditRuleController@queryRecord'); + $router->get('{id}', 'CarrierInvoiceAuditRuleController@findRecord'); + $router->post('/', 'CarrierInvoiceAuditRuleController@createRecord'); + $router->put('{id}', 'CarrierInvoiceAuditRuleController@updateRecord'); + $router->delete('{id}', 'CarrierInvoiceAuditRuleController@deleteRecord'); + }); } ); } From 0a2f7e826ee06a8d020fd119cf4bfd780b8fb4b1 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 13:23:46 -0700 Subject: [PATCH 11/22] feat(BUILD-06): add service agreements, charge templates, and client invoices tables Co-Authored-By: Claude Opus 4.6 (1M context) --- ...000033_create_service_agreements_table.php | 31 +++++++++++++++ ...1_000034_create_charge_templates_table.php | 25 ++++++++++++ ...035_create_charge_template_items_table.php | 29 ++++++++++++++ ...create_service_agreement_charges_table.php | 25 ++++++++++++ ...01_000037_create_client_invoices_table.php | 39 +++++++++++++++++++ ...0038_create_client_invoice_items_table.php | 28 +++++++++++++ 6 files changed, 177 insertions(+) create mode 100644 server/migrations/2024_01_01_000033_create_service_agreements_table.php create mode 100644 server/migrations/2024_01_01_000034_create_charge_templates_table.php create mode 100644 server/migrations/2024_01_01_000035_create_charge_template_items_table.php create mode 100644 server/migrations/2024_01_01_000036_create_service_agreement_charges_table.php create mode 100644 server/migrations/2024_01_01_000037_create_client_invoices_table.php create mode 100644 server/migrations/2024_01_01_000038_create_client_invoice_items_table.php diff --git a/server/migrations/2024_01_01_000033_create_service_agreements_table.php b/server/migrations/2024_01_01_000033_create_service_agreements_table.php new file mode 100644 index 0000000..9fd517e --- /dev/null +++ b/server/migrations/2024_01_01_000033_create_service_agreements_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('customer_uuid', 36)->index(); // the shipper/client + $table->string('name'); + $table->string('status', 20)->default('draft'); // draft, active, expired, cancelled + $table->string('billing_frequency', 20)->default('per_shipment'); // per_shipment, weekly, biweekly, monthly + $table->integer('payment_terms_days')->default(30); // net 30, net 60, etc. + $table->date('effective_date'); + $table->date('expiration_date')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->text('notes')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + public function down() { Schema::dropIfExists('service_agreements'); } +}; diff --git a/server/migrations/2024_01_01_000034_create_charge_templates_table.php b/server/migrations/2024_01_01_000034_create_charge_templates_table.php new file mode 100644 index 0000000..412ed04 --- /dev/null +++ b/server/migrations/2024_01_01_000034_create_charge_templates_table.php @@ -0,0 +1,25 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('name'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + public function down() { Schema::dropIfExists('charge_templates'); } +}; diff --git a/server/migrations/2024_01_01_000035_create_charge_template_items_table.php b/server/migrations/2024_01_01_000035_create_charge_template_items_table.php new file mode 100644 index 0000000..bfa63da --- /dev/null +++ b/server/migrations/2024_01_01_000035_create_charge_template_items_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('charge_template_uuid', 36)->index(); + $table->string('charge_type', 50); // linehaul, fuel_surcharge, accessorial, management_fee, gainshare, etc. + $table->string('description')->nullable(); + $table->string('calculation_method', 30); // flat, per_mile, per_cwt, per_unit, percentage_of_linehaul, percentage_of_total + $table->decimal('rate', 12, 4)->nullable(); // the rate value (amount, per-mile rate, percentage, etc.) + $table->decimal('minimum', 10, 2)->nullable(); // minimum charge + $table->decimal('maximum', 10, 2)->nullable(); // maximum charge cap + $table->integer('sequence')->default(0); // display/calculation order + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('charge_template_uuid')->references('uuid')->on('charge_templates')->cascadeOnDelete(); + }); + } + public function down() { Schema::dropIfExists('charge_template_items'); } +}; diff --git a/server/migrations/2024_01_01_000036_create_service_agreement_charges_table.php b/server/migrations/2024_01_01_000036_create_service_agreement_charges_table.php new file mode 100644 index 0000000..46111cd --- /dev/null +++ b/server/migrations/2024_01_01_000036_create_service_agreement_charges_table.php @@ -0,0 +1,25 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('service_agreement_uuid', 36)->index(); + $table->char('charge_template_uuid', 36)->index(); + $table->json('overrides')->nullable(); // per-client overrides: {"linehaul": {"rate": 2.50}, "fuel_surcharge": {"rate": 15}} + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('service_agreement_uuid')->references('uuid')->on('service_agreements')->cascadeOnDelete(); + $table->foreign('charge_template_uuid')->references('uuid')->on('charge_templates'); + }); + } + public function down() { Schema::dropIfExists('service_agreement_charges'); } +}; diff --git a/server/migrations/2024_01_01_000037_create_client_invoices_table.php b/server/migrations/2024_01_01_000037_create_client_invoices_table.php new file mode 100644 index 0000000..4de858a --- /dev/null +++ b/server/migrations/2024_01_01_000037_create_client_invoices_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('customer_uuid', 36)->index(); + $table->char('service_agreement_uuid', 36)->nullable(); + $table->char('shipment_uuid', 36)->nullable()->index(); + $table->string('invoice_number', 50)->nullable()->index(); + $table->string('status', 20)->default('draft'); // draft, sent, paid, overdue, cancelled + $table->decimal('subtotal', 12, 2)->default(0); + $table->decimal('tax_amount', 10, 2)->default(0); + $table->decimal('total_amount', 12, 2)->default(0); + $table->date('invoice_date')->nullable(); + $table->date('due_date')->nullable(); + $table->date('period_start')->nullable(); // for batch invoices + $table->date('period_end')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->text('notes')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('service_agreement_uuid')->references('uuid')->on('service_agreements')->nullOnDelete(); + }); + } + public function down() { Schema::dropIfExists('client_invoices'); } +}; diff --git a/server/migrations/2024_01_01_000038_create_client_invoice_items_table.php b/server/migrations/2024_01_01_000038_create_client_invoice_items_table.php new file mode 100644 index 0000000..1692331 --- /dev/null +++ b/server/migrations/2024_01_01_000038_create_client_invoice_items_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('client_invoice_uuid', 36)->index(); + $table->string('charge_type', 50); + $table->string('description')->nullable(); + $table->string('calculation_method', 30)->nullable(); + $table->decimal('rate', 12, 4)->nullable(); + $table->decimal('quantity', 12, 2)->nullable(); // miles, cwt, units, etc. + $table->decimal('amount', 12, 2)->default(0); // calculated charge + $table->char('shipment_uuid', 36)->nullable(); // for batch invoices with multiple shipments + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('client_invoice_uuid')->references('uuid')->on('client_invoices')->cascadeOnDelete(); + }); + } + public function down() { Schema::dropIfExists('client_invoice_items'); } +}; From a1dabd38761211dcd24e53293fca98cb251e561d Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 13:23:57 -0700 Subject: [PATCH 12/22] feat(BUILD-06): add ServiceAgreement, ChargeTemplate, ClientInvoice models Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/Models/ChargeTemplate.php | 45 ++++++++++++ server/src/Models/ChargeTemplateItem.php | 41 +++++++++++ server/src/Models/ClientInvoice.php | 69 ++++++++++++++++++ server/src/Models/ClientInvoiceItem.php | 34 +++++++++ server/src/Models/ServiceAgreement.php | 74 ++++++++++++++++++++ server/src/Models/ServiceAgreementCharge.php | 42 +++++++++++ 6 files changed, 305 insertions(+) create mode 100644 server/src/Models/ChargeTemplate.php create mode 100644 server/src/Models/ChargeTemplateItem.php create mode 100644 server/src/Models/ClientInvoice.php create mode 100644 server/src/Models/ClientInvoiceItem.php create mode 100644 server/src/Models/ServiceAgreement.php create mode 100644 server/src/Models/ServiceAgreementCharge.php diff --git a/server/src/Models/ChargeTemplate.php b/server/src/Models/ChargeTemplate.php new file mode 100644 index 0000000..44c189f --- /dev/null +++ b/server/src/Models/ChargeTemplate.php @@ -0,0 +1,45 @@ + 'boolean', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function items() + { + return $this->hasMany(ChargeTemplateItem::class, 'charge_template_uuid', 'uuid') + ->orderBy('sequence'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/server/src/Models/ChargeTemplateItem.php b/server/src/Models/ChargeTemplateItem.php new file mode 100644 index 0000000..5ffb0d6 --- /dev/null +++ b/server/src/Models/ChargeTemplateItem.php @@ -0,0 +1,41 @@ + 'decimal:4', + 'minimum' => 'decimal:2', + 'maximum' => 'decimal:2', + 'sequence' => 'integer', + 'is_active' => 'boolean', + 'meta' => Json::class, + ]; + + public function template() + { + return $this->belongsTo(ChargeTemplate::class, 'charge_template_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/server/src/Models/ClientInvoice.php b/server/src/Models/ClientInvoice.php new file mode 100644 index 0000000..9462fca --- /dev/null +++ b/server/src/Models/ClientInvoice.php @@ -0,0 +1,69 @@ + 'decimal:2', + 'tax_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'invoice_date' => 'date', + 'due_date' => 'date', + 'period_start' => 'date', + 'period_end' => 'date', + 'sent_at' => 'datetime', + 'paid_at' => 'datetime', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function serviceAgreement() + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_uuid', 'uuid'); + } + + public function items() + { + return $this->hasMany(ClientInvoiceItem::class, 'client_invoice_uuid', 'uuid'); + } + + public function scopeDraft($query) + { + return $query->where('status', 'draft'); + } + + public function scopeOverdue($query) + { + return $query->where('status', 'sent') + ->where('due_date', '<', now()); + } +} diff --git a/server/src/Models/ClientInvoiceItem.php b/server/src/Models/ClientInvoiceItem.php new file mode 100644 index 0000000..b9f418d --- /dev/null +++ b/server/src/Models/ClientInvoiceItem.php @@ -0,0 +1,34 @@ + 'decimal:4', + 'quantity' => 'decimal:2', + 'amount' => 'decimal:2', + 'meta' => Json::class, + ]; + + public function clientInvoice() + { + return $this->belongsTo(ClientInvoice::class, 'client_invoice_uuid', 'uuid'); + } +} diff --git a/server/src/Models/ServiceAgreement.php b/server/src/Models/ServiceAgreement.php new file mode 100644 index 0000000..2682e23 --- /dev/null +++ b/server/src/Models/ServiceAgreement.php @@ -0,0 +1,74 @@ + 'integer', + 'effective_date' => 'date', + 'expiration_date' => 'date', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function customer() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Contact::class, 'customer_uuid', 'uuid'); + } + + public function charges() + { + return $this->hasMany(ServiceAgreementCharge::class, 'service_agreement_uuid', 'uuid'); + } + + public function clientInvoices() + { + return $this->hasMany(ClientInvoice::class, 'service_agreement_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function scopeEffective($query) + { + return $query->where('effective_date', '<=', now()) + ->where(function ($q) { + $q->whereNull('expiration_date') + ->orWhere('expiration_date', '>=', now()); + }); + } + + public function scopeForCustomer($query, string $customerUuid) + { + return $query->where('customer_uuid', $customerUuid); + } +} diff --git a/server/src/Models/ServiceAgreementCharge.php b/server/src/Models/ServiceAgreementCharge.php new file mode 100644 index 0000000..c5f85ae --- /dev/null +++ b/server/src/Models/ServiceAgreementCharge.php @@ -0,0 +1,42 @@ + Json::class, + 'is_active' => 'boolean', + 'meta' => Json::class, + ]; + + public function serviceAgreement() + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_uuid', 'uuid'); + } + + public function chargeTemplate() + { + return $this->belongsTo(ChargeTemplate::class, 'charge_template_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} From ddd0ec6204168027ba63980bb129802e354a745a Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 13:30:02 -0700 Subject: [PATCH 13/22] feat(BUILD-06): add ClientInvoiceGeneratorService, BatchInvoiceService, InvoiceNumberGenerator Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/Services/BatchInvoiceService.php | 217 ++++++++++++++++++ .../ClientInvoiceGeneratorService.php | 158 +++++++++++++ .../src/Services/InvoiceNumberGenerator.php | 32 +++ 3 files changed, 407 insertions(+) create mode 100644 server/src/Services/BatchInvoiceService.php create mode 100644 server/src/Services/ClientInvoiceGeneratorService.php create mode 100644 server/src/Services/InvoiceNumberGenerator.php diff --git a/server/src/Services/BatchInvoiceService.php b/server/src/Services/BatchInvoiceService.php new file mode 100644 index 0000000..4d3e2f4 --- /dev/null +++ b/server/src/Services/BatchInvoiceService.php @@ -0,0 +1,217 @@ +numberGenerator = $numberGenerator; + $this->invoiceGenerator = $invoiceGenerator; + } + + /** + * Generate batch invoices for a company and billing period. + * + * Groups delivered/completed shipments by customer, finds active service + * agreements, and generates one consolidated invoice per customer. + * + * @param string $companyUuid The company generating invoices + * @param Carbon $periodStart Start of billing period + * @param Carbon $periodEnd End of billing period + * @return Collection Generated invoices + */ + public function generateBatch(string $companyUuid, Carbon $periodStart, Carbon $periodEnd): Collection + { + $invoices = collect(); + + // Find all batch-eligible service agreements + $agreements = ServiceAgreement::where('company_uuid', $companyUuid) + ->active() + ->effective() + ->whereIn('billing_frequency', ['weekly', 'biweekly', 'monthly']) + ->get(); + + foreach ($agreements as $agreement) { + $customerUuid = $agreement->customer_uuid; + + // Find delivered/completed shipments for this customer in the period + $shipments = Shipment::where('company_uuid', $companyUuid) + ->whereIn('status', ['delivered', 'completed']) + ->whereBetween('actual_delivery_at', [$periodStart, $periodEnd]) + ->whereHas('orders', function ($q) use ($customerUuid) { + $q->where('customer_uuid', $customerUuid); + }) + ->whereDoesntHave('meta', function ($q) { + // Skip shipments already invoiced + }) + ->get(); + + // Filter out already-invoiced shipments + $uninvoicedShipments = $shipments->filter(function ($shipment) { + return !ClientInvoiceItem::where('shipment_uuid', $shipment->uuid)->exists(); + }); + + if ($uninvoicedShipments->isEmpty()) { + continue; + } + + $invoice = $this->generateConsolidatedInvoice( + $agreement, + $uninvoicedShipments, + $periodStart, + $periodEnd + ); + + if ($invoice) { + $invoices->push($invoice); + } + } + + return $invoices; + } + + /** + * Generate a single consolidated invoice for multiple shipments under one agreement. + */ + protected function generateConsolidatedInvoice( + ServiceAgreement $agreement, + Collection $shipments, + Carbon $periodStart, + Carbon $periodEnd + ): ?ClientInvoice { + $companyUuid = $agreement->company_uuid; + + $charges = $agreement->charges() + ->active() + ->with('chargeTemplate.items') + ->get(); + + if ($charges->isEmpty()) { + return null; + } + + $invoice = ClientInvoice::create([ + 'company_uuid' => $companyUuid, + 'customer_uuid' => $agreement->customer_uuid, + 'service_agreement_uuid' => $agreement->uuid, + 'invoice_number' => $this->numberGenerator->generate($companyUuid), + 'status' => 'draft', + 'invoice_date' => now()->toDateString(), + 'due_date' => now()->addDays($agreement->payment_terms_days)->toDateString(), + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + 'currency' => $agreement->currency, + ]); + + $grandTotal = 0; + + foreach ($shipments as $shipment) { + $shipmentSubtotal = 0; + + foreach ($charges as $charge) { + $template = $charge->chargeTemplate; + if (!$template) continue; + + $overrides = $charge->overrides ?? []; + + foreach ($template->items()->active()->orderBy('sequence')->get() as $templateItem) { + $itemOverride = $overrides[$templateItem->charge_type] ?? []; + $rate = $itemOverride['rate'] ?? $templateItem->rate; + $method = $itemOverride['calculation_method'] ?? $templateItem->calculation_method; + $minimum = $itemOverride['minimum'] ?? $templateItem->minimum; + $maximum = $itemOverride['maximum'] ?? $templateItem->maximum; + + $calculated = $this->calculateCharge($method, $rate, $shipment, $shipmentSubtotal); + + if ($minimum !== null && $calculated['amount'] < $minimum) { + $calculated['amount'] = $minimum; + } + if ($maximum !== null && $calculated['amount'] > $maximum) { + $calculated['amount'] = $maximum; + } + + $amount = round($calculated['amount'], 2); + + ClientInvoiceItem::create([ + 'client_invoice_uuid' => $invoice->uuid, + 'charge_type' => $templateItem->charge_type, + 'description' => $templateItem->description . ' — Shipment ' . ($shipment->public_id ?? $shipment->uuid), + 'calculation_method' => $method, + 'rate' => $rate, + 'quantity' => $calculated['quantity'], + 'amount' => $amount, + 'shipment_uuid' => $shipment->uuid, + ]); + + $shipmentSubtotal += $amount; + } + } + + $grandTotal += $shipmentSubtotal; + } + + $invoice->update([ + 'subtotal' => round($grandTotal, 2), + 'total_amount' => round($grandTotal, 2), + ]); + + return $invoice->load('items'); + } + + /** + * Same calculation logic as ClientInvoiceGeneratorService. + */ + protected function calculateCharge(string $method, ?float $rate, Shipment $shipment, float $runningSubtotal): array + { + if ($rate === null) { + return ['amount' => 0, 'quantity' => null]; + } + + return match ($method) { + 'flat' => ['amount' => $rate, 'quantity' => 1], + 'per_mile' => [ + 'amount' => ($shipment->carrier_rate_miles ?? 0) * $rate, + 'quantity' => $shipment->carrier_rate_miles, + ], + 'per_cwt' => [ + 'amount' => (($shipment->total_weight ?? 0) / 100) * $rate, + 'quantity' => ($shipment->total_weight ?? 0) / 100, + ], + 'per_unit' => [ + 'amount' => ($shipment->total_pieces ?? 0) * $rate, + 'quantity' => $shipment->total_pieces, + ], + 'percentage_of_linehaul' => [ + 'amount' => ($shipment->carrier_rate ?? 0) * ($rate / 100), + 'quantity' => null, + ], + 'percentage_of_total' => [ + 'amount' => $runningSubtotal * ($rate / 100), + 'quantity' => null, + ], + default => ['amount' => $rate, 'quantity' => null], + }; + } +} diff --git a/server/src/Services/ClientInvoiceGeneratorService.php b/server/src/Services/ClientInvoiceGeneratorService.php new file mode 100644 index 0000000..3222752 --- /dev/null +++ b/server/src/Services/ClientInvoiceGeneratorService.php @@ -0,0 +1,158 @@ +numberGenerator = $numberGenerator; + } + + /** + * Generate a client invoice for a single shipment. + * + * @param Shipment $shipment The delivered/completed shipment + * @param ServiceAgreement $agreement The active service agreement for the customer + * @return ClientInvoice The generated invoice with items + */ + public function generateForShipment(Shipment $shipment, ServiceAgreement $agreement): ClientInvoice + { + $charges = $agreement->charges() + ->active() + ->with('chargeTemplate.items') + ->get(); + + $invoice = ClientInvoice::create([ + 'company_uuid' => $shipment->company_uuid, + 'customer_uuid' => $agreement->customer_uuid, + 'service_agreement_uuid' => $agreement->uuid, + 'shipment_uuid' => $shipment->uuid, + 'invoice_number' => $this->numberGenerator->generate($shipment->company_uuid), + 'status' => 'draft', + 'invoice_date' => now()->toDateString(), + 'due_date' => now()->addDays($agreement->payment_terms_days)->toDateString(), + 'currency' => $agreement->currency, + ]); + + $subtotal = 0; + + foreach ($charges as $charge) { + $template = $charge->chargeTemplate; + if (!$template) continue; + + $overrides = $charge->overrides ?? []; + + foreach ($template->items()->active()->orderBy('sequence')->get() as $templateItem) { + $itemOverride = $overrides[$templateItem->charge_type] ?? []; + $rate = $itemOverride['rate'] ?? $templateItem->rate; + $method = $itemOverride['calculation_method'] ?? $templateItem->calculation_method; + $minimum = $itemOverride['minimum'] ?? $templateItem->minimum; + $maximum = $itemOverride['maximum'] ?? $templateItem->maximum; + + $calculated = $this->calculateCharge($method, $rate, $shipment, $subtotal); + + // Apply min/max caps + if ($minimum !== null && $calculated['amount'] < $minimum) { + $calculated['amount'] = $minimum; + } + if ($maximum !== null && $calculated['amount'] > $maximum) { + $calculated['amount'] = $maximum; + } + + $amount = round($calculated['amount'], 2); + + ClientInvoiceItem::create([ + 'client_invoice_uuid' => $invoice->uuid, + 'charge_type' => $templateItem->charge_type, + 'description' => $templateItem->description, + 'calculation_method' => $method, + 'rate' => $rate, + 'quantity' => $calculated['quantity'], + 'amount' => $amount, + 'shipment_uuid' => $shipment->uuid, + ]); + + $subtotal += $amount; + } + } + + $invoice->update([ + 'subtotal' => round($subtotal, 2), + 'total_amount' => round($subtotal, 2), // tax_amount stays 0 for now + ]); + + return $invoice->load('items'); + } + + /** + * Calculate a charge amount based on the calculation method. + * + * @param string $method Calculation method + * @param float $rate Rate value + * @param Shipment $shipment Source shipment for context + * @param float $runningSubtotal Running subtotal for percentage_of_total + * @return array ['amount' => float, 'quantity' => float|null] + */ + protected function calculateCharge(string $method, ?float $rate, Shipment $shipment, float $runningSubtotal): array + { + if ($rate === null) { + return ['amount' => 0, 'quantity' => null]; + } + + return match ($method) { + 'flat' => [ + 'amount' => $rate, + 'quantity' => 1, + ], + 'per_mile' => [ + 'amount' => ($shipment->carrier_rate_miles ?? 0) * $rate, + 'quantity' => $shipment->carrier_rate_miles, + ], + 'per_cwt' => [ + 'amount' => (($shipment->total_weight ?? 0) / 100) * $rate, + 'quantity' => ($shipment->total_weight ?? 0) / 100, + ], + 'per_unit' => [ + 'amount' => ($shipment->total_pieces ?? 0) * $rate, + 'quantity' => $shipment->total_pieces, + ], + 'percentage_of_linehaul' => [ + 'amount' => ($shipment->carrier_rate ?? 0) * ($rate / 100), + 'quantity' => null, + ], + 'percentage_of_total' => [ + 'amount' => $runningSubtotal * ($rate / 100), + 'quantity' => null, + ], + default => [ + 'amount' => $rate, + 'quantity' => null, + ], + }; + } +} diff --git a/server/src/Services/InvoiceNumberGenerator.php b/server/src/Services/InvoiceNumberGenerator.php new file mode 100644 index 0000000..63db7b6 --- /dev/null +++ b/server/src/Services/InvoiceNumberGenerator.php @@ -0,0 +1,32 @@ +format('Y'); + $prefix = "INV-{$year}-"; + + $lastInvoice = ClientInvoice::where('company_uuid', $companyUuid) + ->where('invoice_number', 'like', "{$prefix}%") + ->orderBy('invoice_number', 'desc') + ->first(); + + if ($lastInvoice) { + $lastNumber = (int) str_replace($prefix, '', $lastInvoice->invoice_number); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } +} From 2d5d26afd8dc40522da1e8d3540a04dd73ba09f6 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 13:33:36 -0700 Subject: [PATCH 14/22] feat(BUILD-06): add controllers, resources, routes for service agreements and client invoices Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Internal/v1/ChargeTemplateController.php | 11 +++ .../Internal/v1/ClientInvoiceController.php | 68 +++++++++++++++++++ .../v1/ServiceAgreementController.php | 11 +++ .../src/Http/Resources/v1/ClientInvoice.php | 39 +++++++++++ .../Http/Resources/v1/ServiceAgreement.php | 31 +++++++++ .../src/Providers/LedgerServiceProvider.php | 4 ++ server/src/routes.php | 35 ++++++++++ 7 files changed, 199 insertions(+) create mode 100644 server/src/Http/Controllers/Internal/v1/ChargeTemplateController.php create mode 100644 server/src/Http/Controllers/Internal/v1/ClientInvoiceController.php create mode 100644 server/src/Http/Controllers/Internal/v1/ServiceAgreementController.php create mode 100644 server/src/Http/Resources/v1/ClientInvoice.php create mode 100644 server/src/Http/Resources/v1/ServiceAgreement.php diff --git a/server/src/Http/Controllers/Internal/v1/ChargeTemplateController.php b/server/src/Http/Controllers/Internal/v1/ChargeTemplateController.php new file mode 100644 index 0000000..b175e30 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/ChargeTemplateController.php @@ -0,0 +1,11 @@ +validate([ + 'shipment_uuid' => 'required|string', + 'service_agreement_uuid' => 'required|string', + ]); + + $shipment = \Fleetbase\FleetOps\Models\Shipment::where('uuid', $validated['shipment_uuid']) + ->where('company_uuid', session('company')) + ->firstOrFail(); + + $agreement = ServiceAgreement::where('uuid', $validated['service_agreement_uuid']) + ->where('company_uuid', session('company')) + ->firstOrFail(); + + $invoice = app(ClientInvoiceGeneratorService::class) + ->generateForShipment($shipment, $agreement); + + return response()->json(['data' => $invoice]); + } + + /** + * POST /client-invoices/batch-generate + * Generate batch invoices for a billing period. + */ + public function batchGenerate(Request $request) + { + $validated = $request->validate([ + 'period_start' => 'required|date', + 'period_end' => 'required|date|after_or_equal:period_start', + ]); + + $invoices = app(BatchInvoiceService::class)->generateBatch( + session('company'), + Carbon::parse($validated['period_start']), + Carbon::parse($validated['period_end']) + ); + + return response()->json([ + 'data' => $invoices, + 'count' => $invoices->count(), + ]); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/ServiceAgreementController.php b/server/src/Http/Controllers/Internal/v1/ServiceAgreementController.php new file mode 100644 index 0000000..f3f2711 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/ServiceAgreementController.php @@ -0,0 +1,11 @@ + $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), + 'customer_uuid' => $this->when(Http::isInternalRequest(), $this->customer_uuid), + 'service_agreement_uuid' => $this->when(Http::isInternalRequest(), $this->service_agreement_uuid), + 'shipment_uuid' => $this->when(Http::isInternalRequest(), $this->shipment_uuid), + 'invoice_number' => $this->invoice_number, + 'status' => $this->status, + 'subtotal' => $this->subtotal, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'period_start' => $this->period_start, + 'period_end' => $this->period_end, + 'currency' => $this->currency, + 'notes' => $this->notes, + 'items' => $this->whenLoaded('items'), + 'service_agreement' => $this->whenLoaded('serviceAgreement'), + 'meta' => $this->meta, + 'sent_at' => $this->sent_at, + 'paid_at' => $this->paid_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/ServiceAgreement.php b/server/src/Http/Resources/v1/ServiceAgreement.php new file mode 100644 index 0000000..160b0db --- /dev/null +++ b/server/src/Http/Resources/v1/ServiceAgreement.php @@ -0,0 +1,31 @@ + $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), + 'customer_uuid' => $this->when(Http::isInternalRequest(), $this->customer_uuid), + 'name' => $this->name, + 'status' => $this->status, + 'billing_frequency' => $this->billing_frequency, + 'payment_terms_days'=> $this->payment_terms_days, + 'effective_date' => $this->effective_date, + 'expiration_date' => $this->expiration_date, + 'currency' => $this->currency, + 'notes' => $this->notes, + 'charges' => $this->whenLoaded('charges'), + 'meta' => $this->meta, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index 4d04730..670f463 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -83,6 +83,10 @@ public function register() $this->app->singleton(\Fleetbase\Ledger\Services\GlAutoAssignmentService::class); $this->app->singleton(\Fleetbase\Ledger\Services\GlExportService::class); $this->app->singleton(\Fleetbase\Ledger\Services\CarrierInvoiceAuditService::class); + + $this->app->singleton(\Fleetbase\Ledger\Services\InvoiceNumberGenerator::class); + $this->app->singleton(\Fleetbase\Ledger\Services\ClientInvoiceGeneratorService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\BatchInvoiceService::class); } /** diff --git a/server/src/routes.php b/server/src/routes.php index 00be223..7432058 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -232,6 +232,41 @@ function ($router, $controller) { $router->put('{id}', 'CarrierInvoiceAuditRuleController@updateRecord'); $router->delete('{id}', 'CarrierInvoiceAuditRuleController@deleteRecord'); }); + + // ---------------------------------------------------------------- + // Service Agreements + // ---------------------------------------------------------------- + $router->group(['prefix' => 'service-agreements'], function () use ($router) { + $router->get('/', 'ServiceAgreementController@queryRecord'); + $router->get('{id}', 'ServiceAgreementController@findRecord'); + $router->post('/', 'ServiceAgreementController@createRecord'); + $router->put('{id}', 'ServiceAgreementController@updateRecord'); + $router->delete('{id}', 'ServiceAgreementController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // Charge Templates + // ---------------------------------------------------------------- + $router->group(['prefix' => 'charge-templates'], function () use ($router) { + $router->get('/', 'ChargeTemplateController@queryRecord'); + $router->get('{id}', 'ChargeTemplateController@findRecord'); + $router->post('/', 'ChargeTemplateController@createRecord'); + $router->put('{id}', 'ChargeTemplateController@updateRecord'); + $router->delete('{id}', 'ChargeTemplateController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // Client Invoices + // ---------------------------------------------------------------- + $router->group(['prefix' => 'client-invoices'], function () use ($router) { + $router->get('/', 'ClientInvoiceController@queryRecord'); + $router->get('{id}', 'ClientInvoiceController@findRecord'); + $router->post('/', 'ClientInvoiceController@createRecord'); + $router->put('{id}', 'ClientInvoiceController@updateRecord'); + $router->delete('{id}', 'ClientInvoiceController@deleteRecord'); + $router->post('generate', 'ClientInvoiceController@generate'); + $router->post('batch-generate', 'ClientInvoiceController@batchGenerate'); + }); } ); } From 1a7a5ff866f4d645cc6f15476c062a5dd8021b83 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 14:04:51 -0700 Subject: [PATCH 15/22] feat(BUILD-07): add gainshare engine with cost benchmarks, rules, calculation service, and event integration Co-Authored-By: Claude Opus 4.6 (1M context) --- ...01_000039_create_cost_benchmarks_table.php | 34 ++++ ...01_000040_create_gainshare_rules_table.php | 29 ++++ ...0041_create_gainshare_executions_table.php | 36 ++++ .../Internal/v1/CostBenchmarkController.php | 11 ++ .../Internal/v1/GainshareController.php | 39 +++++ .../Internal/v1/GainshareRuleController.php | 11 ++ .../Http/Resources/v1/GainshareExecution.php | 34 ++++ server/src/Models/CostBenchmark.php | 65 +++++++ server/src/Models/GainshareExecution.php | 68 ++++++++ server/src/Models/GainshareRule.php | 52 ++++++ .../src/Providers/LedgerServiceProvider.php | 21 +++ .../Services/GainshareCalculationService.php | 160 ++++++++++++++++++ server/src/routes.php | 31 ++++ 13 files changed, 591 insertions(+) create mode 100644 server/migrations/2024_01_01_000039_create_cost_benchmarks_table.php create mode 100644 server/migrations/2024_01_01_000040_create_gainshare_rules_table.php create mode 100644 server/migrations/2024_01_01_000041_create_gainshare_executions_table.php create mode 100644 server/src/Http/Controllers/Internal/v1/CostBenchmarkController.php create mode 100644 server/src/Http/Controllers/Internal/v1/GainshareController.php create mode 100644 server/src/Http/Controllers/Internal/v1/GainshareRuleController.php create mode 100644 server/src/Http/Resources/v1/GainshareExecution.php create mode 100644 server/src/Models/CostBenchmark.php create mode 100644 server/src/Models/GainshareExecution.php create mode 100644 server/src/Models/GainshareRule.php create mode 100644 server/src/Services/GainshareCalculationService.php diff --git a/server/migrations/2024_01_01_000039_create_cost_benchmarks_table.php b/server/migrations/2024_01_01_000039_create_cost_benchmarks_table.php new file mode 100644 index 0000000..0407005 --- /dev/null +++ b/server/migrations/2024_01_01_000039_create_cost_benchmarks_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('service_agreement_uuid', 36)->nullable()->index(); + $table->string('benchmark_type', 20)->default('contracted'); // market, historical, contracted + $table->string('lane_origin', 50)->nullable(); // state or zip + $table->string('lane_destination', 50)->nullable(); + $table->string('mode', 20)->nullable(); // ftl, ltl, parcel, etc. + $table->string('equipment_type', 50)->nullable(); + $table->decimal('benchmark_rate', 12, 2); + $table->string('rate_unit', 20)->default('flat'); // flat, per_mile, per_cwt + $table->date('effective_date'); + $table->date('expiration_date')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('service_agreement_uuid')->references('uuid')->on('service_agreements')->nullOnDelete(); + }); + } + public function down() { Schema::dropIfExists('cost_benchmarks'); } +}; diff --git a/server/migrations/2024_01_01_000040_create_gainshare_rules_table.php b/server/migrations/2024_01_01_000040_create_gainshare_rules_table.php new file mode 100644 index 0000000..18fa1a0 --- /dev/null +++ b/server/migrations/2024_01_01_000040_create_gainshare_rules_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('service_agreement_uuid', 36)->index(); + $table->string('calculation_basis', 20)->default('per_shipment'); // per_shipment, per_period + $table->decimal('split_percentage_company', 5, 2)->default(50.00); + $table->decimal('split_percentage_client', 5, 2)->default(50.00); + $table->decimal('minimum_savings_threshold', 10, 2)->nullable(); // minimum savings to trigger gainshare + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('service_agreement_uuid')->references('uuid')->on('service_agreements')->cascadeOnDelete(); + }); + } + public function down() { Schema::dropIfExists('gainshare_rules'); } +}; diff --git a/server/migrations/2024_01_01_000041_create_gainshare_executions_table.php b/server/migrations/2024_01_01_000041_create_gainshare_executions_table.php new file mode 100644 index 0000000..bfb4bd4 --- /dev/null +++ b/server/migrations/2024_01_01_000041_create_gainshare_executions_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->char('gainshare_rule_uuid', 36)->index(); + $table->char('shipment_uuid', 36)->nullable()->index(); + $table->char('carrier_invoice_uuid', 36)->nullable(); + $table->char('client_invoice_uuid', 36)->nullable(); + $table->char('cost_benchmark_uuid', 36)->nullable(); + $table->decimal('benchmark_total', 12, 2)->nullable(); + $table->decimal('actual_total', 12, 2)->nullable(); + $table->decimal('savings', 12, 2)->nullable(); + $table->decimal('company_share', 12, 2)->nullable(); + $table->decimal('client_share', 12, 2)->nullable(); + $table->string('status', 20)->default('calculated'); // calculated, approved, invoiced + $table->date('period_start')->nullable(); // for per_period calculations + $table->date('period_end')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + $table->foreign('gainshare_rule_uuid')->references('uuid')->on('gainshare_rules'); + }); + } + public function down() { Schema::dropIfExists('gainshare_executions'); } +}; diff --git a/server/src/Http/Controllers/Internal/v1/CostBenchmarkController.php b/server/src/Http/Controllers/Internal/v1/CostBenchmarkController.php new file mode 100644 index 0000000..46445c6 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/CostBenchmarkController.php @@ -0,0 +1,11 @@ +validate([ + 'customer_uuid' => 'required|string', + 'days' => 'nullable|integer|min:1|max:365', + ]); + + $summary = app(GainshareCalculationService::class)->getCustomerSummary( + session('company'), + $validated['customer_uuid'], + $validated['days'] ?? 90 + ); + + return response()->json(['data' => $summary]); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/GainshareRuleController.php b/server/src/Http/Controllers/Internal/v1/GainshareRuleController.php new file mode 100644 index 0000000..728e2c4 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/GainshareRuleController.php @@ -0,0 +1,11 @@ + $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), + 'shipment_uuid' => $this->when(Http::isInternalRequest(), $this->shipment_uuid), + 'carrier_invoice_uuid' => $this->when(Http::isInternalRequest(), $this->carrier_invoice_uuid), + 'client_invoice_uuid' => $this->when(Http::isInternalRequest(), $this->client_invoice_uuid), + 'benchmark_total' => $this->benchmark_total, + 'actual_total' => $this->actual_total, + 'savings' => $this->savings, + 'company_share' => $this->company_share, + 'client_share' => $this->client_share, + 'status' => $this->status, + 'period_start' => $this->period_start, + 'period_end' => $this->period_end, + 'gainshare_rule' => $this->whenLoaded('gainshareRule'), + 'shipment' => $this->whenLoaded('shipment'), + 'cost_benchmark' => $this->whenLoaded('costBenchmark'), + 'meta' => $this->meta, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Models/CostBenchmark.php b/server/src/Models/CostBenchmark.php new file mode 100644 index 0000000..23cb89b --- /dev/null +++ b/server/src/Models/CostBenchmark.php @@ -0,0 +1,65 @@ + 'decimal:2', + 'is_active' => 'boolean', + 'effective_date' => 'date', + 'expiration_date' => 'date', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function serviceAgreement() + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeEffective($query) + { + return $query->where('effective_date', '<=', now()) + ->where(function ($q) { + $q->whereNull('expiration_date')->orWhere('expiration_date', '>=', now()); + }); + } + + public function scopeForLane($query, ?string $origin, ?string $destination) + { + if ($origin) $query->where('lane_origin', $origin); + if ($destination) $query->where('lane_destination', $destination); + return $query; + } +} diff --git a/server/src/Models/GainshareExecution.php b/server/src/Models/GainshareExecution.php new file mode 100644 index 0000000..e7a38ed --- /dev/null +++ b/server/src/Models/GainshareExecution.php @@ -0,0 +1,68 @@ + 'decimal:2', + 'actual_total' => 'decimal:2', + 'savings' => 'decimal:2', + 'company_share' => 'decimal:2', + 'client_share' => 'decimal:2', + 'period_start' => 'date', + 'period_end' => 'date', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function gainshareRule() + { + return $this->belongsTo(GainshareRule::class, 'gainshare_rule_uuid', 'uuid'); + } + + public function shipment() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Shipment::class, 'shipment_uuid', 'uuid'); + } + + public function carrierInvoice() + { + return $this->belongsTo(CarrierInvoice::class, 'carrier_invoice_uuid', 'uuid'); + } + + public function clientInvoice() + { + return $this->belongsTo(ClientInvoice::class, 'client_invoice_uuid', 'uuid'); + } + + public function costBenchmark() + { + return $this->belongsTo(CostBenchmark::class, 'cost_benchmark_uuid', 'uuid'); + } +} diff --git a/server/src/Models/GainshareRule.php b/server/src/Models/GainshareRule.php new file mode 100644 index 0000000..1fa029c --- /dev/null +++ b/server/src/Models/GainshareRule.php @@ -0,0 +1,52 @@ + 'decimal:2', + 'split_percentage_client' => 'decimal:2', + 'minimum_savings_threshold' => 'decimal:2', + 'is_active' => 'boolean', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function serviceAgreement() + { + return $this->belongsTo(ServiceAgreement::class, 'service_agreement_uuid', 'uuid'); + } + + public function executions() + { + return $this->hasMany(GainshareExecution::class, 'gainshare_rule_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index 670f463..18dfcad 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -87,6 +87,7 @@ public function register() $this->app->singleton(\Fleetbase\Ledger\Services\InvoiceNumberGenerator::class); $this->app->singleton(\Fleetbase\Ledger\Services\ClientInvoiceGeneratorService::class); $this->app->singleton(\Fleetbase\Ledger\Services\BatchInvoiceService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\GainshareCalculationService::class); } /** @@ -124,6 +125,26 @@ function ($event) { } ); + // Gainshare calculation on carrier invoice approval + \Illuminate\Support\Facades\Event::listen( + \Fleetbase\Ledger\Events\CarrierInvoiceApproved::class, + function ($event) { + $invoice = $event->carrierInvoice; + // Find the shipment linked to this invoice + $shipment = $invoice->shipment_uuid + ? \Fleetbase\FleetOps\Models\Shipment::where('uuid', $invoice->shipment_uuid)->first() + : null; + // Also check if shipment is linked via carrier_invoice_uuid + if (!$shipment) { + $shipment = \Fleetbase\FleetOps\Models\Shipment::where('carrier_invoice_uuid', $invoice->uuid)->first(); + } + if ($shipment) { + app(\Fleetbase\Ledger\Services\GainshareCalculationService::class) + ->calculateForShipment($shipment, $invoice); + } + } + ); + // Register Artisan commands if ($this->app->runningInConsole()) { $this->commands([ diff --git a/server/src/Services/GainshareCalculationService.php b/server/src/Services/GainshareCalculationService.php new file mode 100644 index 0000000..d478a19 --- /dev/null +++ b/server/src/Services/GainshareCalculationService.php @@ -0,0 +1,160 @@ +orders()->first()?->customer_uuid; + if (!$customerUuid) { + return null; + } + + // Find active service agreement for this customer + $agreement = ServiceAgreement::where('company_uuid', $shipment->company_uuid) + ->where('customer_uuid', $customerUuid) + ->active() + ->effective() + ->first(); + + if (!$agreement) { + return null; + } + + // Find active gainshare rule for this agreement + $rule = GainshareRule::where('service_agreement_uuid', $agreement->uuid) + ->active() + ->first(); + + if (!$rule || $rule->calculation_basis !== 'per_shipment') { + return null; + } + + // Find benchmark rate for this lane + $origin = $shipment->stops()->where('type', 'pickup')->first(); + $dest = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); + + $benchmark = CostBenchmark::where('company_uuid', $shipment->company_uuid) + ->where(function ($q) use ($agreement) { + $q->where('service_agreement_uuid', $agreement->uuid) + ->orWhereNull('service_agreement_uuid'); + }) + ->forLane( + $origin?->postal_code ?? $origin?->state, + $dest?->postal_code ?? $dest?->state + ) + ->where(function ($q) use ($shipment) { + $q->where('mode', $shipment->mode)->orWhereNull('mode'); + }) + ->active() + ->effective() + ->first(); + + if (!$benchmark) { + return null; + } + + // Calculate actual cost from approved carrier invoice + $actualCost = $invoice->approved_amount; + if (!$actualCost) { + return null; + } + + // Calculate savings + $savings = $benchmark->benchmark_rate - $actualCost; + + // Check minimum threshold + if ($rule->minimum_savings_threshold && $savings < $rule->minimum_savings_threshold) { + return null; + } + + // No savings = no gainshare + if ($savings <= 0) { + return null; + } + + // Calculate shares + $companyShare = round($savings * ($rule->split_percentage_company / 100), 2); + $clientShare = round($savings * ($rule->split_percentage_client / 100), 2); + + // Find associated client invoice if one exists + $clientInvoiceUuid = \Fleetbase\Ledger\Models\ClientInvoice::where('shipment_uuid', $shipment->uuid) + ->value('uuid'); + + return GainshareExecution::create([ + 'company_uuid' => $shipment->company_uuid, + 'gainshare_rule_uuid' => $rule->uuid, + 'shipment_uuid' => $shipment->uuid, + 'carrier_invoice_uuid' => $invoice->uuid, + 'client_invoice_uuid' => $clientInvoiceUuid, + 'cost_benchmark_uuid' => $benchmark->uuid, + 'benchmark_total' => $benchmark->benchmark_rate, + 'actual_total' => $actualCost, + 'savings' => $savings, + 'company_share' => $companyShare, + 'client_share' => $clientShare, + 'status' => 'calculated', + ]); + } + + /** + * Get aggregate gainshare summary for a customer over a period. + */ + public function getCustomerSummary(string $companyUuid, string $customerUuid, int $days = 90): array + { + $since = now()->subDays($days); + + $executions = GainshareExecution::where('company_uuid', $companyUuid) + ->whereHas('gainshareRule.serviceAgreement', function ($q) use ($customerUuid) { + $q->where('customer_uuid', $customerUuid); + }) + ->where('created_at', '>=', $since) + ->get(); + + return [ + 'customer_uuid' => $customerUuid, + 'period_days' => $days, + 'total_shipments' => $executions->count(), + 'total_benchmark' => round($executions->sum('benchmark_total'), 2), + 'total_actual' => round($executions->sum('actual_total'), 2), + 'total_savings' => round($executions->sum('savings'), 2), + 'total_company_share'=> round($executions->sum('company_share'), 2), + 'total_client_share' => round($executions->sum('client_share'), 2), + 'avg_savings_pct' => $executions->avg(function ($e) { + return $e->benchmark_total > 0 ? ($e->savings / $e->benchmark_total * 100) : 0; + }), + ]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7432058..c95d20b 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -267,6 +267,37 @@ function ($router, $controller) { $router->post('generate', 'ClientInvoiceController@generate'); $router->post('batch-generate', 'ClientInvoiceController@batchGenerate'); }); + + // ---------------------------------------------------------------- + // Cost Benchmarks + // ---------------------------------------------------------------- + $router->group(['prefix' => 'cost-benchmarks'], function () use ($router) { + $router->get('/', 'CostBenchmarkController@queryRecord'); + $router->get('{id}', 'CostBenchmarkController@findRecord'); + $router->post('/', 'CostBenchmarkController@createRecord'); + $router->put('{id}', 'CostBenchmarkController@updateRecord'); + $router->delete('{id}', 'CostBenchmarkController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // Gainshare Rules + // ---------------------------------------------------------------- + $router->group(['prefix' => 'gainshare-rules'], function () use ($router) { + $router->get('/', 'GainshareRuleController@queryRecord'); + $router->get('{id}', 'GainshareRuleController@findRecord'); + $router->post('/', 'GainshareRuleController@createRecord'); + $router->put('{id}', 'GainshareRuleController@updateRecord'); + $router->delete('{id}', 'GainshareRuleController@deleteRecord'); + }); + + // ---------------------------------------------------------------- + // Gainshare Executions + // ---------------------------------------------------------------- + $router->group(['prefix' => 'gainshare'], function () use ($router) { + $router->get('/', 'GainshareController@queryRecord'); + $router->get('summary', 'GainshareController@summary'); + $router->get('{id}', 'GainshareController@findRecord'); + }); } ); } From 09db43ef2bcbf10873c6835a4d1ade55ab0592e1 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 14:50:56 -0700 Subject: [PATCH 16/22] fix(BUILD-07): rate unit conversion, loss tracking, deduplication guard 1. Rate unit conversion: benchmark_rate now converted to flat expected_total based on rate_unit (flat/per_mile/per_cwt) before comparing to actual cost. Missing miles or weight safely skips instead of producing wrong numbers. 2. Loss/break-even tracking: all calculable outcomes now recorded including losses, break-even, and below-threshold. New result_type column classifies each execution. Shares are 0 for non-savings results. 3. Deduplication: checks for existing execution with same shipment_uuid + gainshare_rule_uuid. Updates existing record instead of inserting duplicates. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...dd_result_type_to_gainshare_executions.php | 30 +++ server/src/Models/GainshareExecution.php | 14 +- .../Services/GainshareCalculationService.php | 214 ++++++++++++++---- 3 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 server/migrations/2024_01_01_000042_add_result_type_to_gainshare_executions.php diff --git a/server/migrations/2024_01_01_000042_add_result_type_to_gainshare_executions.php b/server/migrations/2024_01_01_000042_add_result_type_to_gainshare_executions.php new file mode 100644 index 0000000..c2dbd7d --- /dev/null +++ b/server/migrations/2024_01_01_000042_add_result_type_to_gainshare_executions.php @@ -0,0 +1,30 @@ +string('result_type', 20)->default('savings')->after('client_share'); + // values: savings, loss, break_even, below_threshold + }); + } + + public function down() + { + Schema::table('gainshare_executions', function (Blueprint $table) { + $table->dropColumn('result_type'); + }); + } +}; diff --git a/server/src/Models/GainshareExecution.php b/server/src/Models/GainshareExecution.php index e7a38ed..dd2cb26 100644 --- a/server/src/Models/GainshareExecution.php +++ b/server/src/Models/GainshareExecution.php @@ -16,12 +16,24 @@ class GainshareExecution extends Model protected $table = 'gainshare_executions'; protected $publicIdType = 'gs_exec'; + /** + * Result type classifications: + * - savings: positive savings above threshold, shares calculated + * - loss: actual cost exceeded benchmark, shares = 0 + * - break_even: actual cost equals benchmark, shares = 0 + * - below_threshold: positive savings but below minimum threshold, shares = 0 + */ + public const RESULT_SAVINGS = 'savings'; + public const RESULT_LOSS = 'loss'; + public const RESULT_BREAK_EVEN = 'break_even'; + public const RESULT_BELOW_THRESHOLD = 'below_threshold'; + protected $fillable = [ 'company_uuid', 'gainshare_rule_uuid', 'shipment_uuid', 'carrier_invoice_uuid', 'client_invoice_uuid', 'cost_benchmark_uuid', 'benchmark_total', 'actual_total', 'savings', - 'company_share', 'client_share', + 'company_share', 'client_share', 'result_type', 'status', 'period_start', 'period_end', 'meta', ]; diff --git a/server/src/Services/GainshareCalculationService.php b/server/src/Services/GainshareCalculationService.php index d478a19..f841b6e 100644 --- a/server/src/Services/GainshareCalculationService.php +++ b/server/src/Services/GainshareCalculationService.php @@ -14,35 +14,41 @@ * * This service is a financial analytics layer. It: * - Finds the applicable benchmark rate for a shipment's lane - * - Compares benchmark vs approved carrier invoice amount - * - Applies gainshare split percentages - * - Stores the result as a GainshareExecution record + * - Converts benchmark to a flat expected_total based on rate_unit (flat, per_mile, per_cwt) + * - Compares expected_total vs actual_total (approved carrier invoice amount) + * - Classifies the result: savings, loss, break_even, below_threshold + * - Applies gainshare split percentages (only for savings above threshold) + * - Stores all results as GainshareExecution records (including losses) + * - Prevents duplicate executions for the same shipment+rule combination * * It does NOT: * - Modify shipment state or execution * - Modify invoice amounts * - Modify routing or tendering - * - Trigger payments - * - Send notifications + * - Trigger payments or send notifications */ class GainshareCalculationService { /** * Calculate gainshare for a shipment after carrier invoice approval. * + * Returns null ONLY when required inputs are missing (no customer, no agreement, + * no rule, no benchmark, missing unit data). All calculable outcomes — including + * losses and break-even — produce a GainshareExecution record. + * * @param Shipment $shipment The delivered shipment * @param CarrierInvoice $invoice The approved carrier invoice - * @return GainshareExecution|null The calculation result, or null if no gainshare applies + * @return GainshareExecution|null The calculation result, or null if inputs are missing */ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice): ?GainshareExecution { - // Find the customer from the first linked order + // --- Resolve required inputs --- + $customerUuid = $shipment->orders()->first()?->customer_uuid; if (!$customerUuid) { - return null; + return null; // No customer on linked orders — cannot determine agreement } - // Find active service agreement for this customer $agreement = ServiceAgreement::where('company_uuid', $shipment->company_uuid) ->where('customer_uuid', $customerUuid) ->active() @@ -50,19 +56,26 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice ->first(); if (!$agreement) { - return null; + return null; // No active agreement — gainshare not applicable } - // Find active gainshare rule for this agreement $rule = GainshareRule::where('service_agreement_uuid', $agreement->uuid) ->active() ->first(); if (!$rule || $rule->calculation_basis !== 'per_shipment') { - return null; + return null; // No rule or not per-shipment basis } - // Find benchmark rate for this lane + // --- Deduplication guard --- + // Prevent duplicate executions for the same shipment+rule combination. + // If one already exists, update it rather than creating a duplicate. + $existingExecution = GainshareExecution::where('shipment_uuid', $shipment->uuid) + ->where('gainshare_rule_uuid', $rule->uuid) + ->first(); + + // --- Find benchmark --- + $origin = $shipment->stops()->where('type', 'pickup')->first(); $dest = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); @@ -83,50 +96,150 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice ->first(); if (!$benchmark) { - return null; + return null; // No benchmark for this lane/mode — cannot calculate } - // Calculate actual cost from approved carrier invoice - $actualCost = $invoice->approved_amount; - if (!$actualCost) { - return null; + // --- Derive actual cost --- + + $actualTotal = (float) $invoice->approved_amount; + if ($actualTotal <= 0) { + return null; // No approved amount — cannot calculate } - // Calculate savings - $savings = $benchmark->benchmark_rate - $actualCost; + // --- Convert benchmark to flat expected_total based on rate_unit --- - // Check minimum threshold - if ($rule->minimum_savings_threshold && $savings < $rule->minimum_savings_threshold) { - return null; - } + $expectedTotal = $this->convertBenchmarkToFlat($benchmark, $shipment); - // No savings = no gainshare - if ($savings <= 0) { + if ($expectedTotal === null) { + // Missing required data (miles or weight) for unit conversion. + // Do NOT fabricate numbers — skip safely. return null; } - // Calculate shares - $companyShare = round($savings * ($rule->split_percentage_company / 100), 2); - $clientShare = round($savings * ($rule->split_percentage_client / 100), 2); + // --- Calculate savings and classify result --- + + $savings = round($expectedTotal - $actualTotal, 2); + + $resultType = $this->classifyResult($savings, $rule); + + // Calculate shares only for qualifying savings + $companyShare = 0; + $clientShare = 0; - // Find associated client invoice if one exists + if ($resultType === GainshareExecution::RESULT_SAVINGS) { + $companyShare = round($savings * ($rule->split_percentage_company / 100), 2); + $clientShare = round($savings * ($rule->split_percentage_client / 100), 2); + } + + // Find associated client invoice $clientInvoiceUuid = \Fleetbase\Ledger\Models\ClientInvoice::where('shipment_uuid', $shipment->uuid) ->value('uuid'); - return GainshareExecution::create([ + // --- Create or update execution --- + + $executionData = [ 'company_uuid' => $shipment->company_uuid, 'gainshare_rule_uuid' => $rule->uuid, 'shipment_uuid' => $shipment->uuid, 'carrier_invoice_uuid' => $invoice->uuid, 'client_invoice_uuid' => $clientInvoiceUuid, 'cost_benchmark_uuid' => $benchmark->uuid, - 'benchmark_total' => $benchmark->benchmark_rate, - 'actual_total' => $actualCost, + 'benchmark_total' => round($expectedTotal, 2), + 'actual_total' => round($actualTotal, 2), 'savings' => $savings, 'company_share' => $companyShare, 'client_share' => $clientShare, + 'result_type' => $resultType, 'status' => 'calculated', - ]); + ]; + + if ($existingExecution) { + // Update existing execution rather than creating duplicate + $existingExecution->update($executionData); + return $existingExecution; + } + + return GainshareExecution::create($executionData); + } + + /** + * Convert a benchmark rate to a flat dollar total based on its rate_unit. + * + * Returns null if required shipment data is missing for the conversion, + * preventing misleading calculations. + * + * @param CostBenchmark $benchmark The benchmark with rate and unit + * @param Shipment $shipment The shipment providing miles/weight context + * @return float|null The flat expected total, or null if conversion is impossible + */ + protected function convertBenchmarkToFlat(CostBenchmark $benchmark, Shipment $shipment): ?float + { + $rate = (float) $benchmark->benchmark_rate; + + return match ($benchmark->rate_unit) { + 'flat' => $rate, + + 'per_mile' => $this->convertPerMile($rate, $shipment), + + 'per_cwt' => $this->convertPerCwt($rate, $shipment), + + // Unknown rate unit — cannot safely convert + default => null, + }; + } + + /** + * Convert per-mile rate to flat total. + * Returns null if shipment miles are unavailable. + */ + protected function convertPerMile(float $rate, Shipment $shipment): ?float + { + $miles = (float) ($shipment->carrier_rate_miles ?? 0); + + if ($miles <= 0) { + // No mileage data on shipment — cannot convert per_mile benchmark. + // Returning null prevents fabricating a misleading calculation. + return null; + } + + return round($rate * $miles, 2); + } + + /** + * Convert per-CWT rate to flat total. + * Returns null if shipment weight is unavailable. + */ + protected function convertPerCwt(float $rate, Shipment $shipment): ?float + { + $weight = (float) ($shipment->total_weight ?? 0); + + if ($weight <= 0) { + // No weight data on shipment — cannot convert per_cwt benchmark. + return null; + } + + return round($rate * ($weight / 100), 2); + } + + /** + * Classify the gainshare result based on savings amount and threshold. + */ + protected function classifyResult(float $savings, GainshareRule $rule): string + { + if ($savings < 0) { + return GainshareExecution::RESULT_LOSS; + } + + // Use abs() < 0.01 for float comparison to handle rounding + if (abs($savings) < 0.01) { + return GainshareExecution::RESULT_BREAK_EVEN; + } + + if ($rule->minimum_savings_threshold && $savings < $rule->minimum_savings_threshold) { + return GainshareExecution::RESULT_BELOW_THRESHOLD; + } + + return GainshareExecution::RESULT_SAVINGS; } /** @@ -143,18 +256,29 @@ public function getCustomerSummary(string $companyUuid, string $customerUuid, in ->where('created_at', '>=', $since) ->get(); + $savingsExecs = $executions->where('result_type', GainshareExecution::RESULT_SAVINGS); + $lossExecs = $executions->where('result_type', GainshareExecution::RESULT_LOSS); + return [ - 'customer_uuid' => $customerUuid, - 'period_days' => $days, - 'total_shipments' => $executions->count(), - 'total_benchmark' => round($executions->sum('benchmark_total'), 2), - 'total_actual' => round($executions->sum('actual_total'), 2), - 'total_savings' => round($executions->sum('savings'), 2), - 'total_company_share'=> round($executions->sum('company_share'), 2), - 'total_client_share' => round($executions->sum('client_share'), 2), - 'avg_savings_pct' => $executions->avg(function ($e) { - return $e->benchmark_total > 0 ? ($e->savings / $e->benchmark_total * 100) : 0; - }), + 'customer_uuid' => $customerUuid, + 'period_days' => $days, + 'total_executions' => $executions->count(), + 'savings_count' => $savingsExecs->count(), + 'loss_count' => $lossExecs->count(), + 'break_even_count' => $executions->where('result_type', GainshareExecution::RESULT_BREAK_EVEN)->count(), + 'below_threshold_count' => $executions->where('result_type', GainshareExecution::RESULT_BELOW_THRESHOLD)->count(), + 'total_benchmark' => round($executions->sum('benchmark_total'), 2), + 'total_actual' => round($executions->sum('actual_total'), 2), + 'net_savings' => round($executions->sum('savings'), 2), // includes negative (losses) + 'total_savings_only' => round($savingsExecs->sum('savings'), 2), + 'total_losses_only' => round(abs($lossExecs->sum('savings')), 2), + 'total_company_share' => round($executions->sum('company_share'), 2), + 'total_client_share' => round($executions->sum('client_share'), 2), + 'avg_savings_pct' => $executions->count() > 0 + ? round($executions->avg(function ($e) { + return $e->benchmark_total > 0 ? ($e->savings / $e->benchmark_total * 100) : 0; + }), 2) + : null, ]; } } From 8109080f8d92f02288f96e45ee9db47801170743 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 15:12:26 -0700 Subject: [PATCH 17/22] feat(BUILD-07): add benchmark_source to GainshareRule with rate_contract future hook - New benchmark_source field on gainshare_rules (default: cost_benchmark) - Service branches on benchmark_source: cost_benchmark runs existing logic, rate_contract returns null safely (not yet implemented) - Added getBenchmarkFromRateContract() stub for BUILD-10 integration - No changes to existing calculation math or result_type logic Co-Authored-By: Claude Opus 4.6 (1M context) --- ...dd_benchmark_source_to_gainshare_rules.php | 28 ++++++++++++++ server/src/Models/GainshareRule.php | 9 +++++ .../Services/GainshareCalculationService.php | 37 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 server/migrations/2024_01_01_000043_add_benchmark_source_to_gainshare_rules.php diff --git a/server/migrations/2024_01_01_000043_add_benchmark_source_to_gainshare_rules.php b/server/migrations/2024_01_01_000043_add_benchmark_source_to_gainshare_rules.php new file mode 100644 index 0000000..22ebb3f --- /dev/null +++ b/server/migrations/2024_01_01_000043_add_benchmark_source_to_gainshare_rules.php @@ -0,0 +1,28 @@ +string('benchmark_source', 20)->default('cost_benchmark')->after('calculation_basis'); + // values: cost_benchmark, rate_contract + }); + } + + public function down() + { + Schema::table('gainshare_rules', function (Blueprint $table) { + $table->dropColumn('benchmark_source'); + }); + } +}; diff --git a/server/src/Models/GainshareRule.php b/server/src/Models/GainshareRule.php index 1fa029c..423f631 100644 --- a/server/src/Models/GainshareRule.php +++ b/server/src/Models/GainshareRule.php @@ -13,11 +13,20 @@ class GainshareRule extends Model { use HasUuid, HasPublicId, HasApiModelBehavior, SoftDeletes; + /** + * Benchmark source constants. + * cost_benchmark: uses CostBenchmark model (current, fully implemented) + * rate_contract: uses RateContract from BUILD-10 (future, not yet implemented) + */ + public const BENCHMARK_COST = 'cost_benchmark'; + public const BENCHMARK_RATE_CONTRACT = 'rate_contract'; + protected $table = 'gainshare_rules'; protected $publicIdType = 'gs_rule'; protected $fillable = [ 'company_uuid', 'service_agreement_uuid', 'calculation_basis', + 'benchmark_source', 'split_percentage_company', 'split_percentage_client', 'minimum_savings_threshold', 'is_active', 'meta', ]; diff --git a/server/src/Services/GainshareCalculationService.php b/server/src/Services/GainshareCalculationService.php index f841b6e..4b5f517 100644 --- a/server/src/Services/GainshareCalculationService.php +++ b/server/src/Services/GainshareCalculationService.php @@ -67,6 +67,18 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice return null; // No rule or not per-shipment basis } + // --- Benchmark source routing --- + // Branch based on which benchmark source the rule is configured to use. + $benchmarkSource = $rule->benchmark_source ?? GainshareRule::BENCHMARK_COST; + + if ($benchmarkSource === GainshareRule::BENCHMARK_RATE_CONTRACT) { + // Rate contract benchmarking is not yet implemented (BUILD-10). + // Return null safely — do not produce fake values. + return null; + } + + // All paths below use cost_benchmark source (current, fully implemented). + // --- Deduplication guard --- // Prevent duplicate executions for the same shipment+rule combination. // If one already exists, update it rather than creating a duplicate. @@ -74,7 +86,7 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice ->where('gainshare_rule_uuid', $rule->uuid) ->first(); - // --- Find benchmark --- + // --- Find benchmark from CostBenchmark --- $origin = $shipment->stops()->where('type', 'pickup')->first(); $dest = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); @@ -281,4 +293,27 @@ public function getCustomerSummary(string $companyUuid, string $customerUuid, in : null, ]; } + + /** + * Resolve benchmark from a rate contract for gainshare comparison. + * + * TODO (BUILD-10): Implement this method when the rating engine is built. + * - Will use RateContract with rate_usage = 'cost_management_benchmark' + * - Will resolve the contracted rate for the shipment's lane/mode/equipment + * - Will return a flat expected_total after applying the contract's rate tables + * - Must handle FAK, fuel surcharge, and discount tables from the rate contract + * + * Until implemented, this method returns null and gainshare rules with + * benchmark_source = 'rate_contract' are safely skipped. + * + * @param Shipment $shipment The shipment to resolve benchmark for + * @param GainshareRule $rule The gainshare rule (carries service_agreement context) + * @return float|null The flat expected total, or null until rating engine is available + */ + protected function getBenchmarkFromRateContract(Shipment $shipment, GainshareRule $rule): ?float + { + // Future: resolve benchmark using rating engine (BUILD-10) + // Will use rate_usage = cost_management_benchmark + return null; + } } From f48f9f3fdcb9b1d5795ba029d7791b0cb2c692de Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 17:09:59 -0700 Subject: [PATCH 18/22] =?UTF-8?q?feat(BUILD-09):=20add=20pay=20files=20wit?= =?UTF-8?q?h=20safe=20lifecycle=20(draft=E2=86=92generated=E2=86=92sent?= =?UTF-8?q?=E2=86=92confirmed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...24_01_01_000044_create_pay_files_table.php | 33 +++ ..._01_000045_create_pay_file_items_table.php | 26 +++ ...000046_create_pay_file_schedules_table.php | 32 +++ .../Commands/GenerateScheduledPayFiles.php | 75 ++++++ .../Internal/v1/PayFileController.php | 111 +++++++++ .../Internal/v1/PayFileScheduleController.php | 38 ++++ server/src/Http/Resources/v1/PayFile.php | 30 +++ server/src/Models/PayFile.php | 144 ++++++++++++ server/src/Models/PayFileItem.php | 41 ++++ server/src/Models/PayFileSchedule.php | 71 ++++++ .../src/Providers/LedgerServiceProvider.php | 2 + .../src/Services/PayFileGeneratorService.php | 213 ++++++++++++++++++ server/src/routes.php | 28 +++ 13 files changed, 844 insertions(+) create mode 100644 server/migrations/2024_01_01_000044_create_pay_files_table.php create mode 100644 server/migrations/2024_01_01_000045_create_pay_file_items_table.php create mode 100644 server/migrations/2024_01_01_000046_create_pay_file_schedules_table.php create mode 100644 server/src/Console/Commands/GenerateScheduledPayFiles.php create mode 100644 server/src/Http/Controllers/Internal/v1/PayFileController.php create mode 100644 server/src/Http/Controllers/Internal/v1/PayFileScheduleController.php create mode 100644 server/src/Http/Resources/v1/PayFile.php create mode 100644 server/src/Models/PayFile.php create mode 100644 server/src/Models/PayFileItem.php create mode 100644 server/src/Models/PayFileSchedule.php create mode 100644 server/src/Services/PayFileGeneratorService.php diff --git a/server/migrations/2024_01_01_000044_create_pay_files_table.php b/server/migrations/2024_01_01_000044_create_pay_files_table.php new file mode 100644 index 0000000..c8dd6ad --- /dev/null +++ b/server/migrations/2024_01_01_000044_create_pay_files_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('name'); + $table->string('format', 20)->default('csv'); // csv, edi_820, ach_nacha + $table->string('status', 20)->default('draft'); // draft, generated, sent, confirmed, cancelled + $table->date('period_start'); + $table->date('period_end'); + $table->char('file_uuid', 36)->nullable(); + $table->integer('record_count')->default(0); + $table->decimal('total_amount', 14, 2)->default(0); + $table->timestamp('generated_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('confirmed_at')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + public function down() { Schema::dropIfExists('pay_files'); } +}; diff --git a/server/migrations/2024_01_01_000045_create_pay_file_items_table.php b/server/migrations/2024_01_01_000045_create_pay_file_items_table.php new file mode 100644 index 0000000..96b51ce --- /dev/null +++ b/server/migrations/2024_01_01_000045_create_pay_file_items_table.php @@ -0,0 +1,26 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->char('pay_file_uuid', 36)->index(); + $table->char('carrier_invoice_uuid', 36)->index(); + $table->char('vendor_uuid', 36)->index(); + $table->decimal('amount', 12, 2); + $table->string('payment_method', 20)->default('ach'); // ach, check, wire + $table->string('reference_number')->nullable(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('pay_file_uuid')->references('uuid')->on('pay_files')->cascadeOnDelete(); + }); + } + public function down() { Schema::dropIfExists('pay_file_items'); } +}; diff --git a/server/migrations/2024_01_01_000046_create_pay_file_schedules_table.php b/server/migrations/2024_01_01_000046_create_pay_file_schedules_table.php new file mode 100644 index 0000000..9f4a202 --- /dev/null +++ b/server/migrations/2024_01_01_000046_create_pay_file_schedules_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('uuid', 191)->nullable()->unique(); + $table->string('public_id', 191)->nullable()->unique(); + $table->uuid('company_uuid')->index(); + $table->string('name'); + $table->string('format', 20)->default('csv'); + $table->string('frequency', 20)->default('weekly'); // weekly, biweekly, monthly + $table->integer('day_of_week')->nullable(); // 0-6 for weekly/biweekly + $table->integer('day_of_month')->nullable(); // 1-31 for monthly + $table->boolean('auto_send')->default(false); + $table->json('recipients')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_run_at')->nullable(); + $table->timestamp('next_run_at')->nullable()->index(); + $table->json('meta')->nullable(); + $table->softDeletes(); + $table->timestamp('created_at')->nullable()->index(); + $table->timestamp('updated_at')->nullable(); + + $table->foreign('company_uuid')->references('uuid')->on('companies'); + }); + } + public function down() { Schema::dropIfExists('pay_file_schedules'); } +}; diff --git a/server/src/Console/Commands/GenerateScheduledPayFiles.php b/server/src/Console/Commands/GenerateScheduledPayFiles.php new file mode 100644 index 0000000..c9f2600 --- /dev/null +++ b/server/src/Console/Commands/GenerateScheduledPayFiles.php @@ -0,0 +1,75 @@ +command('pay-files:generate-scheduled')->daily(); + * + * Or invoke manually: php artisan pay-files:generate-scheduled + */ +class GenerateScheduledPayFiles extends Command +{ + protected $signature = 'pay-files:generate-scheduled + {--dry-run : Show what would be generated without executing}'; + + protected $description = 'Generate pay files for all due schedules. Does NOT mark invoices paid.'; + + public function handle(): int + { + $dueSchedules = PayFileSchedule::active()->dueForRun()->get(); + + if ($dueSchedules->isEmpty()) { + $this->info('No schedules are due for run.'); + return self::SUCCESS; + } + + $this->info("Found {$dueSchedules->count()} schedule(s) due for run."); + + $generator = app(PayFileGeneratorService::class); + + foreach ($dueSchedules as $schedule) { + $start = $schedule->last_run_at ?? now()->subDays(30); + $end = now(); + + $this->line("Schedule [{$schedule->name}]: period {$start} → {$end}, format={$schedule->format}"); + + if ($this->option('dry-run')) { + continue; + } + + try { + $payFile = $generator->generate( + $schedule->company_uuid, + $schedule->format, + Carbon::parse($start), + Carbon::parse($end) + ); + + $schedule->update([ + 'last_run_at' => now(), + 'next_run_at' => $schedule->calculateNextRun(now()), + ]); + + $this->info(" → Generated PayFile {$payFile->public_id} with {$payFile->record_count} invoices, total \${$payFile->total_amount}"); + } catch (\Throwable $e) { + $this->error(" → Failed: {$e->getMessage()}"); + Log::error('Scheduled pay file generation failed', [ + 'schedule_uuid' => $schedule->uuid, + 'error' => $e->getMessage(), + ]); + } + } + + return self::SUCCESS; + } +} diff --git a/server/src/Http/Controllers/Internal/v1/PayFileController.php b/server/src/Http/Controllers/Internal/v1/PayFileController.php new file mode 100644 index 0000000..b8349e3 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/PayFileController.php @@ -0,0 +1,111 @@ +validate([ + 'format' => 'required|string|in:csv,edi_820,ach_nacha', + 'period_start' => 'required|date', + 'period_end' => 'required|date|after_or_equal:period_start', + ]); + + $payFile = app(PayFileGeneratorService::class)->generate( + session('company'), + $validated['format'], + Carbon::parse($validated['period_start']), + Carbon::parse($validated['period_end']) + ); + + return response()->json(['data' => $payFile]); + } + + /** + * GET /pay-files/{id}/download + */ + public function download(string $id) + { + $payFile = PayFile::findRecordOrFail($id); + + if (!$payFile->file_uuid) { + return response()->apiError('Pay file has not been generated yet.', 404); + } + + $file = \Fleetbase\Models\File::where('uuid', $payFile->file_uuid)->firstOrFail(); + + return response()->download( + storage_path('app/' . $file->path), + $file->original_filename + ); + } + + /** + * POST /pay-files/{id}/mark-sent + */ + public function markSent(string $id) + { + $payFile = PayFile::findRecordOrFail($id); + + try { + $payFile->markAsSent(); + } catch (\RuntimeException $e) { + return response()->apiError($e->getMessage()); + } + + return response()->json(['data' => $payFile->fresh()]); + } + + /** + * POST /pay-files/{id}/mark-confirmed + * THIS endpoint is the ONLY way to flip carrier invoices to 'paid'. + */ + public function markConfirmed(string $id) + { + $payFile = PayFile::findRecordOrFail($id); + + try { + $confirmed = $payFile->markAsConfirmed(); + } catch (\RuntimeException $e) { + return response()->apiError($e->getMessage()); + } + + return response()->json([ + 'data' => $confirmed->load('items'), + 'message' => "Pay file confirmed. {$confirmed->record_count} carrier invoices marked as paid.", + ]); + } + + /** + * POST /pay-files/{id}/cancel + */ + public function cancel(string $id) + { + $payFile = PayFile::findRecordOrFail($id); + + try { + $payFile->cancel(); + } catch (\RuntimeException $e) { + return response()->apiError($e->getMessage()); + } + + return response()->json(['data' => $payFile->fresh()]); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/PayFileScheduleController.php b/server/src/Http/Controllers/Internal/v1/PayFileScheduleController.php new file mode 100644 index 0000000..aeabc49 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/PayFileScheduleController.php @@ -0,0 +1,38 @@ +last_run_at ? $schedule->last_run_at : now()->subDays(30); + $end = now(); + + $payFile = app(PayFileGeneratorService::class)->generate( + $schedule->company_uuid, + $schedule->format, + Carbon::parse($start), + $end + ); + + $schedule->update([ + 'last_run_at' => now(), + 'next_run_at' => $schedule->calculateNextRun(now()), + ]); + + return response()->json(['data' => $payFile->load('items')]); + } +} diff --git a/server/src/Http/Resources/v1/PayFile.php b/server/src/Http/Resources/v1/PayFile.php new file mode 100644 index 0000000..23c5ee0 --- /dev/null +++ b/server/src/Http/Resources/v1/PayFile.php @@ -0,0 +1,30 @@ + $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), + 'name' => $this->name, + 'format' => $this->format, + 'status' => $this->status, + 'period_start' => $this->period_start, + 'period_end' => $this->period_end, + 'record_count' => $this->record_count, + 'total_amount' => $this->total_amount, + 'generated_at' => $this->generated_at, + 'sent_at' => $this->sent_at, + 'confirmed_at' => $this->confirmed_at, + 'items' => $this->whenLoaded('items'), + 'file' => $this->whenLoaded('file'), + 'meta' => $this->meta, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Models/PayFile.php b/server/src/Models/PayFile.php new file mode 100644 index 0000000..8f35288 --- /dev/null +++ b/server/src/Models/PayFile.php @@ -0,0 +1,144 @@ + 'date', + 'period_end' => 'date', + 'generated_at' => 'datetime', + 'sent_at' => 'datetime', + 'confirmed_at' => 'datetime', + 'total_amount' => 'decimal:2', + 'record_count' => 'integer', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function items() + { + return $this->hasMany(PayFileItem::class, 'pay_file_uuid', 'uuid'); + } + + public function file() + { + return $this->belongsTo(\Fleetbase\Models\File::class, 'file_uuid', 'uuid'); + } + + /** + * Scope: pay files that "lock" invoices (block re-inclusion in another pay file). + * Anything NOT cancelled holds the invoices. + */ + public function scopeLocking($query) + { + return $query->where('status', '!=', self::STATUS_CANCELLED); + } + + /** + * Mark this pay file as transmitted. Does NOT mark invoices paid yet. + */ + public function markAsSent(): self + { + if ($this->status !== self::STATUS_GENERATED) { + throw new \RuntimeException("PayFile must be in 'generated' state to mark as sent. Current: {$this->status}"); + } + + $this->update([ + 'status' => self::STATUS_SENT, + 'sent_at' => now(), + ]); + + return $this; + } + + /** + * Mark this pay file as confirmed (payment actually completed). + * THIS is the ONLY place that flips CarrierInvoice.status to 'paid'. + * + * Wrapped in a DB transaction so either ALL invoices flip or none do. + */ + public function markAsConfirmed(): self + { + if (!in_array($this->status, [self::STATUS_SENT, self::STATUS_GENERATED])) { + throw new \RuntimeException("PayFile must be in 'sent' or 'generated' state to confirm. Current: {$this->status}"); + } + + DB::transaction(function () { + $invoiceUuids = $this->items()->pluck('carrier_invoice_uuid')->all(); + + if (!empty($invoiceUuids)) { + CarrierInvoice::whereIn('uuid', $invoiceUuids) + ->where('status', 'approved') // safety: only flip approved → paid + ->update(['status' => 'paid']); + } + + $this->update([ + 'status' => self::STATUS_CONFIRMED, + 'confirmed_at' => now(), + ]); + }); + + return $this->fresh(); + } + + /** + * Cancel a pay file. Releases invoices for re-batching. + * Does NOT touch invoice paid state — invoices in a cancelled file + * stay as 'approved' and become eligible for the next pay file. + */ + public function cancel(): self + { + if ($this->status === self::STATUS_CONFIRMED) { + throw new \RuntimeException('Cannot cancel a confirmed pay file — payment has already been recorded.'); + } + + $this->update(['status' => self::STATUS_CANCELLED]); + + return $this; + } +} diff --git a/server/src/Models/PayFileItem.php b/server/src/Models/PayFileItem.php new file mode 100644 index 0000000..9db945d --- /dev/null +++ b/server/src/Models/PayFileItem.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'meta' => Json::class, + ]; + + public function payFile() + { + return $this->belongsTo(PayFile::class, 'pay_file_uuid', 'uuid'); + } + + public function carrierInvoice() + { + return $this->belongsTo(CarrierInvoice::class, 'carrier_invoice_uuid', 'uuid'); + } + + public function vendor() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Vendor::class, 'vendor_uuid', 'uuid'); + } +} diff --git a/server/src/Models/PayFileSchedule.php b/server/src/Models/PayFileSchedule.php new file mode 100644 index 0000000..bd96e16 --- /dev/null +++ b/server/src/Models/PayFileSchedule.php @@ -0,0 +1,71 @@ + 'integer', + 'day_of_month' => 'integer', + 'auto_send' => 'boolean', + 'is_active' => 'boolean', + 'recipients' => Json::class, + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + 'meta' => Json::class, + ]; + + public function company() + { + return $this->belongsTo(\Fleetbase\Models\Company::class, 'company_uuid', 'uuid'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeDueForRun($query) + { + return $query->where('next_run_at', '<=', now()); + } + + /** + * Calculate the next run timestamp based on frequency. + */ + public function calculateNextRun(?Carbon $from = null): Carbon + { + $base = $from ?? now(); + + return match ($this->frequency) { + self::FREQUENCY_WEEKLY => $base->copy()->addWeek(), + self::FREQUENCY_BIWEEKLY => $base->copy()->addWeeks(2), + self::FREQUENCY_MONTHLY => $base->copy()->addMonth(), + default => $base->copy()->addWeek(), + }; + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index 18dfcad..e8123ac 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -88,6 +88,7 @@ public function register() $this->app->singleton(\Fleetbase\Ledger\Services\ClientInvoiceGeneratorService::class); $this->app->singleton(\Fleetbase\Ledger\Services\BatchInvoiceService::class); $this->app->singleton(\Fleetbase\Ledger\Services\GainshareCalculationService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\PayFileGeneratorService::class); } /** @@ -151,6 +152,7 @@ function ($event) { \Fleetbase\Ledger\Console\Commands\ProvisionLedgerDefaults::class, \Fleetbase\Ledger\Console\Commands\BackfillTransactionDirection::class, \Fleetbase\Ledger\Console\Commands\UpdateOverdueInvoices::class, + \Fleetbase\Ledger\Console\Commands\GenerateScheduledPayFiles::class, ]); } } diff --git a/server/src/Services/PayFileGeneratorService.php b/server/src/Services/PayFileGeneratorService.php new file mode 100644 index 0000000..a6dfbf4 --- /dev/null +++ b/server/src/Services/PayFileGeneratorService.php @@ -0,0 +1,213 @@ +selectEligibleInvoices($companyUuid, $start, $end); + + // Wrap creation + items in a transaction to keep state consistent + $payFile = DB::transaction(function () use ($companyUuid, $format, $start, $end, $invoices) { + $payFile = PayFile::create([ + 'company_uuid' => $companyUuid, + 'name' => "Pay File {$start->format('Y-m-d')} to {$end->format('Y-m-d')}", + 'format' => $format, + 'status' => PayFile::STATUS_DRAFT, + 'period_start' => $start->toDateString(), + 'period_end' => $end->toDateString(), + 'record_count' => $invoices->count(), + 'total_amount' => $invoices->sum('approved_amount'), + ]); + + foreach ($invoices as $invoice) { + PayFileItem::create([ + 'pay_file_uuid' => $payFile->uuid, + 'carrier_invoice_uuid' => $invoice->uuid, + 'vendor_uuid' => $invoice->vendor_uuid, + 'amount' => $invoice->approved_amount, + 'payment_method' => 'ach', + 'reference_number' => $invoice->invoice_number ?? $invoice->pro_number, + ]); + } + + return $payFile; + }); + + // Generate export content (outside transaction — file IO) + $content = $this->renderContent($payFile, $format); + + $extension = match ($format) { + PayFile::FORMAT_EDI_820 => 'edi', + PayFile::FORMAT_ACH_NACHA => 'ach', + default => 'csv', + }; + + $filename = "payfile-{$payFile->public_id}.{$extension}"; + $path = "pay-files/{$companyUuid}/{$filename}"; + + try { + Storage::put($path, $content); + + $file = File::create([ + 'company_uuid' => $companyUuid, + 'path' => $path, + 'original_filename' => $filename, + 'content_type' => $this->contentTypeForFormat($format), + 'file_size' => strlen($content), + ]); + + $payFile->update([ + 'file_uuid' => $file->uuid, + 'status' => PayFile::STATUS_GENERATED, + 'generated_at' => now(), + ]); + } catch (\Throwable $e) { + // File storage failed — leave PayFile in draft so it can be retried + $payFile->update([ + 'meta' => array_merge($payFile->meta ?? [], [ + 'generation_error' => $e->getMessage(), + ]), + ]); + throw $e; + } + + return $payFile->fresh()->load('items', 'file'); + } + + /** + * Select carrier invoices eligible for inclusion in a new pay file. + * + * Eligibility: + * - status = 'approved' + * - resolved_at within [start, end] + * - NOT already in any pay_file_item where the parent pay_file is NOT cancelled + * + * The exclusion query prevents an invoice from appearing in two active pay files. + */ + protected function selectEligibleInvoices(string $companyUuid, Carbon $start, Carbon $end): Collection + { + return CarrierInvoice::where('company_uuid', $companyUuid) + ->where('status', 'approved') + ->whereBetween('resolved_at', [$start, $end]) + ->whereNotIn('uuid', function ($q) { + // Exclude invoices already locked in non-cancelled pay files + $q->select('pay_file_items.carrier_invoice_uuid') + ->from('pay_file_items') + ->join('pay_files', 'pay_files.uuid', '=', 'pay_file_items.pay_file_uuid') + ->where('pay_files.status', '!=', PayFile::STATUS_CANCELLED) + ->whereNull('pay_files.deleted_at') + ->whereNull('pay_file_items.deleted_at'); + }) + ->with('vendor') + ->get(); + } + + /** + * Dispatch to the right formatter. + */ + protected function renderContent(PayFile $payFile, string $format): string + { + return match ($format) { + PayFile::FORMAT_CSV => $this->formatCsv($payFile), + PayFile::FORMAT_EDI_820 => $this->formatEdi820($payFile), + PayFile::FORMAT_ACH_NACHA => $this->formatNacha($payFile), + default => $this->formatCsv($payFile), + }; + } + + /** + * CSV format — fully implemented and the recommended default. + */ + protected function formatCsv(PayFile $payFile): string + { + $lines = ['Vendor Name,Vendor UUID,Invoice #,PRO #,Amount,Payment Method,Reference']; + + $items = $payFile->items() + ->with(['vendor', 'carrierInvoice']) + ->get(); + + foreach ($items as $item) { + $invoice = $item->carrierInvoice; + $lines[] = implode(',', [ + '"' . str_replace('"', '""', $item->vendor?->name ?? '') . '"', + $item->vendor_uuid, + $invoice?->invoice_number ?? '', + $invoice?->pro_number ?? '', + number_format((float) $item->amount, 2, '.', ''), + $item->payment_method ?? 'ach', + $item->reference_number ?? '', + ]); + } + + return implode("\n", $lines) . "\n"; + } + + /** + * EDI 820 — STUB ONLY. + * + * TODO: Real EDI 820 (Payment Order/Remittance Advice) requires accurate + * field positioning, control numbers, segment terminators, hash totals, + * envelope structure (ISA/GS/ST...SE/GE/IEA), and trading partner agreements. + * This MUST be implemented by a finance/EDI specialist before production use. + */ + protected function formatEdi820(PayFile $payFile): string + { + return "TODO: EDI 820 format not implemented. PayFile UUID: {$payFile->uuid}\n" + . "Records: {$payFile->record_count}, Total: \${$payFile->total_amount}\n" + . "Real EDI 820 implementation requires industry-compliant segment building,\n" + . "control numbers, hash totals, and trading partner setup.\n"; + } + + /** + * NACHA/ACH — STUB ONLY. + * + * TODO: Real NACHA file format requires 94-character fixed-width records: + * File Header (1), Batch Header (5), Entry Detail (6), Addenda (7), + * Batch Control (8), File Control (9). Includes immediate origin/destination + * routing numbers, batch hash, entry hash, and block count padding. + * This MUST be implemented by someone with bank ACH origination credentials + * and NACHA Operating Rules expertise before production use. + */ + protected function formatNacha(PayFile $payFile): string + { + return "TODO: NACHA/ACH format not implemented. PayFile UUID: {$payFile->uuid}\n" + . "Records: {$payFile->record_count}, Total: \${$payFile->total_amount}\n" + . "Real NACHA implementation requires 94-char fixed-width records,\n" + . "originating bank routing setup, hash totals, and NACHA rules compliance.\n"; + } + + protected function contentTypeForFormat(string $format): string + { + return match ($format) { + PayFile::FORMAT_CSV => 'text/csv', + PayFile::FORMAT_EDI_820 => 'application/edi-x12', + PayFile::FORMAT_ACH_NACHA => 'application/octet-stream', + default => 'text/plain', + }; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index c95d20b..75fa88a 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -298,6 +298,34 @@ function ($router, $controller) { $router->get('summary', 'GainshareController@summary'); $router->get('{id}', 'GainshareController@findRecord'); }); + + // ---------------------------------------------------------------- + // Pay Files + // ---------------------------------------------------------------- + $router->group(['prefix' => 'pay-files'], function () use ($router) { + $router->get('/', 'PayFileController@queryRecord'); + $router->get('{id}', 'PayFileController@findRecord'); + $router->post('/', 'PayFileController@createRecord'); + $router->put('{id}', 'PayFileController@updateRecord'); + $router->delete('{id}', 'PayFileController@deleteRecord'); + $router->post('generate', 'PayFileController@generate'); + $router->get('{id}/download', 'PayFileController@download'); + $router->post('{id}/mark-sent', 'PayFileController@markSent'); + $router->post('{id}/mark-confirmed', 'PayFileController@markConfirmed'); + $router->post('{id}/cancel', 'PayFileController@cancel'); + }); + + // ---------------------------------------------------------------- + // Pay File Schedules + // ---------------------------------------------------------------- + $router->group(['prefix' => 'pay-file-schedules'], function () use ($router) { + $router->get('/', 'PayFileScheduleController@queryRecord'); + $router->get('{id}', 'PayFileScheduleController@findRecord'); + $router->post('/', 'PayFileScheduleController@createRecord'); + $router->put('{id}', 'PayFileScheduleController@updateRecord'); + $router->delete('{id}', 'PayFileScheduleController@deleteRecord'); + $router->post('{id}/run-now', 'PayFileScheduleController@runNow'); + }); } ); } From 97ec25d068b917c136348fb1e50d7b953872e56c Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 21:00:07 -0700 Subject: [PATCH 19/22] feat(BUILD-07): activate rate_contract benchmark path via BUILD-10 rating engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - benchmark_source = 'rate_contract' now resolves expected_total via RateShopService::calculateForContract() against a deterministically-selected RateContract (does NOT rate-shop all carriers). - Selection priority: customer-specific > generic, dedicated cost_management_benchmark usage > both, most recent effective_date, then UUID for full determinism. - Refactored to share dedup/savings/classification/storage logic between both benchmark sources — financial safety guarantees identical for both paths. - cost_benchmark path remains 100% unchanged. - benchmark_source recorded in execution.meta for audit visibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/GainshareCalculationService.php | 278 ++++++++++++++---- 1 file changed, 216 insertions(+), 62 deletions(-) diff --git a/server/src/Services/GainshareCalculationService.php b/server/src/Services/GainshareCalculationService.php index 4b5f517..d75e3fe 100644 --- a/server/src/Services/GainshareCalculationService.php +++ b/server/src/Services/GainshareCalculationService.php @@ -68,48 +68,22 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice } // --- Benchmark source routing --- - // Branch based on which benchmark source the rule is configured to use. + // Branch only for benchmark resolution. Downstream savings/dedup/storage + // logic is shared so both paths get identical financial safety guarantees. $benchmarkSource = $rule->benchmark_source ?? GainshareRule::BENCHMARK_COST; - if ($benchmarkSource === GainshareRule::BENCHMARK_RATE_CONTRACT) { - // Rate contract benchmarking is not yet implemented (BUILD-10). - // Return null safely — do not produce fake values. + $resolution = match ($benchmarkSource) { + GainshareRule::BENCHMARK_RATE_CONTRACT => $this->resolveBenchmarkFromRateContract($shipment, $agreement, $rule), + default => $this->resolveBenchmarkFromCostBenchmark($shipment, $agreement), + }; + + if ($resolution === null) { + // No benchmark resolvable for this shipment under this rule — skip safely return null; } - // All paths below use cost_benchmark source (current, fully implemented). - - // --- Deduplication guard --- - // Prevent duplicate executions for the same shipment+rule combination. - // If one already exists, update it rather than creating a duplicate. - $existingExecution = GainshareExecution::where('shipment_uuid', $shipment->uuid) - ->where('gainshare_rule_uuid', $rule->uuid) - ->first(); - - // --- Find benchmark from CostBenchmark --- - - $origin = $shipment->stops()->where('type', 'pickup')->first(); - $dest = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); - - $benchmark = CostBenchmark::where('company_uuid', $shipment->company_uuid) - ->where(function ($q) use ($agreement) { - $q->where('service_agreement_uuid', $agreement->uuid) - ->orWhereNull('service_agreement_uuid'); - }) - ->forLane( - $origin?->postal_code ?? $origin?->state, - $dest?->postal_code ?? $dest?->state - ) - ->where(function ($q) use ($shipment) { - $q->where('mode', $shipment->mode)->orWhereNull('mode'); - }) - ->active() - ->effective() - ->first(); - - if (!$benchmark) { - return null; // No benchmark for this lane/mode — cannot calculate - } + $expectedTotal = $resolution['expected_total']; + $costBenchmarkUuid = $resolution['cost_benchmark_uuid']; // null for rate_contract path // --- Derive actual cost --- @@ -118,20 +92,15 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice return null; // No approved amount — cannot calculate } - // --- Convert benchmark to flat expected_total based on rate_unit --- - - $expectedTotal = $this->convertBenchmarkToFlat($benchmark, $shipment); - - if ($expectedTotal === null) { - // Missing required data (miles or weight) for unit conversion. - // Do NOT fabricate numbers — skip safely. - return null; - } + // --- Deduplication guard --- + // Prevent duplicate executions for the same shipment+rule combination. + $existingExecution = GainshareExecution::where('shipment_uuid', $shipment->uuid) + ->where('gainshare_rule_uuid', $rule->uuid) + ->first(); // --- Calculate savings and classify result --- $savings = round($expectedTotal - $actualTotal, 2); - $resultType = $this->classifyResult($savings, $rule); // Calculate shares only for qualifying savings @@ -155,7 +124,7 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice 'shipment_uuid' => $shipment->uuid, 'carrier_invoice_uuid' => $invoice->uuid, 'client_invoice_uuid' => $clientInvoiceUuid, - 'cost_benchmark_uuid' => $benchmark->uuid, + 'cost_benchmark_uuid' => $costBenchmarkUuid, // null for rate_contract path; set for cost_benchmark 'benchmark_total' => round($expectedTotal, 2), 'actual_total' => round($actualTotal, 2), 'savings' => $savings, @@ -163,6 +132,9 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice 'client_share' => $clientShare, 'result_type' => $resultType, 'status' => 'calculated', + 'meta' => array_merge(($existingExecution->meta ?? []), [ + 'benchmark_source' => $benchmarkSource, + ]), ]; if ($existingExecution) { @@ -174,6 +146,72 @@ public function calculateForShipment(Shipment $shipment, CarrierInvoice $invoice return GainshareExecution::create($executionData); } + /** + * Resolve expected_total from a CostBenchmark record (the legacy path). + * + * @return array|null ['expected_total' => float, 'cost_benchmark_uuid' => string] + */ + protected function resolveBenchmarkFromCostBenchmark(Shipment $shipment, ServiceAgreement $agreement): ?array + { + $origin = $shipment->stops()->where('type', 'pickup')->first(); + $dest = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); + + $benchmark = CostBenchmark::where('company_uuid', $shipment->company_uuid) + ->where(function ($q) use ($agreement) { + $q->where('service_agreement_uuid', $agreement->uuid) + ->orWhereNull('service_agreement_uuid'); + }) + ->forLane( + $origin?->postal_code ?? $origin?->state, + $dest?->postal_code ?? $dest?->state + ) + ->where(function ($q) use ($shipment) { + $q->where('mode', $shipment->mode)->orWhereNull('mode'); + }) + ->active() + ->effective() + ->first(); + + if (!$benchmark) { + return null; + } + + $expectedTotal = $this->convertBenchmarkToFlat($benchmark, $shipment); + + if ($expectedTotal === null) { + // Missing miles or weight for unit conversion + return null; + } + + return [ + 'expected_total' => $expectedTotal, + 'cost_benchmark_uuid' => $benchmark->uuid, + ]; + } + + /** + * Resolve expected_total from a RateContract via the BUILD-10 rating engine. + * + * Selects the most appropriate benchmark contract using a deterministic + * preference order, then calls RateShopService::calculateForContract() for + * that single contract (does NOT rate-shop all carriers). + * + * @return array|null ['expected_total' => float, 'cost_benchmark_uuid' => null] + */ + protected function resolveBenchmarkFromRateContract(Shipment $shipment, ServiceAgreement $agreement, GainshareRule $rule): ?array + { + $expectedTotal = $this->getBenchmarkFromRateContract($shipment, $rule); + + if ($expectedTotal === null) { + return null; + } + + return [ + 'expected_total' => $expectedTotal, + 'cost_benchmark_uuid' => null, // not from CostBenchmark + ]; + } + /** * Convert a benchmark rate to a flat dollar total based on its rate_unit. * @@ -295,25 +333,141 @@ public function getCustomerSummary(string $companyUuid, string $customerUuid, in } /** - * Resolve benchmark from a rate contract for gainshare comparison. + * Resolve benchmark from a RateContract via the BUILD-10 rating engine. * - * TODO (BUILD-10): Implement this method when the rating engine is built. - * - Will use RateContract with rate_usage = 'cost_management_benchmark' - * - Will resolve the contracted rate for the shipment's lane/mode/equipment - * - Will return a flat expected_total after applying the contract's rate tables - * - Must handle FAK, fuel surcharge, and discount tables from the rate contract + * SELECTION RULES (deterministic): + * 1. Active + effective + same company contracts only + * 2. usage_mode IN ('cost_management_benchmark', 'both') + * 3. mode matches shipment.mode (or contract.mode = 'all') + * 4. Customer-specific contracts preferred over generic (customer_uuid = null) + * 5. usage_mode 'cost_management_benchmark' preferred over 'both' + * (a contract dedicated to benchmarking is more authoritative than dual-use) + * 6. Most recent effective_date wins + * 7. Final tiebreaker: lowest UUID (stable, deterministic) * - * Until implemented, this method returns null and gainshare rules with - * benchmark_source = 'rate_contract' are safely skipped. + * SAFETY: + * - Never rate-shops all carriers — uses the SELECTED benchmark contract only + * - Returns null if no benchmark contract resolves + * - Returns null if rating engine cannot calculate (missing tables, exclusion, etc.) * - * @param Shipment $shipment The shipment to resolve benchmark for - * @param GainshareRule $rule The gainshare rule (carries service_agreement context) - * @return float|null The flat expected total, or null until rating engine is available + * @param Shipment $shipment + * @param GainshareRule $rule + * @return float|null Flat expected_total comparable to actual_total */ protected function getBenchmarkFromRateContract(Shipment $shipment, GainshareRule $rule): ?float { - // Future: resolve benchmark using rating engine (BUILD-10) - // Will use rate_usage = cost_management_benchmark - return null; + // Class existence check — fleetops may not be installed in stripped environments + if (!class_exists(\Fleetbase\FleetOps\Models\RateContract::class) + || !class_exists(\Fleetbase\FleetOps\Services\RateShopService::class)) { + return null; + } + + $customerUuid = $shipment->orders()->first()?->customer_uuid; + + $contract = $this->selectBenchmarkContract($shipment, $customerUuid); + + if (!$contract) { + return null; + } + + // Build rating context from the shipment + $context = $this->buildRatingContextFromShipment($shipment, $customerUuid); + + // Run the rating engine for the selected contract ONLY — never rate-shop + $rateShopService = app(\Fleetbase\FleetOps\Services\RateShopService::class); + $result = $rateShopService->calculateForContract($contract, $context); + + if (!$result || !isset($result['total_charge'])) { + return null; + } + + return (float) $result['total_charge']; + } + + /** + * Select the benchmark contract using deterministic preference ordering. + */ + protected function selectBenchmarkContract(Shipment $shipment, ?string $customerUuid) + { + $contracts = \Fleetbase\FleetOps\Models\RateContract::where('company_uuid', $shipment->company_uuid) + ->active() + ->effective() + ->forMode($shipment->mode) + ->whereIn('usage_mode', [ + \Fleetbase\FleetOps\Models\RateContract::USAGE_COST_MANAGEMENT_BENCHMARK, + \Fleetbase\FleetOps\Models\RateContract::USAGE_BOTH, + ]) + ->where(function ($q) use ($customerUuid) { + if ($customerUuid) { + // Either matches this customer specifically OR is generic (null) + $q->where('customer_uuid', $customerUuid) + ->orWhereNull('customer_uuid'); + } else { + // No customer context — only generic contracts apply + $q->whereNull('customer_uuid'); + } + }) + ->get(); + + if ($contracts->isEmpty()) { + return null; + } + + // Build a deterministic composite sort key per contract. + // Returned as a string with fixed-width zero-padded segments so PHP + // string comparison produces the correct ordering. Higher = earlier. + // Format: customer-match | usage-mode | effective-date | UUID + $keyed = $contracts->map(function ($c) use ($customerUuid) { + $customerMatch = ($customerUuid && $c->customer_uuid === $customerUuid) ? '1' : '0'; + $dedicatedBenchmark = ($c->usage_mode === \Fleetbase\FleetOps\Models\RateContract::USAGE_COST_MANAGEMENT_BENCHMARK) ? '1' : '0'; + $effectiveTs = $c->effective_date ? str_pad((string) $c->effective_date->timestamp, 12, '0', STR_PAD_LEFT) : '000000000000'; + // UUID descending tiebreaker — invert by subtracting from 'z' chars not portable; + // simpler: append uuid ascending and use sortBy (smallest wins overall) + // But we want largest for the high-priority fields. So reverse the uuid sort: + // pad with chars and use sortByDesc on the composite string. + return [ + 'contract' => $c, + 'sort_key' => $customerMatch . $dedicatedBenchmark . $effectiveTs . '|' . $c->uuid, + ]; + }); + + // sortByDesc on string composite — first the priority bits, then ts, then uuid lex order + // For uuid we want ascending tiebreaker; since the priority parts dominate, this is fine. + $best = $keyed->sortByDesc('sort_key')->first(); + + return $best['contract'] ?? null; + } + + /** + * Build a rating context from a Shipment. + * Mirrors the rating context format expected by RateShopService. + */ + protected function buildRatingContextFromShipment(Shipment $shipment, ?string $customerUuid): array + { + $context = [ + 'shipment_uuid' => $shipment->uuid, + 'mode' => $shipment->mode, + 'equipment_type' => $shipment->equipment_type, + 'miles' => (float) ($shipment->carrier_rate_miles ?? 0), + 'customer_uuid' => $customerUuid, + ]; + + $pickup = $shipment->stops()->where('type', 'pickup')->orderBy('sequence')->first(); + $delivery = $shipment->stops()->where('type', 'delivery')->orderBy('sequence', 'desc')->first(); + + if ($pickup) { + $context['origin_zip'] = $pickup->postal_code; + $context['origin_state'] = $pickup->state; + } + if ($delivery) { + $context['dest_zip'] = $delivery->postal_code; + $context['dest_state'] = $delivery->state; + } + + // Best-effort weight/pieces from order links + $context['weight'] = (float) $shipment->orderLinks->sum('weight'); + $context['pieces'] = (float) $shipment->orderLinks->sum('pieces'); + + return $context; } } From b1c2337722149bc4186f37571220a099e71ea4d6 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Mon, 13 Apr 2026 21:25:54 -0700 Subject: [PATCH 20/22] feat(BUILD-11): add batch-approve endpoint to CarrierInvoiceController POST /carrier-invoices/batch-approve bulk-approves invoices using the existing resolve() flow per-invoice so GL assignment and gainshare events fire normally. Skips invoices not in audited/in_review state with a reason. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Internal/v1/CarrierInvoiceController.php | 54 +++++++++++++++++++ server/src/routes.php | 1 + 2 files changed, 55 insertions(+) diff --git a/server/src/Http/Controllers/Internal/v1/CarrierInvoiceController.php b/server/src/Http/Controllers/Internal/v1/CarrierInvoiceController.php index 484a862..315f46b 100644 --- a/server/src/Http/Controllers/Internal/v1/CarrierInvoiceController.php +++ b/server/src/Http/Controllers/Internal/v1/CarrierInvoiceController.php @@ -37,4 +37,58 @@ public function resolve(string $id, Request $request) return response()->json(['data' => $resolved->load('items')]); } + + /** + * POST /carrier-invoices/batch-approve + * Body: { "invoice_uuids": ["..."], "notes": "optional" } + * + * Bulk-approve audited invoices using the existing resolve() flow with + * resolution='pay_invoiced'. Each invoice is resolved individually so + * existing event-driven side effects (GL assignment, gainshare) fire + * normally per invoice. + */ + public function batchApprove(Request $request) + { + $validated = $request->validate([ + 'invoice_uuids' => 'required|array|min:1', + 'notes' => 'nullable|string|max:5000', + ]); + + $service = app(CarrierInvoiceAuditService::class); + $approved = []; + $skipped = []; + + foreach ($validated['invoice_uuids'] as $uuid) { + $invoice = CarrierInvoice::where('uuid', $uuid) + ->where('company_uuid', session('company')) + ->first(); + + if (!$invoice) { + $skipped[] = ['uuid' => $uuid, 'reason' => 'not_found']; + continue; + } + + // Only approve invoices in audit-ready states + if (!in_array($invoice->status, ['audited', 'in_review'])) { + $skipped[] = ['uuid' => $uuid, 'reason' => "status_{$invoice->status}_not_eligible"]; + continue; + } + + try { + $service->resolve($invoice, 'pay_invoiced', null, $validated['notes'] ?? null); + $approved[] = $uuid; + } catch (\Throwable $e) { + $skipped[] = ['uuid' => $uuid, 'reason' => substr($e->getMessage(), 0, 200)]; + } + } + + return response()->json([ + 'data' => [ + 'approved' => $approved, + 'approved_count' => count($approved), + 'skipped' => $skipped, + 'skipped_count' => count($skipped), + ], + ]); + } } diff --git a/server/src/routes.php b/server/src/routes.php index 75fa88a..d208675 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -220,6 +220,7 @@ function ($router, $controller) { $router->delete('{id}', 'CarrierInvoiceController@deleteRecord'); $router->post('{id}/audit', 'CarrierInvoiceController@audit'); $router->post('{id}/resolve', 'CarrierInvoiceController@resolve'); + $router->post('batch-approve', 'CarrierInvoiceController@batchApprove'); }); // ---------------------------------------------------------------- From 963bef4aca9de072368f17ebb0220964cb7be5b9 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Tue, 14 Apr 2026 11:29:58 -0700 Subject: [PATCH 21/22] feat(multi-tenant): apply ScopedToCompanyContext to CarrierInvoice, ServiceAgreement, PayFile --- server/src/Models/CarrierInvoice.php | 2 ++ server/src/Models/PayFile.php | 2 ++ server/src/Models/ServiceAgreement.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/server/src/Models/CarrierInvoice.php b/server/src/Models/CarrierInvoice.php index a23c69d..52bce85 100644 --- a/server/src/Models/CarrierInvoice.php +++ b/server/src/Models/CarrierInvoice.php @@ -3,6 +3,7 @@ namespace Fleetbase\Ledger\Models; use Fleetbase\Casts\Json; +use Fleetbase\Models\Concerns\ScopedToCompanyContext; use Fleetbase\Models\Model; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasComments; @@ -14,6 +15,7 @@ class CarrierInvoice extends Model { + use ScopedToCompanyContext; use HasUuid; use HasPublicId; use HasApiModelBehavior; diff --git a/server/src/Models/PayFile.php b/server/src/Models/PayFile.php index 8f35288..e06e852 100644 --- a/server/src/Models/PayFile.php +++ b/server/src/Models/PayFile.php @@ -3,6 +3,7 @@ namespace Fleetbase\Ledger\Models; use Fleetbase\Casts\Json; +use Fleetbase\Models\Concerns\ScopedToCompanyContext; use Fleetbase\Models\Model; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasComments; @@ -13,6 +14,7 @@ class PayFile extends Model { + use ScopedToCompanyContext; use HasUuid, HasPublicId, HasApiModelBehavior, HasComments, SoftDeletes; /** diff --git a/server/src/Models/ServiceAgreement.php b/server/src/Models/ServiceAgreement.php index 2682e23..63f0de4 100644 --- a/server/src/Models/ServiceAgreement.php +++ b/server/src/Models/ServiceAgreement.php @@ -3,6 +3,7 @@ namespace Fleetbase\Ledger\Models; use Fleetbase\Casts\Json; +use Fleetbase\Models\Concerns\ScopedToCompanyContext; use Fleetbase\Models\Model; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasComments; @@ -13,6 +14,7 @@ class ServiceAgreement extends Model { + use ScopedToCompanyContext; use HasUuid, HasPublicId, HasApiModelBehavior, HasComments, Searchable, SoftDeletes; protected $table = 'service_agreements'; From f14973884e46eeb87edbf9bb14ceb6867d3ca087 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Tue, 14 Apr 2026 21:25:29 -0700 Subject: [PATCH 22/22] feat(settings): wire PayFileGeneratorService payment_method to CompanySettingsResolver --- .../src/Services/PayFileGeneratorService.php | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/server/src/Services/PayFileGeneratorService.php b/server/src/Services/PayFileGeneratorService.php index a6dfbf4..0a993c4 100644 --- a/server/src/Services/PayFileGeneratorService.php +++ b/server/src/Services/PayFileGeneratorService.php @@ -7,6 +7,7 @@ use Fleetbase\Ledger\Models\PayFile; use Fleetbase\Ledger\Models\PayFileItem; use Fleetbase\Models\File; +use Fleetbase\Support\CompanySettingsResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -29,10 +30,11 @@ class PayFileGeneratorService */ public function generate(string $companyUuid, string $format, Carbon $start, Carbon $end): PayFile { - $invoices = $this->selectEligibleInvoices($companyUuid, $start, $end); + $invoices = $this->selectEligibleInvoices($companyUuid, $start, $end); + $paymentMethod = $this->resolvePaymentMethod($companyUuid); // Wrap creation + items in a transaction to keep state consistent - $payFile = DB::transaction(function () use ($companyUuid, $format, $start, $end, $invoices) { + $payFile = DB::transaction(function () use ($companyUuid, $format, $start, $end, $invoices, $paymentMethod) { $payFile = PayFile::create([ 'company_uuid' => $companyUuid, 'name' => "Pay File {$start->format('Y-m-d')} to {$end->format('Y-m-d')}", @@ -50,7 +52,7 @@ public function generate(string $companyUuid, string $format, Carbon $start, Car 'carrier_invoice_uuid' => $invoice->uuid, 'vendor_uuid' => $invoice->vendor_uuid, 'amount' => $invoice->approved_amount, - 'payment_method' => 'ach', + 'payment_method' => $paymentMethod, 'reference_number' => $invoice->invoice_number ?? $invoice->pro_number, ]); } @@ -210,4 +212,18 @@ protected function contentTypeForFormat(string $format): string default => 'text/plain', }; } + + /** + * Resolve the default payment_method from CompanySettingsResolver. + * + * The resolver's built-in default is 'ach' — do not add a redundant + * hardcoded fallback here. Callers who want to override should pass + * payment_method explicitly upstream; this method only supplies the + * default when none was provided. + */ + private function resolvePaymentMethod(string $companyUuid): string + { + return CompanySettingsResolver::forCompany($companyUuid) + ->get('pay_files.default_payment_method'); + } }