Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ Migration 042 (`accounting_statements`), `Statement`, `StatementRepositoryInterf
- **Immuable** : ni modification ni suppression ; un relevé déposé par erreur est **remplacé** (l'ancien reste, marqué « remplacé ») tant que l'exercice est ouvert ; un exercice clos fige ses relevés. Plafonds par exercice : 60 relevés, 200 Mo, 10 Mo par fichier ; dépôt sous le verrou de la ligne d'exercice, fichier retiré si la transaction échoue.
- **Écart** : solde de la banque selon le livre à la fin de la période (solde d'ouverture de l'exercice + écritures hors espèces jusqu'à `period_end`) comparé au solde saisi du relevé (facultatif). Limite connue : le solde d'ouverture de l'exercice est compté en entier côté banque, la caisse en espèces n'ayant pas de solde propre.
- **Téléchargement** : toujours en pièce jointe, `nosniff`, CSP `sandbox`, `Cache-Control: private, no-store`, flux ; fichier absent : 404.
- **À faire (livraison 10)** : inclure `accounting-statements` dans les sauvegardes et le rapport d'orphelins (`DocumentOrphanReport`).
- **Sauvegarde et restauration** : `DocumentOrphanReport` couvre aussi les relevés (page `/admin/restore` et `bin/restore-db.php`), `bin/fetch-files.sh` copie le dossier hors serveur (les fichiers ne sont pas dans un dump), `deploy.sh` crée le dossier en 0700.

## Comptabilité : écritures récurrentes (#362, livraison 5)

Expand Down
1 change: 1 addition & 0 deletions .claude/deploiement.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Deux voies pour la restauration COMPLÈTE (A) : la **page d'administration** ou
- **Migrations** : rejouées automatiquement si le dump est d'avant (la table `migrations_log` revient avec le dump).
- **Messages chiffrés** : « Messages : N valeur(s) se déchiffrent » = rien à faire (dump d'après le chiffrement). Si « MESSAGES ILLISIBLES » : dump **d'avant** — le mode strict refuse le clair. Enchaîne : `php bin/message-keys.php transition` → `php bin/encrypt-messages.php --dry-run` → `php bin/encrypt-messages.php` → `php bin/message-keys.php check` → `php bin/message-keys.php strict`. Dump chiffré avec une **ancienne clé** (après rotation) : elle doit être dans le fichier de clés (elles y restent) ; `check` le prouve. Rien n'est chiffré automatiquement.
- **Documents de groupes** : les fichiers (`shared/storage`) ne sont PAS dans un dump. La page et la commande listent (lecture seule, rien n'est supprimé) les lignes sans fichier et les fichiers sans ligne ; à traiter à la main.
- **Relevés de compte de l'association** (#362) : même principe, section « Relevés de compte après restauration » (lignes sans fichier, fichiers sans ligne). Les fichiers de `shared/storage/accounting-statements` ne sont pas dans un dump : **les copier hors serveur avec `bin/fetch-files.sh`** (`accounting-statements` par défaut, `group-documents` en argument), après chaque lot de relevés déposés ; archive tar.gz vérifiée, rangée en 0600 dans `RB_BACKUP_LOCAL_DIR` (hors dépôt), à garder sur un disque chiffré (IBAN et noms de tiers). Restauration d'un fichier : extraire l'archive dans `shared/storage` puis relire le rapport d'écarts.
4. **Vérifier le site** : se connecter, ouvrir une conversation, envoyer un message ; `php bin/send-reminders.php` ne doit rien envoyer d'absurde (les relances reposent sur des dates : vérifier qu'aucune rafale ne part). OPcache : aucune purge nécessaire (le code n'a pas changé).
5. **Si ça tourne mal** : si l'import échoue après le vidage, le message nomme le dump de sécurité (`pre-…-avant-restauration`) : le restaurer de la même façon. Si le processus a été interrompu (page : « restauration interrompue »), relancer avec la sauvegarde voulue ou avec le dump de sécurité.

Expand Down
2 changes: 1 addition & 1 deletion bin/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ trap 'deploy_log_finish $?' EXIT

# --- 2. Envoi du code (fichiers suivis par git uniquement) ----------------
progress_step "Envoi de la release ${release}"
rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-documents\" \"\$HOME/$BASE/shared/well-known\" \"\$HOME/$BASE/backups\" && chmod 700 \"\$HOME/$BASE/shared\" \"\$HOME/$BASE/backups\" && mkdir \"\$HOME/$BASE/releases/$release\""
rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-documents\" \"\$HOME/$BASE/shared/storage/accounting-statements\" \"\$HOME/$BASE/shared/well-known\" \"\$HOME/$BASE/backups\" && chmod 700 \"\$HOME/$BASE/shared\" \"\$HOME/$BASE/backups\" && mkdir \"\$HOME/$BASE/releases/$release\""
# Jamais le seed (script destructif, comptes au mot de passe connu) : il n'a rien à faire en production.
git archive --format=tar HEAD -- bin config database public src templates composer.json composer.lock ':(exclude)database/seed.php' \
| rb_ssh "tar -x -C \"\$HOME/$BASE/releases/$release\""
Expand Down
79 changes: 79 additions & 0 deletions bin/fetch-files.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Copie HORS SERVEUR d'un dossier de fichiers de la production (#362), lancée depuis le poste de dev : les relevés de compte de l'association
# (`accounting-statements`) ou les documents de groupes (`group-documents`) ne sont PAS dans un dump de la base.
# Archive tar.gz envoyée en flux par SSH (aucune dépendance côté serveur), vérifiée avant d'être rangée, jamais dans le dépôt.
# Les relevés contiennent des IBAN et des noms de tiers : le dossier local est en 0700 ; le ranger sur un disque chiffré.
#
# Usage : bin/fetch-files.sh [accounting-statements|group-documents] (défaut : accounting-statements)
# Variables : RB_SSH_CONFIG (obligatoire), RB_SSH_HOST (rehearsalbox), RB_REMOTE_BASE (rehearsalbox),
# RB_BACKUP_LOCAL_DIR ($HOME/rehearsalbox-backups, jamais dans le dépôt), RB_BACKUP_LOCAL_KEEP (30 archives par dossier)
set -euo pipefail
umask 077

SSH_CONFIG="${RB_SSH_CONFIG:?RB_SSH_CONFIG requis}"
SSH_HOST="${RB_SSH_HOST:-rehearsalbox}"
BASE="${RB_REMOTE_BASE:-rehearsalbox}"
LOCAL_DIR="${RB_BACKUP_LOCAL_DIR:-$HOME/rehearsalbox-backups}"
KEEP="${RB_BACKUP_LOCAL_KEEP:-30}"

# Liste fermée : jamais un nom venu de l'extérieur dans la commande distante
FOLDER="${1:-accounting-statements}"
case "$FOLDER" in
accounting-statements|group-documents) ;;
*) echo "Usage : $0 [accounting-statements|group-documents]" >&2; exit 1 ;;
esac

case "$KEEP" in
''|*[!0-9]*|0) echo "RB_BACKUP_LOCAL_KEEP doit être un entier supérieur ou égal à 1 (sinon l'archive qu'on vient de récupérer serait supprimée)." >&2; exit 1 ;;
esac

REPO="$(cd "$(dirname "$0")/.." && pwd -P)"
LOCAL_ABS="$(realpath -m -- "$LOCAL_DIR")"
case "$LOCAL_ABS/" in
"$REPO/"*)
echo "Refusé : le dossier de sauvegarde ($LOCAL_ABS) est dans le dépôt." >&2
echo "Choisir un dossier hors du dépôt via RB_BACKUP_LOCAL_DIR." >&2
exit 1
;;
esac

mkdir -p "$LOCAL_DIR"
chmod 700 "$LOCAL_DIR"

DEST="$LOCAL_DIR/$FOLDER-$(date -u +%Y%m%d%H%M%S).tgz"
PARTIAL="$DEST.partial"

# Le dossier peut ne pas exister encore (aucun relevé déposé) : ce n'est pas une erreur. Une panne de connexion, elle, en est une.
PRESENT="$(ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "if [ -d \"\$HOME/$BASE/shared/storage/$FOLDER\" ]; then echo present; else echo absent; fi")" \
|| { echo "Connexion SSH impossible vers $SSH_HOST." >&2; exit 1; }
if [ "$PRESENT" != "present" ]; then
echo "Rien à copier : $FOLDER n'existe pas encore sur le serveur."
exit 0
fi

if ! ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "tar -C \"\$HOME/$BASE/shared/storage\" -czf - \"$FOLDER\"" > "$PARTIAL"; then
rm -f "$PARTIAL"
echo "Échec de la copie de $FOLDER." >&2
exit 1
fi

if ! tar -tzf "$PARTIAL" > /dev/null; then
rm -f "$PARTIAL"
echo "Archive corrompue : copie supprimée." >&2
exit 1
fi

chmod 600 "$PARTIAL"
mv "$PARTIAL" "$DEST"

# Rotation locale : on ne garde que les RB_BACKUP_LOCAL_KEEP plus récentes de ce dossier
i=0
while IFS= read -r f; do
i=$((i + 1))
if [ "$i" -gt "$KEEP" ]; then
rm -f -- "$f"
fi
done < <(ls -1 "$LOCAL_DIR/$FOLDER"-*.tgz 2>/dev/null | sort -r || true)

echo "Fichiers récupérés : $(basename "$DEST") ($(du -h "$DEST" | cut -f1), $(tar -tzf "$DEST" | grep -vc '/$' || true) fichier(s))"
echo "Dossier local : $LOCAL_DIR (à ranger sur un disque chiffré : IBAN et noms de tiers)."
2 changes: 2 additions & 0 deletions bin/restore-db.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
}
$orphans = new DocumentOrphanReport($pdo, $config['storage']['group_documents_path']);
$notes[] = sprintf('Documents de groupes : %d ligne(s) sans fichier, %d fichier(s) sans ligne (aucun supprimé).', count($orphans->missingFiles()), count($orphans->orphanFiles()));
$statements = new DocumentOrphanReport($pdo, $config['storage']['accounting_statements_path'], DocumentOrphanReport::ACCOUNTING_STATEMENTS);
$notes[] = sprintf('Relevés de compte : %d ligne(s) sans fichier, %d fichier(s) sans ligne (aucun supprimé).', count($statements->missingFiles()), count($statements->orphanFiles()));

return implode("\n", $notes);
};
Expand Down
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,7 @@
new BackupCatalog($backupDir),
new RestoreStatus($stateDir, $c->get(ClockInterface::class)),
new DocumentOrphanReport($c->get(PDO::class), $config['storage']['group_documents_path']),
new DocumentOrphanReport($c->get(PDO::class), $config['storage']['accounting_statements_path'], DocumentOrphanReport::ACCOUNTING_STATEMENTS),
new CurrentPasswordVerifier($c->get(UserRepositoryInterface::class), $c->get(PasswordHasherInterface::class)),
$launcher->launch(...),
$c->get(ClockInterface::class),
Expand Down
3 changes: 3 additions & 0 deletions src/Backup/Controller/RestoreController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function __construct(
private readonly BackupCatalog $catalog,
private readonly RestoreStatus $status,
private readonly DocumentOrphanReport $documents,
private readonly DocumentOrphanReport $statements,
private readonly CurrentPasswordVerifier $passwordVerifier,
private readonly \Closure $launch,
private readonly ClockInterface $clock,
Expand All @@ -62,6 +63,8 @@ public function page(Request $request): Response
'running' => $this->status->isRunning(),
'missingFiles' => $this->documents->missingFiles(),
'orphanFiles' => $this->documents->orphanFiles(),
'statementMissing' => $this->statements->missingFiles(),
'statementOrphans' => $this->statements->orphanFiles(),
'confirmWord' => self::CONFIRM_WORD,
'restoreEnabled' => $this->safeguard->isEnabled(),
'csrfToken' => $this->csrfTokenManager->getToken(),
Expand Down
22 changes: 15 additions & 7 deletions src/Backup/Restore/DocumentOrphanReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@
namespace App\Backup\Restore;

/**
* #241 : après une restauration, liste les écarts entre group_documents et le dossier de stockage.
* Lecture seule : ne supprime, n'écrit et ne lit jamais le contenu d'un fichier.
* #241 : après une restauration, liste les écarts entre une table de fichiers (documents de groupe, relevés de compte #362) et son dossier de
* stockage. Lecture seule : ne supprime, n'écrit et ne lit jamais le contenu d'un fichier. La table et les colonnes viennent des constantes
* ci-dessous, jamais d'une valeur extérieure.
*/
final class DocumentOrphanReport
{
public function __construct(private readonly \PDO $pdo, private readonly string $storagePath)
/** Documents de groupe : table, colonne du rattachement, clé rendue. */
public const GROUP_DOCUMENTS = ['group_documents', 'group_id', 'groupId'];
/** Relevés de compte de l'association (#362). */
public const ACCOUNTING_STATEMENTS = ['accounting_statements', 'fiscal_year_id', 'fiscalYearId'];

/** @param array{0: string, 1: string, 2: string} $source l'une des constantes GROUP_DOCUMENTS ou ACCOUNTING_STATEMENTS */
public function __construct(private readonly \PDO $pdo, private readonly string $storagePath, private readonly array $source = self::GROUP_DOCUMENTS)
{
}

/** @return list<array{id: int, groupId: int, originalName: string}> lignes dont le fichier est absent */
/** @return list<array<string, int|string>> lignes dont le fichier est absent : `id`, la clé de rattachement de la source (`groupId`, `fiscalYearId`) et `originalName` */
public function missingFiles(): array
{
$statement = $this->pdo->prepare('SELECT id, group_id, original_name, stored_name FROM group_documents ORDER BY id');
[$table, $ownerColumn, $ownerKey] = $this->source;
$statement = $this->pdo->prepare("SELECT id, {$ownerColumn} AS owner_id, original_name, stored_name FROM {$table} ORDER BY id");
$statement->execute();

$missing = [];
Expand All @@ -27,7 +35,7 @@ public function missingFiles(): array
}
$missing[] = [
'id' => (int) $row['id'],
'groupId' => (int) $row['group_id'],
$ownerKey => (int) $row['owner_id'],
'originalName' => (string) $row['original_name'],
];
}
Expand All @@ -42,7 +50,7 @@ public function orphanFiles(): array
return [];
}

$statement = $this->pdo->prepare('SELECT stored_name FROM group_documents');
$statement = $this->pdo->prepare("SELECT stored_name FROM {$this->source[0]}");
$statement->execute();
$known = array_flip($statement->fetchAll(\PDO::FETCH_COLUMN));

Expand Down
26 changes: 26 additions & 0 deletions templates/admin/restore/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* @var bool $running
* @var list<array{id: int, groupId: int, originalName: string}> $missingFiles
* @var list<string> $orphanFiles
* @var list<array{id: int, fiscalYearId: int, originalName: string}> $statementMissing
* @var list<string> $statementOrphans
* @var string $confirmWord
* @var bool $restoreEnabled faux : la restauration par le web est désactivée (config `restore.enabled`, #424) : aucune action proposée
* @var \App\Account\Entity\UserRole $currentUserRole
Expand Down Expand Up @@ -173,6 +175,30 @@
<?php endif; ?>
<?php endif; ?>
</section>

<section class="rb-card rb-restore-section" aria-labelledby="restore-statements-title">
<h2 id="restore-statements-title">Relevés de compte après restauration</h2>
<?php if ($statementMissing === [] && $statementOrphans === []): ?>
<p class="rb-admin-note">Rien à signaler sur les relevés.</p>
<?php else: ?>
<?php if ($statementMissing !== []): ?>
<h3>Lignes sans fichier</h3>
<ul>
<?php foreach ($statementMissing as $statement): ?>
<li><?= e($statement['originalName']) ?> (exercice n° <?= e((string) $statement['fiscalYearId']) ?>)</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($statementOrphans !== []): ?>
<h3>Fichiers sans ligne</h3>
<ul>
<?php foreach ($statementOrphans as $name): ?>
<li><?= e($name) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php endif; ?>
</section>
</div>
<?php require __DIR__ . '/../../partials/nav.php'; ?>
<?php require __DIR__ . '/../../partials/confirm-dialog.php'; ?>
Expand Down
24 changes: 24 additions & 0 deletions tests/Backup/Controller/RestoreControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public function selectActiveGroup(int $groupId): void
new BackupCatalog($this->dir . '/backups'),
new RestoreStatus($this->dir . '/state', new MockClock('2026-10-20 10:00:00')),
new DocumentOrphanReport($this->pdo, $this->dir . '/documents'),
new DocumentOrphanReport($this->pdo, $this->dir . '/statements', DocumentOrphanReport::ACCOUNTING_STATEMENTS),
new CurrentPasswordVerifier($this->users, new FastPasswordHasher()),
$launch ?? function (string $file): void {
$this->launched[] = $file;
Expand Down Expand Up @@ -313,6 +314,29 @@ public function testTheDocumentsWithoutFileAndTheOrphanFilesAreListed(): void
self::assertStringNotContainsString('Aucune incohérence', $html);
}

#[Test]
public function testTheStatementsWithoutFileAndTheirOrphanFilesAreListedApart(): void
{
$owner = $this->user(self::OWNER);
$this->pdo->prepare("INSERT INTO accounting_fiscal_years (label, starts_on, ends_on, created_by_user_id, created_at) VALUES ('2025-2026', '2025-10-01', '2026-09-30', ?, '2025-10-01 00:00:00')")->execute([$owner->id()]);
$year = (int) $this->pdo->lastInsertId();
$this->pdo->prepare("INSERT INTO accounting_statements (fiscal_year_id, period_start, period_end, original_name, stored_name, mime, size_bytes, sha256, uploaded_by_user_id, uploaded_at) VALUES (?, '2025-10-01', '2025-10-31', 'octobre.pdf', ?, 'application/pdf', 10, ?, ?, '2025-11-01 00:00:00')")->execute([$year, str_repeat('a', 32), str_repeat('1', 64), $owner->id()]);
mkdir($this->dir . '/statements', 0o700, true);
file_put_contents($this->dir . '/statements/' . str_repeat('c', 32), 'x');

try {
$html = $this->page($owner);
} finally {
unlink($this->dir . '/statements/' . str_repeat('c', 32));
rmdir($this->dir . '/statements');
}

self::assertStringContainsString('octobre.pdf', $html);
self::assertStringContainsString('exercice n° ' . $year, $html);
self::assertStringContainsString(str_repeat('c', 32), $html);
self::assertStringNotContainsString('Rien à signaler sur les relevés', $html);
}

#[Test]
public function testWithoutBackupsOrInconsistenciesThePageSaysSo(): void
{
Expand Down
18 changes: 18 additions & 0 deletions tests/Backup/Restore/DocumentOrphanReportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ public function testARowWithoutItsFileIsReportedAsMissing(): void
self::assertSame([['id' => $document->id(), 'groupId' => $group->id(), 'originalName' => 'fiche.pdf']], $missing);
}

#[Test]
public function testAccountingStatementsAreReportedTheSameWay(): void
{
$admin = (new MysqlUserRepository($this->pdo))->save(new User(0, 'admin@rehearsalbox.test', 'hash', 'Admin', UserRole::Admin, true, 0, null));
$this->pdo->prepare("INSERT INTO accounting_fiscal_years (label, starts_on, ends_on, created_by_user_id, created_at) VALUES ('2025-2026', '2025-10-01', '2026-09-30', ?, '2025-10-01 00:00:00')")->execute([$admin->id()]);
$year = (int) $this->pdo->lastInsertId();
$insert = $this->pdo->prepare("INSERT INTO accounting_statements (fiscal_year_id, period_start, period_end, original_name, stored_name, mime, size_bytes, sha256, uploaded_by_user_id, uploaded_at) VALUES (?, '2025-10-01', '2025-10-31', ?, ?, 'application/pdf', 10, ?, ?, '2025-11-01 00:00:00')");
$insert->execute([$year, 'present.pdf', str_repeat('a', 32), str_repeat('1', 64), $admin->id()]);
$insert->execute([$year, 'perdu.pdf', str_repeat('b', 32), str_repeat('2', 64), $admin->id()]);
file_put_contents($this->storagePath . '/' . str_repeat('a', 32), 'x');
file_put_contents($this->storagePath . '/' . str_repeat('c', 32), 'x');
$report = new DocumentOrphanReport($this->pdo, $this->storagePath, DocumentOrphanReport::ACCOUNTING_STATEMENTS);

self::assertSame([['id' => (int) $this->pdo->query("SELECT id FROM accounting_statements WHERE original_name = 'perdu.pdf'")->fetchColumn(), 'fiscalYearId' => $year, 'originalName' => 'perdu.pdf']], $report->missingFiles());
self::assertSame([str_repeat('c', 32)], $report->orphanFiles());
self::assertFileExists($this->storagePath . '/' . str_repeat('c', 32), 'lecture seule : rien n\'est supprimé');
}

#[Test]
public function testAFileWithoutRowIsReportedAsOrphan(): void
{
Expand Down
Loading
Loading