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'); + } +}; 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'); + } +}; 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'); } +}; 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/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/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/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/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; + } +} 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')]); + } + + /** + * 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/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/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 @@ +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/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/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), + '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/Http/Resources/v1/ClientInvoice.php b/server/src/Http/Resources/v1/ClientInvoice.php new file mode 100644 index 0000000..ac25019 --- /dev/null +++ b/server/src/Http/Resources/v1/ClientInvoice.php @@ -0,0 +1,39 @@ + $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/GainshareExecution.php b/server/src/Http/Resources/v1/GainshareExecution.php new file mode 100644 index 0000000..0b37845 --- /dev/null +++ b/server/src/Http/Resources/v1/GainshareExecution.php @@ -0,0 +1,34 @@ + $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/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/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/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/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/CarrierInvoice.php b/server/src/Models/CarrierInvoice.php new file mode 100644 index 0000000..52bce85 --- /dev/null +++ b/server/src/Models/CarrierInvoice.php @@ -0,0 +1,114 @@ + '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'); + } +} 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/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..dd2cb26 --- /dev/null +++ b/server/src/Models/GainshareExecution.php @@ -0,0 +1,80 @@ + '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..423f631 --- /dev/null +++ b/server/src/Models/GainshareRule.php @@ -0,0 +1,61 @@ + '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/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/Models/PayFile.php b/server/src/Models/PayFile.php new file mode 100644 index 0000000..e06e852 --- /dev/null +++ b/server/src/Models/PayFile.php @@ -0,0 +1,146 @@ + '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/Models/ServiceAgreement.php b/server/src/Models/ServiceAgreement.php new file mode 100644 index 0000000..63f0de4 --- /dev/null +++ b/server/src/Models/ServiceAgreement.php @@ -0,0 +1,76 @@ + '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); + } +} diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index deaf4eb..e8123ac 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -79,6 +79,16 @@ 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); + $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); + $this->app->singleton(\Fleetbase\Ledger\Services\GainshareCalculationService::class); + $this->app->singleton(\Fleetbase\Ledger\Services\PayFileGeneratorService::class); } /** @@ -104,12 +114,45 @@ 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); + } + } + ); + + // 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([ \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/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/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; + } +} 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/GainshareCalculationService.php b/server/src/Services/GainshareCalculationService.php new file mode 100644 index 0000000..d75e3fe --- /dev/null +++ b/server/src/Services/GainshareCalculationService.php @@ -0,0 +1,473 @@ +orders()->first()?->customer_uuid; + if (!$customerUuid) { + return null; // No customer on linked orders — cannot determine agreement + } + + $agreement = ServiceAgreement::where('company_uuid', $shipment->company_uuid) + ->where('customer_uuid', $customerUuid) + ->active() + ->effective() + ->first(); + + if (!$agreement) { + return null; // No active agreement — gainshare not applicable + } + + $rule = GainshareRule::where('service_agreement_uuid', $agreement->uuid) + ->active() + ->first(); + + if (!$rule || $rule->calculation_basis !== 'per_shipment') { + return null; // No rule or not per-shipment basis + } + + // --- Benchmark source routing --- + // 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; + + $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; + } + + $expectedTotal = $resolution['expected_total']; + $costBenchmarkUuid = $resolution['cost_benchmark_uuid']; // null for rate_contract path + + // --- Derive actual cost --- + + $actualTotal = (float) $invoice->approved_amount; + if ($actualTotal <= 0) { + return null; // No approved amount — cannot calculate + } + + // --- 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 + $companyShare = 0; + $clientShare = 0; + + 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'); + + // --- 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' => $costBenchmarkUuid, // null for rate_contract path; set for cost_benchmark + 'benchmark_total' => round($expectedTotal, 2), + 'actual_total' => round($actualTotal, 2), + 'savings' => $savings, + 'company_share' => $companyShare, + 'client_share' => $clientShare, + 'result_type' => $resultType, + 'status' => 'calculated', + 'meta' => array_merge(($existingExecution->meta ?? []), [ + 'benchmark_source' => $benchmarkSource, + ]), + ]; + + if ($existingExecution) { + // Update existing execution rather than creating duplicate + $existingExecution->update($executionData); + return $existingExecution; + } + + 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. + * + * 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; + } + + /** + * 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(); + + $savingsExecs = $executions->where('result_type', GainshareExecution::RESULT_SAVINGS); + $lossExecs = $executions->where('result_type', GainshareExecution::RESULT_LOSS); + + return [ + '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, + ]; + } + + /** + * Resolve benchmark from a RateContract via the BUILD-10 rating engine. + * + * 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) + * + * 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 + * @param GainshareRule $rule + * @return float|null Flat expected_total comparable to actual_total + */ + protected function getBenchmarkFromRateContract(Shipment $shipment, GainshareRule $rule): ?float + { + // 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; + } +} 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; + } +} 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); + } +} 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); + } +} diff --git a/server/src/Services/PayFileGeneratorService.php b/server/src/Services/PayFileGeneratorService.php new file mode 100644 index 0000000..0a993c4 --- /dev/null +++ b/server/src/Services/PayFileGeneratorService.php @@ -0,0 +1,229 @@ +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, $paymentMethod) { + $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' => $paymentMethod, + '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', + }; + } + + /** + * 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'); + } +} 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; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 81e7893..d208675 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -179,6 +179,154 @@ 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'); + }); + + // ---------------------------------------------------------------- + // 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'); + $router->post('batch-approve', 'CarrierInvoiceController@batchApprove'); + }); + + // ---------------------------------------------------------------- + // 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'); + }); + + // ---------------------------------------------------------------- + // 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'); + }); + + // ---------------------------------------------------------------- + // 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'); + }); + + // ---------------------------------------------------------------- + // 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'); + }); } ); }