@@ -520,6 +520,300 @@ public function vatReport(Request $request): Response
520520 ]);
521521 }
522522
523+ // ─── CSV Export Methods ───────────────────────────────────────────────────
524+
525+ public function exportProfitLoss (Request $ request ): \Symfony \Component \HttpFoundation \StreamedResponse
526+ {
527+ $ this ->authorize ('viewAny ' , Account::class);
528+
529+ $ from = $ request ->from ?? now ()->startOfYear ()->toDateString ();
530+ $ to = $ request ->to ?? now ()->toDateString ();
531+
532+ $ totals = $ this ->aggregateJournalLines ($ from , $ to );
533+ $ accounts = Account::whereIn ('type ' , ['income ' , 'expense ' ])->orderBy ('code ' )->get ();
534+
535+ $ revenue = [];
536+ $ expenses = [];
537+
538+ foreach ($ accounts as $ account ) {
539+ $ row = $ totals ->get ($ account ->id );
540+ $ debit = (float ) ($ row ?->total_debit ?? 0 );
541+ $ credit = (float ) ($ row ?->total_credit ?? 0 );
542+ $ net = $ account ->type === 'income ' ? $ credit - $ debit : $ debit - $ credit ;
543+
544+ $ entry = ['type ' => $ account ->type === 'income ' ? 'Revenue ' : 'Expense ' , 'name ' => $ account ->name , 'net ' => $ net ];
545+
546+ if ($ account ->type === 'income ' ) {
547+ $ revenue [] = $ entry ;
548+ } else {
549+ $ expenses [] = $ entry ;
550+ }
551+ }
552+
553+ $ totalRevenue = array_sum (array_column ($ revenue , 'net ' ));
554+ $ totalExpenses = array_sum (array_column ($ expenses , 'net ' ));
555+
556+ $ rows = [];
557+ foreach ($ revenue as $ r ) { $ rows [] = [$ r ['type ' ], $ r ['name ' ], number_format ($ r ['net ' ], 2 , '. ' , '' )]; }
558+ foreach ($ expenses as $ r ) { $ rows [] = [$ r ['type ' ], $ r ['name ' ], number_format ($ r ['net ' ], 2 , '. ' , '' )]; }
559+ $ rows [] = ['Net ' , 'Net Profit / Loss ' , number_format ($ totalRevenue - $ totalExpenses , 2 , '. ' , '' )];
560+
561+ return $ this ->streamCsv (
562+ "profit-loss- {$ from }- {$ to }.csv " ,
563+ ['Type ' , 'Account ' , 'Amount ' ],
564+ $ rows
565+ );
566+ }
567+
568+ public function exportBalanceSheet (Request $ request ): \Symfony \Component \HttpFoundation \StreamedResponse
569+ {
570+ $ this ->authorize ('viewAny ' , Account::class);
571+
572+ $ asOf = $ request ->as_of ?? now ()->toDateString ();
573+ $ totals = $ this ->aggregateJournalLines (null , $ asOf );
574+
575+ $ accounts = Account::whereIn ('type ' , ['asset ' , 'liability ' , 'equity ' ])->orderBy ('code ' )->get ();
576+
577+ $ rows = [];
578+ foreach ($ accounts as $ account ) {
579+ $ row = $ totals ->get ($ account ->id );
580+ $ debit = (float ) ($ row ?->total_debit ?? 0 );
581+ $ credit = (float ) ($ row ?->total_credit ?? 0 );
582+ $ net = $ account ->type === 'asset ' ? $ debit - $ credit : $ credit - $ debit ;
583+
584+ $ section = ucfirst ($ account ->type );
585+ $ rows [] = [$ section , $ account ->name , number_format ($ net , 2 , '. ' , '' )];
586+ }
587+
588+ return $ this ->streamCsv (
589+ "balance-sheet- {$ asOf }.csv " ,
590+ ['Section ' , 'Account ' , 'Balance ' ],
591+ $ rows
592+ );
593+ }
594+
595+ public function exportAgedReceivables (Request $ request ): \Symfony \Component \HttpFoundation \StreamedResponse
596+ {
597+ $ this ->authorize ('viewAny ' , Account::class);
598+
599+ $ asOf = $ request ->as_of ?? now ()->toDateString ();
600+
601+ $ invoices = Invoice::with (['contact ' , 'items ' , 'payments ' ])
602+ ->whereNotIn ('status ' , ['paid ' , 'cancelled ' ])
603+ ->get ();
604+
605+ $ rows = [];
606+ foreach ($ invoices as $ inv ) {
607+ $ daysOverdue = 0 ;
608+ if ($ inv ->due_date ) {
609+ $ diff = \Carbon \Carbon::parse ($ asOf )->diffInDays ($ inv ->due_date , false );
610+ $ daysOverdue = (int ) max (0 , $ diff * -1 );
611+ }
612+ $ bucket = match (true ) {
613+ $ daysOverdue === 0 => 'current ' ,
614+ $ daysOverdue <= 30 => '1-30 ' ,
615+ $ daysOverdue <= 60 => '31-60 ' ,
616+ $ daysOverdue <= 90 => '61-90 ' ,
617+ default => '90+ ' ,
618+ };
619+ $ amountDue = (float ) $ inv ->amount_due ;
620+ $ rows [] = [
621+ $ inv ->contact ?->name ?? '— ' ,
622+ $ inv ->number ?? '' ,
623+ $ inv ->issue_date ?->toDateString() ?? '' ,
624+ $ inv ->due_date ?->toDateString() ?? '' ,
625+ $ bucket === 'current ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
626+ $ bucket === '1-30 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
627+ $ bucket === '31-60 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
628+ $ bucket === '61-90 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
629+ $ bucket === '90+ ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
630+ number_format ($ amountDue , 2 , '. ' , '' ),
631+ ];
632+ }
633+
634+ return $ this ->streamCsv (
635+ "aged-receivables- {$ asOf }.csv " ,
636+ ['Customer ' , 'Invoice # ' , 'Issue Date ' , 'Due Date ' , 'Current ' , '1-30 ' , '31-60 ' , '61-90 ' , '90+ ' , 'Total ' ],
637+ $ rows
638+ );
639+ }
640+
641+ public function exportAgedPayables (Request $ request ): \Symfony \Component \HttpFoundation \StreamedResponse
642+ {
643+ $ this ->authorize ('viewAny ' , Account::class);
644+
645+ $ asOf = $ request ->as_of ?? now ()->toDateString ();
646+
647+ $ bills = Bill::with (['contact ' , 'items ' , 'payments ' ])
648+ ->whereNotIn ('status ' , ['paid ' , 'cancelled ' ])
649+ ->get ();
650+
651+ $ rows = [];
652+ foreach ($ bills as $ bill ) {
653+ $ daysOverdue = 0 ;
654+ if ($ bill ->due_date ) {
655+ $ diff = \Carbon \Carbon::parse ($ asOf )->diffInDays ($ bill ->due_date , false );
656+ $ daysOverdue = (int ) max (0 , $ diff * -1 );
657+ }
658+ $ bucket = match (true ) {
659+ $ daysOverdue === 0 => 'current ' ,
660+ $ daysOverdue <= 30 => '1-30 ' ,
661+ $ daysOverdue <= 60 => '31-60 ' ,
662+ $ daysOverdue <= 90 => '61-90 ' ,
663+ default => '90+ ' ,
664+ };
665+ $ amountDue = (float ) $ bill ->amount_due ;
666+ $ rows [] = [
667+ $ bill ->contact ?->name ?? '— ' ,
668+ $ bill ->number ?? '' ,
669+ $ bill ->issue_date ?->toDateString() ?? '' ,
670+ $ bill ->due_date ?->toDateString() ?? '' ,
671+ $ bucket === 'current ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
672+ $ bucket === '1-30 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
673+ $ bucket === '31-60 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
674+ $ bucket === '61-90 ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
675+ $ bucket === '90+ ' ? number_format ($ amountDue , 2 , '. ' , '' ) : '0.00 ' ,
676+ number_format ($ amountDue , 2 , '. ' , '' ),
677+ ];
678+ }
679+
680+ return $ this ->streamCsv (
681+ "aged-payables- {$ asOf }.csv " ,
682+ ['Vendor ' , 'Bill # ' , 'Issue Date ' , 'Due Date ' , 'Current ' , '1-30 ' , '31-60 ' , '61-90 ' , '90+ ' , 'Total ' ],
683+ $ rows
684+ );
685+ }
686+
687+ public function exportAccountLedger (Request $ request , Account $ account ): \Symfony \Component \HttpFoundation \StreamedResponse
688+ {
689+ $ this ->authorize ('viewAny ' , Account::class);
690+
691+ $ from = $ request ->from ?? now ()->startOfYear ()->toDateString ();
692+ $ to = $ request ->to ?? now ()->toDateString ();
693+
694+ $ lines = JournalLine::join ('journal_entries ' , 'journal_entries.id ' , '= ' , 'journal_lines.journal_entry_id ' )
695+ ->where ('journal_lines.account_id ' , $ account ->id )
696+ ->where ('journal_entries.status ' , 'posted ' )
697+ ->when ($ from , fn ($ q ) => $ q ->whereDate ('journal_entries.date ' , '>= ' , $ from ))
698+ ->when ($ to , fn ($ q ) => $ q ->whereDate ('journal_entries.date ' , '<= ' , $ to ))
699+ ->orderBy ('journal_entries.date ' )
700+ ->orderBy ('journal_lines.id ' )
701+ ->select ('journal_lines.* ' , 'journal_entries.date as entry_date ' ,
702+ 'journal_entries.reference as entry_reference ' ,
703+ 'journal_entries.description as entry_description ' )
704+ ->get ();
705+
706+ $ isDebitNormal = in_array ($ account ->type , ['asset ' , 'expense ' ], true );
707+ $ runningBalance = 0.0 ;
708+ $ rows = [];
709+
710+ foreach ($ lines as $ line ) {
711+ $ debit = (float ) $ line ->debit ;
712+ $ credit = (float ) $ line ->credit ;
713+ $ runningBalance += $ isDebitNormal ? ($ debit - $ credit ) : ($ credit - $ debit );
714+ $ description = $ line ->description ?? $ line ->entry_description ;
715+ $ rows [] = [
716+ $ line ->entry_date instanceof \Carbon \Carbon
717+ ? $ line ->entry_date ->toDateString ()
718+ : (string ) $ line ->entry_date ,
719+ $ description ?? '' ,
720+ number_format ($ debit , 2 , '. ' , '' ),
721+ number_format ($ credit , 2 , '. ' , '' ),
722+ number_format ($ runningBalance , 2 , '. ' , '' ),
723+ ];
724+ }
725+
726+ return $ this ->streamCsv (
727+ "ledger- {$ account ->code }- {$ from }- {$ to }.csv " ,
728+ ['Date ' , 'Description ' , 'Debit ' , 'Credit ' , 'Balance ' ],
729+ $ rows
730+ );
731+ }
732+
733+ public function exportVatReport (Request $ request ): \Symfony \Component \HttpFoundation \StreamedResponse
734+ {
735+ $ this ->authorize ('viewAny ' , Invoice::class);
736+
737+ $ tenantId = $ request ->user ()->tenant_id ;
738+ $ from = $ request ->query ('from ' , now ()->startOfQuarter ()->toDateString ());
739+ $ to = $ request ->query ('to ' , now ()->endOfQuarter ()->toDateString ());
740+
741+ $ invoices = Invoice::where ('tenant_id ' , $ tenantId )
742+ ->whereNotIn ('status ' , ['cancelled ' ])
743+ ->whereBetween ('issue_date ' , [$ from , $ to ])
744+ ->with ('items ' )
745+ ->get ();
746+
747+ $ outputLines = $ invoices ->map (function ($ invoice ) {
748+ return [
749+ 'number ' => $ invoice ->number ,
750+ 'date ' => $ invoice ->issue_date ,
751+ 'contact ' => $ invoice ->contact ?->name,
752+ 'net ' => round ((float ) $ invoice ->subtotal , 2 ),
753+ 'tax ' => round ((float ) $ invoice ->tax_total , 2 ),
754+ 'type ' => 'Output ' ,
755+ ];
756+ })->filter (fn ($ l ) => $ l ['tax ' ] != 0 )->values ();
757+
758+ $ bills = Bill::where ('tenant_id ' , $ tenantId )
759+ ->whereNotIn ('status ' , ['cancelled ' ])
760+ ->whereBetween ('issue_date ' , [$ from , $ to ])
761+ ->with ('items ' )
762+ ->get ();
763+
764+ $ inputLines = $ bills ->map (function ($ bill ) {
765+ return [
766+ 'number ' => $ bill ->number ,
767+ 'date ' => $ bill ->issue_date ,
768+ 'contact ' => $ bill ->contact ?->name,
769+ 'net ' => round ((float ) $ bill ->subtotal , 2 ),
770+ 'tax ' => round ((float ) $ bill ->tax_total , 2 ),
771+ 'type ' => 'Input ' ,
772+ ];
773+ })->filter (fn ($ l ) => $ l ['tax ' ] != 0 )->values ();
774+
775+ $ totalOutputVat = round ($ outputLines ->sum ('tax ' ), 2 );
776+ $ totalInputVat = round ($ inputLines ->sum ('tax ' ), 2 );
777+ $ netVat = round ($ totalOutputVat - $ totalInputVat , 2 );
778+
779+ $ rows = [];
780+ foreach ($ outputLines as $ line ) {
781+ $ date = $ line ['date ' ] instanceof \Carbon \Carbon ? $ line ['date ' ]->toDateString () : (string ) $ line ['date ' ];
782+ $ rows [] = [$ line ['type ' ], $ line ['number ' ] ?? '' , $ date , $ line ['contact ' ] ?? '' , number_format ($ line ['net ' ], 2 , '. ' , '' ), number_format ($ line ['tax ' ], 2 , '. ' , '' )];
783+ }
784+ $ rows [] = ['' , '' , '' , '' , '' , '' ];
785+ foreach ($ inputLines as $ line ) {
786+ $ date = $ line ['date ' ] instanceof \Carbon \Carbon ? $ line ['date ' ]->toDateString () : (string ) $ line ['date ' ];
787+ $ rows [] = [$ line ['type ' ], $ line ['number ' ] ?? '' , $ date , $ line ['contact ' ] ?? '' , number_format ($ line ['net ' ], 2 , '. ' , '' ), number_format ($ line ['tax ' ], 2 , '. ' , '' )];
788+ }
789+ $ rows [] = ['' , '' , '' , '' , '' , '' ];
790+ $ rows [] = ['Total Output VAT ' , '' , '' , '' , '' , number_format ($ totalOutputVat , 2 , '. ' , '' )];
791+ $ rows [] = ['Total Input VAT ' , '' , '' , '' , '' , number_format ($ totalInputVat , 2 , '. ' , '' )];
792+ $ rows [] = ['Net VAT ' , '' , '' , '' , '' , number_format ($ netVat , 2 , '. ' , '' )];
793+
794+ return $ this ->streamCsv (
795+ "vat-report- {$ from }- {$ to }.csv " ,
796+ ['Type ' , 'Document # ' , 'Date ' , 'Contact ' , 'Net ' , 'Tax ' ],
797+ $ rows
798+ );
799+ }
800+
801+ // ─── Private Helpers ─────────────────────────────────────────────────────
802+
803+ private function streamCsv (string $ filename , array $ headers , iterable $ rows ): \Symfony \Component \HttpFoundation \StreamedResponse
804+ {
805+ return response ()->streamDownload (function () use ($ headers , $ rows ) {
806+ $ handle = fopen ('php://output ' , 'w ' );
807+ fputcsv ($ handle , $ headers );
808+ foreach ($ rows as $ row ) {
809+ fputcsv ($ handle , $ row );
810+ }
811+ fclose ($ handle );
812+ }, $ filename , [
813+ 'Content-Type ' => 'text/csv; charset=UTF-8 ' ,
814+ ]);
815+ }
816+
523817 private function aggregateJournalLines (?string $ from = null , ?string $ to = null ): \Illuminate \Support \Collection
524818 {
525819 return JournalLine::select ('account_id ' ,
0 commit comments