diff --git a/app/Console/Commands/ProcessRecurringTransactions.php b/app/Console/Commands/ProcessRecurringTransactions.php new file mode 100644 index 0000000..437893b --- /dev/null +++ b/app/Console/Commands/ProcessRecurringTransactions.php @@ -0,0 +1,49 @@ +info('Starting to process recurring transactions ...'); + + try { + // call the service to process all transactions that are due + // .1 Find all rules that are due and active + $rules = RecurringTransactionRule::where('next_scheduled_at', '<=', now()) + ->where(function ($query) { + $query->where('recurrence_ends_at', '>=', now()) + ->orWhereNull('recurrence_ends_at'); + }) + ->get(); + + // 2. for each due rule + foreach ($rules as $rule) { + $service->createTransactionFromRule($rule); + logger()->info('Dispatched job for rule '.$rule->id); + } + + $this->info('Recurring transactions processing completed.'); + } catch (\Throwable $e) { + $this->error('Error processing recurring transactions: '.$e->getMessage()); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6b9960..54adb38 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(); + } /** diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php index 13180f0..6b4afd9 100644 --- a/app/Http/Controllers/API/v1/TransactionController.php +++ b/app/Http/Controllers/API/v1/TransactionController.php @@ -5,10 +5,12 @@ use App\Http\Controllers\API\ApiController; use App\Http\Traits\ApiQueryable; use App\Jobs\RecurrentTransactionJob; +use App\Models\RecurringTransactionRule; use App\Models\Transaction; use App\Rules\Iso8601DateTime; use App\Rules\ValidateClientId; use App\Services\FileService; +use App\Services\RecurringTransactionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -21,6 +23,13 @@ class TransactionController extends ApiController { use ApiQueryable; + private RecurringTransactionService $recurring_transaction_service; + + public function __construct(RecurringTransactionService $recurring_transaction_service) + { + $this->recurring_transaction_service = $recurring_transaction_service; + } + #[OA\Get( path: '/transactions', summary: 'List all transactions', @@ -280,14 +289,15 @@ public function store(Request $request): JsonResponse // check if this transaction is recurring if (! empty($recurring_transaction_data)) { - $recurring_transaction = $transaction->recurring_transaction_rule()->create($recurring_transaction_data); - // schedule next task. - $next_date = get_next_transaction_schedule_date($recurring_transaction); + // Create recurring transaction + $recurring_transaction = new RecurringTransactionRule($recurring_transaction_data); + $recurring_transaction->next_scheduled_at = $this->recurring_transaction_service->getNextScheduleDate($recurring_transaction); + $recurring_transaction->transaction_id = $transaction->id; + $recurring_transaction->save(); + RecurrentTransactionJob::dispatch( - id: (int) $recurring_transaction->id, - recurrence_period: $recurring_transaction->recurrence_period, - recurrence_interval: $recurring_transaction->recurrence_interval, - )->delay($next_date); + (int) $recurring_transaction->id + )->delay($recurring_transaction->next_scheduled_at); } return $transaction; @@ -558,25 +568,26 @@ public function update(Request $request, $id): JsonResponse $schedule_job = true; if (is_null($recurring_transaction)) { - // create a new recurring transaction - $recurring_transaction = $transaction->recurring_transaction_rule()->create($recurring_transaction_data); + $recurring_transaction = new RecurringTransactionRule($recurring_transaction_data); // create temporay instance from data array + $recurring_transaction->next_scheduled_at = $this->recurring_transaction_service->getNextScheduleDate($recurring_transaction); + $recurring_transaction->transaction_id = $transaction->id; + $recurring_transaction->save(); } else { // Check if details have changed. If not, do not reschedule the job if (($recurring_transaction_data['recurrence_period'] == $recurring_transaction->recurrence_period) && ($recurring_transaction_data['recurrence_interval'] == $recurring_transaction->recurrence_interval)) { $schedule_job = false; + } else { + $recurring_transaction_data['next_scheduled_at'] = $this->recurring_transaction_service->getNextScheduleDate($recurring_transaction); } $recurring_transaction->update($recurring_transaction_data); } if ($schedule_job) { - // 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); + (int) $recurring_transaction->id + )->delay($recurring_transaction->next_scheduled_at); } } diff --git a/app/Jobs/RecurrentTransactionJob.php b/app/Jobs/RecurrentTransactionJob.php index ddb64c0..2a7264d 100644 --- a/app/Jobs/RecurrentTransactionJob.php +++ b/app/Jobs/RecurrentTransactionJob.php @@ -3,6 +3,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 +14,31 @@ 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 { + if ($service->isRuleValid($this->ruleId)) { + $rule = RecurringTransactionRule::with('transaction')->find($this->ruleId); + // Call the service to do all the work. + $service->createTransactionFromRule($rule); } - } catch (\Exception $e) { - logger()->error('Exception occurred'); - logger()->error($e->getMessage()); + } catch (\Throwable $e) { + logger()->error('Error processing job for rule '.$this->ruleId.': '.$e->getMessage()); } } } diff --git a/app/Models/RecurringTransactionRule.php b/app/Models/RecurringTransactionRule.php index 4da76cb..43185a2 100644 --- a/app/Models/RecurringTransactionRule.php +++ b/app/Models/RecurringTransactionRule.php @@ -15,6 +15,7 @@ new OA\Property(property: 'recurrence_period', description: 'Set how often the transaction should repeat', type: 'string'), new OA\Property(property: 'recurrence_interval', description: 'Set how often the transaction should repeat', type: 'integer'), new OA\Property(property: 'recurrence_ends_at', description: 'When the transaction stops repeating', type: 'string', format: 'date-time'), + new OA\Property(property: 'next_scheduled_at', description: 'when next the transaction should happen', type: 'string', format: 'date-time'), ], type: 'object' )] @@ -27,6 +28,12 @@ class RecurringTransactionRule extends Model 'recurrence_interval', 'recurrence_ends_at', 'transaction_id', + 'next_scheduled_at', + ]; + + protected $casts = [ + 'next_scheduled_at' => 'datetime', + 'recurrence_ends_at' => 'datetime', ]; public function transaction(): BelongsTo diff --git a/app/Services/RecurringTransactionService.php b/app/Services/RecurringTransactionService.php new file mode 100644 index 0000000..ac9542e --- /dev/null +++ b/app/Services/RecurringTransactionService.php @@ -0,0 +1,102 @@ +find($ruleId); + + // 2. If the rule doesn't exist, we stop here. + if (! $rule) { + logger()->info('Recurring transaction rule '.$ruleId.' not found.'); + + return false; + } + + // 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 false; + } + // 4. Check if the scheduled date matches today's date (YYYY-MM-DD only) + if ($rule->next_scheduled_at->toDateString() !== now()->toDateString()) { + logger()->info('Rule '.$ruleId.' scheduled date ('.$rule->next_scheduled_at->toDateString().') does not match current date. Skipping.'); + + return false; + } + + // 5. Check if the transaction is not deleted even though this might never happen + if (! $rule->transaction) { + logger()->info('Rule '.$ruleId.' does not have a transaction.'); + + return false; + } + + return true; + + } + + /** + * Creates a new transaction record from a recurring rule. + */ + public function createTransactionFromRule(RecurringTransactionRule $rule): void + { + // 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(); + + if ($originalTransaction->categories->isNotEmpty()) { + $newTransaction->categories()->sync($originalTransaction->categories->pluck('id')); + } + + // Schedule the next job + $rule->next_scheduled_at = $this->getNextScheduleDate($rule); + $rule->save(); + + // 8. Dispatch next job with delay + RecurrentTransactionJob::dispatch($rule->id)->delay($rule->next_scheduled_at); + logger()->info('Scheduled next transaction for '.$rule->transaction->id.' at '.$rule->next_scheduled_at); + } + + public function getNextScheduleDate(RecurringTransactionRule|Model $recurring_transaction): ?Carbon + { + return match (TransactionRecurringPeriod::from($recurring_transaction->recurrence_period)) { + TransactionRecurringPeriod::DAILY => $this->getLastScheduledDate($recurring_transaction)->addDays($recurring_transaction->recurrence_interval), + TransactionRecurringPeriod::WEEKLY => $this->getLastScheduledDate($recurring_transaction)->addWeeks($recurring_transaction->recurrence_interval), + TransactionRecurringPeriod::MONTHLY => $this->getLastScheduledDate($recurring_transaction)->addMonths($recurring_transaction->recurrence_interval), + TransactionRecurringPeriod::YEARLY => $this->getLastScheduledDate($recurring_transaction)->addYears($recurring_transaction->recurrence_interval), + }; + } + + private function getLastScheduledDate(RecurringTransactionRule|Model $recurring_transaction): Carbon + { + if (is_null($recurring_transaction->next_scheduled_at)) { + return now(); + } + + return Carbon::parse($recurring_transaction->next_scheduled_at); + } +} diff --git a/app/helpers.php b/app/helpers.php index 3036955..a54048a 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,9 +1,6 @@ recurrence_period)) { - TransactionRecurringPeriod::DAILY => now()->addDays($recurring_transaction->recurrence_interval), - TransactionRecurringPeriod::WEEKLY => now()->addWeeks($recurring_transaction->recurrence_interval), - TransactionRecurringPeriod::MONTHLY => now()->addMonths($recurring_transaction->recurrence_interval), - TransactionRecurringPeriod::YEARLY => now()->addYears($recurring_transaction->recurrence_interval), - }; -} diff --git a/database/migrations/2025_06_19_125117_create_recurring_transaction_rules_table.php b/database/migrations/2025_06_19_125117_create_recurring_transaction_rules_table.php index 27e3b89..2fb67ab 100644 --- a/database/migrations/2025_06_19_125117_create_recurring_transaction_rules_table.php +++ b/database/migrations/2025_06_19_125117_create_recurring_transaction_rules_table.php @@ -17,6 +17,10 @@ public function up(): void $table->unsignedInteger('recurrence_interval')->default(1); // e.g., every 2 weeks $table->datetime('recurrence_ends_at')->nullable(); // when to stop recurring $table->unsignedBigInteger('transaction_id')->unique(); // original recurring transaction + + $table->timestamp('next_scheduled_at'); // next scheduled occurrence + $table->index('next_scheduled_at'); // index for efficient querying + $table->timestamps(); $table->foreign('transaction_id')->references('id')->on('transactions')->onDelete('cascade'); diff --git a/docker-compose.yml b/docker-compose.yml index 9cf8483..10d5935 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,4 +61,4 @@ networks: driver: bridge volumes: sailmysql: - driver: local \ No newline at end of file + driver: local diff --git a/public/docs/api.json b/public/docs/api.json index d4d3a8d..eeff12e 100644 --- a/public/docs/api.json +++ b/public/docs/api.json @@ -2405,6 +2405,11 @@ "description": "When the transaction stops repeating", "type": "string", "format": "date-time" + }, + "next_scheduled_at": { + "description": "when next the transaction should happen", + "type": "string", + "format": "date-time" } }, "type": "object" diff --git a/tests/Feature/TransactionsTest.php b/tests/Feature/TransactionsTest.php index 297fcd3..5b3de66 100644 --- a/tests/Feature/TransactionsTest.php +++ b/tests/Feature/TransactionsTest.php @@ -458,6 +458,13 @@ public function test_recurring_transaction_runs_on_next_scheduled_date() $transactions = Transaction::count(); $this->assertEquals(2, $transactions); + + // forward the time so that the second transaction should run + Carbon::setTestNow(now()->addDay()); + $this->runQueueWorkerOnce(); + + $transactions = Transaction::count(); + $this->assertEquals(3, $transactions); } private function runQueueWorkerOnce(): void