Compare commits
22
Commits
7963ddc982
..
benni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b8e62b2f | ||
|
|
d4eaa45baa | ||
|
|
613154a1f9 | ||
|
|
9fe0b2564d | ||
|
|
f2bcbae0a5 | ||
|
|
1c21ad1ef7 | ||
|
|
d1981673a2 | ||
|
|
84f5f0c788 | ||
|
|
d2bd589c96 | ||
|
|
97e2c9e192 | ||
|
|
23f9154eb6 | ||
|
|
9ddcc0adb5 | ||
|
|
a2ed9a9838 | ||
|
|
937004a48c | ||
|
|
bd6e311a73 | ||
|
|
a628dc1424 | ||
|
|
7fa56f7584 | ||
|
|
0ebd39f4fc | ||
|
|
1bd4ffa0f7 | ||
|
|
a6ec45f685 | ||
|
|
474e44ca0c | ||
|
|
75f0a8843d |
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!Dockerfile
|
||||
@@ -0,0 +1,39 @@
|
||||
APP_NAME=Pollux
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=pollux
|
||||
DB_USERNAME=pollux
|
||||
DB_PASSWORD=secret
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
QUEUE_DRIVER=sync
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_HOST=smtp.mailtrap.io
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
FROM composer:2.8 AS composer
|
||||
|
||||
# TODO(compat): Replace PHP 7.4 with a supported PHP release once Laravel and
|
||||
# Composer dependencies support it; verify composer install and `just test`.
|
||||
FROM php:7.4-cli
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libonig-dev libzip-dev \
|
||||
&& docker-php-ext-install -j"$(nproc)" mbstring pdo_mysql zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www
|
||||
@@ -0,0 +1,128 @@
|
||||
# Pollux
|
||||
|
||||
Pollux is a small Laravel 5.6 bookkeeping application for managing bookings,
|
||||
projects, people, targets, and reports.
|
||||
|
||||
## Local development
|
||||
|
||||
The application targets PHP 7.4 and MySQL 5.7. Those old runtimes are run in
|
||||
Docker so they do not have to be installed on the host. [mise](https://mise.jdx.dev/)
|
||||
manages the host-side tools: Just and Node 10 (required by the old Laravel Mix 2
|
||||
asset pipeline).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker with Compose v2 (`docker compose`)
|
||||
- [mise](https://mise.jdx.dev/)
|
||||
|
||||
### First run
|
||||
|
||||
```sh
|
||||
mise install
|
||||
just setup
|
||||
just up
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000>. A development login is created automatically:
|
||||
|
||||
- **Email:** `dev@pollux.local`
|
||||
- **Password:** `password`
|
||||
|
||||
`just setup` builds the PHP image, installs Composer dependencies, creates `.env`
|
||||
from `.env.docker.example`, generates `APP_KEY`, starts MySQL, runs migrations,
|
||||
and loads the constant and demo data.
|
||||
|
||||
### Everyday commands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `just up` | Start the app and database in the foreground |
|
||||
| `just up -d` | Start the app and database in the background |
|
||||
| `just down` | Stop the app and database |
|
||||
| `just logs` | Follow container logs |
|
||||
| `just artisan <command>` | Run an Artisan command |
|
||||
| `just composer <command>` | Run a Composer command |
|
||||
| `just shell` | Open a shell in the app container |
|
||||
| `just db` | Open a MySQL shell |
|
||||
| `just migrate` | Run migrations |
|
||||
| `just seed` | Add the demo/test dataset to an existing database |
|
||||
| `just seed-real` | Recreate the database and load the full dataset from `database/seeds/data/real` |
|
||||
| `just fresh` | Drop and recreate the database, then load demo data |
|
||||
| `just test` | Run PHPUnit |
|
||||
| `just assets` | Install npm packages and build frontend assets |
|
||||
| `just watch` | Rebuild frontend assets on change |
|
||||
|
||||
Run `just --list` to see the full list.
|
||||
|
||||
### Database
|
||||
|
||||
MySQL is exposed on host port **3307** so it does not clash with a local MySQL
|
||||
on 3306. The database is `pollux` with username `pollux` and password `secret`.
|
||||
The app container connects to the `db` service on port 3306.
|
||||
|
||||
Data lives in the Docker volume `pollux_mysql-data`. `just down` keeps the data;
|
||||
use `docker compose down -v` if you want to remove it as well.
|
||||
|
||||
### Frontend assets
|
||||
|
||||
Node 10 is pinned in `mise.toml` because Laravel Mix 2 and `node-sass` do not run
|
||||
on current Node versions. Prebuilt assets are committed, so `just assets` is not
|
||||
needed just to run the application.
|
||||
|
||||
### Tests
|
||||
|
||||
```sh
|
||||
just test
|
||||
```
|
||||
|
||||
`just test` starts a separate MySQL 5.7 service (`db-test`) and runs PHPUnit in
|
||||
the PHP container. The `pollux_test` database uses temporary storage, has no
|
||||
published port, and is separate from the development database and its volume.
|
||||
Tests migrate this database automatically and roll back their fixtures after
|
||||
each test. No demo or real-data seeders are needed. Run `just setup` first on a
|
||||
new checkout to build the PHP image and install dependencies.
|
||||
|
||||
To run a focused subset:
|
||||
|
||||
```sh
|
||||
just test --filter BookingsTest
|
||||
```
|
||||
|
||||
Coverage includes login/logout and guest access, booking forms and changes,
|
||||
split entries, validation, deletion cascades, income/expense totals, monthly
|
||||
balances, year boundaries, project and scope reports, and target payment states.
|
||||
These are PHP HTTP/database tests; they do not exercise browser JavaScript.
|
||||
|
||||
PHPUnit forces isolated database settings, and the test bootstrap refuses to run
|
||||
against another database configuration. If cached application configuration
|
||||
prevents testing, run `just artisan config:clear`. `just down` also stops the test
|
||||
database; its temporary data is discarded when the container stops.
|
||||
|
||||
## Legacy compatibility notes
|
||||
|
||||
This project uses several old dependencies, so a few small compatibility fixes
|
||||
are included:
|
||||
|
||||
Temporary workarounds and legacy runtime pins are marked `TODO(compat)` in the
|
||||
source, with the condition for removing or replacing each one. Search for this
|
||||
marker during upgrades. In particular, remove the test `artisan()` override once
|
||||
Laravel's inherited helper works, and remove both the package-manifest patch
|
||||
and its Composer hook once Laravel supports Composer 2 directly.
|
||||
|
||||
The test startup timestamp, isolated database settings, and database safety
|
||||
check are normal test infrastructure, not temporary version workarounds.
|
||||
|
||||
- `phpdocumentor/phpdocumentor` 2.* was removed from `require-dev`. It requires
|
||||
Symfony 2.x, conflicts with Laravel 5.6, and was never present in
|
||||
`composer.lock`.
|
||||
- `scripts/patch-package-manifest.php` runs after Composer's autoload dump. It
|
||||
adds Composer 2 support to Laravel 5.6's package discovery, which otherwise
|
||||
fails with `Undefined index: name`.
|
||||
- `fzaninotto/faker` was updated to 1.9.2 for PHP 7.4 compatibility.
|
||||
- `bookings.note` is created as `TEXT` because the full dataset in
|
||||
`database/seeds/data/real` contains notes longer than the default
|
||||
191-character string length.
|
||||
|
||||
## License
|
||||
|
||||
Pollux is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -24,48 +24,15 @@ class BookingsController extends Controller
|
||||
*/
|
||||
public function index($year)
|
||||
{
|
||||
$bookings = Booking::with('subBookings.accountType.accountClass.ioType',
|
||||
'subBookings.project')
|
||||
->whereYear('date', '=', $year)
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->groupBy( function ($item) {
|
||||
return Carbon::parse($item->date)->format('n');
|
||||
});
|
||||
$cashbook = $this->getCashbook($year);
|
||||
|
||||
$sums = array();
|
||||
return view('bookings.index', $cashbook);
|
||||
}
|
||||
|
||||
$startBalance = SubBooking::balanceAt($year - 1 . '-12-31');
|
||||
$sums[0]['balance'] = $startBalance;
|
||||
public function report($year) {
|
||||
$cashbook = $this->getCashbook($year);
|
||||
|
||||
$expeditures = SubBooking::expedituresInInterval($year . '-01-01', $year . '-12-31', true);
|
||||
|
||||
$incomes = SubBooking::incomesInInterval($year . '-01-01', $year . '-12-31', true);
|
||||
|
||||
// Create an array with the following properties:
|
||||
// 'balance' - The account balance at the last day of a month
|
||||
// 'expeditures' - the sum of all expeditures in this month
|
||||
// 'incomes' - the sum of all incomes in this month plus the account
|
||||
// balance of the previous month
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$currExpeditures = $expeditures->where('month', '=', $month)->first()->expeditures;
|
||||
|
||||
$currIncomes = $expeditures->where('month', '<', $month)->sum('expeditures')
|
||||
+ $incomes->where('month', '<=', $month)->sum('incomes')
|
||||
+ $startBalance;
|
||||
|
||||
$currBalance = $currExpeditures + $currIncomes;
|
||||
|
||||
$sums[$month]['expeditures'] = $currExpeditures;
|
||||
$sums[$month]['incomes'] = $currIncomes;
|
||||
$sums[$month]['balance'] = $currBalance;
|
||||
}
|
||||
|
||||
$firstDateString = Carbon::parse(Booking::firstDate());
|
||||
$firstDate['Y'] = $firstDateString->format('Y');
|
||||
$firstDate['m'] = $firstDateString->format('m');
|
||||
|
||||
return view('bookings.index', compact('bookings', 'year', 'sums', 'firstDate'));
|
||||
return view('bookings.print', $cashbook);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,4 +313,49 @@ class BookingsController extends Controller
|
||||
|
||||
return compact('openTargetsArray', 'projectsArray', 'accountTypeArray');
|
||||
}
|
||||
|
||||
private function getCashbook($year) {
|
||||
$bookings = Booking::with('subBookings.accountType.accountClass.ioType',
|
||||
'subBookings.project')
|
||||
->whereYear('date', '=', $year)
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->groupBy( function ($item) {
|
||||
return Carbon::parse($item->date)->format('n');
|
||||
});
|
||||
|
||||
$sums = array();
|
||||
|
||||
$startBalance = SubBooking::balanceAt($year - 1 . '-12-31');
|
||||
$sums[0]['balance'] = $startBalance;
|
||||
|
||||
$expeditures = SubBooking::expedituresInInterval($year . '-01-01', $year . '-12-31', true);
|
||||
|
||||
$incomes = SubBooking::incomesInInterval($year . '-01-01', $year . '-12-31', true);
|
||||
|
||||
// Create an array with the following properties:
|
||||
// 'balance' - The account balance at the last day of a month
|
||||
// 'expeditures' - the sum of all expeditures in this month
|
||||
// 'incomes' - the sum of all incomes in this month plus the account
|
||||
// balance of the previous month
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$currExpeditures = $expeditures->where('month', '=', $month)->first()->expeditures;
|
||||
|
||||
$currIncomes = $expeditures->where('month', '<', $month)->sum('expeditures')
|
||||
+ $incomes->where('month', '<=', $month)->sum('incomes')
|
||||
+ $startBalance;
|
||||
|
||||
$currBalance = $currExpeditures + $currIncomes;
|
||||
|
||||
$sums[$month]['expeditures'] = $currExpeditures;
|
||||
$sums[$month]['incomes'] = $currIncomes;
|
||||
$sums[$month]['balance'] = $currBalance;
|
||||
}
|
||||
|
||||
$firstDateString = Carbon::parse(Booking::firstDate());
|
||||
$firstDate['Y'] = $firstDateString->format('Y');
|
||||
$firstDate['m'] = $firstDateString->format('m');
|
||||
|
||||
return compact('bookings', 'year', 'sums', 'firstDate');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,106 @@ class ReportsController extends Controller
|
||||
return view('reports.scopes.compact', compact('scopes', 'year',
|
||||
'startBalance', 'endBalance'));
|
||||
} elseif ($type == 'detailed') {
|
||||
return view('reports.scopes.detailed');
|
||||
$yearFilter = function($query) use ($year) {
|
||||
$query->whereHas('booking', function($query) use ($year) {
|
||||
$query->whereYear('date', $year);
|
||||
});
|
||||
};
|
||||
|
||||
$scopes = Scope::select('id', 'name')
|
||||
->with([
|
||||
'accountClasses:id,io_type_id,scope_id,name',
|
||||
'accountClasses.ioType:id,factor',
|
||||
'accountClasses.accountTypes' => function($query) use ($year, $yearFilter) {
|
||||
return $query->select('id', 'account_class_id', 'name', 'number')
|
||||
->whereHas('subBookings', $yearFilter)
|
||||
->with(['subBookings' => $yearFilter,
|
||||
'subBookings.booking:id,person_or_company,date']);
|
||||
}
|
||||
])->get();
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
foreach ($scope->accountClasses as $accountClass) {
|
||||
$accountClass->amount =
|
||||
$accountClass->accountTypes->sum(function ($accountType) {
|
||||
return $accountType->subBookings->sum('amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return view('reports.scopes.detailed', compact('scopes', 'year'));
|
||||
} else {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
public function printScopes($type, $year)
|
||||
{
|
||||
if ($type == 'compact') {
|
||||
$yearFilter = function($query) use ($year) {
|
||||
$query->whereHas('booking', function($query) use ($year) {
|
||||
$query->whereYear('date', $year);
|
||||
});
|
||||
};
|
||||
|
||||
$scopes = Scope::select('id', 'name')
|
||||
->with([
|
||||
'accountClasses:id,io_type_id,scope_id,name',
|
||||
'accountClasses.ioType:id,factor',
|
||||
'accountClasses.accountTypes' => function($query) use ($year, $yearFilter) {
|
||||
return $query->select('id', 'account_class_id', 'name', 'number')
|
||||
->whereHas('subBookings', $yearFilter)
|
||||
->with(['subBookings' => $yearFilter]);
|
||||
}
|
||||
])->get();
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
foreach ($scope->accountClasses as $accountClass) {
|
||||
$accountClass->amount =
|
||||
$accountClass->accountTypes->sum(function ($accountType) {
|
||||
return $accountType->subBookings->sum('amount');
|
||||
});
|
||||
}
|
||||
|
||||
$scope->balance = $scope->accountClasses->sum(function ($accountClass) {
|
||||
return $accountClass->amount * $accountClass->ioType->factor;
|
||||
});
|
||||
}
|
||||
|
||||
$startBalance = SubBooking::balanceAt(lastDayOfYear($year - 1));
|
||||
$endBalance = $startBalance + $scopes->sum('balance');
|
||||
|
||||
return view('reports.scopes.compact_print', compact('scopes', 'year',
|
||||
'startBalance', 'endBalance'));
|
||||
} elseif ($type == 'detailed') {
|
||||
$yearFilter = function($query) use ($year) {
|
||||
$query->whereHas('booking', function($query) use ($year) {
|
||||
$query->whereYear('date', $year);
|
||||
});
|
||||
};
|
||||
|
||||
$scopes = Scope::select('id', 'name')
|
||||
->with([
|
||||
'accountClasses:id,io_type_id,scope_id,name',
|
||||
'accountClasses.ioType:id,factor',
|
||||
'accountClasses.accountTypes' => function($query) use ($year, $yearFilter) {
|
||||
return $query->select('id', 'account_class_id', 'name', 'number')
|
||||
->whereHas('subBookings', $yearFilter)
|
||||
->with(['subBookings' => $yearFilter,
|
||||
'subBookings.booking:id,person_or_company,date']);
|
||||
}
|
||||
])->get();
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
foreach ($scope->accountClasses as $accountClass) {
|
||||
$accountClass->amount =
|
||||
$accountClass->accountTypes->sum(function ($accountType) {
|
||||
return $accountType->subBookings->sum('amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return view('reports.scopes.detailed_print', compact('scopes', 'year'));
|
||||
} else {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\CustomValidator;
|
||||
use Validator;
|
||||
@@ -18,6 +19,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
Validator::resolver(function ($translator, $data, $rules, $messages) {
|
||||
return new CustomValidator($translator, $data, $rules, $messages);
|
||||
});
|
||||
|
||||
// TODO(compat): This limits strings to 191 characters for older MySQL
|
||||
// utf8mb4 index limits. Remove once every supported database handles
|
||||
// indexed 255-character utf8mb4 strings; verify fresh migrations first.
|
||||
Schema::defaultStringLength(191);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
name: pollux
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
working_dir: /var/www
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
COMPOSER_HOME: /tmp/composer
|
||||
APP_URL: http://localhost:8000
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: db
|
||||
DB_PORT: "3306"
|
||||
DB_DATABASE: pollux
|
||||
DB_USERNAME: pollux
|
||||
DB_PASSWORD: secret
|
||||
volumes:
|
||||
- .:/var/www
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: php artisan serve --host=0.0.0.0 --port=8000
|
||||
|
||||
db:
|
||||
# TODO(compat): Upgrade MySQL 5.7 after validating a restored data copy,
|
||||
# migrations and reports. Update db-test to the same target version.
|
||||
image: mysql:5.7
|
||||
environment:
|
||||
MYSQL_DATABASE: pollux
|
||||
MYSQL_USER: pollux
|
||||
MYSQL_PASSWORD: secret
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3307:3306"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
db-test:
|
||||
# TODO(compat): Replace this legacy image alongside db during the MySQL
|
||||
# upgrade; keep the test database isolated and run the full test suite.
|
||||
image: mysql:5.7
|
||||
profiles: [testing]
|
||||
environment:
|
||||
MYSQL_DATABASE: pollux_test
|
||||
MYSQL_USER: pollux_test
|
||||
MYSQL_PASSWORD: testing
|
||||
MYSQL_ROOT_PASSWORD: testing-root
|
||||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -ptesting-root --silent"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
+2
-2
@@ -22,8 +22,7 @@
|
||||
"fzaninotto/faker": "^1.4",
|
||||
"mockery/mockery": "^1.0",
|
||||
"nunomaduro/collision": "^2.0",
|
||||
"phpunit/phpunit": "^7.0",
|
||||
"phpdocumentor/phpdocumentor": "2.*"
|
||||
"phpunit/phpunit": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
@@ -57,6 +56,7 @@
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php scripts/patch-package-manifest.php",
|
||||
"@php artisan package:discover"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+24
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "68e5550c8775420cfdaef12858446b78",
|
||||
@@ -1118,12 +1118,12 @@
|
||||
"version": "1.25.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/briannesbitt/Carbon.git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/cbcf13da0b531767e39eb86e9687f5deba9857b4",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/cbcf13da0b531767e39eb86e9687f5deba9857b4",
|
||||
"reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4",
|
||||
"shasum": ""
|
||||
},
|
||||
@@ -2604,12 +2604,12 @@
|
||||
"version": "v2.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vlucas/phpdotenv.git",
|
||||
"url": "https://github.com/phpdotenv/phpdotenv.git",
|
||||
"reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e",
|
||||
"url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e",
|
||||
"reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e",
|
||||
"shasum": ""
|
||||
},
|
||||
@@ -2656,12 +2656,12 @@
|
||||
"version": "v3.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-debugbar.git",
|
||||
"url": "https://github.com/fruitcake/laravel-debugbar.git",
|
||||
"reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/5b68f3972083a7eeec0d6f161962fcda71a127c0",
|
||||
"url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/5b68f3972083a7eeec0d6f161962fcda71a127c0",
|
||||
"reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0",
|
||||
"shasum": ""
|
||||
},
|
||||
@@ -2836,16 +2836,16 @@
|
||||
},
|
||||
{
|
||||
"name": "fzaninotto/faker",
|
||||
"version": "v1.8.0",
|
||||
"version": "v1.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/fzaninotto/Faker.git",
|
||||
"reference": "f72816b43e74063c8b10357394b6bba8cb1c10de"
|
||||
"reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/fzaninotto/Faker/zipball/f72816b43e74063c8b10357394b6bba8cb1c10de",
|
||||
"reference": "f72816b43e74063c8b10357394b6bba8cb1c10de",
|
||||
"url": "https://api.github.com/repos/fzaninotto/Faker/zipball/848d8125239d7dbf8ab25cb7f054f1a630e68c2e",
|
||||
"reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2854,12 +2854,12 @@
|
||||
"require-dev": {
|
||||
"ext-intl": "*",
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7",
|
||||
"squizlabs/php_codesniffer": "^1.5"
|
||||
"squizlabs/php_codesniffer": "^2.9.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.8-dev"
|
||||
"dev-master": "1.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -2882,7 +2882,12 @@
|
||||
"faker",
|
||||
"fixtures"
|
||||
],
|
||||
"time": "2018-07-12T10:23:15+00:00"
|
||||
"support": {
|
||||
"issues": "https://github.com/fzaninotto/Faker/issues",
|
||||
"source": "https://github.com/fzaninotto/Faker/tree/v1.9.2"
|
||||
},
|
||||
"abandoned": true,
|
||||
"time": "2020-12-11T09:56:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "hamcrest/hamcrest-php",
|
||||
@@ -4431,12 +4436,12 @@
|
||||
"version": "1.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozart/assert.git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "0df1908962e7a3071564e857d86874dad1ef204a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a",
|
||||
"reference": "0df1908962e7a3071564e857d86874dad1ef204a",
|
||||
"shasum": ""
|
||||
},
|
||||
@@ -4479,11 +4484,12 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^7.1.3"
|
||||
},
|
||||
"platform-dev": []
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ class CreateBookingsTable extends Migration
|
||||
// The name of the person or company which the booking belongs to
|
||||
$table->string('person_or_company');
|
||||
|
||||
// An additional note to the booking
|
||||
$table->string('note')->nullable();
|
||||
// An additional note to the booking. The real dataset contains
|
||||
// notes longer than the 191-character default string length.
|
||||
$table->text('note')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DevUserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Create a local development login.
|
||||
*
|
||||
* Email: dev@pollux.local
|
||||
* Password: password
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
App\User::updateOrCreate(
|
||||
['email' => 'dev@pollux.local'],
|
||||
[
|
||||
'name' => 'Dev User',
|
||||
'password' => bcrypt('password'),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
set shell := ["bash", "-uc"]
|
||||
|
||||
# Keep files created by containers owned by the host user.
|
||||
export UID := `id -u`
|
||||
export GID := `id -g`
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# First-time setup: build, install PHP deps, create .env, migrate and seed.
|
||||
setup:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.docker.example .env
|
||||
fi
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm --no-deps app composer install
|
||||
|
||||
if ! grep -q '^APP_KEY=base64:' .env; then
|
||||
docker compose run --rm --no-deps app php artisan key:generate --force
|
||||
fi
|
||||
|
||||
docker compose up -d --wait db
|
||||
docker compose run --rm app php artisan migrate --force
|
||||
|
||||
# Only seed the demo data on a fresh database.
|
||||
if [ "$(docker compose exec -T db mysql -N -B -upollux -psecret pollux -e 'SELECT COUNT(*) FROM scopes' 2>/dev/null || echo 0)" = "0" ]; then
|
||||
docker compose run --rm app php artisan db:seed --class=ConstantsSeeder --force
|
||||
docker compose run --rm app php artisan db:seed --class=TestDataSeeder --force
|
||||
else
|
||||
echo "Database already seeded; skipping demo data."
|
||||
fi
|
||||
|
||||
docker compose run --rm app php artisan db:seed --class=DevUserSeeder --force
|
||||
|
||||
echo "Setup complete. Run 'just up', then open http://localhost:8000"
|
||||
echo "Login: dev@pollux.local / password"
|
||||
|
||||
# Start the app and database in the foreground. Use `just up -d` for background.
|
||||
up *args:
|
||||
docker compose up --build {{args}}
|
||||
|
||||
# Stop the app and database.
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Tail logs. Optionally pass a service name: just logs app
|
||||
logs *args:
|
||||
docker compose logs -f {{args}}
|
||||
|
||||
# Run an artisan command: just artisan route:list
|
||||
artisan *args:
|
||||
docker compose run --rm app php artisan {{args}}
|
||||
|
||||
# Run a composer command: just composer require foo/bar
|
||||
composer *args:
|
||||
docker compose run --rm app composer {{args}}
|
||||
|
||||
# Open a shell in the app container.
|
||||
shell:
|
||||
docker compose run --rm app bash
|
||||
|
||||
# Open a MySQL shell.
|
||||
db:
|
||||
docker compose up -d --wait db
|
||||
docker compose exec db mysql -upollux -psecret pollux
|
||||
|
||||
# Run migrations.
|
||||
migrate:
|
||||
docker compose run --rm app php artisan migrate
|
||||
|
||||
# Add the demo/test dataset to an existing database.
|
||||
seed:
|
||||
docker compose run --rm app php artisan db:seed --class=TestDataSeeder
|
||||
|
||||
# Recreate the database and load the full dataset from database/seeds/data/real.
|
||||
seed-real:
|
||||
docker compose run --rm app php artisan migrate:fresh
|
||||
docker compose run --rm app php artisan db:seed --force
|
||||
docker compose run --rm app php artisan db:seed --class=DevUserSeeder --force
|
||||
|
||||
# Recreate the database and load test data.
|
||||
fresh:
|
||||
docker compose run --rm app php artisan migrate:fresh
|
||||
docker compose run --rm app php artisan db:seed --class=ConstantsSeeder
|
||||
docker compose run --rm app php artisan db:seed --class=TestDataSeeder
|
||||
docker compose run --rm app php artisan db:seed --class=DevUserSeeder
|
||||
|
||||
# Run the test suite.
|
||||
test *args:
|
||||
docker compose up -d --wait db-test
|
||||
docker compose run --rm --no-deps app vendor/bin/phpunit {{args}}
|
||||
|
||||
# Build frontend assets (uses the mise-pinned Node 10).
|
||||
assets:
|
||||
mise exec -- npm install
|
||||
mise exec -- npm run dev
|
||||
|
||||
# Watch frontend assets.
|
||||
watch:
|
||||
mise exec -- npm run watch
|
||||
@@ -0,0 +1,9 @@
|
||||
[tools]
|
||||
# Old Laravel Mix 2 / node-sass toolchain; use Node 10 for asset builds.
|
||||
# TODO(compat): Replace this pin with a supported Node LTS when Mix 2 and
|
||||
# node-sass are replaced; verify development and production asset builds.
|
||||
node = "10.24.1"
|
||||
just = "1.59.0"
|
||||
|
||||
[env]
|
||||
COMPOSE_PROJECT_NAME = "pollux"
|
||||
+15
-6
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
backupStaticAttributes="false"
|
||||
bootstrap="vendor/autoload.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
@@ -23,11 +23,20 @@
|
||||
</whitelist>
|
||||
</filter>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_DEBUG" value="false" force="true"/>
|
||||
<env name="APP_KEY" value="base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" force="true"/>
|
||||
<env name="DB_CONNECTION" value="mysql" force="true"/>
|
||||
<env name="DB_HOST" value="db-test" force="true"/>
|
||||
<env name="DB_PORT" value="3306" force="true"/>
|
||||
<env name="DB_DATABASE" value="pollux_test" force="true"/>
|
||||
<env name="DB_USERNAME" value="pollux_test" force="true"/>
|
||||
<env name="DB_PASSWORD" value="testing" force="true"/>
|
||||
<env name="DB_SOCKET" value="" force="true"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="array"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="QUEUE_DRIVER" value="sync"/>
|
||||
<env name="MAIL_DRIVER" value="array"/>
|
||||
<env name="CACHE_DRIVER" value="array" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="QUEUE_DRIVER" value="sync" force="true"/>
|
||||
<env name="MAIL_DRIVER" value="array" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<p align="center"><img src="https://laravel.com/assets/img/components/logo-laravel.svg"></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://travis-ci.org/laravel/framework"><img src="https://travis-ci.org/laravel/framework.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/d/total.svg" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/v/stable.svg" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/license.svg" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel attempts to take the pain out of development by easing common tasks used in the majority of web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, yet powerful, providing tools needed for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of any modern web application framework, making it a breeze to get started learning the framework.
|
||||
|
||||
If you're not in the mood to read, [Laracasts](https://laracasts.com) contains over 1100 video tutorials on a range of topics including Laravel, modern PHP, unit testing, JavaScript, and more. Boost the skill level of yourself and your entire team by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for helping fund on-going Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell):
|
||||
|
||||
- **[Vehikl](https://vehikl.com/)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[Cubet Techno Labs](https://cubettech.com)**
|
||||
- **[British Software Development](https://www.britishsoftware.co)**
|
||||
- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
|
||||
- [UserInsights](https://userinsights.com)
|
||||
- [Fragrantica](https://www.fragrantica.com)
|
||||
- [SOFTonSOFA](https://softonsofa.com/)
|
||||
- [User10](https://user10.com)
|
||||
- [Soumettre.fr](https://soumettre.fr/)
|
||||
- [CodeBrisk](https://codebrisk.com)
|
||||
- [1Forge](https://1forge.com)
|
||||
- [TECPRESSO](https://tecpresso.co.jp/)
|
||||
- [Runtime Converter](http://runtimeconverter.com/)
|
||||
- [WebL'Agence](https://weblagence.com/)
|
||||
- [Invoice Ninja](https://www.invoiceninja.com)
|
||||
- [iMi digital](https://www.imi-digital.de/)
|
||||
- [Earthlink](https://www.earthlink.ro/)
|
||||
- [Steadfast Collective](https://steadfastcollective.com/)
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -8,6 +8,8 @@
|
||||
<a href='/reports/balances/{{ $year }}' class="btn btn-outline-warning">HS-Rechnung</a>
|
||||
<a href='/reports/scopes/compact/{{ $year }}' class="btn
|
||||
btn-outline-warning">Abrechnung</a>
|
||||
<a href='/reports/scopes/detailed/{{ $year }}' class="btn
|
||||
btn-outline-warning">Konten</a>
|
||||
</li>
|
||||
@endsection
|
||||
|
||||
@@ -42,7 +44,7 @@
|
||||
or $firstDate['Y'] == $year
|
||||
and $firstDate['m'] <= $month
|
||||
))
|
||||
<tr class='table-danger'>
|
||||
<tr style='color:#ff0000'>
|
||||
<th>{{ startOfMonth($year, $month) }}</th>
|
||||
<th>Saldo</th>
|
||||
<th></th>
|
||||
@@ -100,7 +102,7 @@
|
||||
</tr>
|
||||
@if (!$booking->isSimple())
|
||||
@foreach ($booking->subBookings as $subBooking)
|
||||
<tr class='table-secondary'>
|
||||
<tr style='font-style:italic'>
|
||||
<td></td>
|
||||
<td>{{ $subBooking->reference }}</td>
|
||||
<td></td>
|
||||
@@ -137,7 +139,7 @@
|
||||
or $firstDate['Y'] == $year
|
||||
and $firstDate['m'] <= $month
|
||||
))
|
||||
<tr class='table-primary'>
|
||||
<tr style='color:#000080'>
|
||||
<th>
|
||||
{{ endOfMonth($year, $month) }}
|
||||
</th>
|
||||
@@ -149,7 +151,7 @@
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr class='table-danger'>
|
||||
<tr style='color:#ff0000'>
|
||||
<th>
|
||||
{{ endOfMonth($year, $month) }}
|
||||
</th>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
@extends('layout.print')
|
||||
|
||||
@section('title', 'Kassenbuch ' . $year)
|
||||
|
||||
@section('content')
|
||||
@for ($month = 1; $month <= 12; $month++)
|
||||
<table class='table table-sm table-bordered' style='page-break-after: always'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Positionen</th>
|
||||
<th>Person / Firma</th>
|
||||
<th style='width:2.6cm'>Ausgang</th>
|
||||
<th style='width:2.6cm'>Eingang</th>
|
||||
<th>Konto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@if ((
|
||||
date('Y') > $year
|
||||
or (date('Y') == $year
|
||||
and date('m') >= $month)
|
||||
) and (
|
||||
$firstDate['Y'] < $year
|
||||
or $firstDate['Y'] == $year
|
||||
and $firstDate['m'] <= $month
|
||||
))
|
||||
<tr style='color:#ff0000'>
|
||||
<th>{{ startOfMonth($year, $month) }}</th>
|
||||
<th>Saldo</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>{!! formatAmount($sums[$month - 1]['balance']) !!}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@endif
|
||||
@if (isset($bookings[$month]))
|
||||
@foreach($bookings[$month] as $booking)
|
||||
<tr>
|
||||
<td>{{ formatDate($booking->date) }}</td>
|
||||
<td>{{ $booking->reference }}</td>
|
||||
<td>{{ $booking->person_or_company }}</td>
|
||||
<td align='right'>
|
||||
@if ($booking->signedAmount() < 0)
|
||||
{!! formatAmount($booking->signedAmount()) !!}
|
||||
@endif
|
||||
</td>
|
||||
<td align='right'>
|
||||
@if ($booking->signedAmount() >= 0)
|
||||
{!! formatAmount($booking->signedAmount()) !!}
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if ($booking->isSimple())
|
||||
{{ $booking->accountType()->number }}
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if (!$booking->isSimple())
|
||||
@foreach ($booking->subBookings as $subBooking)
|
||||
<tr style='font-style:italic'>
|
||||
<td></td>
|
||||
<td>{{ $subBooking->reference }}</td>
|
||||
<td></td>
|
||||
<td align='right'>
|
||||
@if ($subBooking->signedAmount() < 0)
|
||||
{!! formatAmount($subBooking->signedAmount()) !!}
|
||||
@endif
|
||||
</td>
|
||||
<td align='right'>
|
||||
@if ($subBooking->signedAmount() >= 0)
|
||||
{!! formatAmount($subBooking->signedAmount()) !!}
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{ $subBooking->accountType->number }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@if ((
|
||||
date('Y') > $year
|
||||
or (date('Y') == $year
|
||||
and date('m') >= $month)
|
||||
) and (
|
||||
$firstDate['Y'] < $year
|
||||
or $firstDate['Y'] == $year
|
||||
and $firstDate['m'] <= $month
|
||||
))
|
||||
<tr style='color:#000080'>
|
||||
<th>
|
||||
{{ endOfMonth($year, $month) }}
|
||||
</th>
|
||||
<th>Monatsabschluss</th>
|
||||
<th></th>
|
||||
<th>{!! formatAmount($sums[$month]['expeditures']) !!}</th>
|
||||
<th>{!! formatAmount($sums[$month]['incomes']) !!}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr style='color:#ff0000'>
|
||||
<th>
|
||||
{{ endOfMonth($year, $month) }}
|
||||
</th>
|
||||
<th>Saldo</th>
|
||||
<th></th>
|
||||
<th>{!! formatAmount($sums[$month]['balance']) !!}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
@endfor
|
||||
@endsection
|
||||
@@ -68,7 +68,5 @@
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Twäng! e. V. - @yield('title')</title>
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
|
||||
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
|
||||
crossorigin="anonymous">
|
||||
|
||||
<style>
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@yield('content')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
@extends('layout.print')
|
||||
|
||||
@section('title', 'Abrechnung ' . $year)
|
||||
|
||||
@section('content')
|
||||
<table class='table table-sm table-borderless'>
|
||||
<tr>
|
||||
<td>
|
||||
<b>Kontostand am 1. 1. {{ $year }}</b>
|
||||
</td>
|
||||
<td>
|
||||
<b>{!! formatAmount($startBalance) !!}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan='2'></td></tr>
|
||||
@foreach($scopes as $scope)
|
||||
<tr>
|
||||
<td colspan='2'><b>{{ $scope->name }}</b></td>
|
||||
</tr>
|
||||
@foreach($scope->accountClasses as $accountClass)
|
||||
<tr>
|
||||
<td colspan='2' style='text-indent: 2em;'>
|
||||
<u>{{ $accountClass->name }}</u>
|
||||
</td>
|
||||
</tr>
|
||||
@foreach($accountClass->accountTypes as $accountType)
|
||||
<tr>
|
||||
<td style='text-indent: 4em;'>
|
||||
{{$accountType->number}} - {{$accountType->name}}
|
||||
</td>
|
||||
<td>
|
||||
{!! formatAmount($accountType->subBookings->sum('amount')) !!}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr><td colspan='2'></td></tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<td colspan='2' style='text-indent: 2em;'>
|
||||
<u>GESAMT</u>
|
||||
</td>
|
||||
<tr>
|
||||
@foreach($scope->accountClasses as $accountClass)
|
||||
<tr>
|
||||
<td style='text-indent: 4em;'>
|
||||
{{ $accountClass->name }}
|
||||
</td>
|
||||
<td>
|
||||
{!! formatAmount($accountClass->amount) !!}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<td style='text-indent: 4em;'>
|
||||
Saldo {{ $scope->name }}
|
||||
</td>
|
||||
<td>{!! formatAmount($scope->balance) !!}</td>
|
||||
</tr>
|
||||
<tr><td colspan='2'></td></tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<td colspan='2'>
|
||||
<b>Übersicht</b>
|
||||
</td>
|
||||
<tr>
|
||||
@foreach($scopes as $scope)
|
||||
<tr>
|
||||
<td style='text-indent: 4em;'>
|
||||
Saldo {{ $scope->name }}
|
||||
</td>
|
||||
<td>{!! formatAmount($scope->balance) !!}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<td style='text-indent: 4em;'>Gesamtsaldo</td>
|
||||
<td>{!! formatAmount($scopes->sum('balance')) !!}</td>
|
||||
</tr>
|
||||
<tr><td colspan='2'></td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<b>Kontostand am 31. 12. {{ $year }}</b>
|
||||
</td>
|
||||
<td>
|
||||
<b>{!! formatAmount($endBalance) !!}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@endsection
|
||||
@@ -0,0 +1,48 @@
|
||||
@extends('layout.master')
|
||||
|
||||
@section('title', 'Buchungen nach Konten ' . $year)
|
||||
|
||||
@section('subnav')
|
||||
<a class='btn btn-outline-secondary' href='/bookings/year-{{ $year }}'>
|
||||
Zurück
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<h2>@yield('title')</h2>
|
||||
@foreach($scopes as $scope)
|
||||
<h3>{{ $scope->name }}</h3>
|
||||
@foreach($scope->accountClasses as $accountClass)
|
||||
<h4>{{ $accountClass->name }}</h4>
|
||||
<table class='table'>
|
||||
<tr style='color: #000080'>
|
||||
<th>Datum</th>
|
||||
<th>Position</th>
|
||||
<th>Person / Firma</th>
|
||||
<th>Betrag</th>
|
||||
</td>
|
||||
@foreach($accountClass->accountTypes as $accountType)
|
||||
<tr>
|
||||
<th colspan='4' style='color: #000080'>
|
||||
{{ $accountType->number . ' - ' . $accountType->name }}
|
||||
</th>
|
||||
</tr>
|
||||
@foreach($accountType->subBookings as $subBooking)
|
||||
<tr>
|
||||
<td>{{ formatDate($subBooking->date()) }}</td>
|
||||
<td>{{ $subBooking->reference }}</td>
|
||||
<td>{{ $subBooking->personOrCompany() }}</td>
|
||||
<td>{!! formatAmount($subBooking->amount) !!}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<th colspan='4' style='text-align: right; color:#ff0000'>
|
||||
{!! formatAmount($accountType->subBookings
|
||||
->sum('amount')) !!}
|
||||
</th>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@extends('layout.print')
|
||||
|
||||
@section('title', 'Buchungen nach Konten ' . $year)
|
||||
|
||||
@section('content')
|
||||
@foreach($scopes as $scope)
|
||||
@foreach($scope->accountClasses as $accountClass)
|
||||
<h5>{{ $scope->name }}</h5>
|
||||
<b>{{ $accountClass->name }}</b>
|
||||
<table class='table table-sm table-borderless' style='page-break-after: always'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Position</th>
|
||||
<th>Person / Firma</th>
|
||||
<th>Betrag</th>
|
||||
</td>
|
||||
</thead>
|
||||
@foreach($accountClass->accountTypes as $accountType)
|
||||
<tr>
|
||||
<th colspan='4' style='color: #000080'>
|
||||
{{ $accountType->number . ' - ' . $accountType->name }}
|
||||
</th>
|
||||
</tr>
|
||||
@foreach($accountType->subBookings as $subBooking)
|
||||
<tr>
|
||||
<td>{{ formatDate($subBooking->date()) }}</td>
|
||||
<td>{{ $subBooking->reference }}</td>
|
||||
<td>{{ $subBooking->personOrCompany() }}</td>
|
||||
<td>{!! formatAmount($subBooking->amount) !!}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr>
|
||||
<th colspan='4' style='text-align: right; color:#ff0000'>
|
||||
{!! formatAmount($accountType->subBookings
|
||||
->sum('amount')) !!}
|
||||
</th>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@endsection
|
||||
@@ -33,6 +33,7 @@ Route::middleware(['auth'])->group(function() {
|
||||
**************************************************************************/
|
||||
Route::redirect('/bookings', 'bookings/year-' . date('Y'));
|
||||
Route::get('/bookings/year-{year}', 'BookingsController@index');
|
||||
Route::get('/bookings/year-{year}/print', 'BookingsController@report');
|
||||
Route::post('/bookings', 'BookingsController@store');
|
||||
|
||||
Route::get('/bookings/create', 'BookingsController@create');
|
||||
@@ -74,6 +75,7 @@ Route::middleware(['auth'])->group(function() {
|
||||
Route::get('/reports/projects/{project}', 'ReportsController@project');
|
||||
Route::get('/reports/balances/{year}', 'ReportsController@balances');
|
||||
Route::get('/reports/scopes/{type}/{year}', 'ReportsController@scopes');
|
||||
Route::get('/reports/scopes/{type}/{year}/print', 'ReportsController@printScopes');
|
||||
Route::get('/reports/targets/', 'ReportsController@targets');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Laravel 5.6's PackageManifest predates Composer 2. Composer 2 stores the
|
||||
* installed package list under a "packages" key, which makes Laravel's
|
||||
* package:discover command fail with "Undefined index: name".
|
||||
*
|
||||
* This script adds support for both formats to the vendored framework file.
|
||||
* Composer runs it before package:discover, so it is safe to run repeatedly.
|
||||
*
|
||||
* TODO(compat): Once the upgraded Laravel supports Composer 2's installed
|
||||
* package format itself, delete this script AND its post-autoload-dump entry
|
||||
* in composer.json. Verify composer install and artisan package:discover
|
||||
* succeed with a clean vendor directory and without this patch.
|
||||
*/
|
||||
|
||||
$file = __DIR__.'/../vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php';
|
||||
|
||||
if (! file_exists($file)) {
|
||||
fwrite(STDERR, "PackageManifest.php not found; skipping Composer 2 patch.\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if (strpos($contents, "\$packages = \$packages['packages'] ?? \$packages;") !== false) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$needle = '$packages = json_decode($this->files->get($path), true);';
|
||||
$replacement = $needle."\n\n \$packages = \$packages['packages'] ?? \$packages;";
|
||||
|
||||
if (strpos($contents, $needle) === false) {
|
||||
fwrite(STDERR, "Could not find PackageManifest build() snippet; skipping patch.\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
file_put_contents($file, str_replace($needle, $replacement, $contents));
|
||||
@@ -17,6 +17,16 @@ trait CreatesApplication
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
// RefreshDatabase runs migrate:fresh. Refuse cached or overridden
|
||||
// configuration that could point it at the application's database.
|
||||
if (!$app->environment('testing')
|
||||
|| config('database.default') !== 'mysql'
|
||||
|| config('database.connections.mysql.host') !== 'db-test'
|
||||
|| config('database.connections.mysql.database') !== 'pollux_test'
|
||||
|| !empty(config('database.connections.mysql.unix_socket'))) {
|
||||
throw new \RuntimeException('Tests require the isolated db-test / pollux_test database. Clear cached configuration before running tests.');
|
||||
}
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use App\AccountClass;
|
||||
use App\AccountType;
|
||||
use App\Booking;
|
||||
use App\IOType;
|
||||
use App\Person;
|
||||
use App\PersonTarget;
|
||||
use App\Project;
|
||||
use App\Scope;
|
||||
use App\SubBooking;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
abstract class DatabaseTestCase extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $incomeAccount;
|
||||
protected $expenseAccount;
|
||||
protected $project;
|
||||
protected $user;
|
||||
|
||||
public function artisan($command, $parameters = [])
|
||||
{
|
||||
// TODO(compat): Laravel 5.6's Kernel::call uses a numeric command key, which the
|
||||
// locked Symfony Console reads as a string offset on PHP 7.4.
|
||||
// After upgrading Laravel/Symfony, remove this entire override and its
|
||||
// Kernel, ArrayInput and BufferedOutput imports once `just test` passes
|
||||
// using Laravel's inherited artisan() helper.
|
||||
$output = new BufferedOutput;
|
||||
$status = $this->app[Kernel::class]->handle(
|
||||
new ArrayInput(array_merge(['command' => $command], $parameters)), $output
|
||||
);
|
||||
$this->assertSame(0, $status, $output->fetch());
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
protected function setUp()
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Small, explicit fixtures; no dependency on demo data or fixed IDs.
|
||||
$scope = $this->record(Scope::class, ['name' => 'Test scope']);
|
||||
foreach ([1 => 'Einnahme', -1 => 'Ausgabe'] as $factor => $name) {
|
||||
$io = $this->record(IOType::class, compact('name', 'factor'));
|
||||
$class = $this->record(AccountClass::class, [
|
||||
'name' => $name, 'number' => $factor > 0 ? 10 : 20,
|
||||
'scope_id' => $scope->id, 'io_type_id' => $io->id,
|
||||
]);
|
||||
$account = $this->record(AccountType::class, [
|
||||
'name' => $name, 'number' => $factor > 0 ? 100 : 200,
|
||||
'account_class_id' => $class->id,
|
||||
]);
|
||||
if ($factor > 0) {
|
||||
$this->incomeAccount = $account;
|
||||
} else {
|
||||
$this->expenseAccount = $account;
|
||||
}
|
||||
}
|
||||
|
||||
$this->project = $this->record(Project::class, [
|
||||
'name' => 'Test project', 'start_date' => '2019-01-01', 'end_date' => '2020-12-31',
|
||||
]);
|
||||
$this->user = $this->record(User::class, [
|
||||
'name' => 'Test user', 'email' => 'test@example.com', 'password' => bcrypt('test-password'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function record($class, array $attributes)
|
||||
{
|
||||
$model = new $class;
|
||||
$model->forceFill($attributes)->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
protected function booking($date, $amount, $account = null, array $attributes = [])
|
||||
{
|
||||
$booking = $this->record(Booking::class, [
|
||||
'date' => $date, 'reference' => 'Test booking', 'person_or_company' => 'Test company',
|
||||
]);
|
||||
$this->record(SubBooking::class, array_merge([
|
||||
'booking_id' => $booking->id, 'reference' => 'Test line', 'amount' => $amount,
|
||||
'account_type_id' => ($account ?: $this->incomeAccount)->id,
|
||||
'project_id' => $this->project->id,
|
||||
], $attributes));
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
protected function target($amount = 100, $account = null)
|
||||
{
|
||||
$person = $this->record(Person::class, ['title' => 'Firma', 'name_or_company' => 'Test company']);
|
||||
|
||||
return $this->record(PersonTarget::class, [
|
||||
'person_id' => $person->id, 'date' => '2020-01-01', 'reference' => 'Test target',
|
||||
'amount' => $amount, 'io_type_id' => ($account ?: $this->incomeAccount)->accountClass->io_type_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\DatabaseTestCase;
|
||||
|
||||
class AuthenticationTest extends DatabaseTestCase
|
||||
{
|
||||
/** @dataProvider protectedRoutes */
|
||||
public function test_guests_are_redirected_to_login($method, $path)
|
||||
{
|
||||
$this->call($method, $path)->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function protectedRoutes()
|
||||
{
|
||||
return [
|
||||
['GET', '/bookings/year-2020'], ['GET', '/people'],
|
||||
['GET', '/projects'], ['GET', '/targets'],
|
||||
['GET', '/reports/balances/2020'], ['GET', '/reports/scopes/compact/2020'],
|
||||
['POST', '/bookings'], ['PATCH', '/bookings/1'], ['DELETE', '/bookings/1'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_login_form_is_available()
|
||||
{
|
||||
$this->get('/login')->assertStatus(200)->assertSee('password');
|
||||
}
|
||||
|
||||
public function test_guests_cannot_change_or_delete_an_existing_booking()
|
||||
{
|
||||
$booking = $this->booking('2020-01-15', 100);
|
||||
$this->patch('/bookings/' . $booking->id, [
|
||||
'booking' => ['reference' => 'Unauthorized change'],
|
||||
])->assertRedirect('/login');
|
||||
$this->delete('/bookings/' . $booking->id)->assertRedirect('/login');
|
||||
$this->assertSame('Test booking', $booking->fresh()->reference);
|
||||
$this->assertCount(1, $booking->fresh()->subBookings);
|
||||
}
|
||||
|
||||
public function test_valid_credentials_log_in_and_redirect_to_bookings()
|
||||
{
|
||||
$this->post('/login', ['email' => $this->user->email, 'password' => 'test-password'])
|
||||
->assertRedirect('/bookings');
|
||||
$this->assertAuthenticatedAs($this->user);
|
||||
}
|
||||
|
||||
public function test_wrong_password_is_rejected()
|
||||
{
|
||||
$this->from('/login')->post('/login', [
|
||||
'email' => $this->user->email, 'password' => 'wrong-password',
|
||||
])->assertRedirect('/login')->assertSessionHasErrors('email');
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_logout_ends_authentication()
|
||||
{
|
||||
$this->actingAs($this->user)->post('/logout')->assertRedirect('/login');
|
||||
$this->assertGuest();
|
||||
$this->get('/bookings/year-2020')->assertRedirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Booking;
|
||||
use App\PersonTarget;
|
||||
use App\SubBooking;
|
||||
use Tests\DatabaseTestCase;
|
||||
|
||||
class BookingsTest extends DatabaseTestCase
|
||||
{
|
||||
protected function payload(array $attributes = [])
|
||||
{
|
||||
return array_replace([
|
||||
'date' => '2020-02-15', 'reference' => 'Membership payment',
|
||||
'person_or_company' => 'Test company', 'note' => 'Test note', 'amount' => '125.50',
|
||||
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
|
||||
'person_target_id' => null, 'personTarget' => ['person_id' => null, 'date' => null],
|
||||
], $attributes);
|
||||
}
|
||||
|
||||
public function test_user_can_create_a_booking_with_its_account_and_project()
|
||||
{
|
||||
$this->actingAs($this->user)->post('/bookings', $this->payload())
|
||||
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
|
||||
|
||||
$booking = Booking::where('reference', 'Membership payment')->firstOrFail();
|
||||
$this->assertSame('2020-02-15', $booking->date);
|
||||
$this->assertSame('Test company', $booking->person_or_company);
|
||||
$this->assertSame('Test note', $booking->note);
|
||||
$this->assertCount(1, $booking->subBookings);
|
||||
$this->assertDatabaseHas('sub_bookings', [
|
||||
'booking_id' => $booking->id, 'amount' => 125.50, 'reference' => 'Membership payment',
|
||||
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_booking_forms_render_with_account_project_and_target_choices()
|
||||
{
|
||||
$target = $this->target();
|
||||
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
|
||||
$this->actingAs($this->user)->get('/bookings/create')->assertStatus(200)
|
||||
->assertSee($this->project->name)->assertSee($this->incomeAccount->name);
|
||||
// A fully paid target must still be selectable when editing its booking.
|
||||
$this->get('/bookings/' . $booking->id . '/edit')->assertStatus(200)
|
||||
->assertSee($target->reference)->assertSee($booking->reference);
|
||||
$this->get('/bookings/' . $booking->id . '/delete')->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @dataProvider invalidBookings */
|
||||
public function test_invalid_bookings_do_not_write_any_records($field, $value)
|
||||
{
|
||||
$this->actingAs($this->user)->from('/bookings/create')
|
||||
->post('/bookings', $this->payload([$field => $value]))
|
||||
->assertRedirect('/bookings/create')->assertSessionHasErrors($field);
|
||||
$this->assertSame(0, Booking::count());
|
||||
$this->assertSame(0, SubBooking::count());
|
||||
}
|
||||
|
||||
public function invalidBookings()
|
||||
{
|
||||
return [
|
||||
['date', ''], ['reference', ''], ['person_or_company', ''],
|
||||
['amount', ''], ['amount', 'not money'], ['amount', '-10'], ['account_type_id', ''],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_booking_can_pay_an_existing_target()
|
||||
{
|
||||
$target = $this->target(125.50);
|
||||
$this->actingAs($this->user)->post('/bookings', $this->payload(['person_target_id' => $target->id]))
|
||||
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseHas('sub_bookings', ['person_target_id' => $target->id, 'amount' => 125.50]);
|
||||
$this->assertTrue($target->fresh()->isPaid());
|
||||
$this->assertSame(1, PersonTarget::count());
|
||||
}
|
||||
|
||||
public function test_booking_can_create_a_target_using_the_booking_date_by_default()
|
||||
{
|
||||
$person = $this->target()->person;
|
||||
$this->actingAs($this->user)->post('/bookings', $this->payload([
|
||||
'personTarget' => ['person_id' => $person->id, 'date' => null],
|
||||
]))->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
|
||||
$target = SubBooking::firstOrFail()->personTarget;
|
||||
$this->assertSame($person->id, $target->person_id);
|
||||
$this->assertSame('2020-02-15', $target->date);
|
||||
$this->assertEquals(125.50, $target->amount);
|
||||
$this->assertTrue($target->isPaid());
|
||||
}
|
||||
|
||||
public function test_user_cannot_choose_an_existing_target_and_create_one_together()
|
||||
{
|
||||
$target = $this->target();
|
||||
$this->actingAs($this->user)->post('/bookings', $this->payload([
|
||||
'person_target_id' => $target->id,
|
||||
'personTarget' => ['person_id' => $target->person_id, 'date' => '2020-01-01'],
|
||||
]))->assertSessionHasErrors('person_target_id');
|
||||
$this->assertSame(0, Booking::count());
|
||||
$this->assertSame(1, PersonTarget::count());
|
||||
}
|
||||
|
||||
public function test_user_can_update_booking_and_split_it_into_income_and_expense_lines()
|
||||
{
|
||||
$booking = $this->booking('2019-12-31', 100);
|
||||
$line = $booking->subBookings->first();
|
||||
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
|
||||
'booking' => ['date' => '2020-01-15', 'reference' => 'Corrected', 'person_or_company' => 'Updated company'],
|
||||
'sub_booking' => [
|
||||
$line->id => ['reference' => 'Income', 'amount' => '120.50', 'account_type_id' => $this->incomeAccount->id],
|
||||
0 => ['reference' => 'Fee', 'amount' => '20.25', 'account_type_id' => $this->expenseAccount->id],
|
||||
],
|
||||
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
|
||||
$booking = $booking->fresh();
|
||||
$this->assertSame('Corrected', $booking->reference);
|
||||
$this->assertSame('2020-01-15', $booking->date);
|
||||
$this->assertSame('Updated company', $booking->person_or_company);
|
||||
$this->assertCount(2, $booking->subBookings);
|
||||
$this->assertEquals(100.25, $booking->signedAmount());
|
||||
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 120.50]);
|
||||
$this->assertDatabaseHas('sub_bookings', ['booking_id' => $booking->id, 'reference' => 'Fee', 'amount' => 20.25]);
|
||||
}
|
||||
|
||||
public function test_invalid_update_leaves_booking_and_lines_unchanged()
|
||||
{
|
||||
$booking = $this->booking('2020-01-15', 100);
|
||||
$line = $booking->subBookings->first();
|
||||
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
|
||||
'booking' => ['date' => '2020-02-01', 'reference' => 'Changed', 'person_or_company' => 'Company'],
|
||||
'sub_booking' => [$line->id => ['reference' => 'Invalid', 'amount' => -1, 'account_type_id' => $this->incomeAccount->id]],
|
||||
])->assertSessionHasErrors('sub_booking.' . $line->id . '.amount');
|
||||
$this->assertSame('2020-01-15', $booking->fresh()->date);
|
||||
$this->assertEquals(100, $line->fresh()->amount);
|
||||
}
|
||||
|
||||
public function test_empty_line_is_removed_while_other_lines_are_kept()
|
||||
{
|
||||
$booking = $this->booking('2020-01-15', 100);
|
||||
$line = $booking->subBookings->first();
|
||||
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
|
||||
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
|
||||
'sub_booking' => [
|
||||
$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null],
|
||||
0 => ['reference' => 'Replacement', 'amount' => 75, 'account_type_id' => $this->incomeAccount->id],
|
||||
],
|
||||
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseMissing('sub_bookings', ['id' => $line->id]);
|
||||
$this->assertCount(1, $booking->fresh()->subBookings);
|
||||
$this->assertEquals(75, $booking->fresh()->signedAmount());
|
||||
}
|
||||
|
||||
public function test_deleting_booking_cascades_to_its_lines_but_keeps_target_and_other_bookings()
|
||||
{
|
||||
$target = $this->target();
|
||||
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
|
||||
$other = $this->booking('2020-02-15', 50);
|
||||
$this->actingAs($this->user)->delete('/bookings/' . $booking->id, ['date' => $booking->date])
|
||||
->assertRedirect('/bookings/year-2020');
|
||||
$this->assertDatabaseMissing('bookings', ['id' => $booking->id]);
|
||||
$this->assertDatabaseMissing('sub_bookings', ['booking_id' => $booking->id]);
|
||||
$this->assertDatabaseHas('bookings', ['id' => $other->id]);
|
||||
$this->assertDatabaseHas('person_targets', ['id' => $target->id]);
|
||||
$this->assertEquals(100, $target->fresh()->difference());
|
||||
}
|
||||
|
||||
public function test_clearing_all_lines_redirects_to_delete_confirmation_without_deleting()
|
||||
{
|
||||
$booking = $this->booking('2020-01-15', 100);
|
||||
$line = $booking->subBookings->first();
|
||||
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
|
||||
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
|
||||
'sub_booking' => [$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null]],
|
||||
])->assertRedirect('/bookings/' . $booking->id . '/delete')->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseHas('bookings', ['id' => $booking->id]);
|
||||
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 100]);
|
||||
}
|
||||
|
||||
public function test_unknown_booking_returns_not_found()
|
||||
{
|
||||
$this->actingAs($this->user)->get('/bookings/999999/edit')->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,6 @@ class ExampleTest extends TestCase
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertRedirect('/bookings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\PersonTarget;
|
||||
use Tests\DatabaseTestCase;
|
||||
|
||||
class PersonTargetsTest extends DatabaseTestCase
|
||||
{
|
||||
/** @dataProvider paymentStates */
|
||||
public function test_payment_states_and_outstanding_balances($expense, $payment, $difference, $state)
|
||||
{
|
||||
$account = $expense ? $this->expenseAccount : $this->incomeAccount;
|
||||
$target = $this->target(100, $account);
|
||||
if ($payment > 0) {
|
||||
$this->booking('2020-01-15', $payment, $account, ['person_target_id' => $target->id]);
|
||||
}
|
||||
$this->assertEquals($difference, $target->difference());
|
||||
$this->assertSame($state === 'paid', $target->isPaid());
|
||||
$this->assertTrue(PersonTarget::$state()->contains('id', $target->id));
|
||||
$this->assertSame($state !== 'paid', PersonTarget::open()->contains('id', $target->id));
|
||||
$this->assertSame($difference > 0, PersonTarget::receivable()->contains('id', $target->id));
|
||||
$this->assertSame($difference < 0, PersonTarget::debt()->contains('id', $target->id));
|
||||
}
|
||||
|
||||
public function paymentStates()
|
||||
{
|
||||
return [
|
||||
'unpaid income' => [false, 0, 100, 'underpaid'],
|
||||
'partial income' => [false, 40, 60, 'underpaid'],
|
||||
'paid income' => [false, 100, 0, 'paid'],
|
||||
'overpaid income' => [false, 120, -20, 'overpaid'],
|
||||
'unpaid expense' => [true, 0, -100, 'underpaid'],
|
||||
'partial expense' => [true, 40, -60, 'underpaid'],
|
||||
'paid expense' => [true, 100, 0, 'paid'],
|
||||
'overpaid expense' => [true, 120, 20, 'overpaid'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_multiple_payments_accumulate_and_targets_page_displays_remaining_total()
|
||||
{
|
||||
$receivable = $this->target(100);
|
||||
$payable = $this->target(80, $this->expenseAccount);
|
||||
$paid = $this->target(25);
|
||||
$this->booking('2020-01-10', 30, null, ['person_target_id' => $receivable->id]);
|
||||
$this->booking('2020-01-20', 20, null, ['person_target_id' => $receivable->id]);
|
||||
$this->booking('2020-01-20', 15, $this->expenseAccount, ['person_target_id' => $payable->id]);
|
||||
$this->booking('2020-01-20', 25, null, ['person_target_id' => $paid->id]);
|
||||
$response = $this->actingAs($this->user)->get('/targets')->assertStatus(200);
|
||||
$data = $response->original->getData();
|
||||
$this->assertEquals(['receivables' => 50, 'debts' => -65], $data['sums']);
|
||||
$this->assertSame([$receivable->id], $data['personTargets']['receivables']->pluck('id')->all());
|
||||
$this->assertSame([$payable->id], $data['personTargets']['debts']->pluck('id')->all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\SubBooking;
|
||||
use Tests\DatabaseTestCase;
|
||||
|
||||
class ReportsTest extends DatabaseTestCase
|
||||
{
|
||||
protected function seedCashFlows()
|
||||
{
|
||||
$this->booking('2019-12-31', 100);
|
||||
$this->booking('2020-01-01', 50.25);
|
||||
$this->booking('2020-01-31', 20.10, $this->expenseAccount);
|
||||
$this->booking('2020-03-01', 10, $this->expenseAccount);
|
||||
$this->booking('2020-12-31', 5.50);
|
||||
$this->booking('2021-01-01', 999);
|
||||
$this->actingAs($this->user);
|
||||
}
|
||||
|
||||
public function test_balance_includes_cutoff_day_and_excludes_future_bookings()
|
||||
{
|
||||
$this->seedCashFlows();
|
||||
$this->assertEquals(0, SubBooking::balanceAt('2019-12-30'));
|
||||
$this->assertEquals(100, SubBooking::balanceAt('2019-12-31'));
|
||||
$this->assertEquals(130.15, SubBooking::balanceAt('2020-01-31'), '', 0.001);
|
||||
$this->assertEquals(125.65, SubBooking::balanceAt('2020-12-31'), '', 0.001);
|
||||
}
|
||||
|
||||
public function test_interval_totals_include_both_endpoints_and_sign_expenses()
|
||||
{
|
||||
$this->seedCashFlows();
|
||||
$this->assertEquals(55.75, SubBooking::incomesInInterval('2020-01-01', '2020-12-31')->first()->incomes, '', 0.001);
|
||||
$this->assertEquals(-30.10, SubBooking::expedituresInInterval('2020-01-01', '2020-12-31')->first()->expeditures, '', 0.001);
|
||||
}
|
||||
|
||||
public function test_monthly_totals_fill_empty_months_across_a_year_boundary()
|
||||
{
|
||||
$this->booking('2019-12-31', 10);
|
||||
$this->booking('2020-02-01', 25);
|
||||
$months = SubBooking::incomesInInterval('2019-12-01', '2020-02-29', true);
|
||||
$this->assertSame([[2019, 12], [2020, 1], [2020, 2]], $months->map(function ($month) {
|
||||
return [(int) $month->year, (int) $month->month];
|
||||
})->all());
|
||||
$this->assertEquals([10, 0, 25], $months->pluck('incomes')->all());
|
||||
}
|
||||
|
||||
public function test_monthly_balance_report_carries_opening_balance_through_empty_months()
|
||||
{
|
||||
$this->seedCashFlows();
|
||||
$response = $this->get('/reports/balances/2020')->assertStatus(200)->assertViewIs('reports.balances');
|
||||
$data = $response->original->getData();
|
||||
$this->assertEquals(100, $data['startBalance']);
|
||||
$balances = $data['balances'];
|
||||
$this->assertCount(12, $balances);
|
||||
$this->assertEquals(50.25, $balances[0]->incomes);
|
||||
$this->assertEquals(-20.10, $balances[0]->expeditures, '', 0.001);
|
||||
$this->assertEquals(30.15, $balances[0]->monthBalance, '', 0.001);
|
||||
$this->assertEquals(130.15, $balances[0]->accountBalance, '', 0.001);
|
||||
$this->assertEquals(0, $balances[1]->monthBalance);
|
||||
$this->assertEquals(130.15, $balances[1]->accountBalance, '', 0.001);
|
||||
$this->assertEquals(120.15, $balances[2]->accountBalance, '', 0.001);
|
||||
$this->assertEquals(125.65, $balances[11]->accountBalance, '', 0.001);
|
||||
$this->assertSame('01.01.2021', $balances[11]->date);
|
||||
}
|
||||
|
||||
/** @dataProvider cashbookPaths */
|
||||
public function test_cashbook_and_print_view_filter_year_and_show_correct_totals($path)
|
||||
{
|
||||
$this->seedCashFlows();
|
||||
$response = $this->get($path)->assertStatus(200);
|
||||
$data = $response->original->getData();
|
||||
$this->assertCount(4, $data['bookings']->flatten());
|
||||
$this->assertEquals(100, $data['sums'][0]['balance']);
|
||||
$this->assertEquals(-20.10, $data['sums'][1]['expeditures'], '', 0.001);
|
||||
$this->assertEquals(150.25, $data['sums'][1]['incomes'], '', 0.001);
|
||||
$this->assertEquals(130.15, $data['sums'][1]['balance'], '', 0.001);
|
||||
$this->assertEquals(130.15, $data['sums'][2]['balance'], '', 0.001);
|
||||
$this->assertEquals(125.65, $data['sums'][12]['balance'], '', 0.001);
|
||||
}
|
||||
|
||||
public function cashbookPaths()
|
||||
{
|
||||
return [['/bookings/year-2020'], ['/bookings/year-2020/print']];
|
||||
}
|
||||
|
||||
/** @dataProvider compactReportPaths */
|
||||
public function test_scope_report_totals_match_cashbook_and_exclude_other_years($path)
|
||||
{
|
||||
$this->seedCashFlows();
|
||||
$response = $this->get($path)->assertStatus(200);
|
||||
$data = $response->original->getData();
|
||||
$this->assertEquals(100, $data['startBalance']);
|
||||
$this->assertEquals(25.65, $data['scopes']->sum('balance'), '', 0.001);
|
||||
$this->assertEquals(125.65, $data['endBalance'], '', 0.001);
|
||||
}
|
||||
|
||||
public function compactReportPaths()
|
||||
{
|
||||
return [['/reports/scopes/compact/2020'], ['/reports/scopes/compact/2020/print']];
|
||||
}
|
||||
|
||||
public function test_project_report_excludes_other_projects_and_orders_bookings_by_date()
|
||||
{
|
||||
$late = $this->booking('2020-03-01', 80);
|
||||
$early = $this->booking('2020-01-01', 20, $this->expenseAccount);
|
||||
$this->booking('2020-02-01', 999, null, ['project_id' => null]);
|
||||
$response = $this->actingAs($this->user)->get('/reports/projects/' . $this->project->id)
|
||||
->assertStatus(200)->assertSee($this->project->name);
|
||||
$data = $response->original->getData();
|
||||
$this->assertEquals(80, $data['incomes']);
|
||||
$this->assertEquals(20, $data['expeditures']);
|
||||
$this->assertSame([$early->id, $late->id], $data['projectBookings']->pluck('booking_id')->values()->all());
|
||||
}
|
||||
|
||||
public function test_reports_render_with_an_empty_database()
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
$response = $this->get('/reports/balances/2020')->assertStatus(200);
|
||||
$data = $response->original->getData();
|
||||
$this->assertEquals(0, $data['startBalance']);
|
||||
$this->assertCount(12, $data['balances']);
|
||||
foreach ($data['balances'] as $balance) {
|
||||
$this->assertEquals(0, $balance->accountBalance);
|
||||
}
|
||||
$this->get('/reports/scopes/compact/2020')->assertStatus(200)->assertViewHas('endBalance', 0);
|
||||
$this->get('/bookings/year-2020')->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\AccountClass;
|
||||
use App\AccountType;
|
||||
use App\Booking;
|
||||
use App\IOType;
|
||||
use App\SubBooking;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookingAmountsTest extends TestCase
|
||||
{
|
||||
public function test_split_booking_uses_account_signs_and_preserves_unsigned_total()
|
||||
{
|
||||
$income = $this->line(125.50, 1);
|
||||
$expense = $this->line(25.25, -1);
|
||||
$booking = new Booking;
|
||||
$booking->setRelation('subBookings', new Collection([$income, $expense]));
|
||||
|
||||
$this->assertEquals(125.50, $income->signedAmount());
|
||||
$this->assertEquals(-25.25, $expense->signedAmount());
|
||||
$this->assertEquals(150.75, $booking->amount());
|
||||
$this->assertEquals(100.25, $booking->signedAmount());
|
||||
$this->assertFalse($booking->isSimple());
|
||||
$this->assertNull($booking->accountType());
|
||||
$this->assertNull($booking->project());
|
||||
$this->assertNull($booking->personTarget());
|
||||
}
|
||||
|
||||
protected function line($amount, $factor)
|
||||
{
|
||||
$io = (new IOType)->forceFill(['factor' => $factor]);
|
||||
$class = (new AccountClass)->setRelation('ioType', $io);
|
||||
$account = (new AccountType)->setRelation('accountClass', $class);
|
||||
|
||||
return (new SubBooking)->forceFill(['amount' => $amount])->setRelation('accountType', $account);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testBasicTest()
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
// Match the HTTP entry point: the shared footer displays elapsed request time.
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
Reference in New Issue
Block a user