GSB AppliFrais

Expense management web app built with CakePHP during my BTS SIO. Users declare their flat-rate and other expenses on claim sheets, which an accountant reviews and then validates.

Overview

GSB is the professional workshop I built during my BTS SIO around the fictional company Galaxy Swiss Bourdin: a web application for managing expenses. Each user opens claim sheets, declares their expenses on them, and an accountant reviews them. I built this application, named AppliFrais in its navigation bar, between November 2023 and April 2024, with CakePHP 4 and MySQL. Authentication and roles rely on the CakeDC/Users plugin.

One sheet, two kinds of expenses

The model revolves around the sheets table. A sheet belongs to a user and carries a state and a sheetvalidated boolean. Flat-rate items (packages) have a unit price set by the administrator, such as hotel or taxi: on the sheet, the user only enters a quantity, stored in the sheets_packages join table. Other expenses (outpackages) have a free title, amount and description, and are linked to the sheet through sheets_outpackages. In SheetsTable, both relations are declared as belongsToMany.

So that the form shows every flat-rate line from the start, I chose to prefill the join table: in SheetsController::list(), once the sheet is saved, each flat-rate item gets a line with a quantity of zero.

$packages = $this->Sheets->Packages->find()->toArray();
 
foreach ($packages as $package) {
    $sheetPackage = $this->Sheets->SheetsPackages->newEntity([
        'sheet_id' => $sheet->id,
        'package_id' => $package->id,
        'quantity' => 0,
    ]);
 
    $this->Sheets->SheetsPackages->save($sheetPackage);
}

The user then edits their quantities in clientview, which saves them in a single submission, with one updateAll per flat-rate item on the join table. The totals (quantity times price for flat-rate items, sum of amounts for other expenses) are computed in the template.

A test sheet in the Créé state, user side: every flat-rate item has its line and quantity field (mileage is still at 0), a single Sauvegarder button saves them all, followed by the other expenses and the totals.

Three roles described in a single file

Permissions live in config/permissions.php, in the CakeDC/Auth RBAC format: rules are read top to bottom and the first match applies. The admin role can reach everything, including the CRUD screens generated with Bake (sheets, states, both kinds of expenses), which I grouped with the user list in an admin panel. The user role only reaches its own actions, including list, clientview, addoutpackage, clientdelete. The comptable (accountant) role has its own, including complist, compview, validate, unvalidate. The menu in templates/layout/default.php only shows the links for the logged-in role.

Logged in with the comptable role: the menu shrinks to that role's links, quantities are shown as plain text and the Validate Sheet button submits the sheet to the validate action.

A sheet goes through five states: Créé, Fermé, Paiement, Remboursé, Fini (created, closed, payment, refunded, done). The administrator moves it forward from the edit screen. As long as it is in the Créé state and not validated, the user can change their quantities, add other expenses or delete the sheet. After that, it is shown read-only. The accountant's list (complist) only shows sheets from the Fermé to the Remboursé state (state_id 2 to 4). The accountant reviews the details and totals, then validates or unvalidates the sheet.

The five states on test sheets, in a user's list: only non-validated Créé sheets open for editing (Edit), the others open read-only (Voir).

In April 2024, I started linking a doctor to each sheet (doctors table, doctor_id foreign key). This part was left unfinished: editdoctor does not process the input yet and listdoctor calls a finder that does not exist.

An API key to read your own sheet

In January 2024, I added a small API in ApiController. From their profile page, a user generates a key: generateApiToken() draws 32 bytes with random_bytes, hex-encodes them and stores them in the api_token column. getSheetInfo() then receives the key and the sheet id, finds the user by their key and returns the sheet as JSON. This action bypasses the session (bypassAuth in permissions.php), so access control happens in the code: if the sheet does not belong to the key holder, the response is a refusal. It is the only place where access to a specific sheet is checked against its owner.

What I would fix today

The RBAC rule allows the user role on clientview, but nothing checks that the requested sheet is theirs: changing the id in the URL is enough to read someone else's. The lock on a validated sheet only exists in the template, not in the controller that receives the POST. Conversely, the link that removes another expense is displayed, but it targets the delete action, which the user role rule refuses. On creation, user_id comes from a hidden form field when it should be taken from the session on the server side. On the API side, the key travels in the URL and is stored in plain text, and key generation compares an API password to a value written in the code instead of asking for it. Today, I would add an ownership rule (the permissions file accepts an allowed callback meant for this), hash the key and send it in a header.

On the maintenance side, I translated the plugin screens and added the key generation link by editing the files in vendor/cakedc/users directly, versioned with the project. A dependency update would have overwritten these changes. I should have overridden the plugin templates in templates/plugin/ and used translation files. The tests generated by Bake stayed marked markTestIncomplete. Finally, a flat-rate item created later does not appear on existing sheets, since their lines are only created once: the gsb-cake.sql dump shows it for the fourth flat-rate item. Filling in the missing lines at display time would have avoided this trap.