├── .env.example ├── .gitignore ├── README.md ├── app ├── Auth.php ├── Command │ └── GenerateAppKeyCommand.php ├── Config.php ├── Contracts │ ├── AuthInterface.php │ ├── EntityManagerServiceInterface.php │ ├── OwnableInterface.php │ ├── RequestValidatorFactoryInterface.php │ ├── RequestValidatorInterface.php │ ├── SessionInterface.php │ ├── UserInterface.php │ └── UserProviderServiceInterface.php ├── Controllers │ ├── AuthController.php │ ├── CategoryController.php │ ├── HomeController.php │ ├── PasswordResetController.php │ ├── ProfileController.php │ ├── ReceiptController.php │ ├── TransactionController.php │ ├── TransactionImporterController.php │ └── VerifyController.php ├── Csrf.php ├── DataObjects │ ├── DataTableQueryParams.php │ ├── RegisterUserData.php │ ├── SessionConfig.php │ ├── TransactionData.php │ └── UserProfileData.php ├── Entity │ ├── Category.php │ ├── PasswordReset.php │ ├── Receipt.php │ ├── Traits │ │ └── HasTimestamps.php │ ├── Transaction.php │ ├── User.php │ └── UserLoginCode.php ├── Enum │ ├── AppEnvironment.php │ ├── AuthAttemptStatus.php │ ├── SameSite.php │ └── StorageDriver.php ├── Exception │ ├── SessionException.php │ └── ValidationException.php ├── Filters │ └── UserFilter.php ├── Mail │ ├── ForgotPasswordEmail.php │ ├── SignupEmail.php │ └── TwoFactorAuthEmail.php ├── Mailer.php ├── Middleware │ ├── AuthMiddleware.php │ ├── CsrfFieldsMiddleware.php │ ├── GuestMiddleware.php │ ├── OldFormDataMiddleware.php │ ├── RateLimitMiddleware.php │ ├── StartSessionsMiddleware.php │ ├── ValidateSignatureMiddleware.php │ ├── ValidationErrorsMiddleware.php │ ├── ValidationExceptionMiddleware.php │ └── VerifyEmailMiddleware.php ├── RequestValidators │ ├── CreateCategoryRequestValidator.php │ ├── ForgotPasswordRequestValidator.php │ ├── RegisterUserRequestValidator.php │ ├── RequestValidatorFactory.php │ ├── ResetPasswordRequestValidator.php │ ├── TransactionImportRequestValidator.php │ ├── TransactionRequestValidator.php │ ├── TwoFactorLoginRequestValidator.php │ ├── UpdateCategoryRequestValidator.php │ ├── UpdatePasswordRequestValidator.php │ ├── UpdateProfileRequestValidator.php │ ├── UploadReceiptRequestValidator.php │ └── UserLoginRequestValidator.php ├── ResponseFormatter.php ├── RouteEntityBindingStrategy.php ├── Services │ ├── CategoryService.php │ ├── EntityManagerService.php │ ├── HashService.php │ ├── PasswordResetService.php │ ├── ReceiptService.php │ ├── RequestService.php │ ├── TransactionImportService.php │ ├── TransactionService.php │ ├── UserLoginCodeService.php │ ├── UserProfileService.php │ └── UserProviderService.php ├── Session.php └── SignedUrl.php ├── bootstrap.php ├── composer.json ├── composer.lock ├── configs ├── app.php ├── commands │ ├── commands.php │ └── migration_commands.php ├── container │ ├── container.php │ └── container_bindings.php ├── middleware.php ├── migrations.php ├── path_constants.php └── routes │ └── web.php ├── docker ├── .gitignore ├── Dockerfile ├── docker-compose.yml ├── local.ini ├── nginx │ └── nginx.conf └── xdebug.ini ├── expennies ├── migrations ├── .keep ├── Version20221006233845.php ├── Version20230209221628.php ├── Version20230219220830.php ├── Version20230325174143.php ├── Version20230508182855.php ├── Version20230609194432.php ├── Version20230614063134.php └── Version20230616180023.php ├── package-lock.json ├── package.json ├── phpunit.xml ├── public └── index.php ├── resources ├── css │ ├── app.scss │ ├── auth.scss │ ├── dashboard.scss │ ├── transactions.scss │ └── variables.scss ├── images │ └── logo.png ├── js │ ├── ajax.js │ ├── app.js │ ├── auth.js │ ├── categories.js │ ├── dashboard.js │ ├── forgot_password.js │ ├── profile.js │ ├── transactions.js │ └── verify.js └── views │ ├── auth │ ├── forgot_password.twig │ ├── layout.twig │ ├── login.twig │ ├── register.twig │ ├── reset_password.twig │ ├── two_factor_modal.twig │ └── verify.twig │ ├── categories │ ├── edit_category_modal.twig │ └── index.twig │ ├── dashboard.twig │ ├── emails │ ├── password_reset.html.twig │ ├── signup.html.twig │ └── two_factor.html.twig │ ├── layout.twig │ ├── profile │ ├── index.twig │ └── update_password_modal.twig │ └── transactions │ ├── import_transactions_modal.twig │ ├── index.twig │ ├── transaction_modal.twig │ └── upload_receipt_modal.twig ├── storage ├── cache │ └── .gitignore ├── logs │ └── .gitignore ├── mail │ └── .gitignore └── receipts │ └── .gitignore ├── tests └── Unit │ └── ConfigTest.php └── webpack.config.js /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME= 2 | APP_VERSION= 3 | APP_DEBUG= 4 | APP_ENV= 5 | DB_HOST= 6 | DB_USER= 7 | DB_PASS= 8 | DB_NAME= 9 | MAILER_DSN= 10 | MAILER_FROM= 11 | APP_URL= 12 | REDIS_HOST= 13 | REDIS_PORT= 14 | REDIS_PASSWORD= 15 | S3_KEY= 16 | S3_SECRET= 17 | S3_REGION= 18 | S3_VERSION=latest 19 | S3_ENDPOINT=https://your-region.digitaloceanspaces.com 20 | S3_BUCKET= 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | vendor 3 | storage/clockwork 4 | node_modules 5 | public/build 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Learn PHP The Right Way Course Project 2 | This repository contains the source code of the project from the fourth/project section of the [Learn PHP The Right Way](https://youtube.com/playlist?list=PLr3d3QYzkw2xabQRUpcZ_IBk9W50M9pe-) series from YouTube. 3 | 4 | ### Branches 5 | The **main** branch will always contain the latest or most up-to-date code for the project. If you want to just work with the finished project/code then stick with the main branch. Each lesson will also have dedicated branches: **PX_Start** & **PX_End**. **X** in here indicates the lesson number & **P** indicates the section. You will find lesson numbers in the thumbnail of the videos. The **Start** & **End** simply indicate the starting code at the beginning of the video & the ending code by the end of the video. Note that some videos may not contain the **PX_End** if the code was not changed. If you want to follow along the video & code along with me then pick the branch appropriate for the lesson that you are watching & make sure it's the one with **_Start**. If you just want to see the solution or the final code for that lesson then you can use the branch appropriate for the lesson with **_End** (if applicable). 6 | 7 | Here are some examples: 8 | 9 | * If you are watching lesson **P1** and want to code along, then use the branch **P1_Start** 10 | * If you are watching lesson **P3** and just want to see the end result of that lesson, then use the branch **P3_End** if it exists, if not then use the **P3_Start** 11 | * If you just want to see the up-to-date source code of the project as we build it or the final project code once we've finished building it then stick to the **main** branch 12 | 13 | ### Tips 14 | * Make sure to run `composer install` & `npm install` after you pull the latest changes or switch to a new branch so that you are always using the same versions of dependencies that I do during the lessons 15 | * Run `npm run dev` if you want to build assets for development 16 | * Run `npm run build` if you want to build assets for productions 17 | * Run `npm run watch` if you want to build assets during development & have it automatically be watched so that it rebuilds after you make updates to front-end 18 | * Run `docker-compose up -d --build` to rebuild docker containers if you are using docker to make sure you are using the same versions as the videos 19 | -------------------------------------------------------------------------------- /app/Auth.php: -------------------------------------------------------------------------------- 1 | user !== null) { 33 | return $this->user; 34 | } 35 | 36 | $userId = $this->session->get('user'); 37 | 38 | if (! $userId) { 39 | return null; 40 | } 41 | 42 | $user = $this->userProvider->getById($userId); 43 | 44 | if (! $user) { 45 | return null; 46 | } 47 | 48 | $this->user = $user; 49 | 50 | return $this->user; 51 | } 52 | 53 | public function attemptLogin(array $credentials): AuthAttemptStatus 54 | { 55 | $user = $this->userProvider->getByCredentials($credentials); 56 | 57 | if (! $user || ! $this->checkCredentials($user, $credentials)) { 58 | return AuthAttemptStatus::FAILED; 59 | } 60 | 61 | if ($user->hasTwoFactorAuthEnabled()) { 62 | $this->startLoginWith2FA($user); 63 | 64 | return AuthAttemptStatus::TWO_FACTOR_AUTH; 65 | } 66 | 67 | $this->logIn($user); 68 | 69 | return AuthAttemptStatus::SUCCESS; 70 | } 71 | 72 | public function checkCredentials(UserInterface $user, array $credentials): bool 73 | { 74 | return password_verify($credentials['password'], $user->getPassword()); 75 | } 76 | 77 | public function logOut(): void 78 | { 79 | $this->session->forget('user'); 80 | $this->session->regenerate(); 81 | 82 | $this->user = null; 83 | } 84 | 85 | public function register(RegisterUserData $data): UserInterface 86 | { 87 | $user = $this->userProvider->createUser($data); 88 | 89 | $this->logIn($user); 90 | 91 | $this->signupEmail->send($user); 92 | 93 | return $user; 94 | } 95 | 96 | public function logIn(UserInterface $user): void 97 | { 98 | $this->session->regenerate(); 99 | $this->session->put('user', $user->getId()); 100 | 101 | $this->user = $user; 102 | } 103 | 104 | public function startLoginWith2FA(UserInterface $user): void 105 | { 106 | $this->session->regenerate(); 107 | $this->session->put('2fa', $user->getId()); 108 | 109 | $this->userLoginCodeService->deactivateAllActiveCodes($user); 110 | 111 | $this->twoFactorAuthEmail->send($this->userLoginCodeService->generate($user)); 112 | } 113 | 114 | public function attemptTwoFactorLogin(array $data): bool 115 | { 116 | $userId = $this->session->get('2fa'); 117 | 118 | if (! $userId) { 119 | return false; 120 | } 121 | 122 | $user = $this->userProvider->getById($userId); 123 | 124 | if (! $user || $user->getEmail() !== $data['email']) { 125 | return false; 126 | } 127 | 128 | if (! $this->userLoginCodeService->verify($user, $data['code'])) { 129 | return false; 130 | } 131 | 132 | $this->session->forget('2fa'); 133 | 134 | $this->logIn($user); 135 | 136 | $this->userLoginCodeService->deactivateAllActiveCodes($user); 137 | 138 | return true; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /app/Command/GenerateAppKeyCommand.php: -------------------------------------------------------------------------------- 1 | config->get('app_key'); 26 | 27 | if ($hasKey) { 28 | $helper = $this->getHelper('question'); 29 | 30 | $question = new ConfirmationQuestion( 31 | 'Generating a new APP_KEY will invalidate any signatures associated with the old key. Are you sure you want to proceed? (y/n)', 32 | false 33 | ); 34 | 35 | if (! $helper->ask($input, $output, $question)) { 36 | return Command::SUCCESS; 37 | } 38 | } 39 | 40 | $key = base64_encode(random_bytes(32)); 41 | 42 | $envFilePath = __DIR__ . '/../../.env'; 43 | 44 | if (! file_exists($envFilePath)) { 45 | throw new \RuntimeException('.env file not found'); 46 | } 47 | 48 | $envFileContent = file_get_contents($envFilePath); 49 | 50 | $pattern = '/^APP_KEY=.*/m'; 51 | 52 | if (preg_match($pattern, $envFileContent)) { 53 | $envFileContent = preg_replace($pattern, 'APP_KEY=' . $key, $envFileContent); 54 | } else { 55 | $envFileContent .= PHP_EOL . 'APP_KEY=' . $key; 56 | } 57 | 58 | file_put_contents($envFilePath, $envFileContent); 59 | 60 | $output->writeln('New APP_KEY has been generated & saved'); 61 | 62 | return Command::SUCCESS; 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /app/Config.php: -------------------------------------------------------------------------------- 1 | config[array_shift($path)] ?? null; 17 | 18 | if ($value === null) { 19 | return $default; 20 | } 21 | 22 | foreach ($path as $key) { 23 | if (! isset($value[$key])) { 24 | return $default; 25 | } 26 | 27 | $value = $value[$key]; 28 | } 29 | 30 | return $value; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Contracts/AuthInterface.php: -------------------------------------------------------------------------------- 1 | twig->render($response, 'auth/login.twig'); 33 | } 34 | 35 | public function registerView(Response $response): Response 36 | { 37 | return $this->twig->render($response, 'auth/register.twig'); 38 | } 39 | 40 | public function register(Request $request, Response $response): Response 41 | { 42 | $data = $this->requestValidatorFactory->make(RegisterUserRequestValidator::class)->validate( 43 | $request->getParsedBody() 44 | ); 45 | 46 | $this->auth->register( 47 | new RegisterUserData($data['name'], $data['email'], $data['password']) 48 | ); 49 | 50 | return $response->withHeader('Location', '/')->withStatus(302); 51 | } 52 | 53 | public function logIn(Request $request, Response $response): Response 54 | { 55 | $data = $this->requestValidatorFactory->make(UserLoginRequestValidator::class)->validate( 56 | $request->getParsedBody() 57 | ); 58 | 59 | $status = $this->auth->attemptLogin($data); 60 | 61 | if ($status === AuthAttemptStatus::FAILED) { 62 | throw new ValidationException(['password' => ['You have entered an invalid username or password']]); 63 | } 64 | 65 | if ($status === AuthAttemptStatus::TWO_FACTOR_AUTH) { 66 | return $this->responseFormatter->asJson($response, ['two_factor' => true]); 67 | } 68 | 69 | return $this->responseFormatter->asJson($response, []); 70 | } 71 | 72 | public function logOut(Response $response): Response 73 | { 74 | $this->auth->logOut(); 75 | 76 | return $response->withHeader('Location', '/')->withStatus(302); 77 | } 78 | 79 | public function twoFactorLogin(Request $request, Response $response): Response 80 | { 81 | $data = $this->requestValidatorFactory->make(TwoFactorLoginRequestValidator::class)->validate( 82 | $request->getParsedBody() 83 | ); 84 | 85 | if (! $this->auth->attemptTwoFactorLogin($data)) { 86 | throw new ValidationException(['code' => ['Invalid Code']]); 87 | } 88 | 89 | return $response; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/Controllers/CategoryController.php: -------------------------------------------------------------------------------- 1 | twig->render($response, 'categories/index.twig'); 34 | } 35 | 36 | public function store(Request $request, Response $response): Response 37 | { 38 | $data = $this->requestValidatorFactory->make(CreateCategoryRequestValidator::class)->validate( 39 | $request->getParsedBody() 40 | ); 41 | 42 | $category = $this->categoryService->create($data['name'], $request->getAttribute('user')); 43 | 44 | $this->entityManagerService->sync($category); 45 | 46 | return $response->withHeader('Location', '/categories')->withStatus(302); 47 | } 48 | 49 | public function delete(Response $response, Category $category): Response 50 | { 51 | $this->entityManagerService->delete($category, true); 52 | 53 | return $response; 54 | } 55 | 56 | public function get(Response $response, Category $category): Response 57 | { 58 | $data = ['id' => $category->getId(), 'name' => $category->getName()]; 59 | 60 | return $this->responseFormatter->asJson($response, $data); 61 | } 62 | 63 | public function update(Request $request, Response $response, Category $category): Response 64 | { 65 | $data = $this->requestValidatorFactory->make(UpdateCategoryRequestValidator::class)->validate( 66 | $request->getParsedBody() 67 | ); 68 | 69 | $this->entityManagerService->sync($this->categoryService->update($category, $data['name'])); 70 | 71 | return $response; 72 | } 73 | 74 | public function load(Request $request, Response $response): Response 75 | { 76 | $params = $this->requestService->getDataTableQueryParameters($request); 77 | $categories = $this->categoryService->getPaginatedCategories($params); 78 | $transformer = function (Category $category) { 79 | return [ 80 | 'id' => $category->getId(), 81 | 'name' => $category->getName(), 82 | 'createdAt' => $category->getCreatedAt()->format('m/d/Y g:i A'), 83 | 'updatedAt' => $category->getUpdatedAt()->format('m/d/Y g:i A'), 84 | ]; 85 | }; 86 | 87 | $totalCategories = count($categories); 88 | 89 | return $this->responseFormatter->asDataTable( 90 | $response, 91 | array_map($transformer, (array) $categories->getIterator()), 92 | $params->draw, 93 | $totalCategories 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/Controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | transactionService->getTotals($startDate, $endDate); 28 | $recentTransactions = $this->transactionService->getRecentTransactions(10); 29 | $topSpendingCategories = $this->categoryService->getTopSpendingCategories(4); 30 | 31 | return $this->twig->render( 32 | $response, 33 | 'dashboard.twig', 34 | [ 35 | 'totals' => $totals, 36 | 'transactions' => $recentTransactions, 37 | 'topSpendingCategories' => $topSpendingCategories, 38 | ] 39 | ); 40 | } 41 | 42 | public function getYearToDateStatistics(Response $response): Response 43 | { 44 | $data = $this->transactionService->getMonthlySummary((int) date('Y')); 45 | 46 | return $this->responseFormatter->asJson($response, $data); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Controllers/PasswordResetController.php: -------------------------------------------------------------------------------- 1 | twig->render($response, 'auth/forgot_password.twig'); 32 | } 33 | 34 | public function handleForgotPasswordRequest(Request $request, Response $response): Response 35 | { 36 | $data = $this->requestValidatorFactory->make(ForgotPasswordRequestValidator::class)->validate( 37 | $request->getParsedBody() 38 | ); 39 | 40 | $user = $this->userProviderService->getByCredentials($data); 41 | 42 | if ($user) { 43 | $this->passwordResetService->deactivateAllPasswordResets($data['email']); 44 | 45 | $passwordReset = $this->passwordResetService->generate($data['email']); 46 | 47 | $this->forgotPasswordEmail->send($passwordReset); 48 | } 49 | 50 | return $response; 51 | } 52 | 53 | public function showResetPasswordForm(Response $response, array $args): Response 54 | { 55 | $passwordReset = $this->passwordResetService->findByToken($args['token']); 56 | 57 | if (! $passwordReset) { 58 | return $response->withHeader('Location', '/')->withStatus(302); 59 | } 60 | 61 | return $this->twig->render($response, 'auth/reset_password.twig', ['token' => $args['token']]); 62 | } 63 | 64 | public function resetPassword(Request $request, Response $response, array $args): Response 65 | { 66 | $data = $this->requestValidatorFactory->make(ResetPasswordRequestValidator::class)->validate( 67 | $request->getParsedBody() 68 | ); 69 | 70 | $passwordReset = $this->passwordResetService->findByToken($args['token']); 71 | 72 | if (! $passwordReset) { 73 | throw new ValidationException(['confirmPassword' => ['Invalid token']]); 74 | } 75 | 76 | $user = $this->userProviderService->getByCredentials(['email' => $passwordReset->getEmail()]); 77 | 78 | if (! $user) { 79 | throw new ValidationException(['confirmPassword' => ['Invalid token']]); 80 | } 81 | 82 | $this->passwordResetService->updatePassword($user, $data['password']); 83 | 84 | return $response; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/Controllers/ProfileController.php: -------------------------------------------------------------------------------- 1 | twig->render( 30 | $response, 31 | 'profile/index.twig', 32 | ['profileData' => $this->userProfileService->get($request->getAttribute('user')->getId())] 33 | ); 34 | } 35 | 36 | public function update(Request $request, Response $response): Response 37 | { 38 | $user = $request->getAttribute('user'); 39 | $data = $this->requestValidatorFactory->make(UpdateProfileRequestValidator::class)->validate( 40 | $request->getParsedBody() 41 | ); 42 | 43 | $this->userProfileService->update( 44 | $user, 45 | new UserProfileData($user->getEmail(), $data['name'], (bool) ($data['twoFactor'] ?? false)) 46 | ); 47 | 48 | return $response; 49 | } 50 | 51 | public function updatePassword(Request $request, Response $response): Response 52 | { 53 | $user = $request->getAttribute('user'); 54 | $data = $this->requestValidatorFactory->make(UpdatePasswordRequestValidator::class)->validate( 55 | $request->getParsedBody() + ['user' => $user] 56 | ); 57 | 58 | $this->passwordResetService->updatePassword($user, $data['newPassword']); 59 | 60 | return $response; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/Controllers/ReceiptController.php: -------------------------------------------------------------------------------- 1 | requestValidatorFactory->make(UploadReceiptRequestValidator::class)->validate( 33 | $request->getUploadedFiles() 34 | )['receipt']; 35 | $filename = $file->getClientFilename(); 36 | 37 | $randomFilename = bin2hex(random_bytes(25)); 38 | 39 | $this->filesystem->write('receipts/' . $randomFilename, $file->getStream()->getContents()); 40 | 41 | $receipt = $this->receiptService->create($transaction, $filename, $randomFilename, $file->getClientMediaType()); 42 | 43 | $this->entityManagerService->sync($receipt); 44 | 45 | return $response; 46 | } 47 | 48 | public function download(Response $response, Transaction $transaction, Receipt $receipt): Response 49 | { 50 | if ($receipt->getTransaction()->getId() !== $transaction->getId()) { 51 | return $response->withStatus(401); 52 | } 53 | 54 | $file = $this->filesystem->readStream('receipts/' . $receipt->getStorageFilename()); 55 | 56 | $response = $response->withHeader('Content-Disposition', 'inline; filename="' . $receipt->getFilename() . '"') 57 | ->withHeader('Content-Type', $receipt->getMediaType()); 58 | 59 | return $response->withBody(new Stream($file)); 60 | } 61 | 62 | public function delete(Response $response, Transaction $transaction, Receipt $receipt): Response 63 | { 64 | if ($receipt->getTransaction()->getId() !== $transaction->getId()) { 65 | return $response->withStatus(401); 66 | } 67 | 68 | $this->filesystem->delete('receipts/' . $receipt->getStorageFilename()); 69 | 70 | $this->entityManagerService->delete($receipt, true); 71 | 72 | return $response; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/Controllers/TransactionImporterController.php: -------------------------------------------------------------------------------- 1 | requestValidatorFactory->make(TransactionImportRequestValidator::class)->validate( 26 | $request->getUploadedFiles() 27 | )['importFile']; 28 | 29 | $user = $request->getAttribute('user'); 30 | 31 | $this->transactionImportService->importFromFile($file->getStream()->getMetadata('uri'), $user); 32 | 33 | return $response; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Controllers/VerifyController.php: -------------------------------------------------------------------------------- 1 | getAttribute('user')->getVerifiedAt()) { 26 | return $response->withHeader('Location', '/')->withStatus(302); 27 | } 28 | 29 | return $this->twig->render($response, 'auth/verify.twig'); 30 | } 31 | 32 | public function verify(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface 33 | { 34 | /** @var User $user */ 35 | $user = $request->getAttribute('user'); 36 | 37 | if (! hash_equals((string) $user->getId(), $args['id']) 38 | || ! hash_equals(sha1($user->getEmail()), $args['hash'])) { 39 | throw new \RuntimeException('Verification failed'); 40 | } 41 | 42 | if (! $user->getVerifiedAt()) { 43 | $this->userProviderService->verifyUser($user); 44 | } 45 | 46 | return $response->withHeader('Location', '/')->withStatus(302); 47 | } 48 | 49 | public function resend(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 50 | { 51 | $this->signupEmail->send($request->getAttribute('user')); 52 | 53 | return $response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/Csrf.php: -------------------------------------------------------------------------------- 1 | $this->responseFactory->createResponse()->withStatus(403); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/DataObjects/DataTableQueryParams.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 27 | private int $id; 28 | 29 | #[Column] 30 | private string $name; 31 | 32 | #[ManyToOne(inversedBy: 'categories')] 33 | private User $user; 34 | 35 | #[OneToMany(mappedBy: 'category', targetEntity: Transaction::class)] 36 | private Collection $transactions; 37 | 38 | public function __construct() 39 | { 40 | $this->transactions = new ArrayCollection(); 41 | } 42 | 43 | public function getId(): int 44 | { 45 | return $this->id; 46 | } 47 | 48 | public function getName(): string 49 | { 50 | return $this->name; 51 | } 52 | 53 | public function setName(string $name): Category 54 | { 55 | $this->name = $name; 56 | 57 | return $this; 58 | } 59 | 60 | public function getUser(): User 61 | { 62 | return $this->user; 63 | } 64 | 65 | public function setUser(User $user): Category 66 | { 67 | $user->addCategory($this); 68 | 69 | $this->user = $user; 70 | 71 | return $this; 72 | } 73 | 74 | public function getTransactions(): ArrayCollection|Collection 75 | { 76 | return $this->transactions; 77 | } 78 | 79 | public function addTransaction(Transaction $transaction): Category 80 | { 81 | $this->transactions->add($transaction); 82 | 83 | return $this; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Entity/PasswordReset.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 22 | private int $id; 23 | 24 | #[Column] 25 | private string $email; 26 | 27 | #[Column(unique: true)] 28 | private string $token; 29 | 30 | #[Column(name: 'is_active', options: ['default' => true])] 31 | private bool $isActive; 32 | 33 | #[Column] 34 | private \DateTime $expiration; 35 | 36 | public function __construct() 37 | { 38 | $this->isActive = true; 39 | } 40 | 41 | public function getId(): int 42 | { 43 | return $this->id; 44 | } 45 | 46 | public function getEmail(): string 47 | { 48 | return $this->email; 49 | } 50 | 51 | public function setEmail(string $email): PasswordReset 52 | { 53 | $this->email = $email; 54 | 55 | return $this; 56 | } 57 | 58 | public function isActive(): bool 59 | { 60 | return $this->isActive; 61 | } 62 | 63 | public function setIsActive(bool $isActive): PasswordReset 64 | { 65 | $this->isActive = $isActive; 66 | 67 | return $this; 68 | } 69 | 70 | public function getExpiration(): \DateTime 71 | { 72 | return $this->expiration; 73 | } 74 | 75 | public function setExpiration(\DateTime $expiration): PasswordReset 76 | { 77 | $this->expiration = $expiration; 78 | 79 | return $this; 80 | } 81 | 82 | public function getToken(): string 83 | { 84 | return $this->token; 85 | } 86 | 87 | public function setToken(string $token): PasswordReset 88 | { 89 | $this->token = $token; 90 | 91 | return $this; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/Entity/Receipt.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 18 | private int $id; 19 | 20 | #[Column] 21 | private string $filename; 22 | 23 | #[Column(name: 'storage_filename')] 24 | private string $storageFilename; 25 | 26 | #[Column(name: 'media_type')] 27 | private string $mediaType; 28 | 29 | #[Column(name: 'created_at')] 30 | private \DateTime $createdAt; 31 | 32 | #[ManyToOne(inversedBy: 'receipts')] 33 | private Transaction $transaction; 34 | 35 | public function getId(): int 36 | { 37 | return $this->id; 38 | } 39 | 40 | public function getFilename(): string 41 | { 42 | return $this->filename; 43 | } 44 | 45 | public function setFilename(string $filename): Receipt 46 | { 47 | $this->filename = $filename; 48 | 49 | return $this; 50 | } 51 | 52 | public function getCreatedAt(): \DateTime 53 | { 54 | return $this->createdAt; 55 | } 56 | 57 | public function setCreatedAt(\DateTime $createdAt): Receipt 58 | { 59 | $this->createdAt = $createdAt; 60 | 61 | return $this; 62 | } 63 | 64 | public function getTransaction(): Transaction 65 | { 66 | return $this->transaction; 67 | } 68 | 69 | public function setTransaction(Transaction $transaction): Receipt 70 | { 71 | $transaction->addReceipt($this); 72 | 73 | $this->transaction = $transaction; 74 | 75 | return $this; 76 | } 77 | 78 | public function getStorageFilename(): string 79 | { 80 | return $this->storageFilename; 81 | } 82 | 83 | public function setStorageFilename(string $storageFilename): Receipt 84 | { 85 | $this->storageFilename = $storageFilename; 86 | 87 | return $this; 88 | } 89 | 90 | public function getMediaType(): string 91 | { 92 | return $this->mediaType; 93 | } 94 | 95 | public function setMediaType(string $mediaType): Receipt 96 | { 97 | $this->mediaType = $mediaType; 98 | 99 | return $this; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/Entity/Traits/HasTimestamps.php: -------------------------------------------------------------------------------- 1 | createdAt)) { 25 | $this->createdAt = new \DateTime(); 26 | } 27 | 28 | $this->updatedAt = new \DateTime(); 29 | } 30 | 31 | public function getCreatedAt(): \DateTime 32 | { 33 | return $this->createdAt; 34 | } 35 | 36 | public function setCreatedAt(\DateTime $createdAt): Category 37 | { 38 | $this->createdAt = $createdAt; 39 | 40 | return $this; 41 | } 42 | 43 | public function getUpdatedAt(): \DateTime 44 | { 45 | return $this->updatedAt; 46 | } 47 | 48 | public function setUpdatedAt(\DateTime $updatedAt): Category 49 | { 50 | $this->updatedAt = $updatedAt; 51 | 52 | return $this; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/Entity/Transaction.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 28 | private int $id; 29 | 30 | #[Column(name: 'was_reviewed', options: ['default' => 0])] 31 | private bool $wasReviewed; 32 | 33 | #[Column] 34 | private string $description; 35 | 36 | #[Column] 37 | private \DateTime $date; 38 | 39 | #[Column(type: Types::DECIMAL, precision: 13, scale: 3)] 40 | private float $amount; 41 | 42 | #[ManyToOne(inversedBy: 'transactions')] 43 | private User $user; 44 | 45 | #[ManyToOne(inversedBy: 'transactions')] 46 | private ?Category $category; 47 | 48 | #[OneToMany(mappedBy: 'transaction', targetEntity: Receipt::class, cascade: ['remove'])] 49 | private Collection $receipts; 50 | 51 | public function __construct() 52 | { 53 | $this->receipts = new ArrayCollection(); 54 | $this->wasReviewed = false; 55 | } 56 | 57 | public function getId(): int 58 | { 59 | return $this->id; 60 | } 61 | 62 | public function getDescription(): string 63 | { 64 | return $this->description; 65 | } 66 | 67 | public function setDescription(string $description): Transaction 68 | { 69 | $this->description = $description; 70 | 71 | return $this; 72 | } 73 | 74 | public function getDate(): \DateTime 75 | { 76 | return $this->date; 77 | } 78 | 79 | public function setDate(\DateTime $date): Transaction 80 | { 81 | $this->date = $date; 82 | 83 | return $this; 84 | } 85 | 86 | public function getAmount(): float 87 | { 88 | return $this->amount; 89 | } 90 | 91 | public function setAmount(float $amount): Transaction 92 | { 93 | $this->amount = $amount; 94 | 95 | return $this; 96 | } 97 | 98 | public function getUser(): User 99 | { 100 | return $this->user; 101 | } 102 | 103 | public function setUser(User $user): Transaction 104 | { 105 | $this->user = $user; 106 | 107 | return $this; 108 | } 109 | 110 | public function getCategory(): ?Category 111 | { 112 | return $this->category; 113 | } 114 | 115 | public function setCategory(?Category $category): Transaction 116 | { 117 | $this->category = $category; 118 | 119 | return $this; 120 | } 121 | 122 | public function getReceipts(): ArrayCollection|Collection 123 | { 124 | return $this->receipts; 125 | } 126 | 127 | public function addReceipt(Receipt $receipt): Transaction 128 | { 129 | $this->receipts->add($receipt); 130 | 131 | return $this; 132 | } 133 | 134 | public function wasReviewed(): bool 135 | { 136 | return $this->wasReviewed; 137 | } 138 | 139 | public function setReviewed(bool $wasReviewed): Transaction 140 | { 141 | $this->wasReviewed = $wasReviewed; 142 | 143 | return $this; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /app/Entity/User.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 27 | private int $id; 28 | 29 | #[Column] 30 | private string $name; 31 | 32 | #[Column] 33 | private string $email; 34 | 35 | #[Column] 36 | private string $password; 37 | 38 | #[Column(name: 'two_factor', options: ['default' => false])] 39 | private bool $twoFactor; 40 | 41 | #[Column(name: 'verified_at', nullable: true)] 42 | private ?\DateTime $verifiedAt; 43 | 44 | #[OneToMany(mappedBy: 'user', targetEntity: Category::class)] 45 | private Collection $categories; 46 | 47 | #[OneToMany(mappedBy: 'user', targetEntity: Transaction::class)] 48 | private Collection $transactions; 49 | 50 | public function __construct() 51 | { 52 | $this->categories = new ArrayCollection(); 53 | $this->transactions = new ArrayCollection(); 54 | $this->twoFactor = false; 55 | } 56 | 57 | public function getId(): int 58 | { 59 | return $this->id; 60 | } 61 | 62 | public function getName(): string 63 | { 64 | return $this->name; 65 | } 66 | 67 | public function setName(string $name): User 68 | { 69 | $this->name = $name; 70 | 71 | return $this; 72 | } 73 | 74 | public function getEmail(): string 75 | { 76 | return $this->email; 77 | } 78 | 79 | public function setEmail(string $email): User 80 | { 81 | $this->email = $email; 82 | 83 | return $this; 84 | } 85 | 86 | public function getPassword(): string 87 | { 88 | return $this->password; 89 | } 90 | 91 | public function setPassword(string $password): User 92 | { 93 | $this->password = $password; 94 | 95 | return $this; 96 | } 97 | 98 | public function getCategories(): ArrayCollection|Collection 99 | { 100 | return $this->categories; 101 | } 102 | 103 | public function addCategory(Category $category): User 104 | { 105 | $this->categories->add($category); 106 | 107 | return $this; 108 | } 109 | 110 | public function getTransactions(): ArrayCollection|Collection 111 | { 112 | return $this->transactions; 113 | } 114 | 115 | public function addTransaction(Transaction $transaction): User 116 | { 117 | $this->transactions->add($transaction); 118 | 119 | return $this; 120 | } 121 | 122 | public function canManage(OwnableInterface $entity): bool 123 | { 124 | return $this->getId() === $entity->getUser()->getId(); 125 | } 126 | 127 | public function getVerifiedAt(): ?\DateTime 128 | { 129 | return $this->verifiedAt; 130 | } 131 | 132 | public function setVerifiedAt(\DateTime $verifiedAt): static 133 | { 134 | $this->verifiedAt = $verifiedAt; 135 | 136 | return $this; 137 | } 138 | 139 | public function hasTwoFactorAuthEnabled(): bool 140 | { 141 | return $this->twoFactor; 142 | } 143 | 144 | public function setTwoFactor(bool $twoFactor): User 145 | { 146 | $this->twoFactor = $twoFactor; 147 | 148 | return $this; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /app/Entity/UserLoginCode.php: -------------------------------------------------------------------------------- 1 | true]), GeneratedValue] 18 | private int $id; 19 | 20 | #[Column(length: 6)] 21 | private string $code; 22 | 23 | #[Column(name: 'is_active', options: ['default' => true])] 24 | private bool $isActive; 25 | 26 | #[Column] 27 | private \DateTime $expiration; 28 | 29 | #[ManyToOne] 30 | private User $user; 31 | 32 | public function __construct() 33 | { 34 | $this->isActive = true; 35 | } 36 | 37 | public function getId(): int 38 | { 39 | return $this->id; 40 | } 41 | 42 | public function getCode(): string 43 | { 44 | return $this->code; 45 | } 46 | 47 | public function setCode(string $code): UserLoginCode 48 | { 49 | $this->code = $code; 50 | 51 | return $this; 52 | } 53 | 54 | public function isActive(): bool 55 | { 56 | return $this->isActive; 57 | } 58 | 59 | public function setIsActive(bool $isActive): UserLoginCode 60 | { 61 | $this->isActive = $isActive; 62 | 63 | return $this; 64 | } 65 | 66 | public function getExpiration(): \DateTime 67 | { 68 | return $this->expiration; 69 | } 70 | 71 | public function setExpiration(\DateTime $expiration): UserLoginCode 72 | { 73 | $this->expiration = $expiration; 74 | 75 | return $this; 76 | } 77 | 78 | public function getUser(): User 79 | { 80 | return $this->user; 81 | } 82 | 83 | public function setUser(User $user): UserLoginCode 84 | { 85 | $this->user = $user; 86 | 87 | return $this; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/Enum/AppEnvironment.php: -------------------------------------------------------------------------------- 1 | getReflectionClass()->implementsInterface(OwnableInterface::class)) { 16 | return ''; 17 | } 18 | 19 | return $targetTableAlias . '.user_id = ' . $this->getParameter('user_id'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/Mail/ForgotPasswordEmail.php: -------------------------------------------------------------------------------- 1 | getEmail(); 27 | $resetLink = $this->signedUrl->fromRoute( 28 | 'password-reset', 29 | ['token' => $passwordReset->getToken()], 30 | $passwordReset->getExpiration() 31 | ); 32 | $message = (new TemplatedEmail()) 33 | ->from($this->config->get('mailer.from')) 34 | ->to($email) 35 | ->subject('Your Expennies Password Reset Instructions') 36 | ->htmlTemplate('emails/password_reset.html.twig') 37 | ->context( 38 | [ 39 | 'resetLink' => $resetLink, 40 | ] 41 | ); 42 | 43 | $this->renderer->render($message); 44 | 45 | $this->mailer->send($message); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Mail/SignupEmail.php: -------------------------------------------------------------------------------- 1 | getEmail(); 27 | $expirationDate = new \DateTime('+30 minutes'); 28 | $activationLink = $this->signedUrl->fromRoute( 29 | 'verify', 30 | ['id' => $user->getId(), 'hash' => sha1($email)], 31 | $expirationDate 32 | ); 33 | 34 | $message = (new TemplatedEmail()) 35 | ->from($this->config->get('mailer.from')) 36 | ->to($email) 37 | ->subject('Welcome to Expennies') 38 | ->htmlTemplate('emails/signup.html.twig') 39 | ->context( 40 | [ 41 | 'activationLink' => $activationLink, 42 | 'expirationDate' => $expirationDate, 43 | ] 44 | ); 45 | 46 | $this->renderer->render($message); 47 | 48 | $this->mailer->send($message); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Mail/TwoFactorAuthEmail.php: -------------------------------------------------------------------------------- 1 | getUser()->getEmail(); 25 | $message = (new TemplatedEmail()) 26 | ->from($this->config->get('mailer.from')) 27 | ->to($email) 28 | ->subject('Your Expennies Verification Code') 29 | ->htmlTemplate('emails/two_factor.html.twig') 30 | ->context( 31 | [ 32 | 'code' => $userLoginCode->getCode(), 33 | ] 34 | ); 35 | 36 | $this->renderer->render($message); 37 | 38 | $this->mailer->send($message); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Mailer.php: -------------------------------------------------------------------------------- 1 | write(time() . '_' . uniqid(more_entropy: true) . '.eml', $message->toString()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Middleware/AuthMiddleware.php: -------------------------------------------------------------------------------- 1 | auth->user()) { 30 | $this->twig->getEnvironment()->addGlobal('auth', ['id' => $user->getId(), 'name' => $user->getName()]); 31 | $this->twig->getEnvironment()->addGlobal( 32 | 'current_route', 33 | RouteContext::fromRequest($request)->getRoute()->getName() 34 | ); 35 | 36 | $this->entityManagerService->enableUserAuthFilter($user->getId()); 37 | 38 | return $handler->handle($request->withAttribute('user', $user)); 39 | } 40 | 41 | return $this->responseFactory->createResponse(302)->withHeader('Location', '/login'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Middleware/CsrfFieldsMiddleware.php: -------------------------------------------------------------------------------- 1 | container->get('csrf'); 23 | 24 | $csrfNameKey = $csrf->getTokenNameKey(); 25 | $csrfValueKey = $csrf->getTokenValueKey(); 26 | $csrfName = $csrf->getTokenName(); 27 | $csrfValue = $csrf->getTokenValue(); 28 | $fields = << 30 | 31 | CSRF_Fields; 32 | 33 | $this->twig->getEnvironment()->addGlobal( 34 | 'csrf', 35 | [ 36 | 'keys' => [ 37 | 'name' => $csrfNameKey, 38 | 'value' => $csrfValueKey, 39 | ], 40 | 'name' => $csrfName, 41 | 'value' => $csrfValue, 42 | 'fields' => $fields, 43 | ] 44 | ); 45 | 46 | return $handler->handle($request); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Middleware/GuestMiddleware.php: -------------------------------------------------------------------------------- 1 | session->get('user')) { 25 | return $this->responseFactory->createResponse(302)->withHeader('Location', '/'); 26 | } 27 | 28 | return $handler->handle($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Middleware/OldFormDataMiddleware.php: -------------------------------------------------------------------------------- 1 | session->getFlash('old')) { 25 | $this->twig->getEnvironment()->addGlobal('old', $old); 26 | } 27 | 28 | return $handler->handle($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Middleware/RateLimitMiddleware.php: -------------------------------------------------------------------------------- 1 | requestService->getClientIp($request, $this->config->get('trusted_proxies')); 30 | $routeContext = RouteContext::fromRequest($request); 31 | $route = $routeContext->getRoute(); 32 | $limiter = $this->rateLimiterFactory->create($route->getName() . '_' . $clientIp); 33 | 34 | if ($limiter->consume()->isAccepted() === false) { 35 | return $this->responseFactory->createResponse(429, 'Too many requests'); 36 | } 37 | 38 | return $handler->handle($request); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Middleware/StartSessionsMiddleware.php: -------------------------------------------------------------------------------- 1 | session->start(); 25 | 26 | $response = $handler->handle($request); 27 | 28 | if ($request->getMethod() === 'GET' && ! $this->requestService->isXhr($request)) { 29 | $this->session->put('previousUrl', (string) $request->getUri()); 30 | } 31 | 32 | $this->session->save(); 33 | 34 | return $response; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/Middleware/ValidateSignatureMiddleware.php: -------------------------------------------------------------------------------- 1 | getUri(); 22 | $queryParams = $request->getQueryParams(); 23 | $originalSignature = $queryParams['signature'] ?? ''; 24 | $expiration = (int) ($queryParams['expiration'] ?? 0); 25 | 26 | unset($queryParams['signature']); 27 | 28 | $url = (string) $uri->withQuery(http_build_query($queryParams)); 29 | $signature = hash_hmac('sha256', $url, $this->config->get('app_key')); 30 | 31 | if ($expiration <= time() || ! hash_equals($signature, $originalSignature)) { 32 | throw new \RuntimeException('Failed to verify signature'); 33 | } 34 | 35 | return $handler->handle($request); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Middleware/ValidationErrorsMiddleware.php: -------------------------------------------------------------------------------- 1 | session->getFlash('errors')) { 25 | $this->twig->getEnvironment()->addGlobal('errors', $errors); 26 | } 27 | 28 | return $handler->handle($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Middleware/ValidationExceptionMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 31 | } catch (ValidationException $e) { 32 | $response = $this->responseFactory->createResponse(); 33 | 34 | if ($this->requestService->isXhr($request)) { 35 | return $this->responseFormatter->asJson($response->withStatus(422), $e->errors); 36 | } 37 | 38 | $referer = $this->requestService->getReferer($request); 39 | $oldData = $request->getParsedBody(); 40 | 41 | $sensitiveFields = ['password', 'confirmPassword']; 42 | 43 | $this->session->flash('errors', $e->errors); 44 | $this->session->flash('old', array_diff_key($oldData, array_flip($sensitiveFields))); 45 | 46 | return $response->withHeader('Location', $referer)->withStatus(302); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/Middleware/VerifyEmailMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('user'); 22 | 23 | if ($user?->getVerifiedAt()) { 24 | return $handler->handle($request); 25 | } 26 | 27 | return $this->responseFactory->createResponse(302)->withHeader('Location', '/verify'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/RequestValidators/CreateCategoryRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', 'name')->message('Required field'); 18 | $v->rule('lengthMax', 'name', 50); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/ForgotPasswordRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', 'email')->message('Required field'); 18 | $v->rule('email', 'email'); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/RegisterUserRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['name', 'email', 'password', 'confirmPassword'])->message('Required field'); 24 | $v->rule('email', 'email'); 25 | $v->rule('equals', 'confirmPassword', 'password')->label('Confirm Password'); 26 | $v->rule( 27 | fn($field, $value, $params, $fields) => ! $this->entityManager->getRepository(User::class)->count( 28 | ['email' => $value] 29 | ), 30 | 'email' 31 | )->message('User with the given email address already exists'); 32 | 33 | if (! $v->validate()) { 34 | throw new ValidationException($v->errors()); 35 | } 36 | 37 | return $data; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/RequestValidators/RequestValidatorFactory.php: -------------------------------------------------------------------------------- 1 | container->get($class); 20 | 21 | if ($validator instanceof RequestValidatorInterface) { 22 | return $validator; 23 | } 24 | 25 | throw new \RuntimeException('Failed to instantiate the request validator class "' . $class . '"'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/RequestValidators/ResetPasswordRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['password', 'confirmPassword'])->message('Required field'); 18 | $v->rule('equals', 'confirmPassword', 'password')->label('Confirm Password'); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/TransactionImportRequestValidator.php: -------------------------------------------------------------------------------- 1 | ['Please select a file to import']]); 21 | } 22 | 23 | if ($uploadedFile->getError() !== UPLOAD_ERR_OK) { 24 | throw new ValidationException(['importFile' => ['Failed to upload the file for import']]); 25 | } 26 | 27 | $maxFileSize = 20 * 1024 * 1024; 28 | 29 | if ($uploadedFile->getSize() > $maxFileSize) { 30 | throw new ValidationException(['importFile' => ['Maximum allowed size is 20 MB']]); 31 | } 32 | 33 | $allowedMimeTypes = ['text/csv']; 34 | 35 | if (! in_array($uploadedFile->getClientMediaType(), $allowedMimeTypes)) { 36 | throw new ValidationException(['importFile' => ['Please select a CSV file to import']]); 37 | } 38 | 39 | $detector = new FinfoMimeTypeDetector(); 40 | $mimeType = $detector->detectMimeTypeFromFile($uploadedFile->getStream()->getMetadata('uri')); 41 | 42 | if (! in_array($mimeType, $allowedMimeTypes)) { 43 | throw new ValidationException(['importFile' => ['Invalid file type']]); 44 | } 45 | 46 | return $data; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/RequestValidators/TransactionRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['description', 'amount', 'date', 'category'])->message('Required field'); 23 | $v->rule('lengthMax', 'description', 255); 24 | $v->rule('dateFormat', 'dateFormat', 'm/d/Y g:i A'); 25 | $v->rule('numeric', 'amount'); 26 | $v->rule('integer', 'category'); 27 | $v->rule( 28 | function($field, $value, $params, $fields) use (&$data) { 29 | $id = (int) $value; 30 | 31 | if (! $id) { 32 | return false; 33 | } 34 | 35 | $category = $this->categoryService->getById($id); 36 | 37 | if ($category) { 38 | $data['category'] = $category; 39 | 40 | return true; 41 | } 42 | 43 | return false; 44 | }, 45 | 'category' 46 | )->message('Category not found'); 47 | 48 | if (! $v->validate()) { 49 | throw new ValidationException($v->errors()); 50 | } 51 | 52 | return $data; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/RequestValidators/TwoFactorLoginRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['email', 'code'])->message('Required field'); 18 | $v->rule('email', 'email'); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/UpdateCategoryRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', 'name')->message('Required field'); 18 | $v->rule('lengthMax', 'name', 50); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/UpdatePasswordRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['currentPassword', 'newPassword'])->message('Required field'); 19 | $v->rule('lengthMin', 'newPassword', '8')->label('Password'); 20 | $v->rule( 21 | fn($field, $value, $params, $fields) => password_verify($data['currentPassword'], $user->getPassword()), 22 | 'currentPassword', 23 | )->message('Invalid current password'); 24 | 25 | if (! $v->validate()) { 26 | throw new ValidationException($v->errors()); 27 | } 28 | 29 | return $data; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/RequestValidators/UpdateProfileRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', 'name')->message('Required field'); 18 | $v->rule('integer', 'twoFactor')->message('Invalid Two-Factor indicator'); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/RequestValidators/UploadReceiptRequestValidator.php: -------------------------------------------------------------------------------- 1 | ['Please select a receipt file']]); 22 | } 23 | 24 | if ($uploadedFile->getError() !== UPLOAD_ERR_OK) { 25 | throw new ValidationException(['receipt' => ['Failed to upload the receipt file']]); 26 | } 27 | 28 | // 2. Validate the file size 29 | $maxFileSize = 5 * 1024 * 1024; 30 | 31 | if ($uploadedFile->getSize() > $maxFileSize) { 32 | throw new ValidationException(['receipt' => ['Maximum allowed size is 5 MB']]); 33 | } 34 | 35 | // 3. Validate the file name 36 | $filename = $uploadedFile->getClientFilename(); 37 | 38 | if (! preg_match('/^[a-zA-Z0-9\s._-]+$/', $filename)) { 39 | throw new ValidationException(['receipt' => ['Invalid filename']]); 40 | } 41 | 42 | // 4. Validate file type 43 | $allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf']; 44 | $tmpFilePath = $uploadedFile->getStream()->getMetadata('uri'); 45 | 46 | if (! in_array($uploadedFile->getClientMediaType(), $allowedMimeTypes)) { 47 | throw new ValidationException(['receipt' => ['Receipt has to be either an image or a pdf document']]); 48 | } 49 | 50 | $detector = new FinfoMimeTypeDetector(); 51 | $mimeType = $detector->detectMimeTypeFromFile($tmpFilePath); 52 | 53 | if (! in_array($mimeType, $allowedMimeTypes)) { 54 | throw new ValidationException(['receipt' => ['Invalid file type']]); 55 | } 56 | 57 | return $data; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/RequestValidators/UserLoginRequestValidator.php: -------------------------------------------------------------------------------- 1 | rule('required', ['email', 'password'])->message('Required field'); 18 | $v->rule('email', 'email'); 19 | 20 | if (! $v->validate()) { 21 | throw new ValidationException($v->errors()); 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/ResponseFormatter.php: -------------------------------------------------------------------------------- 1 | withHeader('Content-Type', 'application/json'); 17 | 18 | $response->getBody()->write(json_encode($data, $flags)); 19 | 20 | return $response; 21 | } 22 | 23 | public function asDataTable(ResponseInterface $response, array $data, int $draw, int $total): ResponseInterface 24 | { 25 | return $this->asJson( 26 | $response, 27 | [ 28 | 'data' => $data, 29 | 'draw' => $draw, 30 | 'recordsTotal' => $total, 31 | 'recordsFiltered' => $total, 32 | ] 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/RouteEntityBindingStrategy.php: -------------------------------------------------------------------------------- 1 | createReflectionForCallable($callable); 31 | $resolvedArguments = []; 32 | 33 | foreach ($callableReflection->getParameters() as $parameter) { 34 | $type = $parameter->getType(); 35 | 36 | if (! $type) { 37 | continue; 38 | } 39 | 40 | $paramName = $parameter->getName(); 41 | $typeName = $type->getName(); 42 | 43 | if ($type->isBuiltin()) { 44 | if ($typeName === 'array' && $paramName === 'args') { 45 | $resolvedArguments[] = $routeArguments; 46 | } 47 | } else { 48 | if ($typeName === ServerRequestInterface::class) { 49 | $resolvedArguments[] = $request; 50 | } elseif ($typeName === ResponseInterface::class) { 51 | $resolvedArguments[] = $response; 52 | } else { 53 | $entityId = $routeArguments[$paramName] ?? null; 54 | 55 | if (! $entityId || $parameter->allowsNull()) { 56 | throw new \InvalidArgumentException( 57 | 'Unable to resolve argument "' . $paramName . '" in the callable' 58 | ); 59 | } 60 | 61 | $entity = $this->entityManagerService->find($typeName, $entityId); 62 | 63 | if (! $entity) { 64 | return $this->responseFactory->createResponse(404, 'Resource Not Found'); 65 | } 66 | 67 | $resolvedArguments[] = $entity; 68 | } 69 | } 70 | } 71 | 72 | return $callable(...$resolvedArguments); 73 | } 74 | 75 | public function createReflectionForCallable(callable $callable): ReflectionFunctionAbstract 76 | { 77 | return is_array($callable) 78 | ? new ReflectionMethod($callable[0], $callable[1]) 79 | : new ReflectionFunction($callable); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/Services/CategoryService.php: -------------------------------------------------------------------------------- 1 | setUser($user); 24 | 25 | return $this->update($category, $name); 26 | } 27 | 28 | public function getPaginatedCategories(DataTableQueryParams $params): Paginator 29 | { 30 | $query = $this->entityManager 31 | ->getRepository(Category::class) 32 | ->createQueryBuilder('c') 33 | ->setFirstResult($params->start) 34 | ->setMaxResults($params->length); 35 | 36 | $orderBy = in_array($params->orderBy, ['name', 'createdAt', 'updatedAt']) ? $params->orderBy : 'updatedAt'; 37 | $orderDir = strtolower($params->orderDir) === 'asc' ? 'asc' : 'desc'; 38 | 39 | if (! empty($params->searchTerm)) { 40 | $query->where('c.name LIKE :name')->setParameter( 41 | 'name', 42 | '%' . addcslashes($params->searchTerm, '%_') . '%' 43 | ); 44 | } 45 | 46 | $query->orderBy('c.' . $orderBy, $orderDir); 47 | 48 | return new Paginator($query); 49 | } 50 | 51 | public function getById(int $id): ?Category 52 | { 53 | return $this->entityManager->find(Category::class, $id); 54 | } 55 | 56 | public function update(Category $category, string $name): Category 57 | { 58 | $category->setName($name); 59 | 60 | return $category; 61 | } 62 | 63 | public function getCategoryNames(): array 64 | { 65 | return $this->entityManager 66 | ->getRepository(Category::class)->createQueryBuilder('c') 67 | ->select('c.id', 'c.name') 68 | ->getQuery() 69 | ->getArrayResult(); 70 | } 71 | 72 | public function findByName(string $name): ?Category 73 | { 74 | return $this->entityManager->getRepository(Category::class)->findBy(['name' => $name])[0] ?? null; 75 | } 76 | 77 | public function getAllKeyedByName(): array 78 | { 79 | $categories = $this->entityManager->getRepository(Category::class)->findAll(); 80 | $categoryMap = []; 81 | 82 | foreach ($categories as $category) { 83 | $categoryMap[strtolower($category->getName())] = $category; 84 | } 85 | 86 | return $categoryMap; 87 | } 88 | 89 | public function getTopSpendingCategories(int $limit): array 90 | { 91 | $query = $this->entityManager->createQuery( 92 | 'SELECT c.name, SUM(ABS(t.amount)) as total 93 | FROM App\Entity\Transaction t 94 | JOIN t.category c 95 | WHERE t.amount < 0 96 | GROUP BY c.id 97 | ORDER BY total DESC' 98 | ); 99 | 100 | $query->setMaxResults($limit); 101 | 102 | return $query->getArrayResult(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/Services/EntityManagerService.php: -------------------------------------------------------------------------------- 1 | entityManager, $name)) { 22 | return call_user_func_array([$this->entityManager, $name], $arguments); 23 | } 24 | 25 | throw new \BadMethodCallException('Call to undefined method "' . $name . '"'); 26 | } 27 | 28 | public function sync($entity = null): void 29 | { 30 | if ($entity) { 31 | $this->entityManager->persist($entity); 32 | } 33 | 34 | $this->entityManager->flush(); 35 | } 36 | 37 | public function delete($entity, bool $sync = false): void 38 | { 39 | $this->entityManager->remove($entity); 40 | 41 | if ($sync) { 42 | $this->sync(); 43 | } 44 | } 45 | 46 | public function clear(?string $entityName = null): void 47 | { 48 | if ($entityName === null) { 49 | $this->entityManager->clear(); 50 | 51 | return; 52 | } 53 | 54 | $unitOfWork = $this->entityManager->getUnitOfWork(); 55 | $entities = $unitOfWork->getIdentityMap()[$entityName] ?? []; 56 | 57 | foreach ($entities as $entity) { 58 | $this->entityManager->detach($entity); 59 | } 60 | } 61 | 62 | public function enableUserAuthFilter(int $userId): void 63 | { 64 | $this->getFilters()->enable('user')->setParameter('user_id', $userId); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Services/HashService.php: -------------------------------------------------------------------------------- 1 | 12]); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/Services/PasswordResetService.php: -------------------------------------------------------------------------------- 1 | setToken(bin2hex(random_bytes(32))); 25 | $passwordReset->setExpiration(new \DateTime('+30 minutes')); 26 | $passwordReset->setEmail($email); 27 | 28 | $this->entityManagerService->sync($passwordReset); 29 | 30 | return $passwordReset; 31 | } 32 | 33 | public function deactivateAllPasswordResets(string $email): void 34 | { 35 | $this->entityManagerService 36 | ->getRepository(PasswordReset::class) 37 | ->createQueryBuilder('pr') 38 | ->update() 39 | ->set('pr.isActive', '0') 40 | ->where('pr.email = :email') 41 | ->andWhere('pr.isActive = 1') 42 | ->setParameter('email', $email) 43 | ->getQuery() 44 | ->execute(); 45 | } 46 | 47 | public function findByToken(string $token): ?PasswordReset 48 | { 49 | return $this->entityManagerService 50 | ->getRepository(PasswordReset::class) 51 | ->createQueryBuilder('pr') 52 | ->select('pr') 53 | ->where('pr.token = :token') 54 | ->andWhere('pr.isActive = :active') 55 | ->andWhere('pr.expiration > :now') 56 | ->setParameters( 57 | [ 58 | 'token' => $token, 59 | 'active' => true, 60 | 'now' => new \DateTime(), 61 | ] 62 | ) 63 | ->getQuery() 64 | ->getOneOrNullResult(); 65 | } 66 | 67 | public function updatePassword(User $user, string $password): void 68 | { 69 | $this->entityManagerService->wrapInTransaction(function () use ($user, $password) { 70 | $this->deactivateAllPasswordResets($user->getEmail()); 71 | 72 | $this->userProviderService->updatePassword($user, $password); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/Services/ReceiptService.php: -------------------------------------------------------------------------------- 1 | setTransaction($transaction); 21 | $receipt->setFilename($filename); 22 | $receipt->setStorageFilename($storageFilename); 23 | $receipt->setMediaType($mediaType); 24 | $receipt->setCreatedAt(new \DateTime()); 25 | 26 | return $receipt; 27 | } 28 | 29 | public function getById(int $id) 30 | { 31 | return $this->entityManager->find(Receipt::class, $id); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Services/RequestService.php: -------------------------------------------------------------------------------- 1 | getHeader('referer')[0] ?? ''; 20 | 21 | if (! $referer) { 22 | return $this->session->get('previousUrl'); 23 | } 24 | 25 | $refererHost = parse_url($referer, PHP_URL_HOST); 26 | 27 | if ($refererHost !== $request->getUri()->getHost()) { 28 | $referer = $this->session->get('previousUrl'); 29 | } 30 | 31 | return $referer; 32 | } 33 | 34 | public function isXhr(ServerRequestInterface $request): bool 35 | { 36 | return $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest'; 37 | } 38 | 39 | public function getDataTableQueryParameters(ServerRequestInterface $request): DataTableQueryParams 40 | { 41 | $params = $request->getQueryParams(); 42 | 43 | $orderBy = $params['columns'][$params['order'][0]['column']]['data']; 44 | $orderDir = $params['order'][0]['dir']; 45 | 46 | return new DataTableQueryParams( 47 | (int) $params['start'], 48 | (int) $params['length'], 49 | $orderBy, 50 | $orderDir, 51 | $params['search']['value'], 52 | (int) $params['draw'] 53 | ); 54 | } 55 | 56 | public function getClientIp(ServerRequestInterface $request, array $trustedProxies): ?string 57 | { 58 | $serverParams = $request->getServerParams(); 59 | 60 | if (in_array($serverParams['REMOTE_ADDR'], $trustedProxies, true) 61 | && isset($serverParams['HTTP_X_FORWARDED_FOR'])) { 62 | $ips = explode(',', $serverParams['HTTP_X_FORWARDED_FOR']); 63 | 64 | return trim($ips[0]); 65 | } 66 | 67 | return $serverParams['REMOTE_ADDR'] ?? null; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/Services/TransactionImportService.php: -------------------------------------------------------------------------------- 1 | categoryService->getAllKeyedByName(); 25 | 26 | fgetcsv($resource); 27 | 28 | $count = 1; 29 | $batchSize = 250; 30 | while (($row = fgetcsv($resource)) !== false) { 31 | [$date, $description, $category, $amount] = $row; 32 | 33 | $date = new \DateTime($date); 34 | $category = $categories[strtolower($category)] ?? null; 35 | $amount = str_replace(['$', ','], '', $amount); 36 | 37 | $transactionData = new TransactionData($description, (float) $amount, $date, $category); 38 | 39 | $this->entityManagerService->persist( 40 | $this->transactionService->create($transactionData, $user) 41 | ); 42 | 43 | if ($count % $batchSize === 0) { 44 | $this->entityManagerService->sync(); 45 | $this->entityManagerService->clear(Transaction::class); 46 | 47 | $count = 1; 48 | } else { 49 | $count++; 50 | } 51 | } 52 | 53 | if ($count > 1) { 54 | $this->entityManagerService->sync(); 55 | $this->entityManagerService->clear(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/Services/TransactionService.php: -------------------------------------------------------------------------------- 1 | setUser($user); 25 | 26 | return $this->update($transaction, $transactionData); 27 | } 28 | 29 | public function getPaginatedTransactions(DataTableQueryParams $params): Paginator 30 | { 31 | $query = $this->entityManager 32 | ->getRepository(Transaction::class) 33 | ->createQueryBuilder('t') 34 | ->select('t', 'c', 'r') 35 | ->leftJoin('t.category', 'c') 36 | ->leftJoin('t.receipts', 'r') 37 | ->setFirstResult($params->start) 38 | ->setMaxResults($params->length); 39 | 40 | $orderBy = in_array($params->orderBy, ['description', 'amount', 'date', 'category']) 41 | ? $params->orderBy 42 | : 'date'; 43 | $orderDir = strtolower($params->orderDir) === 'asc' ? 'asc' : 'desc'; 44 | 45 | if (! empty($params->searchTerm)) { 46 | $query->where('t.description LIKE :description') 47 | ->setParameter('description', '%' . addcslashes($params->searchTerm, '%_') . '%'); 48 | } 49 | 50 | if ($orderBy === 'category') { 51 | $query->orderBy('c.name', $orderDir); 52 | } else { 53 | $query->orderBy('t.' . $orderBy, $orderDir); 54 | } 55 | 56 | return new Paginator($query); 57 | } 58 | 59 | public function getById(int $id): ?Transaction 60 | { 61 | return $this->entityManager->find(Transaction::class, $id); 62 | } 63 | 64 | public function update(Transaction $transaction, TransactionData $transactionData): Transaction 65 | { 66 | $transaction->setDescription($transactionData->description); 67 | $transaction->setAmount($transactionData->amount); 68 | $transaction->setDate($transactionData->date); 69 | $transaction->setCategory($transactionData->category); 70 | 71 | return $transaction; 72 | } 73 | 74 | public function toggleReviewed(Transaction $transaction): void 75 | { 76 | $transaction->setReviewed(! $transaction->wasReviewed()); 77 | } 78 | 79 | public function getTotals(\DateTime $startDate, \DateTime $endDate): array 80 | { 81 | $query = $this->entityManager->createQuery( 82 | 'SELECT SUM(t.amount) AS net, 83 | SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) AS income, 84 | SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END) as expense 85 | FROM App\Entity\Transaction t 86 | WHERE t.date BETWEEN :start AND :end' 87 | ); 88 | 89 | $query->setParameter('start', $startDate->format('Y-m-d 00:00:00')); 90 | $query->setParameter('end', $endDate->format('Y-m-d 23:59:59')); 91 | 92 | return $query->getSingleResult(); 93 | } 94 | 95 | public function getRecentTransactions(int $limit): array 96 | { 97 | return $this->entityManager 98 | ->getRepository(Transaction::class) 99 | ->createQueryBuilder('t') 100 | ->select('t', 'c') 101 | ->leftJoin('t.category', 'c') 102 | ->orderBy('t.date', 'desc') 103 | ->setMaxResults($limit) 104 | ->getQuery() 105 | ->getArrayResult(); 106 | } 107 | 108 | public function getMonthlySummary(int $year): array 109 | { 110 | $query = $this->entityManager->createQuery( 111 | 'SELECT SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) as income, 112 | SUM(CASE WHEN t.amount < 0 THEN abs(t.amount) ELSE 0 END) as expense, 113 | MONTH(t.date) as m 114 | FROM App\Entity\Transaction t 115 | WHERE YEAR(t.date) = :year 116 | GROUP BY m 117 | ORDER BY m ASC' 118 | ); 119 | 120 | $query->setParameter('year', $year); 121 | 122 | return $query->getArrayResult(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /app/Services/UserLoginCodeService.php: -------------------------------------------------------------------------------- 1 | setCode((string) $code); 24 | $userLoginCode->setExpiration(new \DateTime('+10 minutes')); 25 | $userLoginCode->setUser($user); 26 | 27 | $this->entityManagerService->sync($userLoginCode); 28 | 29 | return $userLoginCode; 30 | } 31 | 32 | public function verify(User $user, string $code): bool 33 | { 34 | $userLoginCode = $this->entityManagerService->getRepository(UserLoginCode::class)->findOneBy( 35 | ['user' => $user, 'code' => $code, 'isActive' => true] 36 | ); 37 | 38 | if (! $userLoginCode) { 39 | return false; 40 | } 41 | 42 | if ($userLoginCode->getExpiration() <= new \DateTime()) { 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | 49 | public function deactivateAllActiveCodes(User $user): void 50 | { 51 | $this->entityManagerService->getRepository(UserLoginCode::class) 52 | ->createQueryBuilder('c') 53 | ->update() 54 | ->set('c.isActive', '0') 55 | ->where('c.user = :user') 56 | ->andWhere('c.isActive = 1') 57 | ->setParameter('user', $user) 58 | ->getQuery() 59 | ->execute(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Services/UserProfileService.php: -------------------------------------------------------------------------------- 1 | setName($data->name); 20 | $user->setTwoFactor($data->twoFactor); 21 | 22 | $this->entityManagerService->sync($user); 23 | } 24 | 25 | public function get(int $userId): UserProfileData 26 | { 27 | $user = $this->entityManagerService->find(User::class, $userId); 28 | 29 | return new UserProfileData($user->getEmail(), $user->getName(), $user->hasTwoFactorAuthEnabled()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Services/UserProviderService.php: -------------------------------------------------------------------------------- 1 | entityManager->find(User::class, $userId); 24 | } 25 | 26 | public function getByCredentials(array $credentials): ?UserInterface 27 | { 28 | return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]); 29 | } 30 | 31 | public function createUser(RegisterUserData $data): UserInterface 32 | { 33 | $user = new User(); 34 | 35 | $user->setName($data->name); 36 | $user->setEmail($data->email); 37 | $user->setPassword($this->hashService->hashPassword($data->password)); 38 | 39 | $this->entityManager->sync($user); 40 | 41 | return $user; 42 | } 43 | 44 | public function verifyUser(UserInterface $user): void 45 | { 46 | $user->setVerifiedAt(new \DateTime()); 47 | 48 | $this->entityManager->sync($user); 49 | } 50 | 51 | public function updatePassword(UserInterface $user, string $password): void 52 | { 53 | $user->setPassword($this->hashService->hashPassword($password)); 54 | 55 | $this->entityManager->sync($user); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/Session.php: -------------------------------------------------------------------------------- 1 | isActive()) { 20 | throw new SessionException('Session has already been started'); 21 | } 22 | 23 | if (headers_sent($fileName, $line)) { 24 | throw new SessionException('Headers have already sent by ' . $fileName . ':' . $line); 25 | } 26 | 27 | session_set_cookie_params( 28 | [ 29 | 'secure' => $this->options->secure, 30 | 'httponly' => $this->options->httpOnly, 31 | 'samesite' => $this->options->sameSite->value, 32 | ] 33 | ); 34 | 35 | if (! empty($this->options->name)) { 36 | session_name($this->options->name); 37 | } 38 | 39 | if (! session_start()) { 40 | throw new SessionException('Unable to start the session'); 41 | } 42 | } 43 | 44 | public function save(): void 45 | { 46 | session_write_close(); 47 | } 48 | 49 | public function isActive(): bool 50 | { 51 | return session_status() === PHP_SESSION_ACTIVE; 52 | } 53 | 54 | public function get(string $key, mixed $default = null): mixed 55 | { 56 | return $this->has($key) ? $_SESSION[$key] : $default; 57 | } 58 | 59 | public function has(string $key): bool 60 | { 61 | return array_key_exists($key, $_SESSION); 62 | } 63 | 64 | public function regenerate(): bool 65 | { 66 | return session_regenerate_id(); 67 | } 68 | 69 | public function put(string $key, mixed $value): void 70 | { 71 | $_SESSION[$key] = $value; 72 | } 73 | 74 | public function forget(string $key): void 75 | { 76 | unset($_SESSION[$key]); 77 | } 78 | 79 | public function flash(string $key, array $messages): void 80 | { 81 | $_SESSION[$this->options->flashName][$key] = $messages; 82 | } 83 | 84 | public function getFlash(string $key): array 85 | { 86 | $messages = $_SESSION[$this->options->flashName][$key] ?? []; 87 | 88 | unset($_SESSION[$this->options->flashName][$key]); 89 | 90 | return $messages; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/SignedUrl.php: -------------------------------------------------------------------------------- 1 | getTimestamp(); 18 | $queryParams = ['expiration' => $expiration]; 19 | $baseUrl = trim($this->config->get('app_url'), '/'); 20 | $url = $baseUrl . $this->routeParser->urlFor($routeName, $routeParams, $queryParams); 21 | 22 | $signature = hash_hmac('sha256', $url, $this->config->get('app_key')); 23 | 24 | return $baseUrl . $this->routeParser->urlFor( 25 | $routeName, 26 | $routeParams, 27 | $queryParams + ['signature' => $signature] 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | load(); 12 | 13 | return require CONFIG_PATH . '/container/container.php'; 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "autoload": { 3 | "psr-4": { 4 | "App\\": "app/" 5 | } 6 | }, 7 | "autoload-dev": { 8 | "psr-4": { 9 | "Tests\\": "tests/" 10 | } 11 | }, 12 | "config": { 13 | "sort-packages": true, 14 | "optimize-autoloader": true 15 | }, 16 | "require": { 17 | "ext-pdo": "*", 18 | "ext-redis": "*", 19 | "beberlei/doctrineextensions": "dev-master", 20 | "doctrine/dbal": "^3.4", 21 | "doctrine/migrations": "^3.5", 22 | "doctrine/orm": "^2.13", 23 | "itsgoingd/clockwork": "^5.1", 24 | "league/flysystem": "^3.0", 25 | "league/flysystem-aws-s3-v3": "^3.0", 26 | "league/mime-type-detection": "^1.11", 27 | "php-di/php-di": "^6.4", 28 | "psr/simple-cache": "^3.0", 29 | "slim/csrf": "^1.3", 30 | "slim/psr7": "^1.5", 31 | "slim/slim": "^4.10", 32 | "slim/twig-view": "^3.3", 33 | "symfony/cache": "^6.2", 34 | "symfony/console": "^6.1", 35 | "symfony/mailer": "^6.2", 36 | "symfony/rate-limiter": "^6.3", 37 | "symfony/twig-bridge": "^6.1", 38 | "symfony/webpack-encore-bundle": "^1.15", 39 | "twig/intl-extra": "^3.4", 40 | "twig/twig": "^3.4", 41 | "vlucas/phpdotenv": "^5.4", 42 | "vlucas/valitron": "^1.4" 43 | }, 44 | "require-dev": { 45 | "phpunit/phpunit": "^9.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /configs/app.php: -------------------------------------------------------------------------------- 1 | value; 17 | $appSnakeName = strtolower(str_replace(' ', '_', $_ENV['APP_NAME'])); 18 | 19 | return [ 20 | 'app_key' => $_ENV['APP_KEY'] ?? '', 21 | 'app_name' => $_ENV['APP_NAME'], 22 | 'app_version' => $_ENV['APP_VERSION'] ?? '1.0', 23 | 'app_url' => $_ENV['APP_URL'], 24 | 'app_environment' => $appEnv, 25 | 'display_error_details' => $boolean($_ENV['APP_DEBUG'] ?? 0), 26 | 'log_errors' => true, 27 | 'log_error_details' => true, 28 | 'doctrine' => [ 29 | 'dev_mode' => AppEnvironment::isDevelopment($appEnv), 30 | 'cache_dir' => STORAGE_PATH . '/cache/doctrine', 31 | 'entity_dir' => [APP_PATH . '/Entity'], 32 | 'connection' => [ 33 | 'driver' => $_ENV['DB_DRIVER'] ?? 'pdo_mysql', 34 | 'host' => $_ENV['DB_HOST'] ?? 'localhost', 35 | 'port' => $_ENV['DB_PORT'] ?? 3306, 36 | 'dbname' => $_ENV['DB_NAME'], 37 | 'user' => $_ENV['DB_USER'], 38 | 'password' => $_ENV['DB_PASS'], 39 | ], 40 | ], 41 | 'session' => [ 42 | 'name' => $appSnakeName . '_session', 43 | 'flash_name' => $appSnakeName . '_flash', 44 | 'secure' => $boolean($_ENV['SESSION_SECURE'] ?? true), 45 | 'httponly' => $boolean($_ENV['SESSION_HTTP_ONLY'] ?? true), 46 | 'samesite' => $_ENV['SESSION_SAME_SITE'] ?? 'lax', 47 | ], 48 | 'storage' => [ 49 | 'driver' => ($_ENV['STORAGE_DRIVER'] ?? '') === 's3' ? StorageDriver::Remote_DO : StorageDriver::Local, 50 | 's3' => [ 51 | 'key' => $_ENV['S3_KEY'], 52 | 'secret' => $_ENV['S3_SECRET'], 53 | 'region' => $_ENV['S3_REGION'], 54 | 'version' => $_ENV['S3_VERSION'], 55 | 'endpoint' => $_ENV['S3_ENDPOINT'], 56 | 'bucket' => $_ENV['S3_BUCKET'], 57 | ], 58 | ], 59 | 'mailer' => [ 60 | 'driver' => $_ENV['MAILER_DRIVER'] ?? 'log', 61 | 'dsn' => $_ENV['MAILER_DSN'], 62 | 'from' => $_ENV['MAILER_FROM'], 63 | ], 64 | 'redis' => [ 65 | 'host' => $_ENV['REDIS_HOST'], 66 | 'port' => $_ENV['REDIS_PORT'], 67 | 'password' => $_ENV['REDIS_PASSWORD'] ?? '', 68 | ], 69 | 'trusted_proxies' => [], 70 | 'limiter' => [ 71 | 'id' => 'default', 72 | 'policy' => 'fixed_window', 73 | 'interval' => '1 minute', 74 | 'limit' => 25, 75 | ], 76 | ]; 77 | -------------------------------------------------------------------------------- /configs/commands/commands.php: -------------------------------------------------------------------------------- 1 | [ 21 | new CurrentCommand($dependencyFactory), 22 | new DumpSchemaCommand($dependencyFactory), 23 | new ExecuteCommand($dependencyFactory), 24 | new GenerateCommand($dependencyFactory), 25 | new LatestCommand($dependencyFactory), 26 | new MigrateCommand($dependencyFactory), 27 | new RollupCommand($dependencyFactory), 28 | new StatusCommand($dependencyFactory), 29 | new VersionCommand($dependencyFactory), 30 | new UpToDateCommand($dependencyFactory), 31 | new SyncMetadataCommand($dependencyFactory), 32 | new ListCommand($dependencyFactory), 33 | new DiffCommand($dependencyFactory), 34 | ]; 35 | -------------------------------------------------------------------------------- /configs/container/container.php: -------------------------------------------------------------------------------- 1 | addDefinitions(__DIR__ . '/container_bindings.php'); 10 | 11 | return $containerBuilder->build(); 12 | -------------------------------------------------------------------------------- /configs/middleware.php: -------------------------------------------------------------------------------- 1 | getContainer(); 21 | $config = $container->get(Config::class); 22 | 23 | $app->add(MethodOverrideMiddleware::class); 24 | $app->add(CsrfFieldsMiddleware::class); 25 | $app->add('csrf'); 26 | $app->add(TwigMiddleware::create($app, $container->get(Twig::class))); 27 | $app->add(ValidationExceptionMiddleware::class); 28 | $app->add(ValidationErrorsMiddleware::class); 29 | $app->add(OldFormDataMiddleware::class); 30 | $app->add(StartSessionsMiddleware::class); 31 | if (AppEnvironment::isDevelopment($config->get('app_environment'))) { 32 | $app->add(new ClockworkMiddleware($app, $container->get(Clockwork::class))); 33 | } 34 | $app->addBodyParsingMiddleware(); 35 | $app->addErrorMiddleware( 36 | (bool) $config->get('display_error_details'), 37 | (bool) $config->get('log_errors'), 38 | (bool) $config->get('log_error_details') 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /configs/migrations.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'table_name' => 'migrations', 8 | 'version_column_name' => 'version', 9 | 'version_column_length' => 1024, 10 | 'executed_at_column_name' => 'executed_at', 11 | 'execution_time_column_name' => 'execution_time', 12 | ], 13 | 'migrations_paths' => [ 14 | 'Migrations' => __DIR__ . '/../migrations', 15 | ], 16 | 'all_or_nothing' => true, 17 | 'transactional' => true, 18 | 'check_database_platform' => true, 19 | 'organize_migrations' => 'none', 20 | 'connection' => null, 21 | 'em' => null, 22 | ]; 23 | -------------------------------------------------------------------------------- /configs/path_constants.php: -------------------------------------------------------------------------------- 1 | group('', function (RouteCollectorProxy $group) { 24 | $group->get('/', [HomeController::class, 'index'])->setName('home'); 25 | $group->get('/stats/ytd', [HomeController::class, 'getYearToDateStatistics']); 26 | 27 | $group->group('/categories', function (RouteCollectorProxy $categories) { 28 | $categories->get('', [CategoryController::class, 'index'])->setName('categories'); 29 | $categories->get('/load', [CategoryController::class, 'load']); 30 | $categories->post('', [CategoryController::class, 'store']); 31 | $categories->delete('/{category}', [CategoryController::class, 'delete']); 32 | $categories->get('/{category}', [CategoryController::class, 'get']); 33 | $categories->post('/{category}', [CategoryController::class, 'update']); 34 | }); 35 | 36 | $group->group('/transactions', function (RouteCollectorProxy $transactions) { 37 | $transactions->get('', [TransactionController::class, 'index'])->setName('transactions'); 38 | $transactions->get('/load', [TransactionController::class, 'load']); 39 | $transactions->post('', [TransactionController::class, 'store']); 40 | $transactions->post('/import', [TransactionImporterController::class, 'import']); 41 | $transactions->delete('/{transaction}', [TransactionController::class, 'delete']); 42 | $transactions->get('/{transaction}', [TransactionController::class, 'get']); 43 | $transactions->post('/{transaction}', [TransactionController::class, 'update']); 44 | $transactions->post('/{transaction}/receipts', [ReceiptController::class, 'store']); 45 | $transactions->get( 46 | '/{transaction}/receipts/{receipt}', 47 | [ReceiptController::class, 'download'] 48 | ); 49 | $transactions->delete( 50 | '/{transaction}/receipts/{receipt}', 51 | [ReceiptController::class, 'delete'] 52 | ); 53 | $transactions->post('/{transaction}/review', [TransactionController::class, 'toggleReviewed']); 54 | }); 55 | 56 | $group->group('/profile', function (RouteCollectorProxy $profile) { 57 | $profile->get('', [ProfileController::class, 'index']); 58 | $profile->post('', [ProfileController::class, 'update']); 59 | $profile->post('/update-password', [ProfileController::class, 'updatePassword']); 60 | }); 61 | })->add(VerifyEmailMiddleware::class)->add(AuthMiddleware::class); 62 | 63 | $app->group('', function (RouteCollectorProxy $group) { 64 | $group->post('/logout', [AuthController::class, 'logOut']); 65 | $group->get('/verify', [VerifyController::class, 'index']); 66 | $group->get('/verify/{id}/{hash}', [VerifyController::class, 'verify']) 67 | ->setName('verify') 68 | ->add(ValidateSignatureMiddleware::class); 69 | $group->post('/verify', [VerifyController::class, 'resend']) 70 | ->setName('resendVerification') 71 | ->add(RateLimitMiddleware::class); 72 | })->add(AuthMiddleware::class); 73 | 74 | $app->group('', function (RouteCollectorProxy $guest) { 75 | $guest->get('/login', [AuthController::class, 'loginView']); 76 | $guest->get('/register', [AuthController::class, 'registerView']); 77 | $guest->post('/login', [AuthController::class, 'logIn']) 78 | ->setName('logIn') 79 | ->add(RateLimitMiddleware::class); 80 | $guest->post('/register', [AuthController::class, 'register']) 81 | ->setName('register') 82 | ->add(RateLimitMiddleware::class); 83 | $guest->post('/login/two-factor', [AuthController::class, 'twoFactorLogin']) 84 | ->setName('twoFactorLogin') 85 | ->add(RateLimitMiddleware::class); 86 | $guest->get('/forgot-password', [PasswordResetController::class, 'showForgotPasswordForm']); 87 | $guest->get('/reset-password/{token}', [PasswordResetController::class, 'showResetPasswordForm']) 88 | ->setName('password-reset') 89 | ->add(ValidateSignatureMiddleware::class); 90 | $guest->post('/forgot-password', [PasswordResetController::class, 'handleForgotPasswordRequest']) 91 | ->setName('handleForgotPassword') 92 | ->add(RateLimitMiddleware::class); 93 | $guest->post('/reset-password/{token}', [PasswordResetController::class, 'resetPassword']) 94 | ->setName('resetPassword') 95 | ->add(RateLimitMiddleware::class); 96 | })->add(GuestMiddleware::class); 97 | }; 98 | -------------------------------------------------------------------------------- /docker/.gitignore: -------------------------------------------------------------------------------- 1 | storage 2 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-fpm 2 | 3 | ARG USER 4 | ARG USER_ID 5 | ARG GROUP_ID 6 | 7 | WORKDIR /var/www 8 | 9 | RUN apt-get update && apt-get install -y \ 10 | git \ 11 | zip \ 12 | unzip \ 13 | curl \ 14 | vim \ 15 | libicu-dev 16 | 17 | RUN curl -sL https://deb.nodesource.com/setup_18.x | bash \ 18 | && apt-get install nodejs -y 19 | 20 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 21 | 22 | RUN docker-php-ext-configure intl 23 | RUN docker-php-ext-install pdo pdo_mysql intl 24 | 25 | RUN pecl install xdebug \ 26 | && pecl install redis \ 27 | && docker-php-ext-enable xdebug \ 28 | && docker-php-ext-enable redis 29 | 30 | COPY xdebug.ini "${PHP_INI_DIR}/conf.d" 31 | 32 | RUN groupadd --force -g $GROUP_ID $USER 33 | RUN useradd -ms /bin/bash --no-user-group -g $GROUP_ID -u 1337 $USER 34 | RUN usermod -u $USER_ID $USER 35 | 36 | USER $USER 37 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: "" 7 | dockerfile: Dockerfile 8 | args: 9 | USER_ID: '${WWWUSER:-1000}' 10 | GROUP_ID: '${WWWGROUP:-1000}' 11 | USER: '${USER:-whoami}' 12 | container_name: expennies-app 13 | restart: always 14 | working_dir: /var/www/ 15 | extra_hosts: 16 | - "host.docker.internal:host-gateway" 17 | ports: 18 | - "9003:9003" 19 | volumes: 20 | - ../:/var/www 21 | - ./local.ini:/usr/local/etc/php/conf.d/local.ini 22 | nginx: 23 | image: nginx:1.19-alpine 24 | container_name: expennies-nginx 25 | restart: always 26 | ports: 27 | - "8000:80" 28 | volumes: 29 | - ../:/var/www 30 | - ./nginx:/etc/nginx/conf.d 31 | db: 32 | container_name: expennies-db 33 | image: mysql:8.0 34 | volumes: 35 | - ./storage/mysql:/var/lib/mysql 36 | restart: always 37 | environment: 38 | MYSQL_ROOT_PASSWORD: root 39 | ports: 40 | - "3306:3306" 41 | mailhog: 42 | container_name: expennies-mailhog 43 | image: mailhog/mailhog 44 | restart: always 45 | logging: 46 | driver: "none" 47 | ports: 48 | - "8025:8025" 49 | - "1025:1025" 50 | redis: 51 | image: redis:latest 52 | container_name: expennies-redis 53 | restart: always 54 | ports: 55 | - "6379:6379" 56 | command: redis-server --requirepass mypassword 57 | -------------------------------------------------------------------------------- /docker/local.ini: -------------------------------------------------------------------------------- 1 | fastcgi.logging = Off 2 | error_reporting = E_ALL 3 | log_errors = On 4 | error_log = /var/www/storage/logs/php_errors.log 5 | upload_max_filesize = 20M 6 | post_max_size = 20M 7 | -------------------------------------------------------------------------------- /docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | index index.php; 4 | server_name localhost; 5 | error_log /var/log/nginx/error.log; 6 | access_log /var/log/nginx/access.log; 7 | error_page 404 /index.php; 8 | root /var/www/public; 9 | client_max_body_size 20m; 10 | location ~ \.php$ { 11 | try_files $uri =404; 12 | fastcgi_pass app:9000; 13 | fastcgi_index index.php; 14 | include fastcgi_params; 15 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 16 | } 17 | location / { 18 | try_files $uri $uri/ /index.php?$query_string; 19 | gzip_static on; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker/xdebug.ini: -------------------------------------------------------------------------------- 1 | ;zend_extension=xdebug 2 | xdebug.mode=develop,debug 3 | xdebug.start_with_request=yes 4 | xdebug.discover_client_host=0 5 | xdebug.client_host=host.docker.internal 6 | -------------------------------------------------------------------------------- /expennies: -------------------------------------------------------------------------------- 1 | get(Config::class); 17 | 18 | $entityManager = $container->get(EntityManagerInterface::class); 19 | $dependencyFactory = DependencyFactory::fromEntityManager( 20 | new PhpFile(CONFIG_PATH . '/migrations.php'), 21 | new ExistingEntityManager($entityManager) 22 | ); 23 | 24 | $migrationCommands = require CONFIG_PATH . '/commands/migration_commands.php'; 25 | $customCommands = require CONFIG_PATH . '/commands/commands.php'; 26 | 27 | $cliApp = new Application($config->get('app_name'), $config->get('app_version')); 28 | 29 | ConsoleRunner::addCommands($cliApp, new SingleManagerProvider($entityManager)); 30 | 31 | $cliApp->addCommands($migrationCommands($dependencyFactory)); 32 | $cliApp->addCommands(array_map(fn($command) => $container->get($command), $customCommands)); 33 | 34 | $cliApp->run(); 35 | -------------------------------------------------------------------------------- /migrations/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ggelashvili/expennies/fca179c6569059220c524201d79c614b57cd7f6d/migrations/.keep -------------------------------------------------------------------------------- /migrations/Version20221006233845.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE categories (id INT UNSIGNED AUTO_INCREMENT NOT NULL, user_id INT UNSIGNED DEFAULT NULL, name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_3AF34668A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 24 | $this->addSql('CREATE TABLE receipts (id INT UNSIGNED AUTO_INCREMENT NOT NULL, transaction_id INT UNSIGNED DEFAULT NULL, file_name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_1DEBE3A22FC0CB0F (transaction_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 25 | $this->addSql('CREATE TABLE transactions (id INT UNSIGNED AUTO_INCREMENT NOT NULL, user_id INT UNSIGNED DEFAULT NULL, category_id INT UNSIGNED DEFAULT NULL, description VARCHAR(255) NOT NULL, date DATETIME NOT NULL, amount NUMERIC(13, 3) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_EAA81A4CA76ED395 (user_id), INDEX IDX_EAA81A4C12469DE2 (category_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 26 | $this->addSql('CREATE TABLE users (id INT UNSIGNED AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 27 | $this->addSql('ALTER TABLE categories ADD CONSTRAINT FK_3AF34668A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); 28 | $this->addSql('ALTER TABLE receipts ADD CONSTRAINT FK_1DEBE3A22FC0CB0F FOREIGN KEY (transaction_id) REFERENCES transactions (id)'); 29 | $this->addSql('ALTER TABLE transactions ADD CONSTRAINT FK_EAA81A4CA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); 30 | $this->addSql('ALTER TABLE transactions ADD CONSTRAINT FK_EAA81A4C12469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); 31 | } 32 | 33 | public function down(Schema $schema): void 34 | { 35 | // this down() migration is auto-generated, please modify it to your needs 36 | $this->addSql('ALTER TABLE categories DROP FOREIGN KEY FK_3AF34668A76ED395'); 37 | $this->addSql('ALTER TABLE receipts DROP FOREIGN KEY FK_1DEBE3A22FC0CB0F'); 38 | $this->addSql('ALTER TABLE transactions DROP FOREIGN KEY FK_EAA81A4CA76ED395'); 39 | $this->addSql('ALTER TABLE transactions DROP FOREIGN KEY FK_EAA81A4C12469DE2'); 40 | $this->addSql('DROP TABLE categories'); 41 | $this->addSql('DROP TABLE receipts'); 42 | $this->addSql('DROP TABLE transactions'); 43 | $this->addSql('DROP TABLE users'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /migrations/Version20230209221628.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE receipts ADD storage_filename VARCHAR(255) NOT NULL after filename, CHANGE file_name filename VARCHAR(255) NOT NULL'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('ALTER TABLE receipts CHANGE filename file_name VARCHAR(255) NOT NULL, DROP storage_filename'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20230219220830.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE receipts ADD media_type VARCHAR(255) NOT NULL AFTER storage_filename'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('ALTER TABLE receipts DROP media_type'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20230325174143.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE transactions ADD was_reviewed TINYINT(1) DEFAULT 0 NOT NULL AFTER id'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('ALTER TABLE transactions DROP was_reviewed'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20230508182855.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE users ADD verified_at DATETIME DEFAULT NULL'); 20 | } 21 | 22 | public function down(Schema $schema): void 23 | { 24 | $this->addSql('ALTER TABLE users DROP verified_at'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /migrations/Version20230609194432.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE user_login_codes (id INT UNSIGNED AUTO_INCREMENT NOT NULL, user_id INT UNSIGNED DEFAULT NULL, code VARCHAR(6) NOT NULL, is_active TINYINT(1) DEFAULT 1 NOT NULL, expiration DATETIME NOT NULL, INDEX IDX_4AC6CF4CA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 23 | $this->addSql('ALTER TABLE user_login_codes ADD CONSTRAINT FK_4AC6CF4CA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | $this->addSql('ALTER TABLE user_login_codes DROP FOREIGN KEY FK_4AC6CF4CA76ED395'); 29 | $this->addSql('DROP TABLE user_login_codes'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20230614063134.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE users ADD two_factor TINYINT(1) DEFAULT 0 NOT NULL'); 20 | } 21 | 22 | public function down(Schema $schema): void 23 | { 24 | $this->addSql('ALTER TABLE users DROP two_factor'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /migrations/Version20230616180023.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE password_resets (id INT UNSIGNED AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, token VARCHAR(255) NOT NULL, is_active TINYINT(1) DEFAULT 1 NOT NULL, expiration DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); 20 | } 21 | 22 | public function down(Schema $schema): void 23 | { 24 | $this->addSql('DROP TABLE password_resets'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expennies", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "encore dev", 6 | "watch": "encore dev --watch", 7 | "build": "encore production --progress" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.22.9", 11 | "@babel/plugin-proposal-class-properties": "^7.18.6", 12 | "@babel/preset-env": "^7.22.9", 13 | "@popperjs/core": "^2.11.6", 14 | "@symfony/webpack-encore": "^4.4.0", 15 | "bootstrap": "^5.2.1", 16 | "bootstrap-icons": "^1.10.2", 17 | "chart.js": "^4.3.2", 18 | "core-js": "^3.26.1", 19 | "datatables.net": "^1.13.1", 20 | "datatables.net-dt": "^1.13.1", 21 | "file-loader": "^6.2.0", 22 | "sass": "^1.54.9", 23 | "sass-loader": "^13.0.2", 24 | "webpack": "^5.88.2", 25 | "webpack-cli": "^5.1.4", 26 | "webpack-notifier": "^1.15.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests/Unit 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | get(App::class)->run(); 10 | -------------------------------------------------------------------------------- /resources/css/app.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap-icons/font/bootstrap-icons"; 4 | @import "~datatables.net-dt/css/jquery.dataTables.css"; 5 | 6 | * { 7 | font-family: 'Roboto', sans-serif !important; 8 | } 9 | 10 | body { 11 | background-color: #e7ebee; 12 | } 13 | 14 | svg.icon { 15 | width: 25px; 16 | height: 25px; 17 | } 18 | 19 | .nav-link { 20 | color: #9aa0a9; 21 | 22 | &.active { 23 | background-color: inherit !important; 24 | color: #57585A !important; 25 | } 26 | 27 | &:hover, &:focus { 28 | color: #57585A !important; 29 | } 30 | } 31 | 32 | .content-body { 33 | background-color: #FEFEFF; 34 | min-height: 500px; 35 | border-radius: 1rem; 36 | padding: 20px 25px 25px; 37 | } 38 | -------------------------------------------------------------------------------- /resources/css/auth.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | -------------------------------------------------------------------------------- /resources/css/dashboard.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | 3 | .top-container { 4 | background-color: #FEFEFF; 5 | min-height: 500px; 6 | } 7 | 8 | .category-card { 9 | background-color: #FEFEFF; 10 | height: 150px; 11 | } 12 | -------------------------------------------------------------------------------- /resources/css/transactions.scss: -------------------------------------------------------------------------------- 1 | .delete-receipt { 2 | font-size: 12px !important; 3 | top: -13px; 4 | position: absolute; 5 | right: -2px; 6 | } 7 | -------------------------------------------------------------------------------- /resources/css/variables.scss: -------------------------------------------------------------------------------- 1 | $primary: #0078d6; 2 | -------------------------------------------------------------------------------- /resources/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ggelashvili/expennies/fca179c6569059220c524201d79c614b57cd7f6d/resources/images/logo.png -------------------------------------------------------------------------------- /resources/js/ajax.js: -------------------------------------------------------------------------------- 1 | const ajax = (url, method = 'get', data = {}, domElement = null) => { 2 | method = method.toLowerCase() 3 | 4 | let options = { 5 | method, 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | 'X-Requested-With': 'XMLHttpRequest' 9 | } 10 | } 11 | 12 | const csrfMethods = new Set(['post', 'put', 'delete', 'patch']) 13 | 14 | if (csrfMethods.has(method)) { 15 | let additionalFields = {...getCsrfFields()} 16 | 17 | if (method !== 'post') { 18 | options.method = 'post' 19 | 20 | additionalFields._METHOD = method.toUpperCase() 21 | } 22 | 23 | if (data instanceof FormData) { 24 | for (const additionalField in additionalFields) { 25 | data.append(additionalField, additionalFields[additionalField]) 26 | } 27 | 28 | delete options.headers['Content-Type']; 29 | 30 | options.body = data 31 | } else { 32 | options.body = JSON.stringify({...data, ...additionalFields}) 33 | } 34 | } else if (method === 'get') { 35 | url += '?' + (new URLSearchParams(data)).toString(); 36 | } 37 | 38 | return fetch(url, options).then(response => { 39 | if (domElement) { 40 | clearValidationErrors(domElement) 41 | } 42 | 43 | if (! response.ok) { 44 | if (response.status === 422) { 45 | response.json().then(errors => { 46 | handleValidationErrors(errors, domElement) 47 | }) 48 | } else if (response.status === 404) { 49 | alert(response.statusText) 50 | } 51 | } 52 | 53 | return response 54 | }) 55 | } 56 | 57 | const get = (url, data) => ajax(url, 'get', data) 58 | const post = (url, data, domElement) => ajax(url, 'post', data, domElement) 59 | const del = (url, data) => ajax(url, 'delete', data) 60 | 61 | function handleValidationErrors(errors, domElement) { 62 | for (const name in errors) { 63 | const element = domElement.querySelector(`[name="${ name }"]`) 64 | 65 | element.classList.add('is-invalid') 66 | 67 | const errorDiv = document.createElement('div') 68 | 69 | errorDiv.classList.add('invalid-feedback') 70 | errorDiv.textContent = errors[name][0] 71 | 72 | element.parentNode.append(errorDiv) 73 | } 74 | } 75 | 76 | function clearValidationErrors(domElement) { 77 | domElement.querySelectorAll('.is-invalid').forEach(function (element) { 78 | element.classList.remove('is-invalid') 79 | 80 | element.parentNode.querySelectorAll('.invalid-feedback').forEach(function (e) { 81 | e.remove() 82 | }) 83 | }) 84 | } 85 | 86 | function getCsrfFields() { 87 | const csrfNameField = document.querySelector('#csrfName') 88 | const csrfValueField = document.querySelector('#csrfValue') 89 | const csrfNameKey = csrfNameField.getAttribute('name') 90 | const csrfName = csrfNameField.content 91 | const csrfValueKey = csrfValueField.getAttribute('name') 92 | const csrfValue = csrfValueField.content 93 | 94 | return { 95 | [csrfNameKey]: csrfName, 96 | [csrfValueKey]: csrfValue 97 | } 98 | } 99 | 100 | export { 101 | ajax, 102 | get, 103 | post, 104 | del 105 | } 106 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | import "../css/app.scss" 2 | 3 | require('bootstrap') 4 | -------------------------------------------------------------------------------- /resources/js/auth.js: -------------------------------------------------------------------------------- 1 | import "../css/auth.scss" 2 | import { post } from './ajax'; 3 | import { Modal } from 'bootstrap'; 4 | 5 | window.addEventListener('DOMContentLoaded', function () { 6 | const twoFactorAuthModal = new Modal(document.getElementById('twoFactorAuthModal')) 7 | 8 | document.querySelector('.log-in-btn').addEventListener('click', function (event) { 9 | const form = this.closest('form') 10 | const formData = new FormData(form); 11 | const inputs = Object.fromEntries(formData.entries()); 12 | 13 | post(form.action, inputs, form).then(response => response.json()).then(response => { 14 | if (response.two_factor) { 15 | twoFactorAuthModal.show() 16 | } else { 17 | window.location = '/' 18 | } 19 | }) 20 | }) 21 | 22 | document.querySelector('.log-in-two-factor').addEventListener('click', function (event) { 23 | const code = twoFactorAuthModal._element.querySelector('input[name="code"]').value 24 | const email = document.querySelector('.login-form input[name="email"]').value 25 | 26 | post('/login/two-factor', {email, code}, twoFactorAuthModal._element).then(response => { 27 | if (response.ok) { 28 | window.location = '/' 29 | } 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /resources/js/categories.js: -------------------------------------------------------------------------------- 1 | import { Modal } from "bootstrap" 2 | import { get, post, del } from "./ajax" 3 | import DataTable from "datatables.net" 4 | 5 | window.addEventListener('DOMContentLoaded', function () { 6 | const editCategoryModal = new Modal(document.getElementById('editCategoryModal')) 7 | 8 | const table = new DataTable('#categoriesTable', { 9 | serverSide: true, 10 | ajax: '/categories/load', 11 | orderMulti: false, 12 | columns: [ 13 | {data: "name"}, 14 | {data: "createdAt"}, 15 | {data: "updatedAt"}, 16 | { 17 | sortable: false, 18 | data: row => ` 19 |
20 | 23 | 26 |
27 | ` 28 | } 29 | ] 30 | }); 31 | 32 | document.querySelector('#categoriesTable').addEventListener('click', function (event) { 33 | const editBtn = event.target.closest('.edit-category-btn') 34 | const deleteBtn = event.target.closest('.delete-category-btn') 35 | 36 | if (editBtn) { 37 | const categoryId = editBtn.getAttribute('data-id') 38 | 39 | get(`/categories/${ categoryId }`) 40 | .then(response => response.json()) 41 | .then(response => openEditCategoryModal(editCategoryModal, response)) 42 | } else if (deleteBtn) { 43 | const categoryId = deleteBtn.getAttribute('data-id') 44 | 45 | if (confirm('Are you sure you want to delete this category?')) { 46 | del(`/categories/${ categoryId }`).then(response => { 47 | if (response.ok) { 48 | table.draw() 49 | } 50 | }) 51 | } 52 | } 53 | }) 54 | 55 | document.querySelector('.save-category-btn').addEventListener('click', function (event) { 56 | const categoryId = event.currentTarget.getAttribute('data-id') 57 | 58 | post(`/categories/${ categoryId }`, { 59 | name: editCategoryModal._element.querySelector('input[name="name"]').value 60 | }, editCategoryModal._element).then(response => { 61 | if (response.ok) { 62 | table.draw() 63 | editCategoryModal.hide() 64 | } 65 | }) 66 | }) 67 | }) 68 | 69 | function openEditCategoryModal(modal, {id, name}) { 70 | const nameInput = modal._element.querySelector('input[name="name"]') 71 | 72 | nameInput.value = name 73 | 74 | modal._element.querySelector('.save-category-btn').setAttribute('data-id', id) 75 | 76 | modal.show() 77 | } 78 | -------------------------------------------------------------------------------- /resources/js/dashboard.js: -------------------------------------------------------------------------------- 1 | import "../css/dashboard.scss" 2 | import Chart from 'chart.js/auto' 3 | import { get } from './ajax' 4 | 5 | window.addEventListener('DOMContentLoaded', function () { 6 | const ctx = document.getElementById('yearToDateChart') 7 | 8 | get('/stats/ytd').then(response => response.json()).then(response => { 9 | let expensesData = Array(12).fill(null) 10 | let incomeData = Array(12).fill(null) 11 | 12 | response.forEach(({m, expense, income}) => { 13 | expensesData[m - 1] = expense 14 | incomeData[m - 1] = income 15 | }) 16 | 17 | new Chart(ctx, { 18 | type: 'bar', 19 | data: { 20 | labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 21 | datasets: [ 22 | { 23 | label: 'Expense', 24 | data: expensesData, 25 | borderWidth: 1, 26 | backgroundColor: 'rgba(255, 99, 132, 0.2)', 27 | borderColor: 'rgba(255, 99, 132, 1)', 28 | }, 29 | { 30 | label: 'Income', 31 | data: incomeData, 32 | borderWidth: 1, 33 | backgroundColor: 'rgba(75, 192, 192, 0.2)', 34 | borderColor: 'rgba(75, 192, 192, 1)', 35 | } 36 | ] 37 | }, 38 | options: { 39 | scales: { 40 | y: { 41 | beginAtZero: true 42 | } 43 | } 44 | } 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /resources/js/forgot_password.js: -------------------------------------------------------------------------------- 1 | import { post } from './ajax'; 2 | 3 | window.addEventListener('DOMContentLoaded', function () { 4 | const forgotPasswordBtn = document.querySelector('.forgot-password-btn') 5 | const resetPasswordBtn = document.querySelector('.reset-password-btn') 6 | 7 | if (forgotPasswordBtn) { 8 | forgotPasswordBtn.addEventListener('click', function () { 9 | const form = document.querySelector('.forgot-password-form') 10 | const email = form.querySelector('input[name="email"]').value 11 | 12 | post('/forgot-password', {email}, form).then(response => { 13 | if (response.ok) { 14 | alert('An email with instructions to reset your password has been sent.'); 15 | 16 | window.location = '/login' 17 | } 18 | }) 19 | }) 20 | } 21 | 22 | if (resetPasswordBtn) { 23 | resetPasswordBtn.addEventListener('click', function () { 24 | const form = this.closest('form') 25 | const formData = new FormData(form); 26 | const data = Object.fromEntries(formData.entries()); 27 | 28 | post(form.action, data, form).then(response => { 29 | if (response.ok) { 30 | alert('Password has been updated successfully.'); 31 | 32 | window.location = '/login' 33 | } 34 | }) 35 | }) 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /resources/js/profile.js: -------------------------------------------------------------------------------- 1 | import { post } from './ajax'; 2 | 3 | window.addEventListener('DOMContentLoaded', function () { 4 | const saveProfileBtn = document.querySelector('.save-profile') 5 | const updatePasswordBtn = document.querySelector('.update-password') 6 | 7 | saveProfileBtn.addEventListener('click', function () { 8 | const form = this.closest('form') 9 | const formData = new FormData(form); 10 | const data = Object.fromEntries(formData.entries()); 11 | 12 | saveProfileBtn.classList.add('disabled') 13 | 14 | post('/profile', data, form).then(response => { 15 | saveProfileBtn.classList.remove('disabled') 16 | 17 | if (response.ok) { 18 | alert('Profile has been updated.'); 19 | } 20 | }).catch(() => { 21 | saveProfileBtn.classList.remove('disabled') 22 | }) 23 | }) 24 | 25 | updatePasswordBtn.addEventListener('click', function () { 26 | const form = document.getElementById('passwordForm') 27 | const formData = new FormData(form); 28 | const data = Object.fromEntries(formData.entries()); 29 | 30 | updatePasswordBtn.classList.add('disabled') 31 | 32 | post('/profile/update-password', data, form).then(response => { 33 | updatePasswordBtn.classList.remove('disabled') 34 | 35 | if (response.ok) { 36 | alert('Password has been updated.'); 37 | } 38 | }).catch(() => { 39 | updatePasswordBtn.classList.remove('disabled') 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /resources/js/verify.js: -------------------------------------------------------------------------------- 1 | import { post } from './ajax'; 2 | 3 | window.addEventListener('DOMContentLoaded', function () { 4 | document.querySelector('.resend-verify').addEventListener('click', function (event) { 5 | post(`/verify`).then(response => { 6 | if (response.ok) { 7 | alert('A new email verification has been sent') 8 | } 9 | }) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /resources/views/auth/forgot_password.twig: -------------------------------------------------------------------------------- 1 | {% extends 'auth/layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('forgot_password') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('forgot_password') }} 11 | {% endblock %} 12 | 13 | {% block title %}Forgot Password{% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |

24 | Expennies Logo Forgot Password 26 |

27 |
28 |
29 | 31 |
32 | 36 |
37 |
38 |
39 |

40 | Back to Login 41 |

42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /resources/views/auth/layout.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Expennies Auth{% endblock %} 8 | 9 | 10 | 11 | {% block stylesheets %} 12 | {{ encore_entry_link_tags('app') }} 13 | {% endblock %} 14 | 15 | {% block javascripts %} 16 | {{ encore_entry_script_tags('app') }} 17 | {% endblock %} 18 | 19 | 20 | {% block content %}{% endblock %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /resources/views/auth/login.twig: -------------------------------------------------------------------------------- 1 | {% extends 'auth/layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('auth') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('auth') }} 11 | {% endblock %} 12 | 13 | {% block title %}Log In{% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |

24 | Expennies Logo 26 | Login 27 |

28 | 44 |
45 |
46 |

Don't have an account? 47 | Sign Up 48 |

49 |
50 |
51 |
52 |
53 |
54 |
55 | {% include 'auth/two_factor_modal.twig' %} 56 |
57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /resources/views/auth/register.twig: -------------------------------------------------------------------------------- 1 | {% extends 'auth/layout.twig' %} 2 | 3 | {% block title %}Register{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |

14 | Expennies Logo 16 | Register 17 |

18 |
19 | {{ csrf.fields | raw }} 20 | 21 |
22 | 26 |
27 | {{ errors.name | first }} 28 |
29 |
30 |
31 | 35 |
36 | {{ errors.email | first }} 37 |
38 |
39 |
40 | 43 |
44 | {{ errors.password | first }} 45 |
46 |
47 |
48 | 51 |
52 | {{ errors.confirmPassword | first }} 53 |
54 |
55 | 58 |
59 |
60 |
61 |

Have an account? 62 | Sign In 63 |

64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /resources/views/auth/reset_password.twig: -------------------------------------------------------------------------------- 1 | {% extends 'auth/layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('forgot_password') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('forgot_password') }} 11 | {% endblock %} 12 | 13 | {% block title %}Reset Password{% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |

24 | Expennies Logo 26 | Reset Password 27 |

28 |
29 |
30 | 33 |
34 |
35 | 38 |
39 | 42 |
43 |
44 |
45 |

46 | Back to Login 47 |

48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /resources/views/auth/two_factor_modal.twig: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /resources/views/auth/verify.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.twig' %} 2 | 3 | {% block title %}Verify Email{% endblock %} 4 | 5 | {% block javascripts %} 6 | {{ parent() }} 7 | {{ encore_entry_script_tags('verify') }} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 | 15 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /resources/views/categories/edit_category_modal.twig: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /resources/views/categories/index.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.twig' %} 2 | 3 | {% block javascripts %} 4 | {{ parent() }} 5 | {{ encore_entry_script_tags('categories') }} 6 | {% endblock %} 7 | 8 | {% block title %}Categories{% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 | 17 |
18 | 48 | {% include 'categories/edit_category_modal.twig' %} 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
NameCreated AtUpdated AtActions
61 |
62 |
63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /resources/views/dashboard.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('dashboard') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('dashboard') }} 11 | {% endblock %} 12 | 13 | {% block title %}Dashboard{% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 |
19 |
20 |
{{ "now" | date('M, Y') }}
21 |
22 |
23 |
24 |
Expense
25 |
${{ totals.expense | number_format(2) }}
26 |
27 |
28 |
Income
29 |
${{ totals.income | number_format(2) }}
30 |
31 |
32 |
Net
33 |
34 | ${{ totals.net | number_format(2) }} 35 |
36 |
37 |
38 |
39 |
40 |
{{ "now" | date('Y') }} Summary
41 | 42 |
43 |
44 |
45 |
46 |

Recent Transactions

47 | 48 | 49 | {% for transaction in transactions %} 50 | 51 | 52 | 55 | 59 | 60 | {% endfor %} 61 | 62 |
{{ transaction.description[0:20] }} 53 | {{ transaction.amount < 0 ? '-' : '' }}${{ transaction.amount | abs | number_format(2) }} 54 | 56 |
{{ transaction.category ? transaction.category.name : 'N/A' }}
57 |
{{ transaction.date | date('m/d/Y') }}
58 |
63 |
64 |
65 |
66 | {% for spendingCategory in topSpendingCategories %} 67 |
68 |
69 |
70 |
{{ spendingCategory.name }}
71 |

${{ spendingCategory.total }}

72 |
73 |
74 |
75 | {% endfor %} 76 |
77 |
78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /resources/views/emails/password_reset.html.twig: -------------------------------------------------------------------------------- 1 | Hello, 2 |

3 | You've requested to reset your password. To proceed, please click here 4 |

5 | This link will expire in 30 minutes for your security. If you didn't request a password reset, please ignore this email. 6 | -------------------------------------------------------------------------------- /resources/views/emails/signup.html.twig: -------------------------------------------------------------------------------- 1 | Thank you for signing up to Expennies. Please click here to activate your account. 2 |
The link is valid until {{ expirationDate|date('m/d/Y g:i A') }}. 3 | -------------------------------------------------------------------------------- /resources/views/emails/two_factor.html.twig: -------------------------------------------------------------------------------- 1 | Please enter the following verification code on the authentication screen to complete your login. This code will expire in 10 minutes. 2 |

3 | Your verification code is: {{ code }} 4 | -------------------------------------------------------------------------------- /resources/views/layout.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Expennies{% endblock %} 8 | 9 | 10 | 12 | {% block stylesheets %} 13 | {{ encore_entry_link_tags('app') }} 14 | {% endblock %} 15 | 16 | {% block javascripts %} 17 | {{ encore_entry_script_tags('app') }} 18 | {% endblock %} 19 | 20 | 21 |
22 |
23 | 24 | Expennies Logo 25 | Expennies 26 | 27 | 28 | 39 | 40 | 60 |
61 |
62 |
63 | {% block content %}{% endblock %} 64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /resources/views/profile/index.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('profile') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('profile') }} 11 | {% endblock %} 12 | 13 | {% block title %}Profile{% endblock %} 14 | 15 | {% block content %} 16 |
17 |

Profile

18 |
19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 |
31 | 36 | 37 |
38 |
39 |
40 |
41 |
42 | 48 | 51 |
52 |
53 |
54 | {% include 'profile/update_password_modal.twig' %} 55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /resources/views/profile/update_password_modal.twig: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /resources/views/transactions/import_transactions_modal.twig: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /resources/views/transactions/index.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.twig' %} 2 | 3 | {% block stylesheets %} 4 | {{ parent() }} 5 | {{ encore_entry_link_tags('transactions') }} 6 | {% endblock %} 7 | 8 | {% block javascripts %} 9 | {{ parent() }} 10 | {{ encore_entry_script_tags('transactions') }} 11 | {% endblock %} 12 | 13 | {% block title %}Transactions{% endblock %} 14 | 15 | {% block content %} 16 |
17 |
18 | 22 | 26 |
27 | {% include 'transactions/transaction_modal.twig' with {modal: {title: 'Create Transaction', id: 'newTransactionModal', isEdit: false}} %} 28 | {% include 'transactions/transaction_modal.twig' with {modal: {title: 'Edit Transaction', id: 'editTransactionModal', isEdit: true}} %} 29 | {% include 'transactions/upload_receipt_modal.twig' %} 30 | {% include 'transactions/import_transactions_modal.twig' %} 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
DescriptionAmountCategoryReceipt(s)DateActions
45 |
46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /resources/views/transactions/transaction_modal.twig: -------------------------------------------------------------------------------- 1 | 46 | -------------------------------------------------------------------------------- /resources/views/transactions/upload_receipt_modal.twig: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /storage/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/mail/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/receipts/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tests/Unit/ConfigTest.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'connection' => [ 18 | 'user' => 'root' 19 | ] 20 | ] 21 | ]; 22 | 23 | $config = new Config($config); 24 | 25 | $this->assertEquals('root', $config->get('doctrine.connection.user')); 26 | $this->assertEquals(['user' => 'root'], $config->get('doctrine.connection')); 27 | } 28 | 29 | /** @test */ 30 | public function it_gets_the_default_value_when_setting_is_not_found(): void 31 | { 32 | $config = [ 33 | 'doctrine' => [ 34 | 'connection' => [ 35 | 'user' => 'root' 36 | ] 37 | ] 38 | ]; 39 | 40 | $config = new Config($config); 41 | 42 | $this->assertEquals('pdo_mysql', $config->get('doctrine.connection.driver', 'pdo_mysql')); 43 | $this->assertEquals('bar', $config->get('foo', 'bar')); 44 | $this->assertEquals('baz', $config->get('foo.bar', 'baz')); 45 | } 46 | 47 | /** @test */ 48 | public function it_returns_null_by_default_when_setting_is_not_found(): void 49 | { 50 | $config = [ 51 | 'doctrine' => [ 52 | 'connection' => [ 53 | 'user' => 'root' 54 | ] 55 | ] 56 | ]; 57 | 58 | $config = new Config($config); 59 | 60 | $this->assertNull($config->get('doctrine.connection.driver')); 61 | $this->assertNull($config->get('foo.bar')); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require("@symfony/webpack-encore") 2 | 3 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 4 | // It's useful when you use tools that rely on webpack.config.js file. 5 | if (! Encore.isRuntimeEnvironmentConfigured()) { 6 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || "dev") 7 | } 8 | 9 | Encore 10 | // directory where compiled assets will be stored 11 | .setOutputPath("public/build/") 12 | 13 | // public path used by the web server to access the output path 14 | .setPublicPath("/build") 15 | 16 | /* 17 | * ENTRY CONFIG 18 | * 19 | * Each entry will result in one JavaScript file (e.g. app.js) 20 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 21 | */ 22 | .addEntry("app", "./resources/js/app.js") 23 | .addEntry("dashboard", "./resources/js/dashboard.js") 24 | .addEntry("categories", "./resources/js/categories.js") 25 | .addEntry("transactions", "./resources/js/transactions.js") 26 | .addEntry("auth", "./resources/js/auth.js") 27 | .addEntry("verify", "./resources/js/verify.js") 28 | .addEntry("profile", "./resources/js/profile.js") 29 | .addEntry("forgot_password", "./resources/js/forgot_password.js") 30 | 31 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 32 | .splitEntryChunks() 33 | 34 | // will require an extra script tag for runtime.js 35 | // but, you probably want this, unless you're building a single-page app 36 | .enableSingleRuntimeChunk() 37 | 38 | /* 39 | * FEATURE CONFIG 40 | * 41 | * Enable & configure other features below. For a full 42 | * list of features, see: 43 | * https://symfony.com/doc/current/frontend.html#adding-more-features 44 | */ 45 | .cleanupOutputBeforeBuild() 46 | .enableBuildNotifications() 47 | .enableSourceMaps(! Encore.isProduction()) 48 | 49 | // enables hashed filenames (e.g. app.abc123.css) 50 | .enableVersioning() 51 | 52 | .configureBabel((config) => { 53 | config.plugins.push("@babel/plugin-proposal-class-properties") 54 | }) 55 | 56 | // enables @babel/preset-env polyfills 57 | .configureBabelPresetEnv((config) => { 58 | config.useBuiltIns = "usage" 59 | config.corejs = 3 60 | }) 61 | 62 | .copyFiles({ 63 | from: "./resources/images", 64 | to: "images/[path][name].[hash:8].[ext]", 65 | pattern: /\.(png|jpg|jpeg|gif)$/ 66 | }) 67 | 68 | // enables Sass/SCSS support 69 | .enableSassLoader() 70 | 71 | module.exports = Encore.getWebpackConfig() 72 | --------------------------------------------------------------------------------