From 97a228ee1348de3603d5713190495506c8265553 Mon Sep 17 00:00:00 2001 From: iMercy Date: Wed, 17 Sep 2025 16:04:46 -0600 Subject: [PATCH 1/3] feat: Add recurring transaction service --- .../Commands/ProcessRecurringTransactions.php | 39 +++++++ app/Console/Kernel.php | 1 + .../API/v1/TransactionController.php | 7 +- app/Jobs/RecurrentTransactionJob.php | 65 ++--------- app/Services/RecurringTransactionService.php | 104 ++++++++++++++++++ docker-compose.yml | 12 +- 6 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 app/Console/Commands/ProcessRecurringTransactions.php create mode 100644 app/Services/RecurringTransactionService.php diff --git a/app/Console/Commands/ProcessRecurringTransactions.php b/app/Console/Commands/ProcessRecurringTransactions.php new file mode 100644 index 0000000..8be243e --- /dev/null +++ b/app/Console/Commands/ProcessRecurringTransactions.php @@ -0,0 +1,39 @@ +info('Starting to process recurring transactions ...'); + + try { + //call the service to process all transactions that are due + $service->processAllDueTransactions(); + $this->info('Recurring transactions processing completed.'); + } catch (\Exception $e) { + $this->error('Error processing recurring transactions: ' . $e->getMessage()); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6b9960..e811e2f 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { // $schedule->command('inspire')->hourly(); + $schedule->command('transactions:process-recurring')->daily(); } /** diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php index 13180f0..114c0c2 100644 --- a/app/Http/Controllers/API/v1/TransactionController.php +++ b/app/Http/Controllers/API/v1/TransactionController.php @@ -285,8 +285,6 @@ public function store(Request $request): JsonResponse $next_date = get_next_transaction_schedule_date($recurring_transaction); RecurrentTransactionJob::dispatch( id: (int) $recurring_transaction->id, - recurrence_period: $recurring_transaction->recurrence_period, - recurrence_interval: $recurring_transaction->recurrence_interval, )->delay($next_date); } @@ -574,9 +572,8 @@ public function update(Request $request, $id): JsonResponse // schedule next task. $next_date = get_next_transaction_schedule_date($recurring_transaction); RecurrentTransactionJob::dispatch( - id: (int) $recurring_transaction->id, - recurrence_period: $recurring_transaction->recurrence_period, - recurrence_interval: $recurring_transaction->recurrence_interval)->delay($next_date); + id: (int) $recurring_transaction->id + )->delay($next_date); } } diff --git a/app/Jobs/RecurrentTransactionJob.php b/app/Jobs/RecurrentTransactionJob.php index ddb64c0..9a62fa1 100644 --- a/app/Jobs/RecurrentTransactionJob.php +++ b/app/Jobs/RecurrentTransactionJob.php @@ -2,7 +2,7 @@ namespace App\Jobs; -use App\Models\RecurringTransactionRule; +use App\Services\RecurringTransactionService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -13,69 +13,28 @@ class RecurrentTransactionJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - private int $id; - - private string $recurrence_period; - - private int $recurrence_interval; + public int $ruleId; /** - * Create a new job instance. + * The job only needs the ID of the rule to process. */ - public function __construct(int $id, string $recurrence_period, int $recurrence_interval) + public function __construct(int $ruleId) { - $this->id = $id; - $this->recurrence_period = $recurrence_period; - $this->recurrence_interval = $recurrence_interval; + $this->ruleId = $ruleId; } /** - * Execute the job. + * The job's only purpose is to tell the service to do the work. */ - public function handle(): void + public function handle(RecurringTransactionService $service): void { - try { - logger()->info('Handling recurrent transaction'); - // 1. Check if the user might have cancelled this recurring transaction - $recurring_transaction = RecurringTransactionRule::find($this->id); - if ($recurring_transaction) { - // 2. Cancel this job if the recurrence period or recurrence interval has changed or has exceeded the end date - if ( - ($this->recurrence_period == $recurring_transaction->recurrence_period) - && - ($this->recurrence_interval == $recurring_transaction->recurrence_interval) - && - ($recurring_transaction->recurrence_ends_at >= now() || is_null($recurring_transaction->recurrence_ends_at)) - ) { - logger()->info('Running recurrent transaction with id '.$this->id); - $new_transaction = $recurring_transaction->transaction->replicate(); - $new_transaction->created_at = now(); - $new_transaction->updated_at = now(); - $new_transaction->save(); + logger()->info('Handling recurrent transaction for rule ID: ' . $this->ruleId); - if ($recurring_transaction->transaction->categories->isNotEmpty()) { - $new_transaction->categories()->sync($recurring_transaction->transaction->categories->pluck('id')); - } - - // 3. schedule next transaction - if ($recurring_transaction->recurrence_ends_at > now() || is_null($recurring_transaction->recurrence_ends_at)) { - $next_date = get_next_transaction_schedule_date($recurring_transaction); - logger()->info('Next transaction date for '.$this->id.": {$next_date}"); - RecurrentTransactionJob::dispatch( - id: (int) $recurring_transaction->id, - recurrence_period: $recurring_transaction->recurrence_period, - recurrence_interval: $recurring_transaction->recurrence_interval - )->delay($next_date); - } - } else { - logger()->info('Recurrent transaction details for '.$this->id.' have changed, skipping'); - } - } else { - logger()->info('Recurrent transaction for '.$this->id.' not found'); - } + try { + // Call the service to do all the work. + $service->generateNextTransaction($this->ruleId); } catch (\Exception $e) { - logger()->error('Exception occurred'); - logger()->error($e->getMessage()); + logger()->error('Error processing job for rule ' . $this->ruleId . ': ' . $e->getMessage()); } } } diff --git a/app/Services/RecurringTransactionService.php b/app/Services/RecurringTransactionService.php new file mode 100644 index 0000000..e9ad098 --- /dev/null +++ b/app/Services/RecurringTransactionService.php @@ -0,0 +1,104 @@ +find($ruleId); + + // 2. If the rule doesn't exist, we stop here. + if (!$rule) { + logger()->info('Recurring transaction rule ' . $ruleId . ' not found.'); + return; + } + + // 3. Make sure the rule is still active and hasn't expired. + if ($rule->recurrence_ends_at && $rule->recurrence_ends_at < now() ) { + logger()->info('Rule ' . $ruleId . ' has expired. Skipping.'); + return; + } + + try { + // 4. Create a brand new transaction based on the rule. + $this->createTransactionFromRule($rule); + + // 5. Calculate the next time this transaction should happen. + $nextRunDate = $this->calculateNextRunDate($rule); + + // 6. Tell Laravel to run the job again at that future date. + dispatch(new \App\Jobs\RecurrentTransactionJob($rule->id))->delay($nextRunDate); + logger()->info('Scheduled next transaction for ' . $rule->id . ' at ' . $nextRunDate); + + } catch (\Exception $e) { + logger()->error('Error processing rule ' . $ruleId . ': ' . $e->getMessage()); + } + } + + /** + * This method is called by the command. It finds ALL rules that are due + * and dispatches a separate job for each one. + */ + public function processAllDueTransactions(): void + { + // 1. Find all rules that are due to be run. + $rules = RecurringTransactionRule::where('recurrence_ends_at', '>=', now()) + ->orWhereNull('recurrence_ends_at') + ->get(); + + // 2. For each rule, dispatch a job to handle it. + foreach ($rules as $rule) { + // Dispatch a job for each rule. This is better than doing all the + // work at once, especially for many transactions. + dispatch(new \App\Jobs\RecurrentTransactionJob($rule->id)); + } + } + + /** + * Creates a new transaction record from a recurring rule. + */ + private function createTransactionFromRule(RecurringTransactionRule $rule): Transaction + { + // Get the original transaction data. + $originalTransaction = $rule->transaction; + + // Make a copy of the original transaction. + $newTransaction = $originalTransaction->replicate(); + + // Update the copy with today's date and time. + $newTransaction->created_at = now(); + $newTransaction->updated_at = now(); + + // Save the new copy to the database and synced. + $newTransaction->save(); + $newTransaction->markAsSynced(); + + // Copy over any related data, like categories. + if ($originalTransaction->categories->isNotEmpty()) { + $newTransaction->categories()->sync($originalTransaction->categories->pluck('id')); + } + + return $newTransaction; + } + + /** + * Calculates the date of the next transaction. + */ + private function calculateNextRunDate(RecurringTransactionRule $rule): Carbon + { + // Use a function that exists in your project to calculate the next date. + // I have assumed it's called get_next_transaction_schedule_date + return get_next_transaction_schedule_date($rule); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9cf8483..b448431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,9 @@ services: app: - build: - context: . - dockerfile: Dockerfile - args: - HOST_UID: '${HOST_UID}' - HOST_GID: '${HOST_GID}' + image: shinsenter/laravel:php8.2 + environment: + APP_UID: '${APP_UID:-1001}' + APP_GID: '${APP_GID:-1001}' ports: - '${APP_PORT:-8000}:80' tty: true @@ -61,4 +59,4 @@ networks: driver: bridge volumes: sailmysql: - driver: local \ No newline at end of file + driver: local From 8efb09615742ba920a9e55e4982eb4d5ffbb8f74 Mon Sep 17 00:00:00 2001 From: iMercy Date: Fri, 19 Sep 2025 18:01:21 -0600 Subject: [PATCH 2/3] fix: Test fixed --- app/Http/Controllers/API/v1/TransactionController.php | 6 ++++-- app/Services/RecurringTransactionService.php | 2 -- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php index 114c0c2..ab4428d 100644 --- a/app/Http/Controllers/API/v1/TransactionController.php +++ b/app/Http/Controllers/API/v1/TransactionController.php @@ -284,7 +284,8 @@ public function store(Request $request): JsonResponse // schedule next task. $next_date = get_next_transaction_schedule_date($recurring_transaction); RecurrentTransactionJob::dispatch( - id: (int) $recurring_transaction->id, + // id: (int) $recurring_transaction->id, + (int) $recurring_transaction->ruleId )->delay($next_date); } @@ -572,7 +573,8 @@ public function update(Request $request, $id): JsonResponse // schedule next task. $next_date = get_next_transaction_schedule_date($recurring_transaction); RecurrentTransactionJob::dispatch( - id: (int) $recurring_transaction->id + // id: (int) $recurring_transaction->id + (int) $recurring_transaction->ruleId )->delay($next_date); } } diff --git a/app/Services/RecurringTransactionService.php b/app/Services/RecurringTransactionService.php index e9ad098..0beed16 100644 --- a/app/Services/RecurringTransactionService.php +++ b/app/Services/RecurringTransactionService.php @@ -84,7 +84,6 @@ private function createTransactionFromRule(RecurringTransactionRule $rule): Tran $newTransaction->save(); $newTransaction->markAsSynced(); - // Copy over any related data, like categories. if ($originalTransaction->categories->isNotEmpty()) { $newTransaction->categories()->sync($originalTransaction->categories->pluck('id')); } @@ -98,7 +97,6 @@ private function createTransactionFromRule(RecurringTransactionRule $rule): Tran private function calculateNextRunDate(RecurringTransactionRule $rule): Carbon { // Use a function that exists in your project to calculate the next date. - // I have assumed it's called get_next_transaction_schedule_date return get_next_transaction_schedule_date($rule); } } From 164c4d983610f32b730e2329264022e33a4ef557 Mon Sep 17 00:00:00 2001 From: iMercy Date: Fri, 19 Sep 2025 18:23:41 -0600 Subject: [PATCH 3/3] fix: Pint issues --- .../Commands/ProcessRecurringTransactions.php | 8 ++------ app/Jobs/RecurrentTransactionJob.php | 4 ++-- app/Services/RecurringTransactionService.php | 14 ++++++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/Console/Commands/ProcessRecurringTransactions.php b/app/Console/Commands/ProcessRecurringTransactions.php index 8be243e..66ba6ab 100644 --- a/app/Console/Commands/ProcessRecurringTransactions.php +++ b/app/Console/Commands/ProcessRecurringTransactions.php @@ -9,15 +9,11 @@ class ProcessRecurringTransactions extends Command { /** * The name and signature of the console command. - * - * */ - protected $signature = 'transactions:process-recurring'; /** * The console command description. - * */ protected $description = 'Process and schedule recurring transactions that are due'; @@ -29,11 +25,11 @@ public function handle(RecurringTransactionService $service): void $this->info('Starting to process recurring transactions ...'); try { - //call the service to process all transactions that are due + // call the service to process all transactions that are due $service->processAllDueTransactions(); $this->info('Recurring transactions processing completed.'); } catch (\Exception $e) { - $this->error('Error processing recurring transactions: ' . $e->getMessage()); + $this->error('Error processing recurring transactions: '.$e->getMessage()); } } } diff --git a/app/Jobs/RecurrentTransactionJob.php b/app/Jobs/RecurrentTransactionJob.php index 9a62fa1..2658014 100644 --- a/app/Jobs/RecurrentTransactionJob.php +++ b/app/Jobs/RecurrentTransactionJob.php @@ -28,13 +28,13 @@ public function __construct(int $ruleId) */ public function handle(RecurringTransactionService $service): void { - logger()->info('Handling recurrent transaction for rule ID: ' . $this->ruleId); + logger()->info('Handling recurrent transaction for rule ID: '.$this->ruleId); try { // Call the service to do all the work. $service->generateNextTransaction($this->ruleId); } catch (\Exception $e) { - logger()->error('Error processing job for rule ' . $this->ruleId . ': ' . $e->getMessage()); + logger()->error('Error processing job for rule '.$this->ruleId.': '.$e->getMessage()); } } } diff --git a/app/Services/RecurringTransactionService.php b/app/Services/RecurringTransactionService.php index 0beed16..ecb97db 100644 --- a/app/Services/RecurringTransactionService.php +++ b/app/Services/RecurringTransactionService.php @@ -19,14 +19,16 @@ public function generateNextTransaction(int $ruleId): void $rule = RecurringTransactionRule::with('transaction')->find($ruleId); // 2. If the rule doesn't exist, we stop here. - if (!$rule) { - logger()->info('Recurring transaction rule ' . $ruleId . ' not found.'); + if (! $rule) { + logger()->info('Recurring transaction rule '.$ruleId.' not found.'); + return; } // 3. Make sure the rule is still active and hasn't expired. - if ($rule->recurrence_ends_at && $rule->recurrence_ends_at < now() ) { - logger()->info('Rule ' . $ruleId . ' has expired. Skipping.'); + if ($rule->recurrence_ends_at && $rule->recurrence_ends_at < now()) { + logger()->info('Rule '.$ruleId.' has expired. Skipping.'); + return; } @@ -39,10 +41,10 @@ public function generateNextTransaction(int $ruleId): void // 6. Tell Laravel to run the job again at that future date. dispatch(new \App\Jobs\RecurrentTransactionJob($rule->id))->delay($nextRunDate); - logger()->info('Scheduled next transaction for ' . $rule->id . ' at ' . $nextRunDate); + logger()->info('Scheduled next transaction for '.$rule->id.' at '.$nextRunDate); } catch (\Exception $e) { - logger()->error('Error processing rule ' . $ruleId . ': ' . $e->getMessage()); + logger()->error('Error processing rule '.$ruleId.': '.$e->getMessage()); } }