Changes In Branch dev Excluding Merge-Ins

This is equivalent to a diff from 7e69c7ec72 to 7827e50151

2024-03-24
16:55
Fix references to old table Leaf check-in: 7827e50151 user: bohwaz tags: dev
2024-02-21
22:21
Move Dictionary to locales directory check-in: b562dd4ac5 user: bohwaz tags: trunk
2024-02-18
20:22
Add expected amount to export check-in: a8a7379705 user: bohwaz tags: dev
20:18
Merge changes from trunk check-in: 8b1a3592a4 user: bohwaz tags: dev
20:17
Remove duplicate column check-in: 7e69c7ec72 user: bohwaz tags: trunk, stable
20:09
Reverse order of comparison year in statements, like it was before check-in: acca6f7b6f user: bohwaz tags: trunk, stable

Modified doc/admin/api.md from [983a7a8a85] to [8812c5bfd3].





1
2
3
4
5

6


7

8

9


10

11







12

13


14


15

16
17
18
19
20
21
22

23
24
25
26
27
28



29
30
31
32
33

34





35
36
37
38
39



40

41
42
43
44

45
46
47

48
49
50
51
52

53
54
55

56


57

58


59







60

61
62
63


64
65



66
67

68
69



70
71



72
73
74
75
76

77
78









79
80

81

82
83
84
85

86


87

88
89

90
91
92


93
94
95



96
97
98

99









100
101

102
103
104


105
106
107
108

109


110
111

112

113


114




115







































































116






117

118

119




120

121




122
123

124

125



























































126
127
128
129

130


131

132
133

134

135
136
137
138
















139
140
141
142
143





144

145
146




147
148

149


150









151
152
153
154
155
156
157
158

159
160
161
162
163
164
165
166
167


168

169
170
171
172

173






174

175
176

177
178
179
180
181

182
183
184





























185
186







187
188
189
190

191
192

193
194

195



196


197
198
199
200
201
202
203
204

205
206

207

208


209







210
211

















212

213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233

234
235
236

237
238

239
240
241

242
243
244
245
246
247

248
249
250
251

252
253
254



255
256
257

258
259
260

261
262
263
264
265
266
267
268
269
270

271
272

273
274
275
276
277
278
279
280
281

282


283




284
285
286
287
288
289
290
1
2
3
4
5
6
7
8

9
10
11
12

13
14
15

16
17
18
19

20
21
22
23
24
25
26
27
28

29
30
31
32
33

34
35
36
37
38
39
40

41
42
43
44
45
46

47
48
49
50
51
52
53
54
55

56
57
58
59
60
61
62
63
64
65
66
67
68

69
70
71


72
73


74



75

76
77
78
79
80

81
82
83
84

85
86
87
88
89
90
91
92
93
94

95
96
97
98
99
100


101
102
103
104
105
106


107
108
109


110
111
112





113


114
115
116
117
118
119
120
121
122
123

124

125
126
127
128

129
130
131
132

133
134

135
136


137
138
139
140

141
142
143
144
145
146
147

148
149
150
151
152
153
154
155
156
157

158
159


160
161
162
163
164
165
166

167
168
169

170
171
172

173
174
175
176
177
178
179

180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257

258
259
260

261
262
263
264
265
266

267
268
269
270
271

272
273
274

275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336

337
338
339
340

341
342

343
344
345




346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361





362
363
364
365
366

367
368

369
370
371
372
373

374
375
376
377

378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393

394




395
396
397
398
399
400
401

402
403
404
405

406
407
408
409
410
411
412
413

414
415

416
417
418
419
420
421
422



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452

453
454
455
456
457
458
459
460
461
462

463
464

465
466

467
468
469
470
471

472
473
474
475
476
477
478
479
480

481
482

483
484
485

486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514

515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535

536
537
538

539
540

541
542


543



544


545


546

547
548


549
550
551
552
553

554
555


556








557

558
559

560
561
562
563
564
565
566
567
568

569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
+
+
+
+




-
+

+
+
-
+

+
-
+
+

+
-
+
+
+
+
+
+
+

+
-
+
+

+
+
-
+






-
+





-
+
+
+





+
-
+
+
+
+
+





+
+
+
-
+


-
-
+

-
-
+
-
-
-

-
+



+
-
+
+

+
-
+
+

+
+
+
+
+
+
+
-
+



+
+
-
-
+
+
+


+
-
-
+
+
+
-
-
+
+
+
-
-
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+

-
+
-
+



-
+

+
+
-
+

-
+

-
-
+
+


-
+
+
+



+
-
+
+
+
+
+
+
+
+
+

-
+

-
-
+
+




+
-
+
+

-
+

+
-
+
+

+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
-
+

+
-
+
+
+
+

+
-
+
+
+
+

-
+

+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+



-
+

+
+
-
+

-
+

+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+

-
+
+
+
+

-
+

+
+
-
+
+
+
+
+
+
+
+
+







-
+
-
-
-
-





+
+
-
+



-
+

+
+
+
+
+
+
-
+

-
+





+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

-
+
+
+
+
+
+
+



-
+

-
+

-
+

+
+
+
-
+
+







-
+

-
+

+
-
+
+

+
+
+
+
+
+
+


+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+




















-
+


-
+

-
+

-
-
+
-
-
-

-
-
+
-
-

-
+

-
-
+
+
+


-
+

-
-
+
-
-
-
-
-
-
-
-

-
+

-
+








-
+

+
+

+
+
+
+







# Introduction

### Débuter

Une API de type REST est disponible dans Paheko.

Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu ==Configuration==, onglet ==Fonctions avancées==, puis ==API==.

L'API peut ensuite recevoir des requêtes REST sur l'URL `https://adresse_association/api/{chemin}/`.
L'API peut ensuite recevoir des requêtes REST sur l'URL `https://adresse_association/api{route}`.

Remplacer =={route}== par une des routes de l'API (voir ci-dessous).

Remplacer =={chemin}== par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin.
La méthode HTTP (`GET`, `POST`, etc.) à utiliser est spécifiée pour chaque route.

Des exemples sont donnés pour l'utilisation de l'outil `curl` en ligne de commande, si vous souhaitez utiliser un autre langage de programmation il faudra adapter votre code.
Pour les requêtes de type `POST`, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (`application/x-www-form-urlencoded`) ou sous forme d'objet JSON. Dans ce cas le `Content-Type` doit être positionné sur `application/json`.

### Formats des requêtes et réponses

Les paramètres peuvent être fournis sous les formes suivantes :
Les réponses sont faites en JSON par défaut.

* dans les paramètres de l'URL (query string) : pour toutes les méthodes
* formulaire HTTP classique pour les requêtes `POST` :
  * `Content-Type: application/x-www-form-urlencoded`
  * ou `Content-Type: multipart/form-data`
* objet JSON pour les requêtes POST :
  * `Content-Type: application/json`

Les réponses sont renvoyées en JSON par défaut, sauf quand la route permet de choisir un autre format.
<<toc level=3>>

Les formats ODS et XLSX ne sont disponibles à l'import que si le serveur est configuré pour convertir ces formats.

De la même manière, le format XLSX n'est disponible que si le serveur est configuré pour générer ce format.

# Utiliser l'API
### Utiliser l'API

N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera.

En ligne de commande il est possible d'utiliser `curl`. Exemple pour télécharger la base de données :

```
curl https://test:coucou@[identifiant_association].paheko.cloud/api/download -o association.sqlite
curl -u test:secret https://[identifiant_association].paheko.cloud/api/download -o association.sqlite
```

On peut aussi utiliser `wget` en n'oubliant pas l'option `--auth-no-challenge` sinon l'authentification ne fonctionnera pas :

```
wget https://test:coucou@[identifiant_association].paheko.cloud/api/download  --auth-no-challenge -O association.sqlite
wget https://test:secret@[identifiant_association].paheko.cloud/api/download \
  --auth-no-challenge \
  -O association.sqlite
```

Exemple pour créer une écriture sous forme de formulaire :

```
curl -v -u test:secret \
curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -F id_year=1 -F label=Test -F "date=01/02/2023" …
  https://[identifiant_association].paheko.cloud/api/accounting/transaction \
  -F id_year=1 \
  -F label=Test \
  -F "date=01/02/2023"

```

Ou sous forme d'objet JSON :

```
curl -v -u test:secret \
  https://[identifiant_association].paheko.cloud/api/accounting/transaction \
  -H 'Content-Type: application/json' \
curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -H 'Content-Type: application/json' -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023"}'
  -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023", …}'
```


# Authentification
### Authentification

Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP :

L'API utilise l'authentification [`Basic` de HTTP](https://fr.wikipedia.org/wiki/Authentification_HTTP#M%C3%A9thode_%C2%AB_Basic_%C2%BB).
```
curl http://test:abcd@paheko.monasso.tld/api/download/
```

# Erreurs
### Erreurs

En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé `error` contenant le message d'erreur.

# Routes
# Chemins

## Requêtes SQL

### POST sql.{FORMAT}
## sql (POST)

Exécute une requête SQL en lecture

| Paramètre | Type | Description |
| :- | :- | :- |
| `FORMAT` | `string` | Format de retour : `json`, `csv`, `ods` ou `xlsx` |
| `sql` | `string` | Requête SQL à exécuter. |

Si aucun format n'est passé (exemple : `…/api/sql`, sans point ni extension), `json` sera utilisé.

Permet d'exécuter une requête SQL `SELECT` (uniquement, pas de requête UPDATE, DELETE, INSERT, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre `sql`. Le résultat est retourné dans la clé `results` de l'objet JSON.
Permet d'exécuter une requête SQL `SELECT` (uniquement, pas de requête `UPDATE`, `DELETE`, `INSERT`, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre `sql`.

S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.

Exemple de requête :

```
curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;'
```request
curl -u test:abcd https://paheko.monasso.tld/api/sql \
  -d 'SELECT nom, code_postal FROM users LIMIT 2;'
```

Exemple de réponse :
**ATTENTION :** Les requêtes en écriture (`INSERT, DELETE, UPDATE, CREATE TABLE`, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.


```response
{
Depuis la version 1.2.8, il est possible d'utiliser le paramètre `format` pour choisir le format renvoyé :

    "count": 65,
    "results":
    [
* `json` (défaut) : renvoie un objet JSON, dont la clé est `"results"` et contient un tableau de la liste des membres trouvés
* `csv` : renvoie un fichier CSV
* `ods` : renvoie un tableau LibreOffice Calc (ODS)
* `xlsx` : renvoie un tableau Excel (XLSX)

        {
Exemple :

            "nom": "Ada Lovelace",
            "code_postal": null
        },
        {
            "nom": "James Coincoin",
            "code_postal": "78990"
        }
    ]
}
```
curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv

```
**Attention :** Les requêtes en écriture (`INSERT, DELETE, UPDATE, CREATE TABLE`, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.

## Téléchargements

### download (GET)
### GET download

Télécharger la base de données

Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données.
Renvoie directement le fichier SQLite de la base de données.

Exemple :
Exemple de requête :

```
curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite
```request
curl -u test:abcd https://paheko.monasso.tld/api/download -o db.sqlite
```

### download/files (GET)
### GET download/files

Télécharger un fichier ZIP contenant tous les fichiers

_(Depuis la version 1.3.4)_

Les fichiers inclus sont :
Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.).

* documents
* fichiers liés aux écritures,
* fichiers liés des membres,
* fichiers joints aux pages du site web
* code des modules modifiés
* corbeille
* configuration : logo, icônes, etc.
* anciennes versions des fichiers

Exemple :
Exemple de requête :

```
curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip
```request
curl -u test:abcd https://paheko.monasso.tld/api/download/files -o backup_files.zip
```

## Site web

_(Depuis la version 1.4.0)_
### web/list (GET)

### GET web

Renvoie la liste des pages du site web.
Liste de toutes les pages du site web

### GET web/{PAGE_URI}
### web/attachment/{PAGE_URI}/{FILENAME} (GET)

Métadonnées de la page du site web

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |
| `html` | `bool` | Si `true` ou `1`, une clé `html` sera ajoutée à la réponse avec le contenu de la page au format HTML. |
Renvoie le fichier joint correspondant à la page et nom de fichier indiqués.

Exemple de réponse :

```response
[
    {
        "id": 13,
        "uri": "actualite",
        "title": "Actualit\u00e9",
        "path": null,
        "draft": 0,
        "published": "2019-04-22 18:00:00",
        "modified": "2023-09-12 15:44:55"
    },
    {
        "id": 66,
        "uri": "Affiches-des-bourses-aux-velos",
        "title": "Affiches des bourses aux v\u00e9los",
        "path": "Nos activit\u00e9s",
        "draft": 0,
        "published": "2019-07-18 19:05:00",
        "modified": "2023-04-04 14:44:04"
    },

]
```

### PUT web/{PAGE_URI}

Modifie le contenu de la page

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |

Exemple de requête :

```request
curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X PUT -d 'La bourse aura lieu le 28 septembre'
```

### POST web/{PAGE_URI}

Modifie les métadonnées de la page

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |
| `id_parent` | `int|null` | Numéro de la catégorie parente de cette page. |
| `uri` | `string` | Nouvelle adresse unique de la page. |
| `title` | `string` | Titre de la page. |
| `type` | `int` | Type de page. `1` pour les catégories, `2` pour les pages simples. |
| `status` | `string` | Statut de la page. `online` si la page est en ligne, `draft` si la page est en brouillon. |
| `format` | `string` | Format de la page : `markdown`, `encrypted` ou `skriv` |
| `published` | `string` | Date et heure de publication au format `YYYY-MM-DD HH:mm:ss`. |
| `modified` | `string` | Date et heure de modification au format `YYYY-MM-DD HH:mm:ss`. |
| `content` | `string` | Contenu. |

Exemple de requête :

```request
curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -F title="Bourse aux vélos du 28 septembre"
```

### DELETE web/{PAGE_URI}

Supprime la page et ses fichiers joints

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |

Exemple de requête :

```request
curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X DELETE
```

### web/page/{PAGE_URI} (GET)
### GET web/{PAGE_URI}.html

Contenu de la page web au format HTML
Renvoie un objet JSON avec toutes les infos de la page donnée.

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |

Exemple de requête :
Rajouter le paramètre `?html` à l'URL pour obtenir en plus une clé `html` dans l'objet JSON qui contiendra la page au format HTML.

```request
curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre.html
```

### web/html/{PAGE_URI} (GET)
### GET web/{PAGE_URI}/children

Liste des pages et sous-catégories dans cette catégorie
Renvoie uniquement le contenu de la page au format HTML.

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |

Exemple de requête :

```request
curl -u test:abcd https://paheko.monasso.tld/api/web/actualite/children
```

Exemple de réponse :

```response
{
    "categories": [],
    "pages": [
        {
            "id": 86,
            "id_parent": 13,
            "uri": "bourse-aux-velos-le-30-septembre-et-1er-octobre",
            "title": "Bourse aux v\u00e9los 30 septembre et 1er octobre",
            "type": 2,
            "status": "online",
            "format": "skriv",
            "published": "2023-10-01 18:00:00",
            "modified": "2023-09-11 23:41:41",
            "content": "…"
        },

    ]
}
```

### GET web/{PAGE_URI}/attachments

Liste des fichiers joints à la page

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |

### GET web/{PAGE_URI}/{FILE_NAME}

Récupérer le fichier joint à la page

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |
| `FILENAME` | `string` | Nom du fichier. |

### DELETE web/{PAGE_URI}/{FILE_NAME}

Supprime le fichier joint à la page

| Paramètre | Type | Description |
| :- | :- | :- |
| `PAGE_URI` | `string` | Adresse unique de la page. |
| `FILENAME` | `string` | Nom du fichier. |

## Membres

### user/categories (GET)
### GET user/categories

Liste des catégories de membres

_(Depuis la version 1.3.6)_
_(Depuis la version 1.4.0)_

Renvoie la liste des catégories de membres, triée par nom, et incluant le nombre de membres de la catégorie (dans la clé `count`).
La liste est triée par nom, et inclue le nombre de membres de la catégorie dans la clé `count`.

Exemple de réponse :
### user/category/{ID}.{FORMAT} (GET)

_(Depuis la version 1.3.6)_


```response
{
    "12": {
        "id": 12,
        "name": "Administration technique",
        "perm_web": 9,
        "perm_documents": 9,
        "perm_users": 9,
        "perm_accounting": 9,
        "perm_subscribe": 0,
        "perm_connect": 1,
        "perm_config": 9,
        "hidden": 0,
        "count": 1
    }
Exporte la liste des membres d'une catégorie correspondant à l'ID demandé, au format indiqué :

* `json`
* `csv`
* `ods`
}
```

### GET user/category/{ID}.{FORMAT}

* `xlsx`
Exporte la liste des membres d'une catégorie

### user/new (POST)
| Paramètre | Type | Description |
| :- | :- | :- |
| `ID` | `int` | Identifiant unique de la catégorie. |
| `FORMAT` | `string` | Format de sortie : `json`, `csv`, `ods` ou `xlsx` |

_(Depuis la version 1.3.6)_
_(Depuis la version 1.4.0)_

### POST user/new

Permet de créer un nouveau membre.
Créer un nouveau membre

| Paramètre | Type | Description |
| :- | :- | :- |
| `id_category` | `int` | Identifiant de la catégorie. Si absent, la catégorie par défaut sera utilisée. |
| `password` | `string` | Mot de passe du membre. |
| `force_duplicate` | `bool` | Si `true` ou `1`, alors aucune erreur ne sera renvoyée si le nom du membre correspond à un membre déjà existant. |

_(Depuis la version 1.4.0)_

Attention, cette méthode comporte des restrictions :

* il n'est pas possible de créer un membre dans une catégorie ayant accès à la configuration
* il n'est pas possible de définir l'OTP ou la clé PGP du membre créé
* seul un identifiant API ayant le droit "Administration" pourra créer des membres administrateurs

Il est possible d'utiliser tous les champs de la fiche membre en utilisant leur clé unique, ainsi que les clés suivantes :
Il est possible d'utiliser tous les champs de la fiche membre en utilisant la clé unique du champ.

* `id_category` : indique l'ID d'une catégorie, si absent la catégorie par défaut sera utilisée
* `password` : mot de passe du membre
* `force_duplicate=1` : ne pas renvoyer une erreur si le nom du membre à ajouter est identique au nom d'un membre existant.

Sera renvoyée la liste des infos de la fiche membre.

Si un membre avec le même nom existe déjà (et que `force_duplicate` n'est pas utilisé), une erreur `409` sera renvoyée.

Exemple de requête :

```
```request
curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new
```

### user/{ID} (GET)
### GET user/{ID}

Informations de la fiche d'un membre

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID` | `int` | Identifiant unique du membre (différent du numéro). |

_(Depuis la version 1.3.6)_
_(Depuis la version 1.4.0)_

Renvoie les infos de la fiche d'un membre à partir de son ID, ainsi que 3 autres clés :
Plusieurs clés supplémentaires sont retournées, en plus des champs de la fiche membre :

* `has_password`
* `has_pgp_key`
* `has_otp`

Exemple de réponse :
### user/{ID} (DELETE)

_(Depuis la version 1.3.6)_

```response
{
    "has_password": true,
    "has_otp": false,
    "has_pgp_key": false,
    "id": 1,
    "id_category": 8,
    "date_login": "2021-06-06 09:17:39",
    "date_updated": null,
    "id_parent": null,
    "is_parent": false,
    "preferences": null,
    "numero": 1,
    "nom": "Ada Lovelace",
    "notes": null,
    "groupe_information": true,
    "groupe_benevoles": false,
    "email": "ada@lovelace.org",
    "telephone": "010101010101",
    "adresse": null,
    "code_postal": "21000",
    "ville": "DIJON",
    "pays": "FR",
    "date_inscription": "2012-02-25"
}
```

### DELETE user/{ID}

Supprime un membre à partir de son ID.
Supprime un membre

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID` | `int` | Identifiant unique du membre (différent du numéro). |

_(Depuis la version 1.4.0)_

Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres.

Note : il n'est pas possible de supprimer un membre appartenant à une catégorie ayant accès à la configuration.
Note : il n'est pas possible de supprimer via l'API un membre appartenant à une catégorie ayant accès à la configuration.

### user/{ID} (POST)
### POST user/{ID}

_(Depuis la version 1.3.6)_
Modifie les infos de la fiche d'un membre

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID` | `int` | Identifiant unique du membre (différent du numéro). |
Modifie les infos de la fiche d'un membre à partir de son ID.

_(Depuis la version 1.4.0)_

Notes :

* il n'est pas possible de modifier la catégorie d'un membre
* il n'est pas possible de modifier un membre appartenant à une catégorie ayant accès à la configuration.
* il n'est pas possible de modifier le mot de passe, l'OTP ou la clé PGP du membre créé
* il n'est pas possible de modifier des membres ayant accès à la configuration
* seul un identifiant d'API ayant l'accès en "Administartion" pourra modifier un membre administrateur
* seul un identifiant d'API ayant l'accès en "Administration" pourra modifier un membre administrateur

### user/import (PUT)
### POST user/import

Importer un fichier de tableur de la liste des membres
Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko.

Formats de fichiers acceptés : CSV, ODS, XLSX.

| Paramètre | Type | Description |
| :- | :- | :- |
| `mode` | `string` | Mode d'import du fichier. Voir ci-dessous pour les détails. _(Depuis la version 1.2.8)_ |
| `skip_lines` | `int` | Nombre de lignes à ignorer. Défaut : `1`. |
| `column` | `array` | Correspondance entre la colonne (clé, commence à zéro) et le champ de la fiche membre (valeur). |


Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration.

Le paramètre `mode` permet d'utiliser une de ces options pour spécifier le mode d'import :

* `auto` (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné
* `create` : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite
* `update` : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite

Exemple de requête :

```request
curl -u test:abcd https://monpaheko.tld/api/user/import \
  -F mode=create \
  -F 'column[0]=nom_prenom' \
  -F 'column[1]=code_postal' \
  -F skip_lines=0 \
  -F file=@membres.csv
```

Paheko s'attend à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs *Nom et prénom* et *Adresse postale*, alors le fichier fourni devra ressembler à ceci :
Si aucun paramètre `column` n'est fourni, Paheko s'attend alors à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs *Nom et prénom* et *Adresse postale*, alors le fichier fourni devra ressembler à ceci :

| Nom et prénom | Adresse postale |
| :- | :- |
| Ada Lovelace | 42 rue du binaire, 21000 DIJON |

Ou à ceci :

| nom_prenom | adresse_postale |
| :- | :- |
| Ada Lovelace | 42 rue du binaire, 21000 DIJON |

La méthode renvoie un code HTTP `200 OK` si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse.

Utilisez la route `user/import/preview` avant pour vérifier que l'import correspond à ce que vous attendez.

Exemple pour modifier le nom du membre n°42 :

```
echo 'numero,nom' > membres.csv
echo '42,"Nouveau nom"' >> membres.csv
curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv
curl -u test:abcd https://monpaheko.tld/api/user/import -F file=@membres.csv
```

#### Paramètres
### PUT user/import

Les paramètres sont à spécifier dans l'URL, dans la query string.
Importer un fichier de tableur de la liste des membres

Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire `mode` contenant une de ces options pour spécifier le mode d'import :

Formats de fichiers acceptés : CSV, ODS, XLSX.
* `auto` (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné
* `create` : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite
* `update` : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite

_Depuis la version 1.3.0 il est possible de spécifier :_

Identique à la même méthode en `POST`, mais les paramètres sont passés dans l'URL, et le fichier en contenu de la requête.
* le nombre de lignes à ignorer avec le paramètre `skip_lines=X` : elles ne seront pas importées. Par défaut la première ligne est ignorée.
* la correspondance des colonnes avec des paramètres `column[x]` ou `x` est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre.

Exemple :
Exemple de requête :

```
curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&column[1]=code_postal&skip_lines=0 -T membres.csv
```request
curl -u test:abcd https://monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&skip_lines=0 \
  -T membres.csv
```

### user/import (POST)
### POST user/import/preview

Identique à la même méthode en `PUT`, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors `file`.

Prévisualise un import de membres, sans modifier les membres
```
curl https://test:abcd@monpaheko.tld/api/user/import \
  -F mode=create \
  -F 'column[0]=nom_prenom' \
  -F 'column[1]=code_postal' \
  -F skip_lines=0 \
  -F file=@membres.csv
```

### user/import/preview (PUT)
Identique à `user/import`, mais l'import n'est pas enregistré. À la place l'API indique les modifications qui seraient apportées.

Identique à `user/import`, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier :
Renvoie un objet JSON comme ceci :

* `errors` : liste des erreurs d'import
* `created` : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée
* `modified` : liste des membres modifiés, chaque membre aura une clé `id` et une clé `name`, ainsi qu'un objet `changed` contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés `old` et `new`, contenant respectivement l'ancienne valeur du champ et la nouvelle.
* `unchanged` : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé `name` et une clé `id` indiquant le nom et l'identifiant unique numérique du membre

Note : si `errors` n'est pas vide, alors il sera impossible d'importer le fichier avec `user/import`.

Exemple de retour :
Exemple de requête :

```request
curl -u test:abcd https://monpaheko.tld/api/user/import/preview -F mode=update -F file=@/tmp/membres.csv
```

Exemple de réponse :

```response
{
    "created": [
        {
            "numero": 3434351,
            "nom": "Bla Bli Blu"
        }
    ],
305
306
307
308
309
310
311

312
313

314
315

316
317
318
319





320
321
322
323

324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342

343
344
345
346
347
348
349

350

351


352


353

354
355

356
357
358
359

360
361

362


















363
364
365
366
367



















368
369

370



371


372
373
374


375
376

377
378
379















380

381




382

383
384
385
386










387
388

389

390
391



392
393




394

395
396



397
398

399
400



















401

402
403

404
405

406
407




408
409

410
411

412







413



414
415

416




417




418
419

420
421
422

423
424

425






426
427

428
429



430

431










432

433










434
435

436
437

438
439
440
441



442
443
444
445

446
447
448
449
450
451
452

































453
454



455
456
457




458

459
460
461
462
463
464
465
466
467
468
469
470
471

































472

473




474

475
476
477
478
479
480
481



















482
483

484
485
486

487










599
600
601
602
603
604
605
606
607

608
609

610
611
612
613

614
615
616
617
618
619
620
621

622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640

641
642
643
644
645
646
647

648
649
650

651
652
653
654
655

656
657

658
659
660
661

662
663

664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683





684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703

704
705
706
707
708

709
710
711


712
713


714



715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731

732
733
734
735
736
737




738
739
740
741
742
743
744
745
746
747
748

749
750
751


752
753
754


755
756
757
758
759
760


761
762
763


764


765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783

784
785

786
787

788
789

790
791
792
793
794

795
796

797
798
799
800
801
802
803
804
805

806
807
808
809

810
811
812
813
814
815

816
817
818
819
820

821
822


823

824
825

826
827
828
829
830
831
832

833
834

835
836
837
838
839

840
841
842
843
844
845
846
847
848
849
850
851

852
853
854
855
856
857
858
859
860
861
862

863
864

865
866



867
868
869


870

871
872






873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905


906
907
908



909
910
911
912
913
914













915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949

950
951
952
953
954
955







956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975

976
977
978

979
980
981
982
983
984
985
986
987
988
989
990







+

-
+

-
+



-
+
+
+
+
+



-
+


















-
+






-
+

+
-
+
+

+
+
-
+

-
+



-
+

-
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

-
+

+
+
+
-
+
+

-
-
+
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
-
+
+
+
+

+
-
-
-
-
+
+
+
+
+
+
+
+
+
+

-
+

+
-
-
+
+
+
-
-
+
+
+
+

+
-
-
+
+
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+

-
+

-
+

-
+
+
+
+

-
+

-
+

+
+
+
+
+
+
+
-
+
+
+

-
+

+
+
+
+
-
+
+
+
+

-
+

-
-
+
-

+
-
+
+
+
+
+
+

-
+

-
+
+
+

+
-
+
+
+
+
+
+
+
+
+
+

+
-
+
+
+
+
+
+
+
+
+
+

-
+

-
+

-
-
-
+
+
+
-
-

-
+

-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
+
+
+
+

+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
-
+
+
+
+

+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

-
+


-
+

+
+
+
+
+
+
+
+
+
+
            "id": 2,
            "name": "Paul Muad'Dib"
        }
    ]
}
```

### PUT user/import/preview

### user/import/preview (POST)
Prévisualise un import de membres, sans modifier les membres

Idem quel la méthode en `PUT` mais accepte les paramètres dans le corps de la requête (voir ci-dessus).
Idem quel la méthode en `POST` mais les paramètres doivent être passés dans l'URL, et le fichier dans le corps de la requête.

## Activités

### services/subscriptions/import (PUT)
### PUT services/subscriptions/import

Importer les inscriptions des membres aux activités

Fichiers acceptés : CSV, XLSX, ODS.

_(Depuis Paheko 1.3.2)_

Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. Les activités et tarifs doivent déjà exister avant l'import.
Les activités et tarifs doivent déjà exister avant l'import.

Les colonnes suivantes peuvent être utilisées :

* Numéro de membre`**`
* Activité`**`
* Tarif
* Date d'inscription`**`
* Date d'expiration
* Montant à régler
* Payé ?

Les colonnes suivies de deux astérisques (`**`) sont obligatoires.

Exemple :

```
echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv
echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv
curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv
curl -u test:abcd https://monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv
```

## Erreurs

Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.

### errors/report (POST)
### POST errors/report

Ajouter un rapport d'erreur au log
Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale.

Cette route permet d'ajouter une erreur au log de l'instance. Utile pour centraliser les erreurs de plusieurs instances.

Paheko utilise le format d'erreurs de [AirBrake](https://docs.airbrake.io/docs/devops-tools/api/#post-data-schema-v3) et errbit.

### errors/log (GET)
### GET errors/log

Renvoie le log d'erreurs système, au format airbrake/errbit ([voir la doc AirBrake pour un exemple du format](https://airbrake.io/docs/api/#create-notice-v3))
Log d'erreurs de l'instance

## Comptabilité

### accounting/years (GET)
### GET accounting/years

Renvoie la liste des exercices.
Liste des exercices

Exemple de réponse :

```response
[
    {
        "id": 1,
        "label": "Premier exercice",
        "start_date": "2011-11-01",
        "end_date": "2013-01-31",
        "closed": 1,
        "id_chart": 1,
        "nb_transactions": 1194,
        "chart_name": "Plan comptable associatif 1999"
    },

]
```

### accounting/charts (GET)

Renvoie la liste des plans comptables.

### accounting/charts/{ID_CHART}/accounts (GET)
### GET accounting/charts

Liste des plans comptables

Exemple de réponse :

```response
[
    {
        "id": 2,
        "label": "Plan comptable associatif 2018",
        "country": "FR",
        "code": "PCA_2018",
        "archived": false
    }
]
```

### GET accounting/charts/{ID_CHART}/accounts

Renvoie la liste des comptes pour le plan comptable indiqué (voir `id_chart` dans la liste des exercices).
Liste des comptes pour le plan comptable indiqué

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_CHART` | `int` | ID du plan comptable. |
### accounting/years/{ID_YEAR}/journal (GET)

Exemple de réponse :

Renvoie le journal général des écritures de l'exercice indiqué. 

```response
[
Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.

    {
### accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)

_(Depuis la version 1.3.6)_
        "id": 312,
        "id_chart": 2,
        "code": "1",
        "label": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)",
        "description": null,
        "position": 2,
        "type": 0,
        "user": false,
        "bookmark": false
    },

]
```

### GET accounting/years/{ID_YEAR}/journal

Journal général des écritures de l'exercice indiqué
Exporte l'exercice indiqué au format indiqué. Les formats suivants sont disponibles :

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. |

Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.
* `full` : complet
* `grouped` : complet groupé
* `simple` : simple (ne comporte pas les saisies avancées)
* `fec` : format FEC (Fichier des Écritures Comptables)

### GET accounting/years/{ID_YEAR}/export/{TYPE}.{FORMAT}

Export d'un exercice

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. |
| `TYPE` | `string` | Type d'export : `full`, `grouped`, `simple` ou `fec`. `simple` ne contient pas les écritures avancées. |
| `FORMAT` | `string` | Format d'export : `json`, `csv`, `ods` ou `xlsx` |

L'extension indique le type de fichier :
_(Depuis la version 1.4.0)_

### GET accounting/years/{ID_YEAR}/journal/{CODE}
* `csv` : Tableur CSV
* `ods` : LibreOffice Calc

Journal des écritures d'un compte

* `xlsx` : Microsoft OOXML (Excel) - seulement disponible si l'instance le permet
* `json` : Texte JSON
| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. |
| `CODE` | `int|string` | Code du compte. |

Exemple de réponse :
Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.


```response
[
### accounting/years/{ID_YEAR}/account/journal (GET)

    {
Renvoie le journal des écritures d'un compte pour l'exercice indiqué.

        "id": 9297,
        "id_line": 22401,
        "date": "2022-02-08",
        "debit": 0,
        "credit": 850,
        "change": 850,
        "sum": 850,
        "reference": "POS-SESSION-434",
        "type": 0,
        "label": "Session de caisse n\u00b0434",
        "line_label": null,
        "line_reference": null,
        "id_project": null,
        "project_code": null,
        "files": 1,
        "status": 0
    },

]
Le compte est spécifié soit via le paramètre `code`, soit via le paramètre `id`. Exemple :  `/accounting/years/4/account/journal?code=512A`
```

Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.
### GET accounting/years/{ID_YEAR}/journal/={ID}

### accounting/transaction/{ID_TRANSACTION} (GET)
Journal des écritures d'un compte

Renvoie les détails de l'écriture indiquée.
| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. |
| `ID` | `int` | ID du compte. |

### accounting/transaction/{ID_TRANSACTION} (POST)
### POST accounting/transaction

Modifie l'écriture indiquée. Voir plus bas le format attendu.
Créer une nouvelle écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `id_year` | `int` | Identifiant de l'exercice. |
| `date` | `string` | Date au format `YYYY-MM-DD` ou `DD/MM/YYYY` |
| `type` | `string` | Type d'écriture. |
| `reference` | `string|null` | Numéro de pièce comptable |
| `notes` | `string|null` | Remarques (texte multi ligne) |
### accounting/transaction/{ID_TRANSACTION}/users (GET)
| `linked_transactions` | `array(int, …)|null` | Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)*
| `linked_users` | `array(int, …)|null` | Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)* |
| `linked_subscriptions` | `array(int, …)|null` | Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.4.0)* |

Renvoie la liste des membres liés à une écriture.
#### Types d'écriture

| Type | Description |
| :- | :- |
| `expense` | Dépense |
| `revenue` | Recette |
### accounting/transaction/{ID_TRANSACTION}/users (POST)
| `transfer` | Virement |
| `debt` | Dette |
| `credit` | Créance |
| `advanced` | Saisie avancée |

Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé `users`.
Les écritures avancées (multi-lignes) ont obligatoirement le type `advanced`. Les autres écritures sont appelées *"écritures simplifiées"* et ne peuvent comporter que deux lignes.

```
 curl -v "http://…/api/accounting/transaction/9337/users"  -F 'users[]=2'
#### Paramètres des écritures simplifiées
```

| Paramètre | Type | Description |
### accounting/transaction/{ID_TRANSACTION}/users (DELETE)
| :- | :- | :- |
| `amount` | `string` | Montant de l'écriture, au format flottant (`53,34`) |
| `credit` | `string` | Code du compte porté au crédit |
| `debit` | `string` | Code du compte porté au débit |
| `id_project` | `int|null` | ID du projet à affecter |
| `payment_reference` | `int|null` | référence de paiement |

Efface la liste des membres liés à une écriture.
#### Paramètres des écritures avancées

### accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)
| Paramètre | Type | Description |
| :- | :- | :- |
| `lines` | `array(object, …)` | un tableau dont chaque élément est un objet correspondant à une ligne de l'écriture. |

Structure de l'objet représentant une ligne de l'écriture :
_(Depuis la version 1.3.6)_

| Paramètre | Type | Description |
| :- | :- | :- |
| `account` | `string` | Code du compte |
| `id_account` | `int` | Identifiant du compte (ID). Peut être utilisé en alternative au code du compte. |
| `credit` | `string` | Montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa) |
| `debit` | `string` | montant à inscrire au débit |
| `label` | `string|null` | libellé de la ligne |
| `reference` | `string|null` | référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées) |
| `id_project` | `int|null` | ID du projet à affecter à cette ligne |

Exemple de requête :
Renvoie la liste des inscriptions (aux activités) liées à une écriture.

```request
curl -F 'id_year=12' \
  -F 'label=Test' \
  -F 'date=01/02/2022' \
  -F 'type=expense' \
  -F 'amount=42,45' \
  -F 'debit=512A' \
  -F 'credit=601'
```

### accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)
### GET accounting/transaction/{ID_TRANSACTION}

_(Depuis la version 1.3.6)_
Détails de l'écriture

Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé `subscriptions`.

```
| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |
 curl -v "http://…/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'
```

### accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)
Exemple de réponse :

_(Depuis la version 1.3.6)_

Efface la liste des inscriptions liées à une écriture.

### accounting/transaction (POST)

```response
{
  "id": 9302,
  "type": 0,
  "status": 0,
  "label": "Session de caisse n\u00b0439",
  "notes": null,
  "reference": "POS-SESSION-439",
  "date": "2022-02-12",
  "hash": null,
  "prev_id": null,
  "prev_hash": null,
  "id_year": 12,
  "id_creator": 5883,
  "url": "http:\/\/dev.paheko.localhost\/admin\/acc\/transactions\/details.php?id=9302",
  "lines": [
    {
      "id": 22421,
      "id_transaction": 9302,
      "id_account": 542,
      "credit": 0,
      "debit": 3000,
      "reference": "CE342",
      "label": null,
      "reconciled": false,
      "id_project": null,
      "account_code": "5112",
      "account_label": "Ch\u00e8ques \u00e0 encaisser",
      "account_position": 3,
      "project_name": null,
      "account_selector": {
        "542": "5112 \u2014 Ch\u00e8ques \u00e0 encaisser"
      }
Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu.

    },

  ]
#### Structure pour créer / modifier une écriture

Les champs à spécifier pour créer ou modifier une écriture sont les suivants :
}
```

### POST accounting/transaction/{ID_TRANSACTION}

Modifier l'écriture
* `id_year`
* `date` (format YYYY-MM-DD)
* `type` peut être un type d'écriture simplifié (2 lignes) : `EXPENSE` (dépense), `REVENUE` (recette), `TRANSFER` (virement), `DEBT` (dette), `CREDIT` (créance), ou `ADVANCED` pour une écriture multi-ligne
* `amount` (uniquement pour les écritures simplifiées) : contient le montant de l'écriture
* `credit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit
* `debit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit
* `lines` (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :
  * `account` (numéro du compte) ou `id_account` (ID unique du compte)
  * `credit` : montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa)
  * `debit` : montant à inscrire au débit
  * `label` (facultatif) : libellé de la ligne
  * `reference` (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)
  * `id_project` : ID unique du projet à affecter

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |

Si l'écriture est verrouillée, ou dans un exercice clôturé, la modification sera impossible.

Voir la route `POST accounting/transaction` (création d'une écriture) pour la liste des paramètres.

### GET accounting/transaction/{ID_TRANSACTION}/users

Liste des membres liés à une écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |

### POST accounting/transaction/{ID_TRANSACTION}/users

Met à jour la liste des membres liés à une écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |
| `users` | `array(int, …)` | ID des membres. |

Exemple de requête :

```
 curl -v "https://…/api/accounting/transaction/9337/users"  -F 'users[]=2'
```

### DELETE accounting/transaction/{ID_TRANSACTION}/users

Efface la liste des membres liés à une écriture
Champs optionnels :

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |

### GET accounting/transaction/{ID_TRANSACTION}/subscriptions
* `reference` : numéro de pièce comptable
* `notes` : remarques (texte multi ligne)
* `id_project` : ID unique du projet à affecter (pour les écritures simplifiées uniquement)
* `payment_reference` (uniquement pour les écritures simplifiées) : référence de paiement
* `linked_users` : Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)*
* `linked_transactions` : Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)*
* `linked_subscriptions` : Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.3.6)*

Liste des inscriptions (aux activités) liées à une écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |

_(Depuis la version 1.4.0)_

### POST accounting/transaction/{ID_TRANSACTION}/subscriptions

Met à jour la liste des inscriptions liées à une écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |
| `subscriptions` | `array(int, …)` | ID des inscriptions. |

_(Depuis la version 1.4.0)_

Exemple :
Exemple de requête :

```
curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …
 curl -v "https://…/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'
```

### DELETE accounting/transaction/{ID_TRANSACTION}/subscriptions

Efface la liste des inscriptions liées à une écriture

| Paramètre | Type | Description |
| :- | :- | :- |
| `ID_TRANSACTION` | `int` | ID de l'écriture. |

_(Depuis la version 1.4.0)_

Modified src/VERSION from [a3eb6e0b83] to [ffb2be659d].

1


1
-
+
1.3.5
1.4.0

Deleted src/include/data/schema.sql version [57116110a2].

1

-
../migrations/1.3/schema.sql

Modified src/include/init.php from [87388ff3ec] to [8af4bbd2c7].

156
157
158
159
160
161
162
163

164
165
166
167
168
169
170
156
157
158
159
160
161
162

163
164
165
166
167
168
169
170







-
+







}

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'WEB_CACHE_ROOT'        => DATA_ROOT . '/cache/web/%host%',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'DB_SCHEMA'             => ROOT . '/include/migrations/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',
	'PLUGINS_ALLOWLIST'     => null,
	'PLUGINS_BLOCKLIST'     => null,
	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,

Modified src/include/lib/Paheko/API.php from [eec6be819a] to [d9259214ee].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

17
18
19
20
21
22
23
24
25




26
27
28
29
30
31
32
33
34





35
36
37
38

39
40
41
42
43
44
45
46
47
48
49
50
51
52







53
54
55
56
57
58
59
1
2
3
4
5
6








7

8



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34

35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63






-
-
-
-
-
-
-
-

-
+
-
-
-






+
+
+
+








-
+
+
+
+
+



-
+














+
+
+
+
+
+
+







<?php

namespace Paheko;

use Paheko\Backup;
use Paheko\Users\Session;
use Paheko\Web\Web;
use Paheko\Accounting\Accounts;
use Paheko\Accounting\Charts;
use Paheko\Accounting\Export;
use Paheko\Accounting\Reports;
use Paheko\Accounting\Transactions;
use Paheko\Accounting\Years;
use Paheko\Entities\Accounting\Transaction;
use Paheko\Search;
use Paheko\Services\Services_User;
use Paheko\Services\Subscriptions;
use Paheko\Users\Categories;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;
use Paheko\Files\Files;

use KD2\ErrorManager;

class API
{
	use API\Accounting;
	use API\User;
	use API\Web;

	protected string $path;
	protected array $params;
	protected bool $is_http_client = false;
	protected string $method;
	protected int $access;
	protected $file_pointer = null;
	protected ?string $allowed_files_root = null;

	protected array $allowed_methods = ['GET', 'POST', 'PUT', 'DELETE'];
	const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];

	const EXPORT_FORMATS = ['json', 'xlsx', 'ods', 'csv'];

	const SUCCESS = ['success' => true];

	public function __construct(string $method, string $path, array $params)
	{
		if (!in_array($method, $this->allowed_methods)) {
		if (!in_array($method, self::ALLOWED_METHODS)) {
			throw new APIException('Invalid request method: ' . $method, 405);
		}

		$this->path = trim($path, '/');
		$this->method = $method;
		$this->params = $params;
	}

	public function __destruct()
	{
		if (null !== $this->file_pointer) {
			$this->closeFilePointer();
		}
	}

	protected function requireMethod(string $method)
	{
		if ($this->method !== $method) {
			throw new APIException('Wrong request method', 405);
		}
	}

	public function setAllowedFilesRoot(?string $root): void
	{
		$this->allowed_files_root = rtrim($root, '/') . '/';
	}

	public function isPathAllowed(string $path): bool
108
109
110
111
112
113
114



























































































115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135

136
137
138
139
140








141
142
143
144
145
146
147
148
149
150
151
152
153


154
155
156
157

158
159
160
161
162
163
164
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229

230
231




232
233
234
235
236
237
238
239

240
241
242
243
244
245
246
247
248
249


250
251
252
253
254

255
256
257
258
259
260
261
262







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+




















-
+

-
-
-
-
+
+
+
+
+
+
+
+
-










-
-
+
+



-
+







		}
	}

	protected function hasParam(string $param): bool
	{
		return array_key_exists($param, $this->params);
	}

	protected function hasParamTrue(string $param): bool
	{
		return array_key_exists($param, $this->params) && in_array($this->params[$param], [1, true, 'true', 'TRUE', '1'], true);
	}

	protected function toArray($in, bool $recursive = true): array
	{
		if (!is_array($in) && is_iterable($in)) {
			$in = iterator_to_array($in);
		}

		$in = (array)$in;

		foreach ($in as $key => &$value) {
			if ($recursive && (is_array($value) || is_iterable($value))) {
				$value = $this->toArray($value);
			}
			elseif ($value instanceof \DateTime) {
				if ((int)$value->format('His')) {
					$value = $value->format('Y-m-d H:i:s');
				}
				else {
					$value = $value->format('Y-m-d');
				}
			}
		}

		unset($value);
		return $in;
	}

	public function exportJSON($in, $level = 2): void
	{
		$is_list = null;
		$in = $this->toArray($in, false);

		foreach ($in as $key => $value) {
			if (null === $is_list) {
				if ($key === 0) {
					echo "[\n";
					$is_list = true;
				}
				else {
					echo "{\n";
					$is_list = false;
				}
			}
			else {
				echo ",\n";
			}

			echo str_repeat(" ", $level);

			if (!$is_list) {
				echo json_encode((string)$key) . ': ';
			}

			if (is_array($value) || is_object($value)) {
				$this->exportJSON($value, $level+2);
			}
			else {
				echo json_encode($value);
			}
		}

		$space = str_repeat(' ', max($level-2, 0));
		if ($is_list === true) {
			echo "\n$space]";
		}
		elseif ($is_list === false) {
			echo "\n$space}";
		}
		else {
			echo "[]\n";
		}

		flush();
	}

	public function export($in): ?array
	{
		if (!$this->is_http_client) {
			$in = $this->toArray($in);
			return json_encode($in);
		}

		header("Content-Type: application/json; charset=utf-8", true);
		echo $this->exportJSON($in);
		return null;
	}

	protected function download(string $uri)
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		if ($uri === 'files') {
			Files::zipAll();
		}
		elseif ($uri === '') {
			Backup::dump();
		}
		else {
			throw new APIException('Unknown path: ' . $uri, 404);
		}

		return null;
	}

	protected function sql()
	protected function sql(string $format)
	{
		if ($this->method != 'POST') {
			throw new APIException('Wrong request method', 400);
		}

		$this->requireMethod('POST');

		$body = $this->params['sql'] ?? self::getRequestInput();
		$format = $format ?: ($this->params['format'] ?? 'json');

		if (!in_array($format, self::EXPORT_FORMATS, true)) {
			throw new APIException('Invalid format. Supported formats: ' . implode(', ', self::EXPORT_FORMATS));
		}
		$body = $this->params['sql'] ?? self::getRequestInput();

		if ($body === '') {
			throw new APIException('Missing SQL statement', 400);
		}

		try {
			$s = Search::fromSQL($body);
			$result = $s->iterateResults();
			$header = $s->getHeader();

			if (isset($this->params['format']) && in_array($this->params['format'], ['xlsx', 'ods', 'csv'])) {
				$s->export($this->params['format']);
			if ($format !== 'json') {
				$s->export($format);
				return null;
			}
			elseif (!$this->is_http_client) {
				return ['count' => $s->countResults, 'results' => iterator_to_array($result)];
				return $this->export(['count' => $s->countResults(), 'results' => $result]);
			}
			else {
				// Stream results to client, in case request is slow
				header("Content-Type: application/json; charset=utf-8", true);
				printf("{\n    \"count\": %d,\n    \"results\":\n    [\n", $s->countResults());

				foreach ($result as $i => $row) {
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
288
289
290
291
292
293
294



































































































































































































































































































































































































































































295
296
297
298
299
300
301







-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-







			}
		}
		catch (DB_Exception $e) {
			throw new APIException('Error in SQL statement: ' . $e->getMessage(), 400);
		}
	}

	protected function user(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$fn2 = strtok('/');
		strtok('');

		if ($fn === 'categories') {
			return Categories::listWithStats();
		}
		elseif ($fn === 'category') {
			$id = (int) strtok($fn2, '.');
			$format = strtok('');

			try {
				Users::exportCategory($format ?: 'json', $id);
			}
			catch (\InvalidArgumentException $e) {
				throw new APIException($e->getMessage(), 400, $e);
			}

			return null;
		}
		elseif ($fn === 'new') {
			$this->requireAccess(Session::ACCESS_WRITE);

			$user = Users::create();
			$user->importForm($this->params);
			$user->setNumberIfEmpty();

			if (empty($this->params['force_duplicate']) && $user->checkDuplicate()) {
				throw new APIException('This user seems to be a duplicate of an existing one', 409);
			}

			if (!empty($this->params['id_category']) && !$user->setCategorySafeNoConfig($this->params['id_category'])) {
				throw new APIException('You are not allowed to create a user in this category', 403);
			}

			if (isset($this->params['password'])) {
				$user->importSecurityForm(false, ['password' => $this->params['password'], 'password_confirmed' => $this->params['password']]);
			}

			$user->save();

			return $user->exportAPI();
		}
		elseif (ctype_digit($fn)) {
			$user = Users::get((int)$fn);

			if (!$user) {
				throw new APIException('The requested user ID does not exist', 404);
			}

			if ($this->method === 'POST') {
				$this->requireAccess(Session::ACCESS_WRITE);

				try {
					$user->validateCanChange();
				}
				catch (UserException $e) {
					throw new APIException($e->getMessage(), 403, $e);
				}

				$user->importForm($this->params);
				$user->save();
			}
			elseif ($this->method === 'DELETE') {
				$this->requireAccess(Session::ACCESS_ADMIN);

				try {
					$user->validateCanChange();
				}
				catch (UserException $e) {
					throw new APIException($e->getMessage(), 403, $e);
				}

				$user->delete();
				return ['success' => true];
			}

			return $user->exportAPI();
		}
		elseif ($fn === 'import') {
			$fp = null;

			if ($this->method === 'PUT') {
				$params = $this->params;
			}
			elseif ($this->method === 'POST') {
				$params = $_POST;
			}
			else {
				throw new APIException('Wrong request method', 400);
			}

			$mode = $params['mode'] ?? 'auto';

			if (!in_array($mode, ['auto', 'create', 'update'])) {
				throw new APIException('Unknown mode. Only "auto", "create" and "update" are accepted.', 400);
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$path = tempnam(CACHE_ROOT, 'tmp-import-api');

			if ($this->method === 'POST') {
				if (empty($_FILES['file']['tmp_name']) || !empty($_FILES['file']['error'])) {
					throw new APIException('Empty file or no file was sent.', 400);
				}

				$path = $_FILES['file']['tmp_name'] ?? null;
			}
			else {
				$fp = fopen($path, 'wb');
				stream_copy_to_stream($this->file_pointer, $fp);
				fclose($fp);
				$this->closeFilePointer();
			}

			try {
				if (!filesize($path)) {
					throw new APIException('Empty CSV file', 400);
				}

				$csv = new CSV_Custom;
				$df = DynamicFields::getInstance();
				$csv->setColumns($df->listImportAssocNames());
				$required_fields = $df->listImportRequiredAssocNames($mode === 'update' ? true : false);
				$csv->setMandatoryColumns(array_keys($required_fields));
				$csv->loadFile($path);
				$csv->skip((int)($params['skip_lines'] ?? 1));

				if (!empty($params['column']) && is_array($params['column'])) {
					$csv->setIndexedTable($params['column']);
				}
				else {
					$csv->setTranslationTableAuto();
				}

				if (!$csv->loaded() || !$csv->ready()) {
					throw new APIException('Missing columns or error during columns matching of import table', 400);
				}

				if ($fn2 === 'preview') {
					$report = Users::importReport($csv, $mode);

					$report['unchanged'] = array_map(
						fn($user) => ['id' => $user->id(), 'name' => $user->name()],
						$report['unchanged']
					);

					$report['created'] = array_map(
						fn($user) => $user->asDetailsArray(),
						$report['created']
					);

					$report['modified'] = array_map(
						function ($user) {
							$out = ['id' => $user->id(), 'name' => $user->name(), 'changed' => []];

							foreach ($user->getModifiedProperties() as $key => $value) {
								$out['changed'][$key] = ['old' => $value, 'new' => $user->$key];
							}

							return $out;
						},
						$report['modified']
					);


					return $report;
				}
				else {
					Users::import($csv, $mode);
					return null;
				}
			}
			finally {
				Utils::safe_unlink($path);
			}
		}
		else {
			throw new APIException('Unknown user action', 404);
		}
	}

	protected function web(string $uri): ?array
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':
				return [
					'categories' => array_map(fn($p) => $p->asArray(true), Web::listCategories($param)),
					'pages' => array_map(fn($p) => $p->asArray(true), Web::listPages($param)),
				];
			case 'attachment':
				$attachment = Files::getFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = $page->asArray(true);

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}

	protected function accounting(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$p1 = strtok('/');
		$p2 = strtok('');

		if ($fn == 'transaction') {
			if (!$p1) {
				if ($this->method != 'POST') {
					throw new APIException('Wrong request method', 400);
				}

				$this->requireAccess(Session::ACCESS_WRITE);
				$transaction = new Transaction;
				$transaction->importFromAPI($this->params);
				$transaction->save();

				if (!empty($this->params['linked_users'])) {
					$transaction->updateLinkedUsers((array)$this->params['linked_users']);
				}

				if (!empty($this->params['linked_transactions'])) {
					$transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
				}

				if (!empty($this->params['linked_subscriptions'])) {
					$transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
				}

				if ($this->hasParam('move_attachments_from')
					&& $this->isPathAllowed($this->params['move_attachments_from'])) {
					$file = Files::get($this->params['move_attachments_from']);

					if ($file && $file->isDir()) {
						$file->rename($transaction->getAttachementsDirectory());
					}
				}

				return $transaction->asJournalArray();
			}
			// Return or edit linked users
			elseif ($p1 && ctype_digit($p1) && $p2 == 'users') {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers((array)($_POST['users'] ?? null));
					return ['success' => true];
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers([]);
					return ['success' => true];
				}
				elseif ($this->method === 'GET') {
					return $transaction->listLinkedUsers();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			// Return or edit linked subscriptions
			elseif ($p1 && ctype_digit($p1) && $p2 == 'subscriptions') {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateSubscriptionLinks((array)($_POST['subscriptions'] ?? null));
					return ['success' => true];
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->deleteAllSubscriptionLinks([]);
					return ['success' => true];
				}
				elseif ($this->method === 'GET') {
					return $transaction->listSubscriptionLinks();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			elseif ($p1 && ctype_digit($p1) && !$p2) {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method == 'GET') {
					return $transaction->asJournalArray();
				}
				elseif ($this->method == 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->importFromAPI($this->params);
					$transaction->save();

					if (!empty($this->params['linked_users'])) {
						$transaction->updateLinkedUsers((array)$this->params['linked_users']);
					}

					if (!empty($this->params['linked_transactions'])) {
						$transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
					}

					if (!empty($this->params['linked_subscriptions'])) {
						$transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
					}

					return $transaction->asJournalArray();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			else {
				throw new APIException('Unknown transactions route', 404);
			}
		}
		elseif ($fn == 'charts') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			if ($p1 && ctype_digit($p1) && $p2 === 'accounts') {
				$a = new Accounts((int)$p1);
				return array_map(fn($c) => $c->asArray(), $a->listAll());
			}
			elseif (!$p1 && !$p2) {
				return array_map(fn($c) => $c->asArray(), Charts::list());
			}
			else {
				throw new APIException('Unknown charts action', 404);
			}
		}
		elseif ($fn == 'years') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			if (!$p1 && !$p2) {
				return Years::list();
			}

			$id_year = null;

			if ($p1 === 'current') {
				$id_year = Years::getCurrentOpenYearId();
			}
			elseif ($p1 && ctype_digit($p1)) {
				$id_year = (int)$p1;
			}

			if (!$id_year) {
				throw new APIException('Missing year in request, or no open years exist', 400);
			}

			$year = Years::get($id_year);

			if (!$year) {
				throw new APIException('Invalid year.', 400, $e);
			}

			if ($p2 === 'journal') {
				try {
					return iterator_to_array(Reports::getJournal(['year' => $id_year]));
				}
				catch (\LogicException $e) {
					throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e);
				}
			}
			elseif (0 === strpos($p2, 'export/')) {
				strtok($p2, '/');
				$type = strtok('.');
				$format = strtok('');
				Export::export($year, $format, $type);
				return null;
			}
			elseif ($p2 === 'account/journal') {
				$a = $year->chart()->accounts();

				if (!empty($this->params['code'])) {
					$account = $a->getWithCode($this->params['code']);
				}
				else {
					$account = $a->get((int)$this->params['code'] ?? null);
				}

				if (!$account) {
					throw new APIException('Unknown account id or code.', 400, $e);
				}

				$list = $account->listJournal($year->id, false);
				$list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label));
				$list->loadFromQueryString();
				$list->setPageSize(null);
				$list->orderBy('date', false);
				return iterator_to_array($list->iterate());
			}
			else {
				throw new APIException('Unknown years action', 404);
			}
		}
		else {
			throw new APIException('Unknown accounting action', 404);
		}
	}

	protected function services(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$fn2 = strtok('/');
		strtok('');

		// CSV import
689
690
691
692
693
694
695
696
697


698
699
700
701
702
703

704
705
706

707
708
709
710
711
712
713
336
337
338
339
340
341
342


343
344
345
346
347
348
349

350
351
352

353
354
355
356
357
358
359
360







-
-
+
+





-
+


-
+








			try {
				if (!filesize($path)) {
					throw new APIException('Invalid upload', 400);
				}

				$csv = new CSV_Custom;
				$csv->setColumns(Services_User::listImportColumns());
				$csv->setMandatoryColumns(Services_User::listMandatoryImportColumns());
				$csv->setColumns(Subscriptions::listImportColumns());
				$csv->setMandatoryColumns(Subscriptions::listMandatoryImportColumns());

				$csv->loadFile($path);
				$csv->setTranslationTableAuto();

				if (!$csv->loaded() || !$csv->ready()) {
					throw new APIException('Missing columns or error during columns matching of import table: ' . json_encode(Services_User::listMandatoryImportColumns()), 400);
					throw new APIException('Missing columns or error during columns matching of import table: ' . json_encode(Subscriptions::listMandatoryImportColumns()), 400);
				}

				Services_User::import($csv);
				Subscriptions::import($csv);
				return null;
			}
			finally {
				Utils::safe_unlink($path);
			}
		}
		else {
780
781
782
783
784
785
786





787
788
789
790
791
792
793
794
795
796
797
798
799
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442


443
444
445
446
447
448
449







+
+
+
+
+




-
-








		$this->access = $access;
	}

	public function route()
	{
		$uri = $this->path;

		if (substr($uri, 0, 3) === 'sql') {
			return $this->sql(trim(substr($uri, 3), '.'));
		}

		$fn = strtok($uri, '/');
		$uri = strtok('');

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download($uri);
			case 'web':
				return $this->web($uri);
			case 'user':
				return $this->user($uri);
			case 'errors':
846
847
848
849
850
851
852


853
854
855
856
857
858
859
860
861
862
863
864
865
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517







+
+














			try {
				$return = $api->route();
			}
			catch (UserException|ValidationException $e) {
				throw new APIException($e->getMessage(), 400, $e);
			}

			$return = $api->export($return);

			if (null !== $return) {
				header("Content-Type: application/json; charset=utf-8", true);
				echo json_encode($return, JSON_PRETTY_PRINT);
			}
		}
		catch (APIException $e) {
			http_response_code($e->getCode());
			header("Content-Type: application/json; charset=utf-8", true);
			echo json_encode(['error' => $e->getMessage()]);
		}
	}
}

Added src/include/lib/Paheko/API/Accounting.php version [b46bb90cf1].











































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\API;

use Paheko\Accounting\Accounts;
use Paheko\Accounting\Charts;
use Paheko\Accounting\Export;
use Paheko\Accounting\Reports;
use Paheko\Accounting\Transactions;
use Paheko\Accounting\Years;
use Paheko\Entities\Accounting\Transaction;
use Paheko\APIException;
use Paheko\Utils;

trait Accounting
{
	protected function accounting(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$p1 = strtok('/');
		$p2 = strtok('');

		if ($fn == 'transaction') {
			if (!$p1) {
				$this->requireMethod('POST');
				$this->requireAccess(Session::ACCESS_WRITE);

				$transaction = new Transaction;
				$transaction->importFromAPI($this->params);
				$transaction->save();

				if (!empty($this->params['linked_users'])) {
					$transaction->updateLinkedUsers((array)$this->params['linked_users']);
				}

				if (!empty($this->params['linked_transactions'])) {
					$transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
				}

				if (!empty($this->params['linked_subscriptions'])) {
					$transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
				}

				if ($this->hasParam('move_attachments_from')
					&& $this->isPathAllowed($this->params['move_attachments_from'])) {
					$file = Files::get($this->params['move_attachments_from']);

					if ($file && $file->isDir()) {
						$file->rename($transaction->getAttachementsDirectory());
					}
				}

				return $transaction->asJournalArray();
			}
			// Return or edit linked users
			elseif ($p1 && ctype_digit($p1) && $p2 == 'users') {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers((array)($_POST['users'] ?? null));
					return self::SUCCESS;
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers([]);
					return self::SUCCESS;
				}
				elseif ($this->method === 'GET') {
					return $transaction->listLinkedUsers();
				}
				else {
					throw new APIException('Wrong request method', 405);
				}
			}
			// Return or edit linked subscriptions
			elseif ($p1 && ctype_digit($p1) && $p2 == 'subscriptions') {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateSubscriptionLinks((array)($_POST['subscriptions'] ?? null));
					return self::SUCCESS;
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->deleteAllSubscriptionLinks([]);
					return self::SUCCESS;
				}
				elseif ($this->method === 'GET') {
					return $transaction->listLinkedSubscriptions();
				}
				else {
					throw new APIException('Wrong request method', 405);
				}
			}
			elseif ($p1 && ctype_digit($p1) && !$p2) {
				$transaction = Transactions::get((int)$p1);

				if (!$transaction) {
					throw new APIException(sprintf('Transaction #%d not found', $p1), 404);
				}

				if ($this->method === 'GET') {
					return $transaction->asJournalArray();
				}
				elseif ($this->method === 'POST') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->importFromAPI($this->params);
					$transaction->save();

					if (!empty($this->params['linked_users'])) {
						$transaction->updateLinkedUsers((array)$this->params['linked_users']);
					}

					if (!empty($this->params['linked_transactions'])) {
						$transaction->updateLinkedTransactions((array)$this->params['linked_transactions']);
					}

					if (!empty($this->params['linked_subscriptions'])) {
						$transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']);
					}

					return $transaction->asJournalArray();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			else {
				throw new APIException('Unknown transactions route', 404);
			}
		}
		elseif ($fn == 'charts') {
			$this->requireMethod('GET');

			if ($p1 && ctype_digit($p1) && $p2 === 'accounts') {
				$a = new Accounts((int)$p1);
				return array_map(fn($c) => $c->asArray(), $a->listAll());
			}
			elseif (!$p1 && !$p2) {
				return array_map(fn($c) => $c->asArray(), Charts::list());
			}
			else {
				throw new APIException('Unknown charts action', 404);
			}
		}
		elseif ($fn == 'years') {
			$this->requireMethod('GET');

			if (!$p1 && !$p2) {
				return Years::list();
			}

			$id_year = null;

			if ($p1 === 'current') {
				$id_year = Years::getCurrentOpenYearId();
			}
			elseif ($p1 && ctype_digit($p1)) {
				$id_year = (int)$p1;
			}

			if (!$id_year) {
				throw new APIException('Missing year in request, or no open years exist', 400);
			}

			$year = Years::get($id_year);

			if (!$year) {
				throw new APIException('Invalid year.', 400, $e);
			}

			if ($p2 === 'journal') {
				try {
					return Reports::getJournal(['year' => $id_year]);
				}
				catch (\LogicException $e) {
					throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e);
				}
			}
			elseif (0 === strpos($p2, 'journal/')) {
				$account = substr($p2, strlen('journal/'));
				$a = $year->chart()->accounts();

				if (substr($account, 0, 1) === '=') {
					$account = $a->get(intval(substr($account, 1)));
				}
				else {
					$account = $a->getWithCode($account);
				}

				if (!$account) {
					throw new APIException('Unknown account id or code.', 400, $e);
				}

				$list = $account->listJournal($year->id, false);
				$list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label));
				$list->loadFromQueryString();
				$list->setPageSize(null);
				$list->orderBy('date', false);
				return $list->iterate();
			}
			elseif (0 === strpos($p2, 'export/')) {
				strtok($p2, '/');
				$type = strtok('.');
				$format = strtok('') ?: 'json';

				try {
					Export::export($year, $format, $type);
				}
				catch (\InvalidArgumentException $e) {
					throw new APIException($e->getMessage(), 400, $e);
				}

				return null;
			}
			else {
				throw new APIException('Unknown years action', 404);
			}
		}
		else {
			throw new APIException('Unknown accounting action', 404);
		}
	}
}

Added src/include/lib/Paheko/API/User.php version [0179eda829].





































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\API;

use Paheko\Users\Categories;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;
use Paheko\APIException;

trait User
{
	protected function user(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$fn2 = strtok('/');
		strtok('');

		if ($fn === 'categories') {
			return Categories::listWithStats();
		}
		elseif ($fn === 'category') {
			$id = (int) strtok($fn2, '.');
			$format = strtok('');

			try {
				Users::exportCategory($format ?: 'json', $id);
			}
			catch (\InvalidArgumentException $e) {
				throw new APIException($e->getMessage(), 400, $e);
			}

			return null;
		}
		elseif ($fn === 'new') {
			$this->requireAccess(Session::ACCESS_WRITE);

			$user = Users::create();
			$user->importForm($this->params);
			$user->setNumberIfEmpty();

			if (empty($this->params['force_duplicate']) && $user->checkDuplicate()) {
				throw new APIException('This user seems to be a duplicate of an existing one', 409);
			}

			if (!empty($this->params['id_category']) && !$user->setCategorySafeNoConfig($this->params['id_category'])) {
				throw new APIException('You are not allowed to create a user in this category', 403);
			}

			if (isset($this->params['password'])) {
				$user->importSecurityForm(false, ['password' => $this->params['password'], 'password_confirmed' => $this->params['password']]);
			}

			$user->save();

			return $user->exportAPI();
		}
		elseif (ctype_digit($fn)) {
			$user = Users::get((int)$fn);

			if (!$user) {
				throw new APIException('The requested user ID does not exist', 404);
			}

			if ($this->method === 'POST') {
				$this->requireAccess(Session::ACCESS_WRITE);

				try {
					$user->validateCanChange();
				}
				catch (UserException $e) {
					throw new APIException($e->getMessage(), 403, $e);
				}

				$user->importForm($this->params);
				$user->save();
			}
			elseif ($this->method === 'DELETE') {
				$this->requireAccess(Session::ACCESS_ADMIN);

				try {
					$user->validateCanChange();
				}
				catch (UserException $e) {
					throw new APIException($e->getMessage(), 403, $e);
				}

				$user->delete();
				return self::SUCCESS;
			}

			return $user->exportAPI();
		}
		elseif ($fn === 'import') {
			$fp = null;

			if ($this->method === 'PUT') {
				$params = $this->params;
			}
			elseif ($this->method === 'POST') {
				$params = $_POST;
			}
			else {
				throw new APIException('Wrong request method', 400);
			}

			$mode = $params['mode'] ?? 'auto';

			if (!in_array($mode, ['auto', 'create', 'update'])) {
				throw new APIException('Unknown mode. Only "auto", "create" and "update" are accepted.', 400);
			}

			$this->requireAccess(Session::ACCESS_ADMIN);

			$path = tempnam(CACHE_ROOT, 'tmp-import-api');

			if ($this->method === 'POST') {
				if (empty($_FILES['file']['tmp_name']) || !empty($_FILES['file']['error'])) {
					throw new APIException('Empty file or no file was sent.', 400);
				}

				$path = $_FILES['file']['tmp_name'] ?? null;
			}
			else {
				$fp = fopen($path, 'wb');
				stream_copy_to_stream($this->file_pointer, $fp);
				fclose($fp);
				$this->closeFilePointer();
			}

			try {
				if (!filesize($path)) {
					throw new APIException('Empty CSV file', 400);
				}

				$csv = new CSV_Custom;
				$df = DynamicFields::getInstance();
				$csv->setColumns($df->listImportAssocNames());
				$required_fields = $df->listImportRequiredAssocNames($mode === 'update' ? true : false);
				$csv->setMandatoryColumns(array_keys($required_fields));
				$csv->loadFile($path);
				$csv->skip((int)($params['skip_lines'] ?? 1));

				if (!empty($params['column']) && is_array($params['column'])) {
					$csv->setIndexedTable($params['column']);
				}
				else {
					$csv->setTranslationTableAuto();
				}

				if (!$csv->loaded() || !$csv->ready()) {
					throw new APIException('Missing columns or error during columns matching of import table', 400);
				}

				if ($fn2 === 'preview') {
					$report = Users::importReport($csv, $mode);

					$report['unchanged'] = array_map(
						fn($user) => ['id' => $user->id(), 'name' => $user->name()],
						$report['unchanged']
					);

					$report['created'] = array_map(
						fn($user) => $user->asDetailsArray(),
						$report['created']
					);

					$report['modified'] = array_map(
						function ($user) {
							$out = ['id' => $user->id(), 'name' => $user->name(), 'changed' => []];

							foreach ($user->getModifiedProperties() as $key => $value) {
								$out['changed'][$key] = ['old' => $value, 'new' => $user->$key];
							}

							return $out;
						},
						$report['modified']
					);


					return $report;
				}
				else {
					Users::import($csv, $mode);
					return null;
				}
			}
			finally {
				Utils::safe_unlink($path);
			}
		}
		else {
			throw new APIException('Unknown user action', 404);
		}
	}
}

Added src/include/lib/Paheko/API/Web.php version [622538d5ce].













































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\API;

use Paheko\Web\Web as PWeb;
use Paheko\APIException;

trait Web
{
	protected function web(string $uri): ?array
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		if (!$fn) {
			$this->requireMethod('GET');
			$list = PWeb::getAllList();
			$list->setPageSize(null);
			return $this->export($list->iterate());
		}

		if (substr($fn, 0, -5) === '.html') {
			$fn = substr($fn, 0, -5);
			$param = 'html';
		}

		$page = PWeb::getByURI($fn);

		if (!$page) {
			throw new APIException('Page not found', 404);
		}

		if (!$param) {
			if ($this->method === 'GET') {
				$out = $page->asArray(true);

				if ($this->hasParamTrue('html')) {
					$out['html'] = $page->render();
				}

				return $out;
			}
			elseif ($this->method === 'DELETE') {
				$this->requireAccess(Session::ACCESS_ADMIN);
				$page->delete();
				return self::SUCCESS;
			}
			elseif ($this->method === 'PUT') {
				$this->requireAccess(Session::ACCESS_WRITE);
				$page->set('content', self::getRequestInput());
				$page->saveNewVersion();
				return self::SUCCESS;
			}
			elseif ($this->method === 'POST') {
				$this->requireAccess(Session::ACCESS_WRITE);
				$page->importForm($this->params);
				$page->saveNewVersion();
				return self::SUCCESS;
			}
			else {
				throw new APIException('Invalid request method', 405);
			}
		}
		elseif ($param === 'html') {
			$this->requireMethod('GET');
			http_response_code(200);
			header('Content-Type: text/html; charset=utf-8', true);
			echo $page->html();
			return null;
		}
		elseif ($param === 'children') {
			$this->requireMethod('GET');
			return [
				'categories' => array_map(fn($p) => $p->asArray(true), PWeb::listCategories($page->id())),
				'pages' => array_map(fn($p) => $p->asArray(true), PWeb::listPages($page->id())),
			];
		}
		elseif ($param === 'attachments') {
			$this->requireMethod('GET');
			return $page->listAttachments();
		}
		else {
			$this->requireMethod('GET');
			$attachment = Files::get(File::CONTEXT_WEB . '/' . $page->uri . '/' . $param);

			if (!$attachment) {
				throw new APIException('Attachment not found', 404);
			}

			if ($this->method === 'GET') {
				$attachment->serve();
				return null;
			}
			elseif ($this->method === 'DELETE') {
				$this->requireAccess(Session::ACCESS_WRITE);
				$attachment->delete();
				return self::SUCCESS;
			}
			else {
				throw new APIException('Invalid method', 405);
			}
		}
	}
}

Modified src/include/lib/Paheko/Accounting/Reports.php from [e7c25e62cb] to [ef64e160e0].

55
56
57
58
59
60
61
62

63
64
65
66
67
68
69
55
56
57
58
59
60
61

62
63
64
65
66
67
68
69







-
+







		}

		if (!empty($criterias['creator'])) {
			$where[] = sprintf($transactions_alias . 'id_creator = %d', $criterias['creator']);
		}

		if (!empty($criterias['subscription'])) {
			$where[] = sprintf($transactions_alias . 'id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE id_service_user = %d)', $criterias['subscription']);
			$where[] = sprintf($transactions_alias . 'id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE tu.id_subscription = %d)', $criterias['subscription']);
		}

		if (!empty($criterias['project'])) {
			$where[] = sprintf($lines_alias . 'id_project = %d', $criterias['project']);
		}

		if (!empty($criterias['account'])) {

Modified src/include/lib/Paheko/DB.php from [959b0d731a] to [921ecf06d2].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
1
2
3
4
5
6
7
8
9

10
11
12
13
14
15
16
17









-
+







<?php

namespace Paheko;

use KD2\DB\SQLite3;
use KD2\DB\DB_Exception;
use KD2\ErrorManager;

use Paheko\Users\DynamicFields;
use Paheko\Entities\Email\Email;
use Paheko\Email\Addresses;

class DB extends SQLite3
{
	/**
	 * Application ID pour SQLite
	 * @link https://www.sqlite.org/pragma.html#pragma_application_id
	 */
239
240
241
242
243
244
245
246

247
248
249
250
251
252
253
239
240
241
242
243
244
245

246
247
248
249
250
251
252
253







-
+








	static public function registerCustomFunctions($db)
	{
		$db->createFunction('dirname', [Utils::class, 'dirname']);
		$db->createFunction('basename', [Utils::class, 'basename']);
		$db->createFunction('unicode_like', [self::class, 'unicodeLike']);
		$db->createFunction('transliterate_to_ascii', [Utils::class, 'unicodeTransliterate']);
		$db->createFunction('email_hash', [Email::class, 'getHash']);
		$db->createFunction('email_hash', [Addresses::class, 'hash']);
		$db->createFunction('md5', 'md5');
		$db->createFunction('uuid', [Utils::class, 'uuid']);
		$db->createFunction('print_binary', fn($value) => sprintf('%032d', decbin($value)));

		$db->createFunction('print_dynamic_field', function($name, $value) {
			$field = DynamicFields::get($name);

Modified src/include/lib/Paheko/DynamicList.php from [16f7c52ac0] to [453807817e].

17
18
19
20
21
22
23



24
25
26
27
28
29
30
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33







+
+
+







	 * but it will not be included in HTML table
	 * - If the key 'select' exists, then it will be used as the SELECT clause
	 * - If the key 'label' exists, it will be used in the HTML table as its header
	 * (if not, the result will still be available in the loop, just it will not generate a column in the HTML table)
	 * - If the key 'export' is TRUE, then the column will ONLY be included in CSV/ODS/XLSX exports
	 * - If the key 'export' is FALSE, then the column will NOT be included in exports
	 * (if the key `export` is NULL, or not set, then the column will be included both in HTML and in exports)
	 * - If the key 'only_with_order' exists and is a column alias (key), this column will only appear
	 * if the order is using the designated column for ORDER BY clause.
	 * - If the key 'order' exists, it will be used for ordering this column. %s will be replaced with DESC or ASC.
	 */
	protected array $columns;

	/**
	 * List of tables (including joins)
	 */
	protected string $tables;

Added src/include/lib/Paheko/Email/Addresses.php version [5f2851677a].
















































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\Email;

use Paheko\Config;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Plugins;
use Paheko\UserException;
use Paheko\Utils;
use Paheko\Entities\Email\Address;
use Paheko\Entities\Files\File;
use Paheko\Entities\Users\User;
use Paheko\Users\DynamicFields;
use Paheko\UserTemplate\UserTemplate;
use Paheko\Web\Render\Render;

use Paheko\Files\Files;

use KD2\Mail_Message;
use KD2\SMTP;
use KD2\DB\EntityManager as EM;

class Addresses
{
	/**
	 * Antispam services that require to do a manual action to accept emails
	 */
	const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/';

	const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com', 'proton.me'];

	/**
	 * Return NULL if address is valid, or a string for an error message if invalid
	 */
	static public function checkForErrors(string $email, bool $mx_check = true): ?string
	{
		if (trim($email) === '') {
			return 'Adresse e-mail vide';
		}

		$local_part = null;
		$host = null;
		$email = self::normalize($email, $local_part, $host);

		if (!$email) {
			return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.';
		}

		// Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
		if ($host == 'gmail.fr') {
			return 'Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"';
		}

		if (preg_match('![/@]!', $local_part)) {
			return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.';
		}

		if (!SMTP::checkEmailIsValid($email, false)) {
			if (!trim($host)) {
				return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.';
			}

			foreach (self::COMMON_DOMAINS as $common_domain) {
				similar_text($common_domain, $host, $percent);

				if ($percent > 90) {
					return sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain);
				}
			}

			return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.';
		}

		// Windows does not support MX lookups
		if (PHP_OS_FAMILY == 'Windows' || !$mx_check) {
			return null;
		}

		getmxrr($host, $mx_list);

		if (empty($mx_list)) {
			return 'Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.';
		}

		foreach ($mx_list as $mx) {
  			if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) {
				return 'Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.';
			}
		}

		return null;
	}

	static public function isValid(string $address, bool $check_mx = true): bool
	{
		return self::checkForErrors($address, $check_mx) === null;
	}

	static public function validate(string $address, bool $check_mx = true): void
	{
		$error = self::checkForErrors($address);

		if (null !== $error) {
			throw new UserException($error);
		}
	}

	static public function normalize(string $address, ?string &$local_part = null, ?string &$host = null): ?string
	{
		$address = strtolower(trim($address));

		$pos = strrpos($address, '@');

		if (!$pos) {
			return null;
		}

		$local_part = substr($address, 0, $pos);
		$host = substr($address, $pos + 1);
		$host = idn_to_ascii($host);

		$address = $local_part . '@' . $host;
		return $address;
	}

	/**
	 * Normalize email address and create a hash from this
	 */
	static public function hash(string $address): string
	{
		$address = self::normalize($address);
		return sha1($address);
	}

	/**
	 * Return an Email entity from the optout code
	 */
	static public function getFromOptout(string $code): ?Address
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
	}

	/**
	 * Sets the address as invalid (no email can be sent to this address ever)
	 */
	static public function markAddressAsInvalid(string $address): void
	{
		$e = self::get($address);

		if (!$e) {
			return;
		}

		$e->set('invalid', true);
		$e->set('optout', false);
		$e->set('verified', false);
		$e->save();
	}

	/**
	 * Return an Email entity from an email address
	 */
	static public function get(string $address): ?Address
	{
		return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE hash = ?;', self::hash($address));
	}

	/**
	 * Return an Email entity from an ID
	 */
	static public function getByID(int $id): ?Address
	{
		return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE id = ?;', $id);
	}

	/**
	 * Return or create a new email entity
	 */
	static public function getOrCreate(string $address): Address
	{
		$e = self::get($address);
		$e ??= self::create($address);
		return $e;
	}

	static public function create(string $address): Address
	{
		$e = new Address;
		$e->setAddress($address);
		$e->save();
		return $e;
	}

	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();
		$email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());

		$columns = [
			'id' => [
				'select' => 'a.id',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => DynamicFields::getNameFieldsSQL('u'),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => $email_field,
			],
			'user_id' => [
				'select' => 'u.id',
			],
			'hash' => [
			],
			'status' => [
				'label' => 'Statut',
			],
			'sent_count' => [
				'label' => 'Messages envoyés',
			],
			'last_sent' => [
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = sprintf('emails_addresses a INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND a.hash = email_hash(%1$s)', $email_field);

		$conditions = 'a.status < 0';

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		$list->setModifier(function (&$row) {
			$row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null;
		});
		return $list;
	}

	/**
	 * Handle a bounce message
	 * @param  string $raw_message Raw MIME message from SMTP
	 */
	static public function handleBounce(string $raw_message): ?array
	{
		$message = new Mail_Message;
		$message->parse($raw_message);

		$return = $message->identifyBounce();
		$address = $return['recipient'] ?? null;

		$signal = Plugins::fire('email.bounce', false, compact('address', 'message', 'return', 'raw_message'));

		if ($signal && $signal->isStopped()) {
			return null;
		}

		if (!$return) {
			return null;
		}

		if ($return['type'] == 'autoreply') {
			// Ignore auto-responders
			return $return;
		}
		elseif ($return['type'] == 'genuine') {
			// Forward emails that are not automatic to the organization email
			$config = Config::getInstance();

			$new = new Mail_Message;
			$new->setHeaders([
				'To'      => $config->org_email,
				'Subject' => 'Réponse à un message que vous avez envoyé',
			]);

			$new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.');

			$new->attachMessage($message->output());

			self::sendMessage(self::CONTEXT_SYSTEM, $new);
			return $return;
		}

		return self::handleManualBounce($return['recipient'], $return['type'], $return['message']);
	}

	static public function handleManualBounce(string $address, string $type, ?string $message): ?array
	{
		$return = compact('address', 'type', 'message');
		$email = self::getOrCreate($address);

		if (!$email) {
			return null;
		}

		$email->hasFailed($return);
		Plugins::fire('email.bounce.save.before', false, compact('email', 'address', 'return', 'type', 'message'));
		$email->save();

		return $return;
	}


	static public function getFromHeader(string $name = null, string $email = null): string
	{
		$config = Config::getInstance();

		if (null === $name) {
			$name = $config->org_name;
		}
		if (null === $email) {
			$email = $config->org_email;
		}

		$name = str_replace('"', '\\"', $name);
		$name = str_replace(',', '', $name); // Remove commas

		return sprintf('"%s" <%s>', $name, $email);
	}

	static public function getVerificationLimitDate(): \DateTime
	{
		$delay = Address::RESEND_VERIFICATION_DELAY . ' hours ago';
		return new \DateTime($delay);
	}
}

Deleted src/include/lib/Paheko/Email/Emails.php version [70fa0ab420].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767































































































































































































































































































































































































































































































































































































































































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko\Email;

use Paheko\Config;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Plugins;
use Paheko\UserException;
use Paheko\Utils;
use Paheko\Entities\Email\Email;
use Paheko\Entities\Files\File;
use Paheko\Entities\Users\User;
use Paheko\Users\DynamicFields;
use Paheko\UserTemplate\UserTemplate;
use Paheko\Web\Render\Render;

use Paheko\Files\Files;

use const Paheko\{USE_CRON, MAIL_SENDER, MAIL_RETURN_PATH, DISABLE_EMAIL};
use const Paheko\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY, SMTP_HELO_HOSTNAME};

use KD2\SMTP;
use KD2\Security;
use KD2\Mail_Message;
use KD2\DB\EntityManager as EM;

class Emails
{
	const RENDER_FORMATS = [
		null => 'Texte brut',
		Render::FORMAT_MARKDOWN => 'MarkDown',
	];

	/**
	 * Email sending contexts
	 */
	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_SYSTEM = 0;
	const CONTEXT_NOTIFICATION = 3;

	/**
	 * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
	 */
	const FAIL_LIMIT = 5;

	/**
	 * Add a message to the sending queue using templates
	 * @param  int          $context
	 * @param  iterable        $recipients List of recipients, this accepts a wide range of types:
	 * - a single e-mail address
	 * - array of e-mail addresses as values ['a@b.c', 'd@e.f']
	 * - array of user entities
	 * - array where each key is the email address, and the value is an array or a \stdClass containing
	 *   pgp_key, data and user items
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate|string $content
	 * @return void
	 */
	static public function queue(int $context, iterable $recipients, ?string $sender, string $subject, $content, array $attachments = []): void
	{
		if (DISABLE_EMAIL) {
			return;
		}

		foreach ($attachments as $i => $file) {
			if (!is_object($file) || !($file instanceof File) || $file->context() != $file::CONTEXT_ATTACHMENTS) {
				throw new \InvalidArgumentException(sprintf('Attachment #%d is not a valid file', $i));
			}
		}

		$list = [];

		// Build email list
		foreach ($recipients as $key => $r) {
			$data = [];
			$emails = [];
			$user = null;
			$pgp_key = null;

			if (is_array($r)) {
				$user = $r['user'] ?? null;
				$data = $r['data'] ?? null;
				$pgp_key = $r['pgp_key'] ?? null;
			}
			elseif (is_object($r) && $r instanceof User) {
				$user = $r;
				$data = $r->asArray();
				$pgp_key = $user->pgp_key ?? null;
			}
			elseif (is_object($r)) {
				$user = $r->user ?? null;
				$data = $r->data ?? null;
				$pgp_key = $user->pgp_key ?? ($r->pgp_key ?? null);
			}

			// Get e-mail address from key
			if (is_string($key) && false !== strpos($key, '@')) {
				$emails[] = $key;
			}
			// Get e-mail address from value
			elseif (is_string($r) && false !== strpos($r, '@')) {
				$emails[] = $r;
			}
			// Get email list from user object
			elseif ($user) {
				$emails = $user->getEmails();
			}
			else {
				// E-mail not found
				continue;
			}

			// Filter out invalid addresses
			foreach ($emails as $key => $value) {
				if (!preg_match('/.+@.+\..+$/', $value)) {
					unset($emails[$key]);
				}
			}

			if (!count($emails)) {
				continue;
			}

			$data = compact('user', 'data', 'pgp_key');

			foreach ($emails as $value) {
				$list[$value] = $data;
			}
		}

		if (!count($list)) {
			return;
		}

		$recipients = $list;
		unset($list);

		$is_system = $context === self::CONTEXT_SYSTEM;
		$template = (!$is_system && $content instanceof UserTemplate) ? $content : null;

		if ($template) {
			$template->toggleSafeMode(true);
		}

		$signal = Plugins::fire('email.queue.before', true,
			compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));

		// queue handling was done by a plugin, stop here
		if ($signal && $signal->isStopped()) {
			return;
		}

		$db = DB::getInstance();
		$db->begin();
		$html = null;
		$main_tpl = null;

		// Apart from SYSTEM emails, all others should be wrapped in the email.html template
		if (!$is_system) {
			$main_tpl = new UserTemplate('web/email.html');
		}

		if (!$is_system && !$template) {
			// If E-Mail does not have placeholders, we can render the MarkDown just once for HTML
			$html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
		}

		foreach ($recipients as $recipient => $r) {
			$data = $r['data'];
			$recipient_pgp_key = $r['pgp_key'];

			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$recipient_hash = Email::getHash($recipient);

			// Replace placeholders: {{$name}}, etc.
			if ($template) {
				$template->assignArray((array) $data, null, false);

				// Disable HTML escaping for plaintext emails
				$template->setEscapeDefault(null);
				$content = $template->fetch();

				// Add Markdown rendering
				$content_html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
			}
			else {
				$content_html = $html;
			}

			if (!$is_system) {
				// Wrap HTML content in the email skeleton
				$main_tpl->assignArray([
					'html'      => $content_html,
					'address'   => $recipient,
					'data'      => $data,
					'context'   => $context,
					'from'      => $sender,
				]);

				$content_html = $main_tpl->fetch();
			}

			$signal = Plugins::fire('email.queue.insert', true,
				compact('context', 'recipient', 'sender', 'subject', 'content', 'recipient_hash', 'recipient_pgp_key', 'content_html', 'attachments'));

			if ($signal && $signal->isStopped()) {
				// queue insert was done by a plugin, stop here
				continue;
			}

			unset($signal);

			$db->insert('emails_queue', compact('sender', 'subject', 'context', 'recipient', 'recipient_pgp_key', 'recipient_hash', 'content', 'content_html'));

			// Clean up memory
			unset($content_html);

			$id = $db->lastInsertId();

			foreach ($attachments as $file) {
				$db->insert('emails_queue_attachments', ['id_queue' => $id, 'path' => $file->path]);
			}
		}

		$db->commit();

		$signal = Plugins::fire('email.queue.after', true,
			compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));

		if ($signal && $signal->isStopped()) {
			return;
		}

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::runQueue();
		}
		// Always send system emails right away
		elseif ($is_system) {
			self::runQueue(self::CONTEXT_SYSTEM);
		}
	}

	/**
	 * Return an Email entity from the optout code
	 */
	static public function getEmailFromOptout(string $code): ?Email
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash);
	}

	/**
	 * Sets the address as invalid (no email can be sent to this address ever)
	 */
	static public function markAddressAsInvalid(string $address): void
	{
		$e = self::getEmail($address);

		if (!$e) {
			return;
		}

		$e->set('invalid', true);
		$e->set('optout', false);
		$e->set('verified', false);
		$e->save();
	}

	/**
	 * Return an Email entity from an email address
	 */
	static public function getEmail(string $address): ?Email
	{
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address)));
	}

	/**
	 * Return or create a new email entity
	 */
	static public function getOrCreateEmail(string $address): ?Email
	{
		$address = strtolower($address);

		if (!Email::isAddressValid($address, false)) {
			return null;
		}

		$e = self::getEmail($address);

		if (!$e) {
			$e = self::createEmail($address);
		}

		return $e;
	}

	static public function createEmail(string $address): Email
	{
		$e = new Email;
		$e->added = new \DateTime;
		$e->hash = $e::getHash($address);
		$e->validate($address);
		$e->save();
		return $e;
	}

	/**
	 * Run the queue of emails that are waiting to be sent
	 */
	static public function runQueue(?int $context = null): ?int
	{
		$db = DB::getInstance();

		$queue = self::listQueueAndMarkAsSending($context);
		$ids = [];

		$save_sent = function () use (&$ids, $db) {
			if (!count($ids)) {
				return null;
			}

			$db->exec(sprintf('UPDATE emails_queue SET sending = 2 WHERE %s;', $db->where('id', $ids)));
			$ids = [];
		};

		$limit_time = strtotime('1 month ago');
		$count = 0;
		$all_attachments = [];

		// listQueue nettoie déjà la queue
		foreach ($queue as $row) {
			// We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time
			if ($row->optout || $row->invalid || $row->fail_count >= self::FAIL_LIMIT) {
				if ($row->context != self::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) {
					self::deleteFromQueue($row->id);
					continue;
				}
			}

			// Create email address in database
			if (!$row->email_hash) {
				$email = self::getOrCreateEmail($row->recipient);

				if (!$email || !$email->canSend()) {
					// Email address is invalid, skip
					self::deleteFromQueue($row->id);
					continue;
				}
			}

			$headers = [
				'From'    => $row->sender,
				'To'      => $row->recipient,
				'Subject' => $row->subject,
			];

			try {
				$attachments = $db->getAssoc('SELECT id, path FROM emails_queue_attachments WHERE id_queue = ?;', $row->id);
				$all_attachments = array_merge($all_attachments, $attachments);
				$sent = self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key, $attachments);

				// Keep waiting until email is sent
				if (!$sent) {
					continue;
				}
			}
			catch (\Exception $e) {
				// If sending fails, at least save what has been sent so far
				// so they won't get re-sent again
				$save_sent();
				throw $e;
			}

			$ids[] = $row->id;
			$count++;

			// Mark messages as sent from time to time
			// to avoid starting from the beginning if the queue is killed
			// and also avoid passing too many IDs to SQLite at once
			if (count($ids) >= 50) {
				$save_sent();
			}
		}

		// Update emails list and send count
		// then delete messages from queue
		$db->begin();
		$db->exec(sprintf('
			UPDATE emails_queue SET sending = 2 WHERE %s;
			INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2;
			UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
			DELETE FROM emails_queue WHERE sending = 2;',
			$db->where('id', $ids),
			Email::TABLE));
		$db->commit();

		$unused_attachments = array_diff($all_attachments, $db->getAssoc('SELECT id, path FROM emails_queue_attachments;'));

		foreach ($unused_attachments as $path) {
			$file = Files::get($path);

			if ($file) {
				$file->delete();
			}
		}

		return $count;
	}

	/**
	 * Lists the queue, marks listed elements as "sending"
	 * @return array
	 */
	static protected function listQueueAndMarkAsSending(?int $context = null): array
	{
		$queue = self::listQueue($context);

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids));

		return $queue;
	}

	/**
	 * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much
	 *
	 * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending
	 * the same email over and over.
	 *
	 * @param int|null $context Context to list, leave NULL to have all contexts
	 * @return array
	 */
	static protected function listQueue(?int $context = null): array
	{
		// Clean-up the queue from reject emails
		self::purgeQueueFromRejected();

		// Reset messages that failed during the queue run
		self::resetFailed();

		$condition = null === $context ? '' : sprintf(' AND context = %d', $context);

		return DB::getInstance()->get(sprintf('SELECT q.*, e.optout, e.verified, e.hash AS email_hash,
				e.invalid, e.fail_count, strftime(\'%%s\', e.last_sent) AS last_sent
			FROM emails_queue q
			LEFT JOIN emails e ON e.hash = q.recipient_hash
			WHERE q.sending = 0 %s;', $condition));
	}

	static public function countQueue(): int
	{
		return DB::getInstance()->count('emails_queue');
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function purgeQueueFromRejected(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails WHERE (invalid = 1 OR fail_count >= ?)
			AND last_sent >= datetime(\'now\', \'-1 month\'));',
			self::FAIL_LIMIT);
	}

	/**
	 * If emails have been marked as sending but sending failed, mark them for resend after a while
	 */
	static protected function resetFailed(): void
	{
		$sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL
			WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 * @param  integer $id
	 * @return boolean
	 */
	static protected function deleteFromQueue($id)
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static public function getRejectionStatusClause(string $prefix): string
	{
		$prefix .= '.';

		return sprintf('CASE
			WHEN %1$soptout = 1 THEN \'Désinscription\'
			WHEN %1$sinvalid = 1 THEN \'Invalide\'
			WHEN %1$sfail_count >= %2$d THEN \'Trop d\'\'erreurs\'
			ELSE \'\'
		END', $prefix, self::FAIL_LIMIT);
	}

	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();
		$email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());

		$columns = [
			'id' => [
				'select' => 'e.id',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => DynamicFields::getNameFieldsSQL('u'),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => $email_field,
			],
			'user_id' => [
				'select' => 'u.id',
			],
			'hash' => [
			],
			'status' => [
				'label' => 'Statut',
				'select' => self::getRejectionStatusClause('e'),
			],
			'sent_count' => [
				'label' => 'Messages envoyés',
			],
			'fail_log' => [
				'label' => 'Journal d\'erreurs',
			],
			'last_sent' => [
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = sprintf('emails e INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND e.hash = email_hash(%1$s)', $email_field);

		$conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		$list->setModifier(function (&$row) {
			$row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null;
		});
		return $list;
	}

	static public function getOptoutText(): string
	{
		return "Vous recevez ce message car vous êtes dans nos contacts.\n"
			. "Pour ne plus jamais recevoir de message de notre part cliquez ici :\n";
	}

	static public function appendHTMLOptoutFooter(string $html, string $url): string
	{
		$footer = '<p style="color: #666; background: #fff; padding: 10px; text-align: center; font-size: 9pt">' . nl2br(htmlspecialchars(self::getOptoutText()));
		$footer .= sprintf('<br /><a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 5px; background: #eee; border: 1px outset #ccc;">Me désinscrire</a></p>', $url);

		if (stripos($html, '</body>') !== false) {
			$html = str_ireplace('</body>', $footer . '</body>', $html);
		}
		else {
			$html .= $footer;
		}

		return $html;
	}

	static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html, ?string $pgp_key = null, array $attachments = []): bool
	{
		$config = Config::getInstance();
		$message = new Mail_Message;
		$message->setHeaders($headers);

		if (!$message->getFrom()) {
			$message->setHeader('From', self::getFromHeader());
		}

		if (MAIL_SENDER) {
			$message->setHeader('Reply-To', $message->getFromAddress());
			$message->setHeader('From', self::getFromHeader($message->getFromName(), MAIL_SENDER));
		}

		$message->setMessageId();

		// Append unsubscribe, except for password reminders
		if ($context != self::CONTEXT_SYSTEM) {
			$url = Email::getOptoutURL($recipient_hash);

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$content .= sprintf("\n\n-- \n%s\n\n%s\n%s", $config->org_name, self::getOptoutText(), $url);

			if (null !== $content_html) {
				$content_html = self::appendHTMLOptoutFooter($content_html, $url);
			}
		}

		$message->setBody($content);

		if (null !== $content_html) {
			$message->addPart('text/html', $content_html);
		}

		$message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email);
		$message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers

		foreach ($attachments as $path) {
			$file = Files::get($path);

			if (!$file) {
				continue;
			}

			$message->addPart($file->mime, $file->fetch(), $file->name);
		}

		static $can_use_encryption = null;

		if (null === $can_use_encryption) {
			$can_use_encryption = Security::canUseEncryption();
		}

		if ($pgp_key && $can_use_encryption) {
			$message->encrypt($pgp_key);
		}

		return self::sendMessage($context, $message);
	}

	static public function sendMessage(int $context, Mail_Message $message): bool
	{
		if (DISABLE_EMAIL) {
			return false;
		}

		$signal = Plugins::fire('email.send.before', true, compact('context', 'message'), ['sent' => null]);

		if ($signal && $signal->isStopped()) {
			return $signal->getOut('sent') ?? true;
		}

		if (SMTP_HOST) {
			$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
			$secure = constant($const);

			$smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure, SMTP_HELO_HOSTNAME);

			$smtp->send($message);
		}
		else {
			$message->send();
		}

		Plugins::fire('email.send.after', false, compact('context', 'message'));
		return true;
	}

	/**
	 * Handle a bounce message
	 * @param  string $raw_message Raw MIME message from SMTP
	 */
	static public function handleBounce(string $raw_message): ?array
	{
		$message = new Mail_Message;
		$message->parse($raw_message);

		$return = $message->identifyBounce();
		$address = $return['recipient'] ?? null;

		$signal = Plugins::fire('email.bounce', false, compact('address', 'message', 'return', 'raw_message'));

		if ($signal && $signal->isStopped()) {
			return null;
		}

		if (!$return) {
			return null;
		}

		if ($return['type'] == 'autoreply') {
			// Ignore auto-responders
			return $return;
		}
		elseif ($return['type'] == 'genuine') {
			// Forward emails that are not automatic to the organization email
			$config = Config::getInstance();

			$new = new Mail_Message;
			$new->setHeaders([
				'To'      => $config->org_email,
				'Subject' => 'Réponse à un message que vous avez envoyé',
			]);

			$new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.');

			$new->attachMessage($message->output());

			self::sendMessage(self::CONTEXT_SYSTEM, $new);
			return $return;
		}

		return self::handleManualBounce($return['recipient'], $return['type'], $return['message']);
	}

	static public function handleManualBounce(string $address, string $type, ?string $message): ?array
	{
		$return = compact('address', 'type', 'message');
		$email = self::getOrCreateEmail($address);

		if (!$email) {
			return null;
		}

		$email->hasFailed($return);
		Plugins::fire('email.bounce.save.before', false, compact('email', 'address', 'return', 'type', 'message'));
		$email->save();

		return $return;
	}


	static public function getFromHeader(string $name = null, string $email = null): string
	{
		$config = Config::getInstance();

		if (null === $name) {
			$name = $config->org_name;
		}
		if (null === $email) {
			$email = $config->org_email;
		}

		$name = str_replace('"', '\\"', $name);
		$name = str_replace(',', '', $name); // Remove commas

		return sprintf('"%s" <%s>', $name, $email);
	}

}

Modified src/include/lib/Paheko/Email/Mailings.php from [d36b26c7af] to [31ae3ddd32].

1
2
3
4
5
6
7






8
9
10
11
12
13
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20







+
+
+
+
+
+







<?php

namespace Paheko\Email;

use Paheko\Entities\Email\Mailing;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Users\DynamicFields;
use Paheko\Search;
use Paheko\Entities\Search as SearchEntity;
use Paheko\Users\Categories;
use Paheko\UserException;
use Paheko\Services\Services;

use KD2\DB\EntityManager;

class Mailings
{
	static public function getList(): DynamicList
	{
36
37
38
39
40
41
42
43

44
45
46
47
48
49

50
51

52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71










































































































42
43
44
45
46
47
48

49
50
51
52
53
54
55
56
57

58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183







-
+






+

-
+



















-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
	}

	static public function get(int $id): ?Mailing
	{
		return EntityManager::findOneById(Mailing::class, $id);
	}

	static public function create(string $subject, string $target, ?string $target_id): Mailing
	static public function create(string $subject, string $target_type, ?string $target_value, ?string $target_label): Mailing
	{
		$db = DB::getInstance();
		$db->begin();

		$m = new Mailing;
		$m->set('subject', $subject);
		$m->importForm(compact('subject', 'target_type', 'target_value', 'target_label'));
		$m->save();
		$m->populate($target, $target_id);
		$m->populate();

		$db->commit();
		return $m;
	}

	static public function anonymize(): void
	{
		$em = EntityManager::getInstance(Mailing::class);
		$db = DB::getInstance();

		$db->begin();
		foreach ($em->iterate('SELECT * FROM @TABLE WHERE sent < datetime(\'now\', \'-6 month\') AND anonymous = 0;') as $m) {
			$m->anonymize();
			$m->set('anonymous', true);
			$m->save();
		}

		$db->commit();
	}
}

	static public function listTargets(string $type): array
	{
		if ($type === 'field') {
			$list = self::listCheckboxFieldsTargets();
		}
		elseif ($type === 'category') {
			$list = Categories::listWithStats(Categories::WITHOUT_HIDDEN);
		}
		elseif ($type === 'service') {
			$list = iterator_to_array(Services::listWithStats(true)->iterate());
		}
		elseif ($type === 'search') {
			$list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId());
			$list = array_filter($list, fn($s) => $s->hasUserId());
			array_walk($search_list, function (&$s) {
				$s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()];
			});

		}
		else {
			throw new \InvalidArgumentException('Unknown target type: ' . $type);
		}

		if (!count($list)) {
			throw new UserException('Il n\'y aucun résultat correspondant à cette cible d\'envoi.');
		}

		return $list;
	}

	static public function listCheckboxFieldsTargets(): array
	{
		 $fields = DynamicFields::getInstance()->fieldsByType('checkbox');

		 if (!count($fields)) {
		 	return [];
		 }

		 $db = DB::getInstance();
		 $sql = [];

		 foreach ($fields as $field) {
		 	$sql[] = sprintf('SELECT %s AS name, %s AS label, COUNT(*) AS count FROM users WHERE %s = 1 AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)',
		 		$db->quote($field->name),
		 		$db->quote($field->label),
		 		$db->quoteIdentifier($field->name)
		 	);
		 }

		 $sql = implode(' UNION ALL ', $sql);
		 return $db->get($sql);
	}

	static public function getOptoutUsersList(): DynamicList
	{
		$db = DB::getInstance();
		$email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());

		$columns = [
			'id' => [
				'select' => 'a.id',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => DynamicFields::getNameFieldsSQL('u'),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => $email_field,
			],
			'user_id' => [
				'select' => 'u.id',
			],
			'hash' => [
			],
			'status' => [
				'label' => 'Désinscription',
				'select' => 'CASE WHEN a.optout = 1 THEN \'Désinscription globale\' ELSE o.target_label END',
			],
			'sent_count' => [
				'label' => 'Messages envoyés',
			],
			'last_sent' => [
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'target_type' => [],
			'target_label' => [],
		];

		$tables = sprintf('users u
			INNER JOIN emails_addresses a ON a.hash = email_hash(%1$s)
			LEFT JOIN mailings_optouts o ON o.email_hash = a.hash', $email_field);

		$conditions = sprintf('%s IS NOT NULL AND %1$s != \'\' AND (a.optout = 1 OR o.email_hash IS NOT NULL)', $email_field);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('last_sent', true);
		$list->setModifier(function (&$row) {
			$row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null;
		});
		return $list;
	}

}

Added src/include/lib/Paheko/Email/Queue.php version [fcda273eb9].








































































































































































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\Email;

use Paheko\Entities\Email\Message;

use Paheko\DB;
use Paheko\DynamicList;

class Queue
{
	static public function append(int $context, string $email, array $data = [])
	{

	}

	/**
	 * Add a message to the sending queue using templates
	 * @param  int          $context
	 * @param  iterable        $recipients List of recipients, this accepts a wide range of types:
	 * - a single e-mail address
	 * - array of e-mail addresses as values ['a@b.c', 'd@e.f']
	 * - array of user entities
	 * - array where each key is the email address, and the value is an array or a \stdClass containing
	 *   pgp_key, data and user items
	 * @param  string       $sender
	 * @param  string       $subject
	 * @param  UserTemplate|string $content
	 * @return void
	 */
	static public function queue(int $context, iterable $recipients, ?string $sender, string $subject, $content, array $attachments = []): void
	{
		if (DISABLE_EMAIL) {
			return;
		}

		foreach ($attachments as $i => $file) {
			if (!is_object($file) || !($file instanceof File) || $file->context() != $file::CONTEXT_ATTACHMENTS) {
				throw new \InvalidArgumentException(sprintf('Attachment #%d is not a valid file', $i));
			}
		}

		$list = [];

		// Build email list
		foreach ($recipients as $key => $r) {
			$data = [];
			$emails = [];
			$user = null;
			$pgp_key = null;

			if (is_array($r)) {
				$user = $r['user'] ?? null;
				$data = $r['data'] ?? null;
				$pgp_key = $r['pgp_key'] ?? null;
			}
			elseif (is_object($r) && $r instanceof User) {
				$user = $r;
				$data = $r->asArray();
				$pgp_key = $user->pgp_key ?? null;
			}
			elseif (is_object($r)) {
				$user = $r->user ?? null;
				$data = $r->data ?? null;
				$pgp_key = $user->pgp_key ?? ($r->pgp_key ?? null);
			}

			// Get e-mail address from key
			if (is_string($key) && false !== strpos($key, '@')) {
				$emails[] = $key;
			}
			// Get e-mail address from value
			elseif (is_string($r) && false !== strpos($r, '@')) {
				$emails[] = $r;
			}
			// Get email list from user object
			elseif ($user) {
				$emails = $user->getEmails();
			}
			else {
				// E-mail not found
				continue;
			}

			// Filter out invalid addresses
			foreach ($emails as $key => $value) {
				if (!preg_match('/.+@.+\..+$/', $value)) {
					unset($emails[$key]);
				}
			}

			if (!count($emails)) {
				continue;
			}

			$data = compact('user', 'data', 'pgp_key');

			foreach ($emails as $value) {
				$list[$value] = $data;
			}
		}

		if (!count($list)) {
			return;
		}

		$recipients = $list;
		unset($list);

		$is_system = $context === Message::CONTEXT_SYSTEM;
		$template = (!$is_system && $content instanceof UserTemplate) ? $content : null;

		if ($template) {
			$template->toggleSafeMode(true);
		}

		$signal = Plugins::fire('email.queue.before', true,
			compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));

		// queue handling was done by a plugin, stop here
		if ($signal && $signal->isStopped()) {
			return;
		}

		$db = DB::getInstance();
		$db->begin();
		$html = null;
		$main_tpl = null;

		// Apart from SYSTEM emails, all others should be wrapped in the email.html template
		if (!$is_system) {
			$main_tpl = new UserTemplate('web/email.html');
		}

		if (!$is_system && !$template) {
			// If E-Mail does not have placeholders, we can render the MarkDown just once for HTML
			$html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
		}

		foreach ($recipients as $recipient => $r) {
			$data = $r['data'];
			$recipient_pgp_key = $r['pgp_key'];

			// We won't try to reject invalid/optout recipients here,
			// it's done in the queue clearing (more efficient)
			$recipient_hash = Addresses::hash($recipient);

			// Replace placeholders: {{$name}}, etc.
			if ($template) {
				$template->assignArray((array) $data, null, false);

				// Disable HTML escaping for plaintext emails
				$template->setEscapeDefault(null);
				$content = $template->fetch();

				// Add Markdown rendering
				$content_html = Render::render(Render::FORMAT_MARKDOWN, null, $content);
			}
			else {
				$content_html = $html;
			}

			if (!$is_system) {
				// Wrap HTML content in the email skeleton
				$main_tpl->assignArray([
					'html'      => $content_html,
					'address'   => $recipient,
					'data'      => $data,
					'context'   => $context,
					'from'      => $sender,
				]);

				$content_html = $main_tpl->fetch();
			}

			$signal = Plugins::fire('email.queue.insert', true,
				compact('context', 'recipient', 'sender', 'subject', 'content', 'recipient_hash', 'recipient_pgp_key', 'content_html', 'attachments'));

			if ($signal && $signal->isStopped()) {
				// queue insert was done by a plugin, stop here
				continue;
			}

			unset($signal);

			$db->insert('emails_queue', compact('sender', 'subject', 'context', 'recipient', 'recipient_pgp_key', 'recipient_hash', 'content', 'content_html'));

			// Clean up memory
			unset($content_html);

			$id = $db->lastInsertId();

			foreach ($attachments as $file) {
				$db->insert('emails_queue_attachments', ['id_queue' => $id, 'path' => $file->path]);
			}
		}

		$db->commit();

		$signal = Plugins::fire('email.queue.after', true,
			compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments'));

		if ($signal && $signal->isStopped()) {
			return;
		}

		// If no crontab is used, then the queue should be run now
		if (!USE_CRON) {
			self::run();
		}
		// Always send system emails right away
		elseif ($is_system) {
			self::run(Message::CONTEXT_SYSTEM);
		}
	}

	/**
	 * Run the queue of emails that are waiting to be sent
	 */
	static public function runQueue(?int $context = null): ?int
	{
		$db = DB::getInstance();

		$queue = self::listQueueAndMarkAsSending($context);
		$ids = [];

		$save_sent = function () use (&$ids, $db) {
			if (!count($ids)) {
				return null;
			}

			$db->exec(sprintf('UPDATE emails_queue SET status = %d WHERE %s;', Message::STATUS_SENT, $db->where('id', $ids)));
			$ids = [];
		};

		$limit_time = strtotime('1 month ago');
		$count = 0;
		$all_attachments = [];

		// listQueue nettoie déjà la queue
		foreach ($queue as $row) {
			// We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time
			if ($row->optout || $row->invalid || $row->fail_count >= Message::FAIL_LIMIT) {
				if ($row->context != Message::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) {
					self::delete($row->id);
					continue;
				}
			}

			// Create email address in database
			if (!$row->email_hash) {
				$email = Addresses::getOrCreate($row->recipient);

				if (!$email->canSend()) {
					// Email address is invalid, skip
					self::delete($row->id);
					continue;
				}
			}

			$headers = [
				'From'    => $row->sender,
				'To'      => $row->recipient,
				'Subject' => $row->subject,
			];

			try {
				$attachments = $db->getAssoc('SELECT id, path FROM emails_queue_attachments WHERE id_queue = ?;', $row->id);
				$all_attachments = array_merge($all_attachments, $attachments);
				$sent = self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key, $attachments);

				// Keep waiting until email is sent
				if (!$sent) {
					continue;
				}
			}
			catch (\Exception $e) {
				// If sending fails, at least save what has been sent so far
				// so they won't get re-sent again
				$save_sent();
				throw $e;
			}

			$ids[] = $row->id;
			$count++;

			// Mark messages as sent from time to time
			// to avoid starting from the beginning if the queue is killed
			// and also avoid passing too many IDs to SQLite at once
			if (count($ids) >= 50) {
				$save_sent();
			}
		}

		// Update emails list and send count
		// then delete messages from queue
		$db->begin();
		$db->exec(sprintf('
			UPDATE emails_queue SET status = %d WHERE %s;
			INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE status = %1$d;
			UPDATE %3$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE status = %1$d);
			DELETE FROM emails_queue WHERE status = %1$d;',
			Message::STATUS_SENT,
			$db->where('id', $ids),
			Email::TABLE));
		$db->commit();

		$unused_attachments = array_diff($all_attachments, $db->getAssoc('SELECT id, path FROM emails_queue_attachments;'));

		foreach ($unused_attachments as $path) {
			$file = Files::get($path);

			if ($file) {
				$file->delete();
			}
		}

		return $count;
	}

	/**
	 * Lists the queue, marks listed elements as "sending"
	 * @return array
	 */
	static protected function listQueueAndMarkAsSending(?int $context = null): array
	{
		$queue = self::list($context);

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['status' => Message::STATUS_SENDING, 'sending_started' => new \DateTime], $db->where('id', $ids));

		return $queue;
	}

	/**
	 * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much
	 *
	 * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending
	 * the same email over and over.
	 *
	 * @param int|null $context Context to list, leave NULL to have all contexts
	 * @return array
	 */
	static protected function list(?int $context = null): array
	{
		// Clean-up the queue from reject emails
		self::removeRejectedRecipients();

		// Reset messages that failed during the queue run
		self::resetFailed();

		$condition = null === $context ? '' : sprintf(' AND context = %d', $context);

		return DB::getInstance()->get(sprintf('SELECT q.*, a.optout, a.verified, a.hash AS email_hash,
				a.invalid, a.fail_count, strftime(\'%%s\', a.last_sent) AS last_sent
			FROM emails_queue q
			LEFT JOIN emails_addresses a ON a.hash = q.recipient_hash
			WHERE q.status = %d %s;', Message::STATUS_WAITING, $condition));
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function removeRejectedRecipients(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails_addresses WHERE (invalid = 1 OR fail_count >= ?)
			AND last_sent >= datetime(\'now\', \'-1 month\'));',
			self::FAIL_LIMIT);
	}

	/**
	 * If emails have been marked as sending but sending failed, mark them for resend after a while
	 */
	static protected function resetFailed(): void
	{
		$sql = 'UPDATE emails_queue SET status = %d, sending_started = NULL
			WHERE status = %d AND sending_started < datetime(\'now\', \'-3 hours\');';
		$sql = sprintf($sql, Message::STATUS_WAITING, Message::STATUS_SENDING);
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 */
	static protected function delete(int $id): bool
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static public function count(): int
	{
		return DB::getInstance()->count('emails_queue', 'status = ' . Message::STATUS_WAITING);
	}

	static public function getList(): DynamicList
	{
		$columns = [
			'id' => [],
			'context' => [
				'label' => 'Contexte',
			],
			'status' => [
				'label' => 'Statut',
				'order' => 'status %s, id %1$s',
			],
			'sender' => [
				'label' => 'Expéditeur',
			],
			'recipient' => [
				'label' => 'Destinataire',
			],
			'subject' => [
				'label' => 'Sujet',
			],
		];

		$list = new DynamicList($columns, 'emails_queue');
		$list->orderBy('status', true);
		return $list;
	}

	static public function createMessage(int $context, ?string $subject = null, ?string $body = null, ?string $html_body = null): Message
	{
		$msg = new Message;
		$msg->set('context', $context);

		if (null !== $subject) {
			$msg->set('subject', $subject);
		}

		if (null !== $body) {
			$msg->set('body', $body);
		}

		if (null !== $html_body) {
			$msg->set('html_body', $html_body);
		}

		return $msg;
	}
}

Modified src/include/lib/Paheko/Entities/Accounting/Account.php from [94745dbc9e] to [51d2aace20].

73
74
75
76
77
78
79


80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

97
98
99
100
101
102
103
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106







+
+

















+







	const TYPE_NEGATIVE_RESULT = 12;

	const TYPE_APPROPRIATION_RESULT = 13;

	const TYPE_CREDIT_REPORT = 14;
	const TYPE_DEBIT_REPORT = 15;

	const TYPE_TEMPORARY_TRANSFER = 16;

	const TYPES_NAMES = [
		'',
		'Banque',
		'Caisse',
		'Attente d\'encaissement',
		'Tiers',
		'Dépenses',
		'Recettes',
		'Bénévolat — Emploi', // Used to be Analytique
		'Bénévolat — Contribution',
		'Ouverture',
		'Clôture',
		'Résultat excédentaire',
		'Résultat déficitaire',
		'Affectation du résultat',
		'Report à nouveau créditeur',
		'Report à nouveau débiteur',
		'Virements internes',
	];

	/**
	 * Show only these types of accounts in the quick account view
	 */
	const COMMON_TYPES = [
		self::TYPE_BANK,
147
148
149
150
151
152
153

154
155
156
157
158
159
160
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164







+








	/**
	 * Codes that should be enforced according to type (and vice-versa)
	 */
	const LOCAL_TYPES = [
		'FR' => [
			self::TYPE_BANK => '512',
			self::TYPE_TEMPORARY_TRANSFER => '580',
			self::TYPE_CASH => '53',
			self::TYPE_OUTSTANDING => '511',
			self::TYPE_THIRD_PARTY => '4',
			self::TYPE_EXPENSE => '6',
			self::TYPE_REVENUE => '7',
			self::TYPE_VOLUNTEERING_EXPENSE => '86',
			self::TYPE_VOLUNTEERING_REVENUE => '87',

Modified src/include/lib/Paheko/Entities/Accounting/Transaction.php from [090c1a364f] to [877151f889].

1134
1135
1136
1137
1138
1139
1140
1141

1142
1143
1144
1145
1146
1147
1148
1149
1150

1151
1152
1153
1154
1155
1156
1157
1134
1135
1136
1137
1138
1139
1140

1141
1142
1143
1144
1145
1146
1147
1148
1149

1150
1151
1152
1153
1154
1155
1156
1157







-
+








-
+







				'label' => self::TYPES_NAMES[self::TYPE_EXPENSE],
				'help' => null,
			],
			self::TYPE_TRANSFER => [
				'accounts' => [
					[
						'label' => 'De',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_EXPENSE => 'credit',
							self::TYPE_REVENUE => 'debit',
						],
					],
					[
						'label' => 'Vers',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER],
						'direction' => 'debit',
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_TRANSFER],
				'help' => 'Dépôt en banque, virement interne, etc.',
			],
			self::TYPE_DEBT => [

Modified src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php from [6d7d38b625] to [ee23f0d7c1].

10
11
12
13
14
15
16
17
18


19
20
21
22
23
24
25
26
27

28
29
30
31
32
33

34
35
36

37
38
39
40
41

42


43

44
45

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66


67
68
69
70
71
72
73
74
10
11
12
13
14
15
16


17
18
19
20
21
22
23
24
25
26

27
28
29
30
31
32

33
34
35

36
37
38
39
40

41
42
43
44

45


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65


66
67
68
69
70
71
72
73
74
75







-
-
+
+








-
+





-
+


-
+




-
+

+
+
-
+
-
-
+



















-
-
+
+








 */
trait TransactionSubscriptionsTrait
{
	public function linkToSubscription(int $id_subscription)
	{
		$db = EntityManager::getInstance(self::class)->DB();

		return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_user, id_service_user)
			SELECT ?, id_user, id FROM services_users WHERE id = ?;',
		return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_subscription, id_user)
			SELECT ?, id, id_user FROM services_subscriptions WHERE id = ?;',
			$this->id(),
			$id_subscription
		);
	}

	public function deleteAllSubscriptionLinks(): void
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NOT NULL', $this->id());
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription IS NOT NULL', $this->id());
	}

	public function deleteSubscriptionLink(int $id): void
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user = ?', $this->id(), $id);
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription = ?', $this->id(), $id);
	}

	public function listSubscriptionLinks(): array
	public function listLinkedSubscriptions(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$number_column = DynamicFields::getNumberFieldSQL('u');
		$sql = sprintf('SELECT s.*, %s AS user_identity, %s AS user_number, l.id_service_user AS id_subscription
		$sql = sprintf('SELECT sub.*, s.label, %s AS user_identity, %s AS user_number, l.id_subscription
			FROM users u
			INNER JOIN services_subscriptions sub ON sub.id_user = u.id
			INNER JOIN services s ON s.id = sub.id_service
			INNER JOIN acc_transactions_users l ON l.id_user = u.id
			INNER JOIN acc_transactions_users l ON l.id_subscription = sub.id
			INNER JOIN services_users s ON s.id = l.id_service_user
			WHERE l.id_transaction = ? AND l.id_service_user IS NOT NULL;', $identity_column, $number_column);
			WHERE l.id_transaction = ?;', $identity_column, $number_column);
		return $db->get($sql, $this->id());
	}

	public function updateSubscriptionLinks(array $subscriptions): void
	{
		$subscriptions = array_values($subscriptions);

		foreach ($subscriptions as $i => $subscription) {
			if (!(is_int($subscription) || (is_string($subscription) && ctype_digit($subscription)))) {
				throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid subscription ID', $i, $subscription));
			}
		}

		$db = EntityManager::getInstance(self::class)->DB();

		$db->begin();
		$this->deleteAllSubscriptionLinks();

		foreach ($subscriptions as $id) {
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user)
				SELECT ?, id_user, id FROM services_users WHERE id = ?;',
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_subscription, id_user)
				SELECT ?, id, id_user FROM services_subscriptions WHERE id = ?;',
				$this->id(),
				(int)$id
			);
		}

		$db->commit();
	}
}

Modified src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php from [5038ed021f] to [25697bf675].

1
2
3
4
5
6
7
8
9
10
11
12
13

14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

33
34
35
36
37
38
39
40
41
42
43
44
45
46


47
48
49
50
51
52
53
54
55
56
57
58


59
60
61
1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
42
43
44


45
46
47
48
49
50
51
52
53
54
55
56


57
58
59
60
61












-
+


















-
+












-
-
+
+










-
-
+
+



<?php

namespace Paheko\Entities\Accounting;

use KD2\DB\EntityManager;
use Paheko\Users\DynamicFields;

trait TransactionUsersTrait
{
	public function deleteLinkedUsers(): void
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_service_user IS NULL', $this->id());
		$db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription IS NULL', $this->id());
	}

	public function updateLinkedUsers(array $users): void
	{
		$users = array_values($users);

		foreach ($users as $i => $user) {
			if (!(is_int($user) || (is_string($user) && ctype_digit($user)))) {
				throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid user ID', $i, $user));
			}
		}

		$db = EntityManager::getInstance(self::class)->DB();

		$db->begin();
		$this->deleteLinkedUsers();

		foreach ($users as $id) {
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, NULL);', $this->id(), (int)$id);
			$db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_subscription) VALUES (?, ?, NULL);', $this->id(), (int)$id);
		}

		$db->commit();
	}

	public function listLinkedUsers(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$number_column = DynamicFields::getNumberFieldSQL('u');
		$sql = sprintf('SELECT u.id, %s AS identity, %s AS number
			FROM users u
			INNER JOIN acc_transactions_users l ON l.id_user = u.id
			WHERE l.id_transaction = ? AND l.id_service_user IS NULL
			INNER JOIN acc_transactions_users l ON l.id_subscription IS NULL AND l.id_user = u.id
			WHERE l.id_transaction = ?
			ORDER BY id;', $identity_column, $number_column);
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$sql = sprintf('SELECT u.id, %s AS identity
			FROM users u
			INNER JOIN acc_transactions_users l ON l.id_user = u.id
			WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column);
			INNER JOIN acc_transactions_users l ON l.id_subscription IS NULL AND l.id_user = u.id
			WHERE l.id_transaction = ?;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}
}

Added src/include/lib/Paheko/Entities/Email/Address.php version [c45468c3e3].















































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
declare(strict_types=1);

namespace Paheko\Entities\Email;

use Paheko\Entity;
use Paheko\UserException;
use Paheko\Email\Addresses;
use Paheko\Email\Templates as EmailsTemplates;

use KD2\SMTP;

use const Paheko\{WWW_URL, SECRET_KEY};

class Address extends Entity
{
	const TABLE = 'emails_addresses';

	const RESEND_VERIFICATION_DELAY = 24;

	/**
	 * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
	 */
	const SOFT_BOUNCE_LIMIT = 5;

	const STATUS_UNKNOWN = 0;
	const STATUS_VERIFIED = 1;
	const STATUS_INVALID = -1;
	const STATUS_SOFT_BOUNCE_LIMIT_REACHED = -2;
	const STATUS_HARD_BOUNCE = -3;
	const STATUS_OPTOUT = -4;
	const STATUS_SPAM = -5;

	const STATUS_LIST = [
		self::STATUS_UNKNOWN => 'OK',
		self::STATUS_VERIFIED => 'Vérifiée',
		self::STATUS_INVALID => 'Invalide',
		self::STATUS_SOFT_BOUNCE_LIMIT_REACHED => 'Trop d\'erreurs',
		self::STATUS_HARD_BOUNCE => 'Échec',
		self::STATUS_OPTOUT => 'Refus',
		self::STATUS_SPAM => 'Spam',
	];

	const STATUS_COLORS = [
		self::STATUS_UNKNOWN => 'steelblue',
		self::STATUS_VERIFIED => 'darkgreen',
		self::STATUS_INVALID => 'crimson',
		self::STATUS_SOFT_BOUNCE_LIMIT_REACHED => 'darkorange',
		self::STATUS_HARD_BOUNCE => 'darkred',
		self::STATUS_OPTOUT => 'palevioletred',
		self::STATUS_SPAM => 'darkmagenta',
	];

	protected int $id;
	protected string $hash;
	protected int $status = self::STATUS_UNKNOWN;
	protected int $sent_count = 0;
	protected int $bounce_count = 0;
	protected ?string $log;
	protected \DateTime $added;
	protected ?\DateTime $last_sent;

	static public function getOptoutURL(string $hash = null): string
	{
		$hash = hex2bin($hash);
		$hash = base64_encode($hash);
		// Make base64 hash valid for URLs
		$hash = rtrim(strtr($hash, '+/', '-_'), '=');
		return sprintf('%s?un=%s', WWW_URL, $hash);
	}

	public function getVerificationCode(): string
	{
		$code = sha1($this->hash . SECRET_KEY);
		return substr($code, 0, 10);
	}

	public function sendVerification(string $email): void
	{
		if (Addresses::hash($email) !== $this->hash) {
			throw new UserException('Adresse email inconnue');
		}

		$verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		EmailsTemplates::verifyAddress($email, $verify_url);
	}

	public function canSendVerificationAfterFail(): bool
	{
		$limit_date = Addresses::getVerificationLimitDate();
		return isset($this->last_sent) ? $this->last_sent < $limit_date : false;
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}

		$this->set('status', self::STATUS_VERIFIED);
		$this->set('bounce_count', 0);
		$this->log('Adresse vérifiée par le destinataire');
		return true;
	}

	public function setAddress(string $address): bool
	{
		$this->set('added', new \DateTime);
		$this->set('hash', Addresses::hash($address));

		$error = Addresses::checkForErrors($address);

		if (null !== $error) {
			$this->set('status', self::STATUS_INVALID);
			$this->log($error);
		}

		return $error === null;
	}

	public function canSend(): bool
	{
		if ($this->status < self::STATUS_UNKNOWN) {
			return false;
		}

		return true;
	}

	public function incrementSentCount(): void
	{
		$this->set('sent_count', $this->sent_count+1);
	}

	public function setOptout(string $message = null): void
	{
		$this->set('status', self::STATUS_OPTOUT);
		$this->log($message ?? 'Demande de désinscription');
	}

	public function log(string $message): void
	{
		$log = $this->log ?? '';

		if ($log) {
			$log .= "\n";
		}

		$log .= date('d/m/Y H:i:s - ') . trim($message);
		$this->set('log', $log);
	}

	public function hasFailed(array $return): void
	{
		if (!isset($return['type'])) {
			throw new \InvalidArgumentException('Bounce email type not supplied in argument.');
		}

		// Treat complaints as opt-out
		if ($return['type'] == 'complaint') {
			$this->set('status', self::STATUS_SPAM);
			$this->log("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
		}
		elseif ($return['type'] == 'permanent') {
			$this->set('status', self::STATUS_HARD_BOUNCE);
			$this->set('bounce_count', $this->bounce_count+1);
			$this->log($return['message']);
		}
		elseif ($return['type'] == 'temporary') {
			$this->set('bounce_count', $this->bounce_count+1);
			$this->log($return['message']);

			if ($this->bounce_count > self::SOFT_BOUNCE_LIMIT) {
				$this->set('status', self::STATUS_SOFT_BOUNCE_LIMIT_REACHED);
			}
		}
	}

	public function save(bool $selfcheck = true): bool
	{
		$optout = false;

		if ($this->isModified('optout')) {
			$optout = true;
		}

		$return = parent::save($selfcheck);

		if ($return && $optout) {
			// Delete all specific optouts when opting out of everything
			DB::getInstance()->preparedQuery('DELETE FROM mailings_optouts WHERE email_hash = ?;', $this->hash);
		}

		return $return;
	}

	public function getStatusColor(): string
	{
		return self::STATUS_COLORS[$this->status];
	}

	public function getStatusLabel(): string
	{
		return self::STATUS_LIST[$this->status];
	}
}

Deleted src/include/lib/Paheko/Entities/Email/Email.php version [f1da9fc120].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262






































































































































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
declare(strict_types=1);

namespace Paheko\Entities\Email;

use Paheko\Entity;
use Paheko\UserException;
use Paheko\Email\Emails;
use Paheko\Email\Templates as EmailsTemplates;

use KD2\SMTP;

use const Paheko\{WWW_URL, SECRET_KEY};

class Email extends Entity
{
	const TABLE = 'emails';

	const RESEND_VERIFICATION_DELAY = '1 month ago';
	const RESEND_VERIFICATION_DELAY_OPTOUT = '3 days ago';

	/**
	 * Antispam services that require to do a manual action to accept emails
	 */
	const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/';

	const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com'];

	protected int $id;
	protected string $hash;
	protected bool $verified = false;
	protected bool $optout = false;
	protected bool $invalid = false;
	protected int $sent_count = 0;
	protected int $fail_count = 0;
	protected ?string $fail_log;
	protected \DateTime $added;
	protected ?\DateTime $last_sent;

	/**
	 * Normalize email address and create a hash from this
	 */
	static public function getHash(string $email): string
	{
		$email = strtolower(trim($email));

		$host = substr($email, strrpos($email, '@')+1);
		$host = idn_to_ascii($host);

		$email = substr($email, 0, strrpos($email, '@')+1) . $host;

		return sha1($email);
	}

	static public function getOptoutURL(string $hash = null): string
	{
		$hash = hex2bin($hash);
		$hash = base64_encode($hash);
		// Make base64 hash valid for URLs
		$hash = rtrim(strtr($hash, '+/', '-_'), '=');
		return sprintf('%s?un=%s', WWW_URL, $hash);
	}

	public function getVerificationCode(): string
	{
		$code = sha1($this->hash . SECRET_KEY);
		return substr($code, 0, 10);
	}

	public function sendVerification(string $email): void
	{
		if (self::getHash($email) !== $this->hash) {
			throw new UserException('Adresse email inconnue');
		}

		$verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		EmailsTemplates::verifyAddress($email, $verify_url);
	}

	public function canSendVerificationAfterFail(): bool
	{
		$limit_date = new \DateTime($this->optout ? self::RESEND_VERIFICATION_DELAY_OPTOUT : self::RESEND_VERIFICATION_DELAY);
		return isset($this->last_sent) ? $this->last_sent < $limit_date : false;
	}

	public function verify(string $code): bool
	{
		if ($code !== $this->getVerificationCode()) {
			return false;
		}

		$this->set('verified', true);
		$this->set('optout', false);
		$this->set('invalid', false);
		$this->set('fail_count', 0);
		$this->set('fail_log', null);
		return true;
	}

	public function validate(string $email): bool
	{
		if (!$this->canSend()) {
			return false;
		}

		try {
			self::validateAddress($email);
		}
		catch (UserException $e) {
			$this->setFailedValidation($e->getMessage());
			return false;
		}

		return true;
	}

	public function setFailedValidation(string $message): void
	{
		$this->hasFailed(['type' => 'permanent', 'message' => $message]);
	}

	static public function isAddressValid(string $email, bool $mx_check = true): bool
	{
		try {
			self::validateAddress($email);
			return true;
		}
		catch (UserException $e) {
			return false;
		}
	}

	static public function validateAddress(string $email, bool $mx_check = true): void
	{
		$pos = strrpos($email, '@');

		if ($pos === false) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		$user = substr($email, 0, $pos);
		$host = substr($email, $pos+1);

		// Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
		if ($host == 'gmail.fr') {
			throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"');
		}

		if (preg_match('![/@]!', $user)) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		if (!SMTP::checkEmailIsValid($email, false)) {
			if (!trim($host)) {
				throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
			}

			foreach (self::COMMON_DOMAINS as $common_domain) {
				similar_text($common_domain, $host, $percent);

				if ($percent > 90) {
					throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain));
				}
			}

			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		// Windows does not support MX lookups
		if (PHP_OS_FAMILY == 'Windows' || !$mx_check) {
			return;
		}

		self::checkMX($host);
	}

	static public function checkMX(string $host)
	{
		if (PHP_OS_FAMILY == 'Windows') {
			return;
		}

		getmxrr($host, $mx_list);

		if (empty($mx_list)) {
			throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		foreach ($mx_list as $mx) {
  			if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) {
				throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.');
			}
		}
	}

	public function canSend(): bool
	{
		if (!empty($this->optout)) {
			return false;
		}

		if (!empty($this->invalid)) {
			return false;
		}

		if ($this->hasReachedFailLimit()) {
			return false;
		}

		return true;
	}

	public function hasReachedFailLimit(): bool
	{
		return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT);
	}

	public function incrementSentCount(): void
	{
		$this->set('sent_count', $this->sent_count+1);
	}

	public function setOptout(string $message = null): void
	{
		$this->set('optout', true);
		$this->appendFailLog($message ?? 'Demande de désinscription');
	}

	public function appendFailLog(string $message): void
	{
		$log = $this->fail_log ?? '';

		if ($log) {
			$log .= "\n";
		}

		$log .= date('d/m/Y H:i:s - ') . trim($message);
		$this->set('fail_log', $log);
	}

	public function hasFailed(array $return): void
	{
		if (!isset($return['type'])) {
			throw new \InvalidArgumentException('Bounce email type not supplied in argument.');
		}

		// Treat complaints as opt-out
		if ($return['type'] == 'complaint') {
			$this->set('optout', true);
			$this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
		}
		elseif ($return['type'] == 'permanent') {
			$this->set('invalid', true);
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
		elseif ($return['type'] == 'temporary') {
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
	}
}

Modified src/include/lib/Paheko/Entities/Email/Mailing.php from [a4f1806841] to [e57761a85e].

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27









28
29
30
31
32







33
34
35
36
37
38
39
1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54











-
+














-
+
+
+
+
+
+
+
+
+





+
+
+
+
+
+
+







<?php

namespace Paheko\Entities\Email;

use Paheko\Config;
use Paheko\CSV;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Entity;
use Paheko\Log;
use Paheko\UserException;
use Paheko\Email\Emails;
use Paheko\Email\Addresses;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;
use Paheko\UserTemplate\UserTemplate;
use Paheko\Web\Render\Render;

use Paheko\Entities\Users\DynamicField;

use DateTime;
use stdClass;

class Mailing extends Entity
{
	const TABLE = 'mailings';
	const NAME = 'Message collectif';
	const PRIVATE_URL = '!users/mailing/details.php?id=%d';
	const PRIVATE_URL = '!users/email/mailing/details.php?id=%d';

	const TARGETS_TYPES = [
		'all'      => 'Tous les membres (sauf catégories cachées)',
		'field'    => 'Champ de la fiche membre',
		'category' => 'Catégorie',
		'service'  => 'Inscrits à jour d\'une activité',
		'search'   => 'Recherche enregistrée',
	];

	protected ?int $id = null;
	protected string $subject;
	protected ?string $body;

	/**
	 * We need to store these in order to have opt-out per-target
	 */
	protected ?string $target_type;
	protected ?string $target_value;
	protected ?string $target_label;

	/**
	 * Leave sender name and email NULL to use org name + email
	 */
	protected ?string $sender_name;
	protected ?string $sender_email;

	/**
53
54
55
56
57
58
59


60

61
62
63





64

65
66

67
68
69



70

71
72
73
74


75
76
77


78
79
80


81
82
83
84
85
86
87
68
69
70
71
72
73
74
75
76

77
78
79
80
81
82
83
84
85

86
87

88
89
90
91
92
93
94

95
96
97


98
99
100


101
102
103


104
105
106
107
108
109
110
111
112







+
+
-
+



+
+
+
+
+
-
+

-
+



+
+
+
-
+


-
-
+
+

-
-
+
+

-
-
+
+








		$this->assert(trim($this->subject) !== '', 'Le sujet ne peut rester vide.');
		$this->assert(!isset($this->body) || trim($this->body) !== '', 'Le corps du message ne peut rester vide.');

		if (isset($this->sender_name) || isset($this->sender_email)) {
			$this->assert(trim($this->sender_name) !== '', 'Le nom d\'expéditeur est vide.');
			$this->assert(trim($this->sender_email) !== '', 'L\'adresse e-mail de l\'expéditeur est manquante.');

			$error = Addresses::checkForErrors($this->sender_email);
			$this->assert(Email::isAddressValid($this->sender_email), 'L\'adresse e-mail de l\'expéditeur est invalide.');
			$this->assert($error === null, 'L\'adresse e-mail de l\'expéditeur est invalide : ' . $error);
		}
	}

	public function getTargetTypeLabel(): string
	{
		return self::TARGETS_TYPES[$this->target_type] ?? '';
	}

	public function populate(string $target, ?int $target_id = null): void
	public function populate(): void
	{
		if ($target !== 'all' && empty($target_id)) {
		if ($this->target_type !== 'all' && empty($this->target_value)) {
			throw new \InvalidArgumentException('Missing target ID');
		}

		if ($this->target_type === 'field') {
			$recipients = Users::iterateEmailsByField($this->target_value, true);
		}
		if ($target == 'all') {
		elseif ($this->target_type === 'all') {
			$recipients = Users::iterateEmailsByCategory(null);
		}
		elseif ($target == 'category') {
			$recipients = Users::iterateEmailsByCategory($target_id);
		elseif ($this->target_type === 'category') {
			$recipients = Users::iterateEmailsByCategory((int) $this->target_value);
		}
		elseif ($target == 'search') {
			$recipients = Users::iterateEmailsBySearch($target_id);
		elseif ($this->target_type === 'search') {
			$recipients = Users::iterateEmailsBySearch((int) $this->target_value);
		}
		elseif ($target == 'service') {
			$recipients = Users::iterateEmailsByActiveService($target_id);
		elseif ($this->target_type === 'service') {
			$recipients = Users::iterateEmailsByActiveService((int) $this->target_value);
		}
		else {
			throw new \InvalidArgumentException('Invalid target');
		}

		$db = DB::getInstance();
		$db->begin();
99
100
101
102
103
104
105


106








107
108
109
110
111
112
113
114
115
116

117
118

119
120
121
122
123
124
125
126
127
128
129
130


131
132
133
134
135
136
137
138
139
140
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

151
152

153
154
155
156









157
158



159
160
161
162
163
164
165







+
+

+
+
+
+
+
+
+
+









-
+

-
+



-
-
-
-
-
-
-
-
-
+
+
-
-
-







		}

		if (!$count) {
			$db->rollback();
			throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
		}

		$this->cleanupRecipients();

		$db->commit();
	}

	/**
	 * Remove opt-out recipients from list
	 */
	public function cleanupRecipients(): void
	{

	}

	public function addRecipient(string $email, ?stdClass $data = null): void
	{
		if (!$this->exists()) {
			throw new \LogicException('Mailing does not exist');
		}

		$email = strtolower(trim($email));
		$e = Emails::getEmail($email);
		$e = Addresses::getOrCreate($email);

		if ($e && !$e->canSend()) {
		if (!$e->canSend()) {
			$data = null;
		}
		else {
			try {
				// Validate e-mail address, but not MX (quick check)
				Email::validateAddress($email, false);
			}
			catch (UserException $ex) {
				$e = Emails::createEmail($email);
				$e->setFailedValidation($ex->getMessage());
				$data = null;
			}
			$this->cleanExtraData($data);
		}
		}

		$this->cleanExtraData($data);

		DB::getInstance()->insert('mailings_recipients', [
			'id_mailing' => $this->id,
			'id_email'   => $e ? $e->id : null,
			'email'      => $email,
			'extra_data' => $data ? json_encode($data) : null,
		]);
219
220
221
222
223
224
225
226

227
228
229
230
231
232
233



234
235
236


237
238
239
240
241
242
243
244
245
246
247
248
249
250

251
252
253
254
255
256
257

258
259
260
261
262
263
264
265
266
267
268
269
270
271
272







-
+






-
+
+
+



+
+







			],
			'name' => [
				'label' => 'Nom',
				'select' => $this->getNameFieldsSQL('r'),
			],
			'status' => [
				'label' => 'Erreur',
				'select' => Emails::getRejectionStatusClause('e'),
				'select' => sprintf('CASE WHEN o.email_hash IS NOT NULL THEN \'Désinscription de cet envoi\' ELSE (%s) END', Emails::getRejectionStatusClause('e')),
			],
			'has_extra_data' => [
				'select' => 'r.extra_data IS NOT NULL',
			],
		];

		$tables = 'mailings_recipients AS r LEFT JOIN emails e ON e.id = r.id_email';
		$tables = 'mailings_recipients AS r
			LEFT JOIN emails_addresses a ON a.id = r.id_email
			LEFT JOIN mailings_optouts o ON a.hash = o.email_hash AND o.target_type = :target_type AND o.target_value = :target_value';
		$conditions = 'id_mailing = ' . $this->id;

		$list = new DynamicList($columns, $tables, $conditions);
		$list->setParameter(':target_type', $this->target_type);
		$list->setParameter(':target_value', $this->target_value);
		$list->orderBy('email', false);
		$list->setTitle('Liste des destinataires');
		return $list;
	}

	public function countRecipients(): int
	{

Added src/include/lib/Paheko/Entities/Email/Message.php version [c6e40e85ff].















































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\Entities\Email;

use Paheko\Config;
use Paheko\Entity;

use const Paheko\{DISABLE_EMAIL, MAIL_RETURN_PATH, MAIL_SENDER};
use const Paheko\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY, SMTP_HELO_HOSTNAME};

use KD2\SMTP;
use KD2\Security;
use KD2\Mail_Message;

class Message extends Entity
{
	const TABLE = 'emails_queue';

	protected int $context;
	protected int $status = self::WAITING;

	protected ?string $sender;
	protected string $recipient;
	protected string $recipient_hash;
	protected ?string $recipient_pgp_key;

	protected string $subject;
	protected string $body;
	protected string $html_body;
	protected array $attachments;

	protected ?string $context_optout;

	const STATUS_WAITING = 0;
	const STATUS_SENDING = 1;
	const STATUS_SENT = 2;

	const STATUS_LIST = [
		self::STATUS_WAITING => 'En attente',
		self::STATUS_SENDING => 'Envoi en cours',
		self::STATUS_SENT    => 'Envoyé',
	];

	const STATUS_COLORS = [
		self::STATUS_WAITING => 'cadetblue',
		self::STATUS_SENDING => 'chocolate',
		self::STATUS_SENT    => 'darkgreen',
	];

	const CONTEXT_SYSTEM = 0;
	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_NOTIFICATION = 3;

	const CONTEXT_LIST = [
		self::CONTEXT_SYSTEM => 'Système',
		self::CONTEXT_BULK => 'Collectif',
		self::CONTEXT_PRIVATE => 'Privé',
		self::CONTEXT_NOTIFICATION => 'Notification',
	];

	public function selfCheck(): void
	{
		$this->assert(in_array($this->context, self::CONTEXT_LIST), 'Contexte inconnu');
		$this->assert(in_array($this->status, self::STATUS_LIST), 'Statut inconnu');
		$this->assert(strlen($this->subject), 'Sujet vide');
		$this->assert(strlen($this->body), 'Corps vide');
		$this->assert(strlen($this->recipient), 'Destinataire absent');
		$this->assert(strlen($this->recipient_hash) === 40, 'Hash invalide');
	}

	public function setBodyFromUserTemplate(UserTemplate $template, array $data = [], bool $markdown = false): void
	{
		// Replace placeholders: {{$name}}, etc.
		$template->assignArray((array) $data, null, false);

		// Disable HTML escaping for plaintext emails
		$template->setEscapeDefault(null);
		$this->body = $template->fetch();

		if ($markdown) {
			$this->markdownToHTML();
		}
	}

	public function markdownToHTML(): void
	{
		$this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body);
	}

	public function wrapHTML(): ?string
	{
		if ($this->context === self::CONTEXT_SYSTEM) {
			return null;
		}

		if (null === self::$main_tpl) {
			self::$main_tpl = new UserTemplate('web/email.html');
		}

		// Wrap HTML content in the email skeleton
		$main_tpl->assignArray([
			'html'    => $this->body_html,
			'address' => $this->recipient,
			'context' => $this->context,
			'sender'  => $this->sender,
			'message' => $this,
		]);

		return $main_tpl->fetch();
	}

	public function getOptoutText(): string
	{
		$out = "Vous recevez ce message car vous êtes dans nos contacts.\n";

		if (isset($this->context_optout)) {
			$out .= "Pour vous désinscrire uniquement de ces envois, cliquez ici :\n";
			$out .= "[context_optout_url]\n\n";
		}

		$out .= "Pour ne plus jamais recevoir aucun message de notre part cliquez ici :\n";
		$out .= "[optout_url]\n\n";
		return $out;

	}

	public function getOptoutFooter(): string
	{
		return strtr($this->getOptoutText(), [
			'[context_optout_url]' => $this->getContextSpecificOptoutURL(),
			'[optout_url]' => $this->getOptoutURL(),
		]);
	}

	public function appendHTMLOptoutFooter(): string
	{
		$text = nl2br(htmlspecialchars($this->getOptoutText()));

		if (isset($this->context_optout)) {
			$button = sprintf('<a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 5px; background: #eee; border: 1px outset #ccc;">Me désinscrire de ces envois uniquement</a></p>', $this->getContextSpecificOptoutURL());
			$text = str_replace('[context_optout_url]', $button, $text);
		}

		$button = sprintf('<a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 3px; background: #eee; border: 1px outset #ccc;">Me désinscrire de <b>tous les envois</b></a></p>', $this->getOptoutURL());
		$text = str_replace('[optout_url]', $button, $text);

		$footer = '<p style="color: #666; background: #fff; padding: 10px; text-align: center; font-size: 9pt">';
		$footer .= $text;

		$html = $this->body_html;

		if (stripos($html, '</body>') !== false) {
			$html = str_ireplace('</body>', $footer . '</body>', $html);
		}
		else {
			$html .= $footer;
		}

		return $html;
	}

	public function getOptoutURL(): string
	{
		return Email::getOptoutURL($this->recipient_hash);
	}

	public function getContextSpecificOptoutURL(): ?string
	{
		if (!isset($this->context_optout)) {
			return null;
		}

		return Email::getOptoutURL() . '&c=' . $this->context_optout;
	}

	public function setRecipient(string $email, ?string $pgp_key = null)
	{
		$this->set('recipient', $email);
		$this->set('recipient_pgp_key', $pgp_key);
	}

	public function queue(): bool
	{
		return $this->save();
	}

	public function createSMTPMessage(): Mail_Message
	{

		$config = Config::getInstance();
		$message = new Mail_Message;

		$message->setHeader('From', $this->sender ?? self::getDefaultFromHeader());
		$message->setHeader('To', $this->recipient);
		$message->setHeader('Subject', $this->subject);

		if (!$message->getFrom()) {
			$message->setHeader('From', self::getFromHeader());
		}

		if (MAIL_SENDER) {
			$message->setHeader('Reply-To', $message->getFromAddress());
			$message->setHeader('From', self::getFromHeader($message->getFromName(), MAIL_SENDER));
		}

		$message->setMessageId();

		$text = $this->body;
		$html = $this->body_html;

		// Append unsubscribe, except for password reminders
		if ($this->context != self::CONTEXT_SYSTEM) {
			$url = $this->getContextSpecificOptoutURL() ?? $this->getOptoutURL();

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$text .= sprintf("\n\n-- \n%s\n\n%s", $config->org_name, $this->getOptoutText());

			if (null !== $html) {
				$html = $this->appendHTMLOptoutFooter();
			}
		}

		$message->setBody($text);

		if (null !== $html) {
			$message->addPart('text/html', $html);
		}

		$message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email);
		$message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers

		foreach ($attachments as $path) {
			$file = Files::get($path);

			if (!$file) {
				continue;
			}

			$message->addPart($file->mime, $file->fetch(), $file->name);
		}

		static $can_use_encryption = null;

		if (null === $can_use_encryption) {
			$can_use_encryption = Security::canUseEncryption();
		}

		if ($this->recipient_pgp_key && $can_use_encryption) {
			$message->encrypt($this->recipient_pgp_key);
		}

		return $message;
	}

	public function send(): bool
	{
		if (DISABLE_EMAIL) {
			return false;
		}

		$message = $this->createSMTPMessage();
		$entity = $this;
		$context = $this->context;
		$fail = false;

		try {
			$signal = Plugins::fire('email.send.before', true, compact('message', 'context', 'entity'), ['sent' => null]);

			if ($signal && $signal->isStopped()) {
				return $signal->getOut('sent') ?? true;
			}

			if (SMTP_HOST) {
				$const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY);
				$secure = constant($const);

				$smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure, SMTP_HELO_HOSTNAME);

				$smtp->send($message);
			}
			else {
				// Send using PHP mail() function
				$message->send();
			}

			Plugins::fire('email.send.after', false, compact('context', 'message', 'entity'));
			return true;
		}
		catch (\Throwable $e) {
			$fail = true;
		}
		finally {
			if (!$fail) {
				$this->set('status', self::STATUS_SENT);
			}
		}
	}
}

Modified src/include/lib/Paheko/Entities/Services/Fee.php from [3116155735] to [db1614ccc0].

116
117
118
119
120
121
122
123

124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142

143
144
145
146
147
148
149
116
117
118
119
120
121
122

123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141

142
143
144
145
146
147
148
149







-
+


















-
+







	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['users' => null, 'services_users' => null, 'services' => null, 'services_fees' => null], $sql);
			$db->protectSelect(['users' => null, 'services_subscriptions' => null, 'services' => null, 'services_fees' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function allUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$identity = DynamicFields::getNameFieldsSQL('u');

		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
				'select' => 'sub.id_user',
			],
			'service_label' => [
				'select' => 's.label',
				'label' => 'Activité',
				'export' => true,
			],
			'fee_label' => [
158
159
160
161
162
163
164
165
166


167
168
169
170

171
172
173
174

175
176
177
178
179
180



181
182
183
184
185




186
187
188
189
190
191
192

193
194

195
196
197
198
199
200
201
202
203
204
205
206
207


208
209
210
211
212
213
214
215
216
217
218
219
220

221
222
223
224
225
226
227
228
229
230
231
232
233

234
235
236
237
238
239
240
241
242
243
244
245
246
247
248

249
250
251
252
253
254

255
256
158
159
160
161
162
163
164


165
166
167
168
169

170
171
172
173

174
175
176
177



178
179
180
181




182
183
184
185
186
187
188
189
190
191

192
193

194
195
196
197
198
199
200
201
202
203
204
205


206
207
208
209
210
211
212
213
214
215
216
217
218
219

220
221
222
223
224
225
226
227
228
229
230
231
232

233
234
235
236
237
238
239
240
241
242
243
244
245
246
247

248
249
250
251
252
253

254
255
256







-
-
+
+



-
+



-
+



-
-
-
+
+
+

-
-
-
-
+
+
+
+






-
+

-
+











-
-
+
+












-
+












-
+














-
+





-
+


			],
			'identity' => [
				'label' => 'Membre',
				'select' => $identity,
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
				'select' => 'sub.paid',
				'order' => 'sub.paid %s, sub.date %1$s',
			],
			'paid_amount' => [
				'label' => 'Montant payé',
				'select' => 'CASE WHEN tu.id_service_user IS NOT NULL THEN SUM(l.credit) ELSE NULL END',
				'select' => 'CASE WHEN link.id_subscription IS NOT NULL THEN SUM(l.credit) ELSE NULL END',
			],
			'date' => [
				'label' => 'Date',
				'select' => 'su.date',
				'select' => 'sub.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN users u ON u.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
		$tables = 'services_subscriptions sub
			INNER JOIN users u ON u.id = sub.id_user
			INNER JOIN services_fees sf ON sf.id = sub.id_fee
			INNER JOIN services s ON s.id = sf.id_service
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_fee = %d', $this->id());
			INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_fee) AS su2 ON su2.id = sub.id
			LEFT JOIN acc_transactions_users link ON link.id_subscription = sub.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = link.id_transaction';
		$conditions = sprintf('sub.id_fee = %d', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->groupBy('sub.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');
		$list->setCount('COUNT(DISTINCT sub.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null;
		});

		return $list;
	}

	public function activeUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.paid = 1', $this->id());
		$conditions = sprintf('sub.id_fee = %d AND (sub.expiry_date >= date() OR sub.expiry_date IS NULL)
			AND sub.paid = 1', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}

	public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0', $this->id());
		$conditions = sprintf('sub.id_fee = %d AND sub.paid = 0', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date()', $this->id());
		$conditions = sprintf('sub.id_fee = %d AND sub.expiry_date < date()', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}


	public function getUsers(bool $paid_only = false): array
	{
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = DynamicFields::getNameFieldsSQL('u');
		$sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where);
		$sql = sprintf('SELECT sub.id_user, %s FROM services_subscriptions sub INNER JOIN users u ON u.id = sub.id_user WHERE sub.id_fee = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function hasSubscriptions(): bool
	{
		return DB::getInstance()->test('services_users', 'id_fee = ?', $this->id());
		return DB::getInstance()->test('services_subscriptions', 'id_fee = ?', $this->id());
	}
}

Modified src/include/lib/Paheko/Entities/Services/Reminder.php from [dbea8dc3fe] to [de83493dfe].

1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18
19
20
21
22
23
24

25
26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33











+













+







<?php

namespace Paheko\Entities\Services;

use Paheko\DynamicList;
use Paheko\DB;
use Paheko\Entity;
use Paheko\ValidationException;
use Paheko\Users\DynamicFields;
use Paheko\Services\Reminders;

use KD2\DB\Date;
use KD2\DB\EntityManager;

class Reminder extends Entity
{
	const NAME = 'Rappel';

	const TABLE = 'services_reminders';

	protected int $id;
	protected int $id_service;
	protected int $delay;
	protected string $subject;
	protected string $body;
	protected ?Date $not_before_date = null;

	const DEFAULT_SUBJECT = 'Votre inscription arrive à expiration';
	const DEFAULT_BODY = 'Bonjour {{$identity}},' . "\n\n" .
		'Votre inscription pour « {{$label}} » arrive à échéance dans {{$nb_days}} jours.' . "\n\n" .
		'Merci de nous contacter pour renouveler votre inscription.' . "\n\nCordialement.";

	public function selfCheck(): void
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64








65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

83
84
85
86
87
88
89
90
91
92
93
94

95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111



112
113
114
115


116
117
118
119
120
121
122
123
124
125


126
127
128
129
130
131
132
133
134
135







48
49
50
51
52
53
54

55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

91
92
93
94
95
96
97
98
99
100
101
102

103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125


126
127
128
129
130
131
132
133
134
135


136
137
138
139
140
141
142
143
144
145
146

147
148
149
150
151
152
153







-











+
+
+
+
+
+
+
+

















-
+











-
+

















+
+
+


-
-
+
+








-
-
+
+









-
+
+
+
+
+
+
+

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}


		if (isset($source['delay_type'])) {
			if (1 == $source['delay_type'] && !empty($source['delay_before'])) {
				$source['delay'] = (int)$source['delay_before'] * -1;
			}
			elseif (2 == $source['delay_type'] && !empty($source['delay_after'])) {
				$source['delay'] = (int)$source['delay_after'];
			}
			else {
				$source['delay'] = 0;
			}
		}

		// Warning: inverse logic here
		if (!empty($source['yes_before'])) {
			$source['not_before_date'] = null;
		}
		elseif (isset($source['yes_before']) && empty($source['yes_before'])) {
			$source['not_before_date'] = date('Y-m-d');
		}

		parent::importForm($source);
	}

	public function sentList(): DynamicList
	{
		$id_field = DynamicFields::getNameFieldsSQL('u');
		$db = DB::getInstance();

		$columns = [
			'id_user' => [
				'select' => 'srs.id_user',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => $id_field,
			],
			'date' => [
			'reminder_date' => [
				'label' => 'Date d\'envoi',
				'select' => 'srs.sent_date',
				'order' => 'srs.sent_date %s, srs.id %1$s',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN users u ON u.id = srs.id_user';
		$conditions = sprintf('srs.id_reminder = %d', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		$list->orderBy('reminder_date', true);
		return $list;
	}

	public function pendingList(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'id_user' => [
				'select' => 'id',
			],
			'identity' => [
				'label' => 'Membre',
			],
			'expiry_date' => [
				'label' => 'Date d\'expiration',
			],
			'reminder_date' => [
				'label' => 'Date d\'envoi',
			],
		];

		$conditions = sprintf('su.id_service = %d AND sr.id = %d', $this->id_service, $this->id);
		$tables = '(' . Reminders::getPendingSQL($conditions) . ') AS pending';
		$conditions = sprintf('sub.id_service = %d AND sr.id = %d', $this->id_service, $this->id);
		$tables = '(' . Reminders::getPendingSQL(false, $conditions) . ') AS pending';

		$list = new DynamicList($columns, $tables);
		$list->orderBy('expiry_date', false);
		return $list;
	}

	public function getPreview(int $id_user): ?string
	{
		$conditions = sprintf('su.id_service = %d AND su.id_user = %d AND sr.id = %d', $this->id_service, $id_user, $this->id);
		$sql = Reminders::getPendingSQL($conditions);
		$conditions = sprintf('sub.id_service = %d AND sub.id_user = %d AND sr.id = %d', $this->id_service, $id_user, $this->id);
		$sql = Reminders::getPendingSQL(false, $conditions);
		$db = DB::getInstance();

		foreach ($db->iterate($sql) as $reminder) {
			$m = Reminders::createMessage($reminder);
			return $m->getMessage($reminder);
		}

		return null;
	}
}

	public function deleteHistory(): void
	{
		$db = DB::getInstance();
		$db->exec(sprintf('DELETE FROM services_reminders_sent WHERE id_reminder = %s;', $this->id));
	}
}

Modified src/include/lib/Paheko/Entities/Services/ReminderMessage.php from [6b38646a9a] to [fbaef97c81].

20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34







-
+







class ReminderMessage extends Entity
{
	const TABLE = 'services_reminders_sent';

	protected ?int $id;
	protected int $id_service;
	protected int $id_user;
	protected int $id_reminder;
	protected ?int $id_reminder = null;
	protected Date $sent_date;
	protected Date $due_date;

	protected ?Reminder $_reminder = null;

	/**
	 * @return UserTemplate|string
72
73
74
75
76
77
78
79

80




81
82
83
84
85
86
87
72
73
74
75
76
77
78

79
80
81
82
83
84
85
86
87
88
89
90
91







-
+

+
+
+
+







		$reminder->user_amount = CommonModifiers::money_currency($reminder->user_amount ?? 0, true, false, false);
		$reminder->reminder_date = CommonModifiers::date_short($reminder->reminder_date);
		$reminder->expiry_date = CommonModifiers::date_short($reminder->expiry_date);

		return (array) $reminder;
	}

	public function reminder(): Reminder
	public function reminder(): ?Reminder
	{
		if (!$this->id_reminder) {
			return null;
		}

		$this->_reminder ??= Reminders::get($this->id_reminder);
		return $this->_reminder;
	}

	public function send(stdClass $reminder, $body = null)
	{
		$body ??= $this->getBody($reminder);

Modified src/include/lib/Paheko/Entities/Services/Service.php from [6bcf955bd3] to [41f1b2037b].

21
22
23
24
25
26
27

28
29
30
31
32
33
34
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35







+








	protected int $id;
	protected string $label;
	protected ?string $description = null;
	protected ?int $duration = null;
	protected ?Date $start_date = null;
	protected ?Date $end_date = null;
	protected bool $archived = false;

	public function selfCheck(): void
	{
		parent::selfCheck();
		$this->assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné');
		$this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères');
		$this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères');
52
53
54
55
56
57
58




59
60
61
62
63
64
65
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70







+
+
+
+







			elseif (2 == $source['period']) {
				$source['duration'] = null;
			}
			else {
				$source['duration'] = $source['start_date'] = $source['end_date'] = null;
			}
		}

		if (isset($source['archived_present']) && empty($source['archived'])) {
			$source['archived'] = false;
		}

		parent::importForm($source);
	}

	public function fees()
	{
		return new Fees($this->id());
85
86
87
88
89
90
91
92

93
94
95
96
97


98
99
100
101

102
103
104
105
106





107
108
109

110
111
112
113
114
115
116
117
118






119
120
121
122
123
124
125

126
127

128
129
130
131

132
133
134
135
136
137
138
139
140
141


142
143
144
145
146
147
148
149
150
151
152
153
154

155
156
157
158
159
160
161
162
163
164
165
166
167

168
169
170
171
172
173
174
175
176
177
178
179

180
181
182
183
184

185





186
187
188
189
190
191
192
90
91
92
93
94
95
96

97
98
99
100


101
102
103
104
105

106
107
108
109
110
111
112
113
114
115
116
117
118

119
120
121
122






123
124
125
126
127
128
129
130
131
132
133
134

135
136

137
138
139
140
141
142
143
144
145
146
147
148
149
150


151
152
153
154
155
156
157
158
159
160
161
162
163
164

165
166
167
168
169
170
171
172
173
174
175
176
177

178
179
180
181
182
183
184
185
186
187
188
189

190
191
192
193
194
195
196

197
198
199
200
201
202
203
204
205
206
207
208







-
+



-
-
+
+



-
+





+
+
+
+
+


-
+



-
-
-
-
-
-
+
+
+
+
+
+






-
+

-
+




+








-
-
+
+












-
+












-
+











-
+





+
-
+
+
+
+
+







			],
			'identity' => [
				'label' => 'Membre',
				'select' => $id_field,
			],
			'status' => [
				'label' => 'Statut',
				'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END',
				'select' => 'CASE WHEN sub.expiry_date < date() THEN -1 WHEN sub.expiry_date >= date() THEN 1 ELSE 0 END',
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
				'select' => 'sub.paid',
				'order' => 'sub.paid %s, sub.date %1$s',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(su.expiry_date)',
				'select' => 'MAX(sub.expiry_date)',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'amount' => [
				'label' => 'Montant de l\'inscription',
				'select' => 'sub.expected_amount',
				'export' => true,
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
				'select' => 'sub.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN users u ON u.id = su.id_user
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
		$conditions = sprintf('su.id_service = %d', $this->id());
		$tables = 'services_subscriptions AS sub
			INNER JOIN users u ON u.id = sub.id_user
			INNER JOIN services s ON s.id = sub.id_service
			LEFT JOIN services_fees sf ON sf.id = sub.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_service) AS su2 ON su2.id = sub.id';
		$conditions = sprintf('sub.id_service = %d', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->groupBy('sub.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');
		$list->setCount('COUNT(DISTINCT sub.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->status = $row->status == -1 ? 'En retard' : ($row->status == 1 ? 'En cours' : '');
			$row->paid = $row->paid ? 'Oui' : 'Non';
			$row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
		});

		return $list;
	}

	public function activeUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.paid = 1', $this->id());
		$conditions = sprintf('sub.id_service = %d AND (sub.expiry_date >= date() OR sub.expiry_date IS NULL)
			AND sub.paid = 1', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}

	public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND su.paid = 0', $this->id());
		$conditions = sprintf('sub.id_service = %d AND sub.paid = 0', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND su.expiry_date < date()', $this->id());
		$conditions = sprintf('sub.id_service = %d AND sub.expiry_date < date()', $this->id());

		if (!$include_hidden_categories) {
			$conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)';
		}

		$list->setConditions($conditions);
		return $list;
	}

	public function hasSubscriptions(): bool
	{
		return DB::getInstance()->test('services_users', 'id_service = ?', $this->id());
		return DB::getInstance()->test('services_subscriptions', 'id_service = ?', $this->id());
	}

	public function getUsers(bool $paid_only = false) {
		$where = $paid_only ? 'AND paid = 1' : '';
		$id_field = DynamicFields::getNameFieldsSQL('u');
		$sql = sprintf('SELECT sub.id_user, %s FROM services_subscriptions sub
		$sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where);
			INNER JOIN users u ON u.id = sub.id_user
			WHERE subn.id_service = ? %s;',
			$id_field,
			$where
		);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function long_label(): string
	{
		if ($this->duration) {
			$duration = sprintf('%d jours', $this->duration);

Deleted src/include/lib/Paheko/Entities/Services/Service_User.php version [40f2eaf44b].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278






















































































































































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko\Entities\Services;

use Paheko\DB;
use Paheko\Entity;
use Paheko\Form;
use Paheko\ValidationException;
use Paheko\Services\Fees;
use Paheko\Services\Services;
use Paheko\Users\Users;
use Paheko\Accounting\Transactions;
use Paheko\Entities\Accounting\Transaction;
use Paheko\Entities\Accounting\Line;

use KD2\DB\Date;

class Service_User extends Entity
{
	const TABLE = 'services_users';

	protected ?int $id;
	protected int $id_user;
	protected int $id_service;
	/**
	 * This can be NULL if there is no fee for the service
	 * @var null|int
	 */
	protected ?int $id_fee = null;
	protected bool $paid;
	protected ?int $expected_amount = null;
	protected Date $date;
	protected ?Date $expiry_date = null;

	protected $_service, $_fee;

	public function selfCheck(): void
	{
		$this->assert($this->id_service, 'Aucune activité spécifiée');
		$this->assert($this->id_user, 'Aucun membre spécifié');
		$this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date');

		$db = DB::getInstance();
		// don't allow an id_fee that does not match a service
		if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) {
			$this->set('id_fee', null);
		}
	}

	public function isDuplicate(bool $using_date = true): bool
	{
		if (!isset($this->id_user, $this->id_service)) {
			throw new \LogicException('Entity does not define either user or service');
		}

		$params = [
			'id_user' => $this->id_user,
			'id_service' => $this->id_service,
			'id_fee' => $this->id_fee,
		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
		$where = implode(' AND ', $where);

		if ($this->exists()) {
			$where .= sprintf(' AND id != %d', $this->id());
		}

		return DB::getInstance()->test(self::TABLE, $where, array_values($params));
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$service = null;

		if (!empty($source['id_service']) && empty($source['expiry_date'])) {
			$service = $this->_service = Services::get((int) $source['id_service']);

			if (!$service) {
				throw new \LogicException('The requested service is not found');
			}

			if ($service->duration) {
				$dt = new Date;
				$dt->modify(sprintf('+%d days', $service->duration));
				$this->set('expiry_date', $dt);
			}
			elseif ($service->end_date) {
				$this->set('expiry_date', $service->end_date);
			}
			else {
				$this->set('expiry_date', null);
			}
		}

		if (!empty($source['id_service'])) {
			if (!$service) {
				$service = $this->_service = Services::get((int) $source['id_service']);
			}
		}

		return parent::importForm($source);
	}

	public function service(): Service
	{
		if (null === $this->_service) {
			$this->_service = Services::get($this->id_service);
		}

		return $this->_service;
	}

	/**
	 * Returns the Fee entity linked to this subscription
	 * This can be NULL if there was no fee existing at the time of subscription
	 * (that way you can use subscriptions without fees if you want)
	 */
	public function fee(): ?Fee
	{
		if (null === $this->id_fee) {
			return null;
		}

		if (null === $this->_fee) {
			$this->_fee = Fees::get($this->id_fee);
		}

		return $this->_fee;
	}

	public function addPayment(int $user_id, ?array $source = null): Transaction
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!$this->id_fee) {
			throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
		}

		if (!$this->fee()->id_year) {
			throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
		}

		if (empty($source['amount'])) {
			throw new ValidationException('Montant non précisé');
		}

		$account = Form::getSelectorValue($source['account_selector'] ?? null);

		if (!$account) {
			throw new ValidationException('Aucune compte n\'a été sélectionné.');
		}

		$label = $this->service()->label;

		if ($this->fee()->label != $label) {
			$label .= ' - ' . $this->fee()->label;
		}

		$label .= sprintf(' (%s)', Users::getName($this->id_user));

		$transaction = Transactions::create(array_merge($source, [
			'type' => Transaction::TYPE_REVENUE,
			'label' => $label,
			'id_project' => $source['id_project'] ?? $this->fee()->id_project,
			'simple' => [Transaction::TYPE_REVENUE => [
				'credit' => [$this->fee()->id_account => null],
				'debit' => $source['account_selector'],
			]],
			'id_year' => $this->fee()->id_year,
		]));

		$transaction->id_creator = $user_id;
		$transaction->id_year = $this->fee()->id_year;
		$transaction->type = Transaction::TYPE_REVENUE;

		$transaction->save();
		$transaction->linkToSubscription($this->id());

		return $transaction;
	}

	public function updateExpectedAmount(): void
	{
		$fee = $this->fee();

		if ($fee && $fee->id_account && $this->id_user) {
			$this->set('expected_amount', $fee->getAmountForUser($this->id_user));
		}
		else {
			$this->set('expected_amount', null);
		}
	}

	static public function createFromForm(array &$users, int $creator_id, bool $from_copy = false, ?array $source = null): self
	{
		if (null === $source) {
			$source = $_POST;
		}

		$db = DB::getInstance();
		$db->begin();

		if (!count($users)) {
			throw new ValidationException('Aucun membre n\'a été sélectionné.');
		}

		$multiple_users = count($users) > 1;
		$errors = [];

		foreach ($users as $id => $name) {
			$su = new self;
			$su->date = new Date;
			$su->importForm($source);
			$su->id_user = (int) $id;

			if (empty($su->id_service)) {
				throw new ValidationException('Aucune activité n\'a été sélectionnée.');
			}

			$su->updateExpectedAmount();

			if ($su->isDuplicate($from_copy ? false : true)) {
				if ($from_copy) {
					continue;
				}
				else {
					$errors[] = $name;

					if (!$multiple_users) {
						throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name));
					}

					unset($users[$id]);
					continue;
				}
			}

			$su->save();

			if ($su->id_fee && $su->fee()->id_account
				&& !empty($source['amount'])
				&& !empty($source['create_payment'])) {
				try {
					$su->addPayment($creator_id, $source);
				}
				catch (ValidationException $e) {
					if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') {
						throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e);
					}
					else {
						throw $e;
					}
				}
			}
		}

		if (count($errors)) {
			$db->rollback();

			throw new ValidationException(sprintf("Les membres suivants ne pourront pas être inscrits car ils sont déjà inscrits à cette activité et à la date indiquée :\n%s\n\nValidez à nouveau le formulaire pour confirmer les inscriptions des autres membres.", implode(', ', $errors)));
		}

		$db->commit();

		return $su;
	}
}

Added src/include/lib/Paheko/Entities/Services/Subscription.php version [04703690c0].























































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\Entities\Services;

use Paheko\DB;
use Paheko\Entity;
use Paheko\Form;
use Paheko\ValidationException;
use Paheko\Services\Fees;
use Paheko\Services\Services;
use Paheko\Users\Users;
use Paheko\Accounting\Transactions;
use Paheko\Entities\Accounting\Transaction;
use Paheko\Entities\Accounting\Line;

use KD2\DB\Date;

class Subscription extends Entity
{
	const TABLE = 'services_subscriptions';

	protected ?int $id;
	protected int $id_user;
	protected int $id_service;
	/**
	 * This can be NULL if there is no fee for the service
	 * @var null|int
	 */
	protected ?int $id_fee = null;
	protected bool $paid;
	protected ?int $expected_amount = null;
	protected Date $date;
	protected ?Date $expiry_date = null;

	protected $_service, $_fee;

	public function selfCheck(): void
	{
		$this->assert($this->id_service, 'Aucune activité spécifiée');
		$this->assert($this->id_user, 'Aucun membre spécifié');
		$this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date');

		$db = DB::getInstance();
		// don't allow an id_fee that does not match a service
		if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) {
			$this->set('id_fee', null);
		}
	}

	public function isDuplicate(bool $using_date = true): bool
	{
		if (!isset($this->id_user, $this->id_service)) {
			throw new \LogicException('Entity does not define either user or service');
		}

		$params = [
			'id_user' => $this->id_user,
			'id_service' => $this->id_service,
			'id_fee' => $this->id_fee,
		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));
		$where = implode(' AND ', $where);

		if ($this->exists()) {
			$where .= sprintf(' AND id != %d', $this->id());
		}

		return DB::getInstance()->test(self::TABLE, $where, array_values($params));
	}

	public function importForm(?array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$service = null;

		if (!empty($source['id_service']) && empty($source['expiry_date'])) {
			$service = $this->_service = Services::get((int) $source['id_service']);

			if (!$service) {
				throw new \LogicException('The requested service is not found');
			}

			if ($service->duration) {
				$dt = new Date;
				$dt->modify(sprintf('+%d days', $service->duration));
				$this->set('expiry_date', $dt);
			}
			elseif ($service->end_date) {
				$this->set('expiry_date', $service->end_date);
			}
			else {
				$this->set('expiry_date', null);
			}
		}

		if (!empty($source['id_service'])) {
			if (!$service) {
				$service = $this->_service = Services::get((int) $source['id_service']);
			}
		}

		return parent::importForm($source);
	}

	public function service(): Service
	{
		if (null === $this->_service) {
			$this->_service = Services::get($this->id_service);
		}

		return $this->_service;
	}

	/**
	 * Returns the Fee entity linked to this subscription
	 * This can be NULL if there was no fee existing at the time of subscription
	 * (that way you can use subscriptions without fees if you want)
	 */
	public function fee(): ?Fee
	{
		if (null === $this->id_fee) {
			return null;
		}

		if (null === $this->_fee) {
			$this->_fee = Fees::get($this->id_fee);
		}

		return $this->_fee;
	}

	public function addPayment(int $user_id, ?array $source = null): Transaction
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (!$this->id_fee) {
			throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee');
		}

		if (!$this->fee()->id_year) {
			throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
		}

		if (empty($source['amount'])) {
			throw new ValidationException('Montant non précisé');
		}

		$account = Form::getSelectorValue($source['account_selector'] ?? null);

		if (!$account) {
			throw new ValidationException('Aucune compte n\'a été sélectionné.');
		}

		$label = $this->service()->label;

		if ($this->fee()->label != $label) {
			$label .= ' - ' . $this->fee()->label;
		}

		$label .= sprintf(' (%s)', Users::getName($this->id_user));

		$transaction = Transactions::create(array_merge($source, [
			'type' => Transaction::TYPE_REVENUE,
			'label' => $label,
			'id_project' => $source['id_project'] ?? $this->fee()->id_project,
			'simple' => [Transaction::TYPE_REVENUE => [
				'credit' => [$this->fee()->id_account => null],
				'debit' => $source['account_selector'],
			]],
			'id_year' => $this->fee()->id_year,
		]));

		$transaction->id_creator = $user_id;
		$transaction->id_year = $this->fee()->id_year;
		$transaction->type = Transaction::TYPE_REVENUE;

		$transaction->save();
		$transaction->linkToSubscription($this->id());

		return $transaction;
	}

	public function updateExpectedAmount(): void
	{
		$fee = $this->fee();

		if ($fee && $fee->id_account && $this->id_user) {
			$this->set('expected_amount', $fee->getAmountForUser($this->id_user));
		}
		else {
			$this->set('expected_amount', null);
		}
	}

	static public function createFromForm(array &$users, int $creator_id, bool $from_copy = false, ?array $source = null): self
	{
		if (null === $source) {
			$source = $_POST;
		}

		$db = DB::getInstance();
		$db->begin();

		if (!count($users)) {
			throw new ValidationException('Aucun membre n\'a été sélectionné.');
		}

		$multiple_users = count($users) > 1;
		$errors = [];

		foreach ($users as $id => $name) {
			$su = new self;
			$su->date = new Date;
			$su->importForm($source);
			$su->id_user = (int) $id;

			if (empty($su->id_service)) {
				throw new ValidationException('Aucune activité n\'a été sélectionnée.');
			}

			$su->updateExpectedAmount();

			if ($su->isDuplicate($from_copy ? false : true)) {
				if ($from_copy) {
					continue;
				}
				else {
					$errors[] = $name;

					if (!$multiple_users) {
						throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name));
					}

					unset($users[$id]);
					continue;
				}
			}

			$su->save();

			if ($su->id_fee && $su->fee()->id_account
				&& !empty($source['amount'])
				&& !empty($source['create_payment'])) {
				try {
					$su->addPayment($creator_id, $source);
				}
				catch (ValidationException $e) {
					if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') {
						throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e);
					}
					else {
						throw $e;
					}
				}
			}
		}

		if (count($errors)) {
			$db->rollback();

			throw new ValidationException(sprintf("Les membres suivants ne pourront pas être inscrits car ils sont déjà inscrits à cette activité et à la date indiquée :\n%s\n\nValidez à nouveau le formulaire pour confirmer les inscriptions des autres membres.", implode(', ', $errors)));
		}

		$db->commit();

		return $su;
	}
}

Modified src/include/lib/Paheko/Entities/Users/User.php from [28f087dc00] to [2c50ce6906].

15
16
17
18
19
20
21
22

23
24
25
26
27

28
29
30
31
32
33
34
15
16
17
18
19
20
21

22
23
24
25
26

27
28
29
30
31
32
33
34







-
+




-
+







use Paheko\Utils;
use Paheko\UserException;
use Paheko\ValidationException;

use Paheko\Files\Files;

use Paheko\Users\Categories;
use Paheko\Email\Emails;
use Paheko\Email\Queue;
use Paheko\Email\Templates as EmailTemplates;
use Paheko\Users\DynamicFields;
use Paheko\Users\Session;
use Paheko\Users\Users;
use Paheko\Services\Services_User;
use Paheko\Services\Subscriptions;

use Paheko\Entities\Files\File;

use KD2\SMTP;
use KD2\DB\EntityManager as EM;
use KD2\DB\Date;
use KD2\ZipWriter;
553
554
555
556
557
558
559
560
561
562
563





564
565




566
567
568





569
570
571
572
573
574
575
553
554
555
556
557
558
559




560
561
562
563
564
565

566
567
568
569
570


571
572
573
574
575
576
577
578
579
580
581
582







-
-
-
-
+
+
+
+
+

-
+
+
+
+

-
-
+
+
+
+
+








		$name = DynamicFields::getNameFieldsSQL();
		return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id());
	}

	public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null)
	{
		$config = Config::getInstance();
		$email_field = DynamicFields::getFirstEmailField();

		$from = $from ? $from->getNameAndEmail() : null;
		if (!$this->email()) {
			throw new UserException('Ce membre n\'a pas d\'adresse e-mail');
		}

		$sender = $from ? $from->getNameAndEmail() : null;

		Emails::queue(Emails::CONTEXT_PRIVATE, [$this->{$email_field} => ['pgp_key' => $this->pgp_key]], $from, $subject, $message);
		$message = Queue::createMessage(Message::CONTEXT_PRIVATE, $subject, $message);
		$message->setSender($sender);
		$message->setRecipient($this->email(), $this->pgp_key);
		$message->queue();

		if ($send_copy) {
			Emails::queue(Emails::CONTEXT_PRIVATE, [$config->org_email], null, $subject, $message);
		if ($send_copy && $from->email()) {
			$message = clone $message;
			$message->set('subject', 'Message envoyé : ' . $message->subject);
			$message->setRecipient($from->email());
			$message->queue();
		}
	}

	public function checkLoginFieldForUserEdit()
	{
		$session = Session::getInstance();

670
671
672
673
674
675
676
677

678
679
680
681
682
683
684
677
678
679
680
681
682
683

684
685
686
687
688
689
690
691







-
+







		}

		return $out;
	}

	public function downloadExport(): void
	{
		$services_list = Services_User::perUserList($this->id);
		$services_list = Subscriptions::perUserList($this->id);
		$services_list->setPageSize(null);

		$export_data = [
			'user'     => $this,
			'services' => $services_list->asArray(true),
		];

Modified src/include/lib/Paheko/Services/Fees.php from [a75ded20d2] to [368243dcc9].

102
103
104
105
106
107
108
109
110


111
112
113
114



115
116
117
118
119
120
121
102
103
104
105
106
107
108


109
110
111



112
113
114
115
116
117
118
119
120
121







-
-
+
+

-
-
-
+
+
+







		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY));

		$sql = sprintf('DROP TABLE IF EXISTS fees_list_stats;
			CREATE TEMP TABLE IF NOT EXISTS fees_list_stats (id_fee, id_user, ok, expired, paid);
			INSERT INTO fees_list_stats SELECT
				id_fee, id_user,
				CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
				CASE WHEN (sub.expiry_date IS NULL OR sub.expiry_date >= date()) AND sub.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN sub.expiry_date < date() THEN 1 ELSE 0 END,
				paid
			FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) su2 ON su2.id = su.id
			INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
			FROM services_subscriptions sub
			INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_fee) sub2 ON sub2.id = sub.id
			INNER JOIN users u ON u.id = sub.id_user WHERE u.%s',
			$db->where('id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);

		$columns = [
			'id' => [],
			'formula' => [],

Modified src/include/lib/Paheko/Services/Reminders.php from [0396b26bbf] to [d826b60405].

40
41
42
43
44
45
46
47

48
49
50
51
52
53
54
40
41
42
43
44
45
46

47
48
49
50
51
52
53
54







-
+







			'date' => [
				'label' => 'Date d\'envoi du message',
				'select' => 'srs.sent_date',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER JOIN services_reminders r ON r.id = srs.id_reminder
			LEFT JOIN services_reminders r ON r.id = srs.id_reminder
			INNER JOIN services s ON s.id = srs.id_service';
		$conditions = sprintf('srs.id_user = %d', $user_id);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}
63
64
65
66
67
68
69
70

71
72
73
74
75
76
77
78


79
80

81
82
83

84
85

86
87

88
89

90
91
92
93

94
95
96


97

98
99


100
101
102
103
104

105





106
107
108
109
110
111
112
63
64
65
66
67
68
69

70
71
72
73
74
75
76


77
78
79

80
81
82

83
84

85
86

87
88

89
90
91
92

93
94


95
96
97
98


99
100
101
102
103
104
105
106

107
108
109
110
111
112
113
114
115
116
117
118







-
+






-
-
+
+

-
+


-
+

-
+

-
+

-
+



-
+

-
-
+
+

+
-
-
+
+





+
-
+
+
+
+
+







	}

	static public function listForService(int $service_id)
	{
		return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id);
	}

	static public function getPendingSQL(string $conditions = '1')
	static public function getPendingSQL(bool $due_only = true, string $conditions = '1')
	{
		$db = DB::getInstance();

		$sql = 'SELECT
			u.*, %s AS identity,
			u.id AS id_user,
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(su.expiry_date)) AS nb_days,
			date(sub.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(sub.expiry_date)) AS nb_days,
			MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description,
			su.expiry_date, sr.id AS id_reminder, su.id_service, su.id_user,
			sub.expiry_date, sr.id AS id_reminder, sub.id_service, sub.id_user,
			sf.label AS fee_label, sf.amount, sf.formula
			FROM services_reminders sr
			INNER JOIN services s ON s.id = sr.id_service
			INNER JOIN services s ON s.id = sr.id_service AND s.archived = 0
			-- Select latest subscription to a service (MAX) only
			INNER JOIN (SELECT MAX(su2.expiry_date) AS expiry_date, su2.id_user, su2.id_service, su2.id_fee FROM services_users AS su2 GROUP BY id_user, id_service) AS su ON s.id = su.id_service
			INNER JOIN (SELECT MAX(sub2.expiry_date) AS expiry_date, sub2.id_user, sub2.id_service, sub2.id_fee FROM services_subscriptions AS sub2 GROUP BY id_user, id_service) AS sub ON s.id = sub.id_service
			-- Select fee
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			LEFT JOIN services_fees sf ON sf.id = sub.id_fee
			-- Join with users, but not ones part of a hidden category
			INNER JOIN users u ON su.id_user = u.id
			INNER JOIN users u ON sub.id_user = u.id
				AND (%s)
				AND (u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1))
			-- Join with sent reminders to exclude users that already have received this reminder
			LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON su.id_user = srs.id_user AND srs.id_reminder = sr.id
			LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON sub.id_user = srs.id_user AND srs.id_reminder = sr.id
			WHERE
				date() > date(su.expiry_date, sr.delay || \' days\')
				AND (srs.id IS NULL OR srs.due_date < date(su.expiry_date, (sr.delay - 1) || \' days\'))
				(sr.not_before_date IS NULL OR sr.not_before_date <= date(sub.expiry_date, sr.delay || \' days\'))
				AND (srs.id IS NULL OR srs.due_date < date(sub.expiry_date, (sr.delay - 1) || \' days\'))
				AND %s
				AND %s
			GROUP BY su.id_user, sr.id_service
			ORDER BY su.id_user';
			GROUP BY sub.id_user, sr.id_service
			ORDER BY sub.id_user';

		$emails = DynamicFields::getEmailFields();
		$emails = array_map(fn($e) => sprintf('u.%s IS NOT NULL', $db->quoteIdentifier($e)), $emails);
		$emails = implode(' OR ', $emails);

		$sql = sprintf($sql,
		$sql = sprintf($sql, DynamicFields::getNameFieldsSQL('u'), $emails, $conditions);
			DynamicFields::getNameFieldsSQL('u'),
			$emails,
			$due_only ? 'date() > date(sub.expiry_date, sr.delay || \' days\')' : '1',
			$conditions
		);

		return $sql;
	}

	static public function createMessage(stdClass $reminder): ReminderMessage
	{
		$m = new ReminderMessage;
123
124
125
126
127
128
129
130

131
132
133
134
135
136
137
129
130
131
132
133
134
135

136
137
138
139
140
141
142
143







-
+







	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès
	 */
	static public function sendPending(): void
	{
		$db = DB::getInstance();
		$sql = self::getPendingSQL();
		$sql = self::getPendingSQL(true);

		$date = new \DateTime;

		$db->begin();
		$body = null;

		foreach ($db->iterate($sql) as $row) {

Modified src/include/lib/Paheko/Services/Services.php from [64d1da90fa] to [1cc5f0aae4].

39
40
41
42
43
44
45
46

47
48
49
50
51
52
53
54
55
56
57
58

59
60

61

62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80







81

82
83
84
85
86
87
88
89
90
91


92
93
94
95



96
97
98
99
100
101
102
39
40
41
42
43
44
45

46
47











48
49
50
51

52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

79
80
81
82
83
84
85
86
87


88
89
90



91
92
93
94
95
96
97
98
99
100







-
+

-
-
-
-
-
-
-
-
-
-
-
+


+
-
+



















+
+
+
+
+
+
+
-
+








-
-
+
+

-
-
-
+
+
+







	}

	static public function count()
	{
		return DB::getInstance()->count(Service::TABLE, 1);
	}

	static public function listGroupedWithFees(?int $user_id = null, int $current = 1)
	static public function listGroupedWithFees(?int $user_id = null)
	{
		if ($current === 1) {
			$where = 'WHERE end_date IS NULL OR end_date >= date()';
		}
		elseif ($current === 2) {
			$where = '';
		}
		else {
			$where = 'WHERE end_date IS NOT NULL AND end_date < date()';
		}

		$sql = sprintf('SELECT
		$sql = 'SELECT
			id, label, duration, start_date, end_date, description,
			CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date
			FROM services
			FROM services %s ORDER BY label COLLATE U_NOCASE;', $where);
			WHERE archived = 0 ORDER BY label COLLATE U_NOCASE;';

		$services = DB::getInstance()->getGrouped($sql);
		$fees = Fees::listAllByService($user_id);
		$out = [];

		foreach ($services as $service) {
			$out[$service->id] = $service;
			$out[$service->id]->fees = [];
		}

		foreach ($fees as $fee) {
			if (isset($out[$fee->id_service])) {
				$out[$fee->id_service]->fees[] = $fee;
			}
		}

		return $out;
	}

	static public function listArchivedWithStats(): DynamicList
	{
		$list = self::listWithStats();
		$list->setConditions('archived = 1');
		return $list;
	}

	static public function listWithStats(bool $current_only = true): DynamicList
	static public function listWithStats(): DynamicList
	{
		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY));

		$sql = sprintf('DROP TABLE IF EXISTS services_list_stats;
			CREATE TEMP TABLE IF NOT EXISTS services_list_stats (id_service, id_user, ok, expired, paid);
			INSERT INTO services_list_stats SELECT
				id_service, id_user,
				CASE WHEN (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
				CASE WHEN (sub.expiry_date IS NULL OR sub.expiry_date >= date()) AND sub.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN sub.expiry_date < date() THEN 1 ELSE 0 END,
				paid
			FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id
			INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
			FROM services_subscriptions sub
			INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_service) sub2 ON sub2.id = sub.id
			INNER JOIN users u ON u.id = sub.id_user WHERE u.%s',
			$db->where('id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);


		$columns = [
			'id' => [],
124
125
126
127
128
129
130
131
132
133

134
135
136
137
138
139

140
141

142
143
122
123
124
125
126
127
128



129
130
131
132
133
134

135
136

137
138
139







-
-
-
+





-
+

-
+


			'nb_users_unpaid' => [
				'label' => 'Membres en attente de règlement',
				'order' => null,
				'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)',
			],
		];

		$current_condition = $current_only ? '(end_date IS NULL OR end_date >= datetime())' : '(end_date IS NOT NULL AND end_date < datetime())';

		$list = new DynamicList($columns, 'services', $current_condition);
		$list = new DynamicList($columns, 'services', 'archived = 0');
		$list->setPageSize(null);
		$list->orderBy('label', false);
		return $list;
	}

	static public function countOldServices(): int
	static public function hasArchivedServices(): bool
	{
		return DB::getInstance()->count(Service::TABLE, 'end_date IS NOT NULL AND end_date < datetime()');
		return DB::getInstance()->test(Service::TABLE, 'archived = 1');
	}
}

Deleted src/include/lib/Paheko/Services/Services_User.php version [c5d474b2db].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228




































































































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko\Services;

use Paheko\CSV_Custom;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Utils;
use Paheko\UserException;
use Paheko\Entities\Services\Service_User;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;

use KD2\DB\EntityManager;

class Services_User
{
	static public function get(int $id)
	{
		return EntityManager::findOneById(Service_User::class, $id);
	}

	static public function countForUser(int $user_id)
	{
		return DB::getInstance()->count(Service_User::TABLE, 'id_user = ?', $user_id);
	}

	static public function listDistinctForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT
			s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date,
			CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END AS status,
			CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived
			FROM services_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			WHERE su.id_user = ?
			GROUP BY su.id_service ORDER BY expiry_date DESC;', $user_id);
	}

	static public function perUserList(int $user_id, ?int $only_id = null, ?\DateTime $after = null): DynamicList
	{
		$columns = [
			'id' => [
				'select' => 'su.id',
			],
			'id_account' => [
				'select' => 'sf.id_account',
			],
			'id_year' => [
				'select' => 'sf.id_year',
			],
			'account_code' => [
				'select' => 'a.code',
			],
			'has_transactions' => [
				'select' => 'tu.id_user',
			],
			'label' => [
				'select' => 's.label',
				'label' => 'Activité',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(su.expiry_date)',
			],
			'paid' => [
				'label' => 'Payé',
				'select' => 'su.paid',
			],
			'amount' => [
				'label' => 'Reste à régler',
				'select' => 'CASE WHEN su.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL
					ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END',
			],
			'expected_amount' => [],
		];

		$tables = 'services_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			LEFT JOIN acc_accounts a ON sf.id_account = a.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_user = %d', $user_id);

		if ($only_id) {
			$conditions .= sprintf(' AND su.id = %d', $only_id);
		}

		if ($after) {
			$conditions .= sprintf(' AND su.date >= %s', DB::getInstance()->quote($after->format('Y-m-d')));
		}

		$list = new DynamicList($columns, $tables, $conditions);

		$list->setExportCallback(function (&$row) {
			$row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
		});

		$list->orderBy('date', true);
		$list->groupBy('su.id');
		$list->setCount('COUNT(DISTINCT su.id)');
		return $list;
	}

	static protected function iterateImport(CSV_Custom $csv, array &$errors = null): \Generator
	{
		$number_field = DynamicFields::getNumberField();
		$services = Services::listAssoc();
		$fees = Fees::listGroupedById();

		foreach ($csv->iterate() as $i => $row) {
			try {
				if (empty($row->$number_field)) {
					throw new UserException('Aucun numéro de membre n\'a été indiqué');
				}

				$id_user = Users::getIdFromNumber($row->$number_field);

				if (!$id_user) {
					throw new UserException(sprintf('Le numéro de membre "%s" n\'existe pas', $row->$number_field));
				}

				$id_service = array_search($row->service, $services);

				if (!$id_service) {
					throw new UserException(sprintf('L\'activité "%s" n\'existe pas', $row->service));
				}

				if (empty($row->date)) {
					throw new UserException('La date est vide');
				}

				$id_fee = null;

				if (!empty($row->fee)) {
					foreach ($fees as $fee) {
						if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id_service === $id_service) {
							$id_fee = $fee->id;
							break;
						}
					}

					if (!$id_fee) {
						throw new UserException(sprintf('Le tarif "%s" n\'existe pas pour cette activité', $row->fee));
					}
				}

				$su = new Service_User;
				$su->set('id_user', $id_user);
				$su->set('id_service', $id_service);
				$su->set('id_fee', $id_fee);
				unset($row->fee, $row->service, $row->$number_field);

				if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') {
					$row->paid = false;
				}
				else {
					$row->paid = true;
				}

				$su->import((array)$row);

				yield $i => $su;
			}
			catch (UserException $e) {
				if (null !== $errors) {
					$errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage());
					continue;
				}

				throw $e;
			}
		}
	}

	static public function import(CSV_Custom $csv): void
	{
		$db = DB::getInstance();
		$db->begin();

		foreach (self::iterateImport($csv) as $i => $su) {
			try {
				$su->save();
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e);
			}
		}

		$db->commit();
	}

	static public function listImportColumns(): array
	{
		$number_field = DynamicFields::getNumberField();

		return [
			$number_field     => 'Numéro de membre',
			'service'         => 'Activité',
			'fee'             => 'Tarif',
			'paid'            => 'Payé ?',
			'expected_amount' => 'Montant à régler',
			'date'            => 'Date d\'inscription',
			'expiry_date'     => 'Date d\'expiration',
		];
	}

	static public function listMandatoryImportColumns(): array
	{
		$number_field = DynamicFields::getNumberField();

		return [
			$number_field,
			'service',
			'date',
		];
	}
}

Added src/include/lib/Paheko/Services/Subscriptions.php version [9b8db3b5a6].






































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko\Services;

use Paheko\CSV_Custom;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Utils;
use Paheko\UserException;
use Paheko\Entities\Services\Subscription;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;

use KD2\DB\EntityManager;

class Subscriptions
{
	static public function get(int $id)
	{
		return EntityManager::findOneById(Subscription::class, $id);
	}

	static public function countForUser(int $user_id)
	{
		return DB::getInstance()->count(Subscription::TABLE, 'id_user = ?', $user_id);
	}

	static public function listDistinctForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT
			s.label, MAX(sub.date) AS last_date, sub.expiry_date AS expiry_date, sf.label AS fee_label, sub.paid, s.end_date,
			CASE WHEN sub.expiry_date < date() THEN -1 WHEN sub.expiry_date >= date() THEN 1 ELSE 0 END AS status,
			CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived
			FROM services_subscriptions sub
			INNER JOIN services s ON s.id = sub.id_service
			LEFT JOIN services_fees sf ON sf.id = sub.id_fee
			WHERE sub.id_user = ?
			AND s.archived = 0
			GROUP BY sub.id_service ORDER BY expiry_date DESC;', $user_id);
	}

	static public function perUserList(int $user_id, ?int $only_id = null, ?\DateTime $after = null): DynamicList
	{
		$columns = [
			'archived' => [
				'select' => 's.archived',
			],
			'id' => [
				'select' => 'sub.id',
			],
			'id_account' => [
				'select' => 'sf.id_account',
			],
			'id_year' => [
				'select' => 'sf.id_year',
			],
			'account_code' => [
				'select' => 'a.code',
			],
			'has_transactions' => [
				'select' => 'tu.id_transaction',
			],
			'label' => [
				'select' => 's.label',
				'label' => 'Activité',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'sub.date',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(sub.expiry_date)',
			],
			'paid' => [
				'label' => 'Payé',
				'select' => 'sub.paid',
			],
			'amount' => [
				'label' => 'Reste à régler',
				'select' => 'CASE WHEN sub.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL
					ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END',
			],
			'expected_amount' => [],
		];

		$tables = 'services_subscriptions sub
			INNER JOIN services s ON s.id = sub.id_service
			LEFT JOIN services_fees sf ON sf.id = sub.id_fee
			LEFT JOIN acc_accounts a ON sf.id_account = a.id
			LEFT JOIN acc_transactions_users tu ON tu.id_subscription = sub.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
		$conditions = sprintf('sub.id_user = %d', $user_id);

		if ($only_id) {
			$conditions .= sprintf(' AND sub.id = %d', $only_id);
		}

		if ($after) {
			$conditions .= sprintf(' AND sub.date >= %s', DB::getInstance()->quote($after->format('Y-m-d')));
		}

		$list = new DynamicList($columns, $tables, $conditions);

		$list->setExportCallback(function (&$row) {
			$row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null;
		});

		$list->orderBy('date', true);
		$list->groupBy('sub.id');
		$list->setCount('COUNT(DISTINCT sub.id)');
		return $list;
	}

	static protected function iterateImport(CSV_Custom $csv, array &$errors = null): \Generator
	{
		$number_field = DynamicFields::getNumberField();
		$services = Services::listAssoc();
		$fees = Fees::listGroupedById();

		foreach ($csv->iterate() as $i => $row) {
			try {
				if (empty($row->$number_field)) {
					throw new UserException('Aucun numéro de membre n\'a été indiqué');
				}

				$id_user = Users::getIdFromNumber($row->$number_field);

				if (!$id_user) {
					throw new UserException(sprintf('Le numéro de membre "%s" n\'existe pas', $row->$number_field));
				}

				$id_service = array_search($row->service, $services);

				if (!$id_service) {
					throw new UserException(sprintf('L\'activité "%s" n\'existe pas', $row->service));
				}

				if (empty($row->date)) {
					throw new UserException('La date est vide');
				}

				$id_fee = null;

				if (!empty($row->fee)) {
					foreach ($fees as $fee) {
						if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id_service === $id_service) {
							$id_fee = $fee->id;
							break;
						}
					}

					if (!$id_fee) {
						throw new UserException(sprintf('Le tarif "%s" n\'existe pas pour cette activité', $row->fee));
					}
				}

				if (!empty($row->id)) {
					$su = self::get((int)$row->id);

					if (!$su) {
						throw new UserException(sprintf('L\'inscription numéro %d n\'existe pas', $row->id));
					}
				}
				else {
					$su = new Subscription;
					$su->set('id_user', $id_user);
					$su->set('id_service', $id_service);
					$su->set('id_fee', $id_fee);
				}

				unset($row->fee, $row->service, $row->$number_field, $row->id_service, $row->id_fee, $row->id);

				if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') {
					$row->paid = false;
				}
				else {
					$row->paid = true;
				}

				$su->importForm((array)$row);

				yield $i => $su;
			}
			catch (UserException $e) {
				if (null !== $errors) {
					$errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage());
					continue;
				}

				throw $e;
			}
		}
	}

	static public function import(CSV_Custom $csv): void
	{
		$db = DB::getInstance();
		$db->begin();

		foreach (self::iterateImport($csv) as $i => $su) {
			try {
				$su->save();
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e);
			}
		}

		$db->commit();
	}

	static public function getList(): DynamicList
	{
		$number_field = DynamicFields::getNumberFieldSQL('u');
		$name_field = DynamicFields::getNameFieldsSQL('u');

		$columns = [
			'number' => [
				'label' => 'Numéro de membre',
				'select' => $number_field,
				'export' => true,
			],
			'name' => [
				'label' => 'Nom du membre',
				'select' => $name_field,
			],
			'id' => [
				'label' => 'Numéro d\'inscription',
				'select' => 'sub.id',
				'export' => true,
			],
			'service' => [
				'label' => 'Activité',
				'select' => 's.label',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'paid' => [
				'label' => 'Payé',
				'select' => 'sub.paid',
			],
			'expected_amount' => [
				'label' => 'Montant de l\'inscription',
				'select' => 'sub.expected_amount',
				'export' => true,
			],
			'paid_amount' => [
				'label' => 'Montant réglé',
				'select' => 'SUM(tl.credit)',
				'export' => true,
			],
			'left_amount' => [
				'label' => 'Reste à régler',
				'select' => 'CASE WHEN sub.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL
					ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'sub.date',
			],
			'expiry_date' => [
				'label' => 'Date d\'expiration',
				'select' => 'sub.expiry_date',
			],
			'id_user' => ['select' => 'sub.id_user'],
			'id_fee' => ['select' => 'sub.id_fee'],
			'id_service' => ['select' => 'sub.id_service'],
		];

		$tables = 'services_subscriptions sub
			INNER JOIN services s ON s.id = sub.id_service
			INNER JOIN users u ON u.id = sub.id_user
			LEFT JOIN services_fees sf ON sf.id = sub.id_fee
			LEFT JOIN acc_transactions_users tu ON tu.id_subscription = sub.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';

		$list = new DynamicList($columns, $tables);
		$list->orderBy('id', true);
		$list->groupBy('sub.id');
		$list->setTitle('Historique des inscriptions');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
			$row->expiry_date = \DateTime::createFromFormat('!Y-m-d', $row->expiry_date);
		});
		$list->setExportCallback(function (&$row) {
			$row->paid = $row->paid ? 'Oui' : '';
		});

		return $list;
	}

	static public function listImportColumns(): array
	{
		$number_field = DynamicFields::getNumberField();

		return [
			'id'              => 'Numéro d\'inscription',
			$number_field     => 'Numéro de membre',
			'service'         => 'Activité',
			'fee'             => 'Tarif',
			'paid'            => 'Payé ?',
			'expected_amount' => 'Montant à régler',
			'date'            => 'Date d\'inscription',
			'expiry_date'     => 'Date d\'expiration',
		];
	}

	static public function listMandatoryImportColumns(): array
	{
		$number_field = DynamicFields::getNumberField();

		return [
			$number_field,
			'service',
			'date',
		];
	}
}

Modified src/include/lib/Paheko/Upgrade.php from [e3ac5397cd] to [b25d601165].

182
183
184
185
186
187
188
189

190
191
192
193
194
195
196
182
183
184
185
186
187
188

189
190
191
192
193
194
195
196







-
+







			}

			if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc2', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0-rc2.php';
			}

			if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc5', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0-rc5.php';
				throw new UserException('Merci de faire la mise à jour vers la dernière version de la 1.3.0');
			}

			if (version_compare($v, '1.3.0-rc7', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0-rc7.php';
			}

			if (version_compare($v, '1.3.0-rc12', '<')) {
222
223
224
225
226
227
228




229
230
231
232
233
234
235
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239







+
+
+
+







			}

			if (version_compare($v, '1.3.5', '<')) {
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/migrations/1.3/1.3.5.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.4.0', '<')) {
				require ROOT . '/include/migrations/1.4/1.4.0.php';
			}

			Plugins::upgradeAllIfRequired();

			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files

Modified src/include/lib/Paheko/UserTemplate/CommonFunctions.php from [c020cd06b7] to [7337f3f3bb].

30
31
32
33
34
35
36

37
38
39
40
41

42
43
44
45
46
47
48
30
31
32
33
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
49







+




-
+







		'icon',
		'linkbutton',
		'linkmenu',
		'exportmenu',
		'delete_form',
		'edit_user_field',
		'user_field',
		'tag',
	];

	static public function input(array $params)
	{
		static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit', 'copy', 'suffix', 'prefix_title', 'prefix_help', 'prefix_required'];
		static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit', 'copy', 'suffix', 'prefix_label', 'prefix_help', 'prefix_required'];

		// Extract params and keep attributes separated
		$attributes = array_diff_key($params, array_flip($params_list));
		$params = array_intersect_key($params, array_flip($params_list));
		extract($params, \EXTR_SKIP);

		if (!isset($name, $type)) {
221
222
223
224
225
226
227
















228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246

247
248
249
250
251
252
253
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262

263
264
265
266
267
268
269
270







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+


















-
+








		$attributes_string = implode(' ', $attributes_string);

		if (isset($label)) {
			$label = htmlspecialchars((string)$label);
			$label = preg_replace_callback('!\[icon=([\w-]+)\]!', fn ($match) => self::icon(['shape' => $match[1]]), $label);
		}

		$prefix = '';

		if (!empty($params['prefix_label'])) {
			$prefix .= sprintf('<dt><label for="%s">%s</label>%s</dt>',
				$attributes['id'],
				htmlspecialchars($params['prefix_label']),
				$required_label
			);
		}

		if (!empty($params['prefix_help'])) {
			$prefix .= sprintf('<dd class="help">%s</dd>',
				htmlspecialchars($params['prefix_help'])
			);
		}

		if ($type === 'radio-btn') {
			if (!empty($attributes['disabled'])) {
				$attributes['class'] = ($attributes['class'] ?? '') . ' disabled';
			}

			$radio = self::input(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null, 'disabled' => $attributes['disabled'] ?? null]));

			$input = sprintf('<dd class="radio-btn %s">%s
				<label for="%s"><div><h3>%s</h3>%s</div></label>
			</dd>',
				$attributes['class'] ?? '',
				$radio,
				$attributes['id'],
				$label,
				isset($params['help']) ? '<p class="help">' . nl2br(htmlspecialchars($params['help'])) . '</p>' : ''
			);

			unset($help, $label);
			return $prefix . $input;
		}
		elseif ($type === 'select') {
			$input = sprintf('<select %s>', $attributes_string);

			if (empty($attributes['required']) || isset($attributes['default_empty'])) {
				$input .= sprintf('<option value="">%s</option>', $attributes['default_empty'] ?? '');
			}
353
354
355
356
357
358
359
360

361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
370
371
372
373
374
375
376

377














378
379
380
381
382
383
384







-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-







			if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) {
				$input .= sprintf('<label for="%s"></label>', $attributes['id']);
			}

			return $input;
		}

		$out = '';
		$out = $prefix;

		if (!empty($params['prefix_title'])) {
			$out .= sprintf('<dt><label for="%s">%s</label>%s</dt>',
				$attributes['id'],
				htmlspecialchars($params['prefix_title']),
				$required_label
			);
		}

		if (!empty($params['prefix_help'])) {
			$out .= sprintf('<dd class="help">%s</dd>',
				htmlspecialchars($params['prefix_help'])
			);
		}

		$label = sprintf('<label for="%s">%s</label>', $attributes['id'], $label);

		if ($type == 'radio' || $type == 'checkbox') {
			$out .= sprintf('<dd>%s %s', $input, $label);

			if (isset($help)) {
902
903
904
905
906
907
908
909






905
906
907
908
909
910
911

912
913
914
915
916
917







-
+
+
+
+
+
+

		if (!empty($params['link_name_id']) && ($name === 'identity' || ($field && $field->isName() && substr($out, 0, 2) !== '<a'))) {
			$out = sprintf('<a href="%s">%s</a>', Utils::getLocalURL('!users/details.php?id=' . (int)$params['link_name_id']), $out);
		}

		return $out;
	}
}

	static public function tag(array $params): string
	{
		return sprintf('<span class="tag" style="--tag-color: %s;">%s</span>', htmlspecialchars($params['color'] ?? '#999'), htmlspecialchars($params['label'] ?? ''));
	}
}

Modified src/include/lib/Paheko/UserTemplate/Functions.php from [7fe254a8dc] to [512bf19c21].

14
15
16
17
18
19
20
21


22
23
24
25

26
27
28
29
30
31
32
14
15
16
17
18
19
20

21
22
23
24
25

26
27
28
29
30
31
32
33







-
+
+



-
+







use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Extensions;
use Paheko\Template;
use Paheko\Utils;
use Paheko\UserException;
use Paheko\UserTemplate\UserTemplate;
use Paheko\Email\Emails;
use Paheko\Email\Addresses;
use Paheko\Email\Queue;
use Paheko\Files\Files;
use Paheko\Entities\Files\File;
use Paheko\Entities\Module;
use Paheko\Entities\Email\Email;
use Paheko\Entities\Email\Message;
use Paheko\Users\DynamicFields;
use Paheko\Users\Session;

use Paheko\Entities\Accounting\Transaction;

use const Paheko\{ROOT, WWW_URL, BASE_URL, SECRET_KEY};

443
444
445
446
447
448
449
450

451
452
453
454
455
456
457
444
445
446
447
448
449
450

451
452
453
454
455
456
457
458







-
+








		if (!count($params['to'])) {
			throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line));
		}

		foreach ($params['to'] as &$to) {
			$to = trim($to);
			Email::validateAddress($to);
			Addresses::validate($to);
		}

		unset($to);

		// Restrict sending recipients
		if (!$ut->isTrusted()) {
			$db = DB::getInstance();
487
488
489
490
491
492
493
494
495




496
497
498
499
500
501
502
488
489
490
491
492
493
494


495
496
497
498
499
500
501
502
503
504
505







-
-
+
+
+
+







					if (!$allowed) {
						throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m));
					}
				}
			}
		}

		$context = count($params['to']) == 1 ? Emails::CONTEXT_PRIVATE : Emails::CONTEXT_BULK;
		Emails::queue($context, $params['to'], null, $params['subject'], $params['body'], $attachments);
		$context = count($params['to']) === 1 ? Message::CONTEXT_PRIVATE : Message::CONTEXT_BULK;
		$message = Queue::createMessage($context, $params['subject'], $params['body'], $attachments);
		$message->setAttachments($attachments);
		$message->queueToArray($params['to']);

		if (!$ut->isTrusted()) {
			$internal += $internal_count;
			$external_count += $external_count;
		}
	}

Modified src/include/lib/Paheko/UserTemplate/Modifiers.php from [2220119724] to [e428361f7a].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
1
2
3
4
5
6
7
8
9

10
11
12
13
14
15
16
17









-
+







<?php

namespace Paheko\UserTemplate;

use Paheko\DB;
use Paheko\Utils;
use Paheko\UserException;

use Paheko\Users\DynamicFields;
use Paheko\Entities\Email\Email;
use Paheko\Email\Addresses;

use KD2\SMTP;

use KD2\Brindille;
use KD2\Brindille_Exception;

class Modifiers
91
92
93
94
95
96
97
98

99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
91
92
93
94
95
96
97

98











99
100
101
102
103
104
105







-
+
-
-
-
-
-
-
-
-
-
-
-







	static public function match($str, $pattern)
	{
		return (int) (stripos($str, $pattern) !== false);
	}

	static public function check_email($str)
	{
		if (!trim((string)$str)) {
		return Addresses::check((string)$str);
			return false;
		}

		try {
			Email::validateAddress((string)$str);
		}
		catch (UserException $e) {
			return false;
		}

		return true;
	}

	/**
	 * UTF-8 aware intelligent substr
	 * @param  string  $str         UTF-8 string
	 * @param  integer $length      Maximum string length
	 * @param  string  $placeholder Placeholder text to append at the string if it has been cut

Modified src/include/lib/Paheko/UserTemplate/Sections.php from [47a344bbd1] to [ff0321951d].

825
826
827
828
829
830
831
832
833


834
835
836

837
838
839
840
841
842

843
844
845
846
847
848

849
850
851
852
853

854
855
856
857
858

859
860
861

862
863
864
865
866
867
868
825
826
827
828
829
830
831


832
833
834
835

836
837
838
839
840
841

842
843
844
845
846
847

848
849
850
851
852

853
854
855
856
857

858
859
860

861
862
863
864
865
866
867
868







-
-
+
+


-
+





-
+





-
+




-
+




-
+


-
+








	static public function subscriptions(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$params['where'] ??= '';

		$number_field = DynamicFields::getNumberField();

		$params['select'] = sprintf('su.expiry_date, su.date, s.label, su.paid, su.expected_amount');
		$params['tables'] = 'services_users su INNER JOIN services s ON s.id = su.id_service';
		$params['select'] = sprintf('sub.expiry_date, sub.date, s.label, sub.paid, sub.expected_amount');
		$params['tables'] = 'services_subscriptions sub INNER JOIN services s ON s.id = sub.id_service';

		if (isset($params['user'])) {
			$params['where'] .= ' AND su.id_user = :id_user';
			$params['where'] .= ' AND sub.id_user = :id_user';
			$params[':id_user'] = (int) $params['user'];
			unset($params['user']);
		}

		if (isset($params['id_service'])) {
			$params['where'] .= ' AND su.id_service = :id_service';
			$params['where'] .= ' AND sub.id_service = :id_service';
			$params[':id_service'] = (int) $params['id_service'];
			unset($params['id_service']);
		}

		if (!empty($params['active'])) {
			$params['having'] = 'MAX(su.expiry_date) >= date()';
			$params['having'] = 'MAX(sub.expiry_date) >= date()';
			unset($params['active']);
		}

		if (isset($params['active']) && empty($params['active'])) {
			$params['having'] = 'MAX(su.expiry_date) < date()';
			$params['having'] = 'MAX(sub.expiry_date) < date()';
			unset($params['active']);
		}

		if (empty($params['order'])) {
			$params['order'] = 'su.id';
			$params['order'] = 'sub.id';
		}

		$params['group'] = 'su.id_user, su.id_service';
		$params['group'] = 'sub.id_user, sub.id_service';

		return self::sql($params, $tpl, $line);
	}

	static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$db = DB::getInstance();

Modified src/include/lib/Paheko/Users/AdvancedSearch.php from [c95a087831] to [08202a5bdb].

156
157
158
159
160
161
162
163

164
165
166
167
168
169
170
171
172

173
174
175
176
177
178
179
180
181

182
183
184
185
186
187
188
189
190

191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209

210
211
212
213
214
215
216
156
157
158
159
160
161
162

163
164
165
166
167
168
169
170
171

172
173
174
175
176
177
178
179
180

181
182
183
184
185
186
187
188
189

190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208

209
210
211
212
213
214
215
216







-
+








-
+








-
+








-
+


















-
+








		$columns['service'] = [
			'label'  => 'Est inscrit à l\'activité',
			'type'   => 'enum',
			'null'   => false,
			'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
			'select' => '\'Inscrit\'',
			'where'  => 'id IN (SELECT id_user FROM services_users WHERE id_service %s)',
			'where'  => 'id IN (SELECT id_user FROM services_subscriptions WHERE id_service %s)',
		];

		$columns['service_not'] = [
			'label'  => 'N\'est pas inscrit à l\'activité',
			'type'   => 'enum',
			'null'   => false,
			'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
			'select' => '\'Inscrit\'',
			'where'  => 'id NOT IN (SELECT id_user FROM services_users WHERE id_service %s)',
			'where'  => 'id NOT IN (SELECT id_user FROM services_subscriptions WHERE id_service %s)',
		];

		$columns['service_active'] = [
			'label'  => 'Est à jour de l\'activité',
			'type'   => 'enum',
			'null'   => false,
			'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
			'select' => '\'À jour\'',
			'where'  => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate >= date())',
			'where'  => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_subscriptions WHERE id_service %s GROUP BY id_user) WHERE edate >= date())',
		];

		$columns['service_expired'] = [
			'label'  => 'N\'est pas à jour de l\'activité',
			'type'   => 'enum',
			'null'   => false,
			'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'),
			'select' => '\'Expiré\'',
			'where'  => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_users WHERE id_service %s GROUP BY id_user) WHERE edate < date())',
			'where'  => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_subscriptions WHERE id_service %s GROUP BY id_user) WHERE edate < date())',
		];

		$columns['date_login'] = [
			'label' => 'Date de dernière connexion',
			'type'  => 'date',
			'null'  => true,
		];

		return $columns;
	}

	public function schemaTables(): array
	{
		return [
			'users' => 'Membres',
			'users_categories' => 'Catégories de membres',
			'services' => 'Activités',
			'services_fees' => 'Tarifs des activités',
			'services_users' => 'Inscriptions aux activités',
			'services_subscriptions' => 'Inscriptions aux activités',
		];
	}

	public function tables(): array
	{
		return array_merge(array_keys($this->schemaTables()), [
			'users_search',

Modified src/include/lib/Paheko/Users/Users.php from [93d6fdd2e4] to [d2efc79000].

57
58
59
60
61
62
63
































64
65
66
67
68
69
70
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+








	static protected function iterateEmails(array $sql, string $email_column = '_email'): \Generator
	{
		foreach (DB::getInstance()->iterate(implode(' UNION ALL ', $sql)) as $row) {
			yield $row->$email_column => $row;
		}
	}

	/**
	 * Return a list for all emails for a specific mailing checkbox
	 */
	static public function iterateEmailsByField(string $field_name, $field_value): iterable
	{
		$db = DB::getInstance();
		$field = DynamicFields::get($field_name);

		if (!$field) {
			throw new \InvalidArgumentException('Unknown field: ' . $field_name);
		}

		if (is_bool($field_value)) {
			$field_value = (int)$field_value;
		}
		else {
			$field_value = $db->quote($field_value);
		}

		$sql = [];
		$where = sprintf('%s = %d', $db->quoteIdentifier($field->name), $field_value);
		$where .= ' AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)';

		$fields = DynamicFields::getEmailFields();

		foreach ($fields as $field) {
			$sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where);
		}

		return self::iterateEmails($sql);
	}

	/**
	 * Return a list for all emails by category
	 * @param  int|null $id_category If NULL, then all categories except hidden ones will be returned
	 */
	static public function iterateEmailsByCategory(?int $id_category = null): iterable
	{
88
89
90
91
92
93
94
95

96
97
98
99
100
101
102
120
121
122
123
124
125
126

127
128
129
130
131
132
133
134







-
+







		$db = DB::getInstance();

		// Create a temporary table
		if (!$db->test('sqlite_temp_master', 'type = \'table\' AND name=\'users_active_services\'')) {
			$db->exec('DROP TABLE IF EXISTS users_active_services;
				CREATE TEMPORARY TABLE IF NOT EXISTS users_active_services (id, service);
				INSERT INTO users_active_services SELECT id_user, id_service FROM (
					SELECT id_user, id_service, MAX(expiry_date) FROM services_users
					SELECT id_user, id_service, MAX(expiry_date) FROM services_subscriptions
					WHERE expiry_date IS NULL OR expiry_date >= date()
					GROUP BY id_user, id_service
				);
				DELETE FROM users_active_services WHERE id IN (SELECT id FROM users WHERE id_category IN (SELECT id FROM users_categories WHERE hidden =1));');
		}

		$fields = DynamicFields::getEmailFields();

Deleted src/include/migrations/1.3/1.3.0-rc5.php version [04b6f67038].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42










































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko;

use Paheko\Files\Files;

/**
 * This file *only* applies to setups having updated to 1.3.0 RC1-4
 * as the filesystem handling was not working correctly
 */

$db->beginSchemaUpdate();
$db->dropIndexes();

$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc5.sql');

$db->commitSchemaUpdate();

if (FILE_STORAGE_BACKEND == 'FileSystem' && file_exists(FILE_STORAGE_CONFIG)) {
	// Trash works differently now
	$db->exec('UPDATE files SET trash = NULL WHERE trash IS NOT NULL;');

	rename(FILE_STORAGE_CONFIG, FILE_STORAGE_CONFIG . '.deprecated');
	Files::disableVersioning();

	foreach (Files::all() as $file) {
		if ($file->isDir()) {
			continue;
		}

		// Copy files from old location to new
		$path = sprintf(FILE_STORAGE_CONFIG . '.deprecated/%.2s/%1$s', md5($file->id()));

		if (!file_exists($path)) {
			$file->delete();
			continue;
		}

		$file->store(compact('path'));
		Utils::safe_unlink($path);
	}
}

Deleted src/include/migrations/1.3/1.3.0-rc5.sql version [870ceef216].

1
2
3
4
5
6
7
8








-
-
-
-
-
-
-
-
ALTER TABLE web_pages RENAME TO web_pages_old;

.read schema.sql

-- Drop foreign key constant between web_pages and files, as files can just be a cache,
-- with missing web pages directories
INSERT INTO web_pages SELECT * FROM web_pages_old;
DROP TABLE web_pages_old;

Deleted src/include/migrations/1.3/schema.sql version [65f1fda055].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616








































































































































































































































































































































































































































































































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
---
--- Main stuff
---

CREATE TABLE IF NOT EXISTS config (
-- Configuration, key/value store
	key TEXT PRIMARY KEY NOT NULL,
	value TEXT NULL
);

CREATE TABLE IF NOT EXISTS config_users_fields (
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	sort_order INTEGER NOT NULL,
	type TEXT NOT NULL,
	label TEXT NOT NULL,
	help TEXT NULL,
	required INTEGER NOT NULL DEFAULT 0,
	user_access_level INTEGER NOT NULL DEFAULT 0,
	management_access_level INTEGER NOT NULL DEFAULT 1,
	list_table INTEGER NOT NULL DEFAULT 0,
	options TEXT NULL,
	default_value TEXT NULL,
	sql TEXT NULL,
	system TEXT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);

CREATE TABLE IF NOT EXISTS plugins
(
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	label TEXT NOT NULL,
	description TEXT NULL,
	author TEXT NULL,
	author_url TEXT NULL,
	version TEXT NOT NULL,
	menu INT NOT NULL DEFAULT 0,
	home_button INT NOT NULL DEFAULT 0,
	restrict_section TEXT NULL,
	restrict_level INT NULL,
	config TEXT NULL,
	enabled INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name);

CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
	signal TEXT NOT NULL,
	plugin TEXT NOT NULL REFERENCES plugins (name),
	callback TEXT NOT NULL,
	PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS modules
-- List of modules
(
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	label TEXT NOT NULL,
	description TEXT NULL,
	author TEXT NULL,
	author_url TEXT NULL,
	menu INT NOT NULL DEFAULT 0,
	home_button INT NOT NULL DEFAULT 0,
	restrict_section TEXT NULL,
	restrict_level INT NULL,
	config TEXT NULL,
	enabled INTEGER NOT NULL DEFAULT 0,
	web INTEGER NOT NULL DEFAULT 0,
	system INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);

CREATE TABLE IF NOT EXISTS modules_templates
-- List of forms special templates
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
	name TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);

CREATE TABLE IF NOT EXISTS api_credentials
(
	id INTEGER NOT NULL PRIMARY KEY,
	label TEXT NOT NULL,
	key TEXT NOT NULL,
	secret TEXT NOT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
	last_use TEXT NULL,
	access_level INT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);

CREATE TABLE IF NOT EXISTS searches
-- Saved searches
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user
	label TEXT NOT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
	target TEXT NOT NULL, -- "users" ou "accounting"
	type TEXT NOT NULL, -- "json" ou "sql"
	content TEXT NOT NULL
);


CREATE TABLE IF NOT EXISTS compromised_passwords_cache
-- Cache des hash de mots de passe compromis
(
	hash TEXT NOT NULL PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
	prefix TEXT NOT NULL PRIMARY KEY,
	date INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS emails (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
	id INTEGER NOT NULL PRIMARY KEY,
	hash TEXT NOT NULL,
	verified INTEGER NOT NULL DEFAULT 0,
	optout INTEGER NOT NULL DEFAULT 0,
	invalid INTEGER NOT NULL DEFAULT 0,
	fail_count INTEGER NOT NULL DEFAULT 0,
	sent_count INTEGER NOT NULL DEFAULT 0,
	fail_log TEXT NULL,
	last_sent TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (hash);

CREATE TABLE IF NOT EXISTS emails_queue (
-- List of emails waiting to be sent
	id INTEGER NOT NULL PRIMARY KEY,
	sender TEXT NULL,
	recipient TEXT NOT NULL,
	recipient_hash TEXT NOT NULL,
	recipient_pgp_key TEXT NULL,
	subject TEXT NOT NULL,
	content TEXT NOT NULL,
	content_html TEXT NULL,
	sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
	sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
	context INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS emails_queue_attachments (
	id INTEGER NOT NULL PRIMARY KEY,
	id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE,
	path TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS mailings (
	id INTEGER NOT NULL PRIMARY KEY,
	subject TEXT NOT NULL,
	body TEXT NULL,
	sender_name TEXT NULL,
	sender_email TEXT NULL,
	sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
	anonymous INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);

CREATE TABLE IF NOT EXISTS mailings_recipients (
	id INTEGER NOT NULL PRIMARY KEY,
	id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE,
	email TEXT NULL,
	id_email TEXT NULL REFERENCES emails (id) ON DELETE CASCADE,
	extra_data TEXT NULL
);

CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);

---
--- Users
---

-- CREATE TABLE users (...);
-- Organization users table, dynamically created, see config_users_fields table

CREATE TABLE IF NOT EXISTS users_categories
-- Users categories, mainly used to manage rights
(
	id INTEGER PRIMARY KEY NOT NULL,
	name TEXT NOT NULL,

	-- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin
	perm_web INTEGER NOT NULL DEFAULT 1,
	perm_documents INTEGER NOT NULL DEFAULT 1,
	perm_users INTEGER NOT NULL DEFAULT 1,
	perm_accounting INTEGER NOT NULL DEFAULT 1,

	perm_subscribe INTEGER NOT NULL DEFAULT 0,
	perm_connect INTEGER NOT NULL DEFAULT 1,
	perm_config INTEGER NOT NULL DEFAULT 0,

	hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);
CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name);
CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name);

CREATE TABLE IF NOT EXISTS users_sessions
-- Permanent sessions for logged-in users
(
	selector TEXT NOT NULL PRIMARY KEY,
	hash TEXT NOT NULL,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
	expiry INT NOT NULL
);

CREATE TABLE IF NOT EXISTS logs
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
	type INTEGER NOT NULL,
	details TEXT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
	ip_address TEXT NULL
);

CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created);
CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created);
CREATE INDEX IF NOT EXISTS logs_created ON logs (created);

---
--- Services
---

CREATE TABLE IF NOT EXISTS services
-- Services types (French: cotisations)
(
	id INTEGER PRIMARY KEY NOT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
	start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
	end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
);

CREATE TABLE IF NOT EXISTS services_fees
-- Services fees
(
	id INTEGER PRIMARY KEY NOT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	amount INTEGER NULL,
	formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement)

	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
	id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
	id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS services_users
-- Records of services and fees linked to users
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service

	paid INTEGER NOT NULL DEFAULT 0,
	expected_amount INTEGER NULL,

	date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
	expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, id_fee, date);

CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);

CREATE TABLE IF NOT EXISTS services_reminders
-- Reminders for service expiry
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

	delay INTEGER NOT NULL, -- Delay in days before or after expiry date

	subject TEXT NOT NULL,
	body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Records of sent reminders, to keep track
(
	id INTEGER NOT NULL PRIMARY KEY,

	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

	sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
	due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- Accounting
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Accounting charts (plans comptables)
(
	id INTEGER NOT NULL PRIMARY KEY,
	country TEXT NOT NULL,
	code TEXT NULL, -- the code is NULL if the chart is user-created or imported
	label TEXT NOT NULL,
	archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Accounts of the charts (comptes)
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE,

	code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B...

	label TEXT NOT NULL,
	description TEXT NULL,

	position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit)
	type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc.
	user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user
	bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
	SELECT id_year, id, label, code, type, debit, credit, bookmark,
		CASE -- 3 = dynamic asset or liability depending on balance
			WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
			WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
			ELSE position
		END AS position,
		CASE
			WHEN position IN (1, 4) -- 1 = asset, 4 = expense
				OR (position = 3 AND (debit - credit) > 0)
			THEN
				debit - credit
			ELSE
				credit - debit
		END AS balance,
		CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
	FROM (
		SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark,
			SUM(l.credit) AS credit,
			SUM(l.debit) AS debit
		FROM acc_accounts a
		INNER JOIN acc_transactions_lines l ON l.id_account = a.id
		INNER JOIN acc_transactions t ON t.id = l.id_transaction
		GROUP BY t.id_year, a.id
	);

CREATE TABLE IF NOT EXISTS acc_projects
-- Analytical projects
(
	id INTEGER NOT NULL PRIMARY KEY,

	code TEXT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	archived INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code);
CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code);

CREATE TABLE IF NOT EXISTS acc_years
-- Years (exercices)
(
	id INTEGER NOT NULL PRIMARY KEY,

	label TEXT NOT NULL,

	start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
	end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),

	closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed

	id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
	UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

CREATE TABLE IF NOT EXISTS acc_transactions
-- Transactions (écritures comptables)
(
	id INTEGER PRIMARY KEY NOT NULL,

	type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced
	status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask)

	label TEXT NOT NULL,
	notes TEXT NULL,
	reference TEXT NULL, -- N° de pièce comptable

	date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),

	hash TEXT NULL,
	prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL,
	prev_hash TEXT NULL,

	id_year INTEGER NOT NULL REFERENCES acc_years(id),
	id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash);
CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference);

CREATE TABLE IF NOT EXISTS acc_transactions_lines
-- Transactions lines (lignes des écritures)
(
	id INTEGER PRIMARY KEY NOT NULL,

	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_account INTEGER NOT NULL REFERENCES acc_accounts (id),

	credit INTEGER NOT NULL,
	debit INTEGER NOT NULL,

	reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque)
	label TEXT NULL,

	reconciled INTEGER NOT NULL DEFAULT 0,

	id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL,

	CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
	CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_links
(
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE,
	id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related),
	PRIMARY KEY (id_transaction, id_related)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Linking transactions and users
(
	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,

	PRIMARY KEY (id_user, id_transaction, id_service_user)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
	id INTEGER NOT NULL PRIMARY KEY,
	path TEXT NOT NULL,
	parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE,
	name TEXT NOT NULL, -- File name
	type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
	mime TEXT NULL,
	size INT NULL,
	modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
	image INT NOT NULL DEFAULT 0,
	md5 TEXT NULL,
	trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash),

	CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
);

-- Unique index as this is used to make up a file path
CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path);
CREATE INDEX IF NOT EXISTS files_name ON files (name);
CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
CREATE INDEX IF NOT EXISTS files_trash ON files (trash);
CREATE INDEX IF NOT EXISTS files_size ON files (size);

CREATE TABLE IF NOT EXISTS files_contents
-- Files contents (empty if using another storage backend)
(
	id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	content BLOB NOT NULL
);

CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
-- Search inside files content
(
	tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
	path TEXT NOT NULL,
	title TEXT NOT NULL,
	content TEXT NULL, -- Text content
	notindexed=path
);

-- Delete/insert search item when item is deleted/inserted from files
CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN
	DELETE FROM files_search WHERE docid = OLD.rowid;
END;

CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN
	INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL);
END;

CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN
	UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid;
END;

CREATE TABLE IF NOT EXISTS acc_transactions_files
-- Link between transactions and files
(
	id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction);

CREATE TABLE IF NOT EXISTS users_files
-- Link between users and files
(
	id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user);
CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field);

CREATE TABLE IF NOT EXISTS web_pages
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE,
	uri TEXT NOT NULL, -- Page identifier
	type INTEGER NOT NULL, -- 1 = Category, 2 = Page
	status TEXT NOT NULL,
	format TEXT NOT NULL,
	published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published),
	modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
	title TEXT NOT NULL,
	content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);

CREATE TABLE IF NOT EXISTS web_pages_versions
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL,
	date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
	size INTEGER NOT NULL,
	changes INTEGER NOT NULL,
	content TEXT NOT NULL
);

Added src/include/migrations/1.4/1.4.0.php version [ad50315ca3].













1
2
3
4
5
6
7
8
9
10
11
12
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko;

use Paheko\Accounting\Charts;

Charts::resetRules(['FR']);

$db->beginSchemaUpdate();
$db->import(ROOT . '/include/migrations/1.4/1.4.0.sql');
$db->commitSchemaUpdate();

Added src/include/migrations/1.4/1.4.0.sql version [0cc6ba4452].














































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-- Delete old unmaintained plugin
DELETE FROM plugins_signals WHERE plugin = 'git_documents';
DELETE FROM plugins WHERE name = 'git_documents';

-- Fix access level of number field
UPDATE config_users_fields SET user_access_level = 1 WHERE user_access_level = 2 AND name = 'numero';

-- Update services to add archived column
ALTER TABLE services RENAME TO services_old;

CREATE TABLE IF NOT EXISTS services
-- Services types (French: cotisations)
(
	id INTEGER PRIMARY KEY NOT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
	start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
	end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))),

	archived INTEGER NOT NULL DEFAULT 0
);

INSERT INTO services
	SELECT *, CASE WHEN end_date IS NOT NULL AND end_date < datetime() THEN 1 ELSE 0 END FROM services_old;

DROP TABLE services_old;

-- Update services_reminders to add not_before_date
ALTER TABLE services_reminders RENAME TO services_reminders_old;

CREATE TABLE IF NOT EXISTS services_reminders
-- Reminders for service expiry
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

	delay INTEGER NOT NULL, -- Delay in days before or after expiry date

	subject TEXT NOT NULL,
	body TEXT NOT NULL,

	not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date
);

INSERT INTO services_reminders SELECT *, NULL FROM services_reminders_old;
DROP TABLE services_reminders_old;

ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
DROP INDEX IF EXISTS srs_index;
DROP INDEX IF EXISTS srs_reminder;
DROP INDEX IF EXISTS srs_user;

-- Allow NULL for id_reminder
CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Records of sent reminders, to keep track
(
	id INTEGER NOT NULL PRIMARY KEY,

	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL,

	sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
	due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old;
DROP TABLE services_reminders_sent_old;

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

-- Rename services_users to services_subscriptions
DROP INDEX IF EXISTS acc_transactions_users_service;
ALTER TABLE services_users RENAME TO services_subscriptions;

ALTER TABLE acc_transactions_users RENAME TO acc_transactions_users_old;


CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Linking transactions and users
(
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_subscription INTEGER NULL REFERENCES services_subscriptions (id) ON DELETE CASCADE,

	PRIMARY KEY (id_transaction, id_user, id_subscription)
);

INSERT INTO acc_transactions_users SELECT id_transaction, id_user, id_service_user FROM acc_transactions_users_old;
DROP TABLE acc_transactions_users_old;

CREATE INDEX IF NOT EXISTS acc_transactions_users_transaction ON acc_transactions_users (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_user ON acc_transactions_users (id_user);
CREATE INDEX IF NOT EXISTS acc_transactions_subscription ON acc_transactions_users (id_subscription);

-- Update mailings
ALTER TABLE mailings RENAME TO mailings_old;

DROP INDEX IF EXISTS mailings_sent;

CREATE TABLE IF NOT EXISTS mailings (
	id INTEGER NOT NULL PRIMARY KEY,
	subject TEXT NOT NULL,
	body TEXT NULL,
	target_type TEXT NULL,
	target_value TEXT NULL,
	target_label TEXT NULL,
	sender_name TEXT NULL,
	sender_email TEXT NULL,
	sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
	anonymous INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);

INSERT INTO mailings (id, subject, body, sender_name, sender_email, sent, anonymous)
	SELECT id, subject, body, sender_name, sender_email, sent, anonymous
	FROM mailings_old;

DROP TABLE mailings_old;

CREATE TABLE IF NOT EXISTS mailings_optouts (
	email_hash TEXT NOT NULL,
	target_type TEXT NOT NULL,
	target_value TEXT NOT NULL,
	target_label TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value);

ALTER TABLE emails_queue RENAME TO emails_queue_old;

CREATE TABLE IF NOT EXISTS emails_queue (
-- List of emails waiting to be sent
	id INTEGER NOT NULL PRIMARY KEY,
	sender TEXT NULL,
	recipient TEXT NOT NULL,
	recipient_hash TEXT NOT NULL,
	recipient_pgp_key TEXT NULL,
	subject TEXT NOT NULL,
	body TEXT NOT NULL,
	html_body TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(added) = added),
	status INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
	sending_started TEXT NULL CHECK (datetime(sending_started) IS NULL OR datetime(sending_started) = sending_started), -- Will be filled with the datetime when the email queue sending has started
	context INTEGER NOT NULL
);

INSERT INTO emails_queue SELECT id, sender, recipient, recipient_hash,
	recipient_pgp_key, subject, content, content_html, datetime(), sending,
	sending_started, context FROM emails_queue_old;

CREATE INDEX IF NOT EXISTS emails_queue_status ON emails_queue (status);

DROP TABLE emails_queue_old;

CREATE TABLE IF NOT EXISTS emails_addresses (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
	id INTEGER NOT NULL PRIMARY KEY,
	hash TEXT NOT NULL,
	status INTEGER NOT NULL,
	bounce_count INTEGER NOT NULL DEFAULT 0,
	sent_count INTEGER NOT NULL DEFAULT 0,
	log TEXT NULL,
	last_sent TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO emails_addresses SELECT id, hash,
	CASE
		WHEN invalid = 1 THEN -3
		WHEN optout = 1 THEN -4
		WHEN verified = 1 THEN 1
		WHEN fail_count > 5 THEN -2
		ELSE 0
	END,
	fail_count, sent_count, fail_log, last_sent, added
	FROM emails;

DROP TABLE emails;

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails_addresses (hash);

ALTER TABLE mailings_recipients RENAME TO mailings_recipients_old;

CREATE TABLE IF NOT EXISTS mailings_recipients (
	id INTEGER NOT NULL PRIMARY KEY,
	id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE,
	email TEXT NULL,
	id_email TEXT NULL REFERENCES emails_addresses (id) ON DELETE CASCADE,
	extra_data TEXT NULL
);

INSERT INTO mailings_recipients SELECT * FROM mailings_recipients_old;

DROP INDEX mailings_recipients_id;
CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);

DROP TABLE mailings_recipients_old;

Added src/include/migrations/schema.sql version [1057212392].



























































































































































































































































































































































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
---
--- This file contains the schema used when doing a fresh install
--- Any schema change must be done in this file and the migration as well!
---

CREATE TABLE IF NOT EXISTS config (
-- Configuration, key/value store
	key TEXT PRIMARY KEY NOT NULL,
	value TEXT NULL
);

CREATE TABLE IF NOT EXISTS config_users_fields (
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	sort_order INTEGER NOT NULL,
	type TEXT NOT NULL,
	label TEXT NOT NULL,
	help TEXT NULL,
	required INTEGER NOT NULL DEFAULT 0,
	user_access_level INTEGER NOT NULL DEFAULT 0,
	management_access_level INTEGER NOT NULL DEFAULT 1,
	list_table INTEGER NOT NULL DEFAULT 0,
	options TEXT NULL,
	default_value TEXT NULL,
	sql TEXT NULL,
	system TEXT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name);

CREATE TABLE IF NOT EXISTS plugins
(
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	label TEXT NOT NULL,
	description TEXT NULL,
	author TEXT NULL,
	author_url TEXT NULL,
	version TEXT NOT NULL,
	menu INT NOT NULL DEFAULT 0,
	home_button INT NOT NULL DEFAULT 0,
	restrict_section TEXT NULL,
	restrict_level INT NULL,
	config TEXT NULL,
	enabled INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name);

CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
	signal TEXT NOT NULL,
	plugin TEXT NOT NULL REFERENCES plugins (name),
	callback TEXT NOT NULL,
	PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS modules
-- List of modules
(
	id INTEGER NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	label TEXT NOT NULL,
	description TEXT NULL,
	author TEXT NULL,
	author_url TEXT NULL,
	menu INT NOT NULL DEFAULT 0,
	home_button INT NOT NULL DEFAULT 0,
	restrict_section TEXT NULL,
	restrict_level INT NULL,
	config TEXT NULL,
	enabled INTEGER NOT NULL DEFAULT 0,
	web INTEGER NOT NULL DEFAULT 0,
	system INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name);

CREATE TABLE IF NOT EXISTS modules_templates
-- List of forms special templates
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
	name TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name);

CREATE TABLE IF NOT EXISTS api_credentials
(
	id INTEGER NOT NULL PRIMARY KEY,
	label TEXT NOT NULL,
	key TEXT NOT NULL,
	secret TEXT NOT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
	last_use TEXT NULL,
	access_level INT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key);

CREATE TABLE IF NOT EXISTS searches
-- Saved searches
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user
	label TEXT NOT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
	target TEXT NOT NULL, -- "users" ou "accounting"
	type TEXT NOT NULL, -- "json" ou "sql"
	content TEXT NOT NULL
);


CREATE TABLE IF NOT EXISTS compromised_passwords_cache
-- Cache des hash de mots de passe compromis
(
	hash TEXT NOT NULL PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
	prefix TEXT NOT NULL PRIMARY KEY,
	date INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS emails_addresses (
-- List of emails addresses
-- We are not storing actual email addresses here for privacy reasons
-- So that we can keep the record (for opt-out reasons) even when the
-- email address has been removed from the users table
	id INTEGER NOT NULL PRIMARY KEY,
	hash TEXT NOT NULL,
	status INTEGER NOT NULL,
	bounce_count INTEGER NOT NULL DEFAULT 0,
	sent_count INTEGER NOT NULL DEFAULT 0,
	log TEXT NULL,
	last_sent TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails_addresses (hash);

CREATE TABLE IF NOT EXISTS emails_queue (
-- List of emails waiting to be sent
	id INTEGER NOT NULL PRIMARY KEY,
	sender TEXT NULL,
	recipient TEXT NOT NULL,
	recipient_hash TEXT NOT NULL,
	recipient_pgp_key TEXT NULL,
	subject TEXT NOT NULL,
	body TEXT NOT NULL,
	html_body TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(added) = added),
	status INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
	sending_started TEXT NULL CHECK (datetime(sending_started) IS NULL OR datetime(sending_started) = sending_started), -- Will be filled with the datetime when the email queue sending has started
	context INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS emails_queue_status ON emails_queue (status);

CREATE TABLE IF NOT EXISTS emails_queue_attachments (
	id INTEGER NOT NULL PRIMARY KEY,
	id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE,
	path TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS mailings (
	id INTEGER NOT NULL PRIMARY KEY,
	subject TEXT NOT NULL,
	body TEXT NULL,
	target_type TEXT NULL,
	target_value TEXT NULL,
	target_label TEXT NULL,
	sender_name TEXT NULL,
	sender_email TEXT NULL,
	sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent),
	anonymous INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent);

CREATE TABLE IF NOT EXISTS mailings_recipients (
	id INTEGER NOT NULL PRIMARY KEY,
	id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE,
	email TEXT NULL,
	id_email TEXT NULL REFERENCES emails_addresses (id) ON DELETE CASCADE,
	extra_data TEXT NULL
);

CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);

CREATE TABLE IF NOT EXISTS mailings_optouts (
	email_hash TEXT NOT NULL,
	target_type TEXT NOT NULL,
	target_value TEXT NOT NULL,
	target_label TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value);

---
--- Users
---

-- CREATE TABLE users (...);
-- Organization users table, dynamically created, see config_users_fields table

CREATE TABLE IF NOT EXISTS users_categories
-- Users categories, mainly used to manage rights
(
	id INTEGER PRIMARY KEY NOT NULL,
	name TEXT NOT NULL,

	-- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin
	perm_web INTEGER NOT NULL DEFAULT 1,
	perm_documents INTEGER NOT NULL DEFAULT 1,
	perm_users INTEGER NOT NULL DEFAULT 1,
	perm_accounting INTEGER NOT NULL DEFAULT 1,

	perm_subscribe INTEGER NOT NULL DEFAULT 0,
	perm_connect INTEGER NOT NULL DEFAULT 1,
	perm_config INTEGER NOT NULL DEFAULT 0,

	hidden INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden);
CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name);
CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name);

CREATE TABLE IF NOT EXISTS users_sessions
-- Permanent sessions for logged-in users
(
	selector TEXT NOT NULL PRIMARY KEY,
	hash TEXT NOT NULL,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
	expiry INT NOT NULL
);

CREATE TABLE IF NOT EXISTS logs
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
	type INTEGER NOT NULL,
	details TEXT NULL,
	created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created),
	ip_address TEXT NULL
);

CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created);
CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created);
CREATE INDEX IF NOT EXISTS logs_created ON logs (created);

---
--- Services
---

CREATE TABLE IF NOT EXISTS services
-- Services types (French: cotisations)
(
	id INTEGER PRIMARY KEY NOT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
	start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
	end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
);

CREATE TABLE IF NOT EXISTS services_fees
-- Services fees
(
	id INTEGER PRIMARY KEY NOT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	amount INTEGER NULL,
	formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement)

	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted
	id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting
	id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS services_subscriptions
-- Records of services and fees linked to users
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service

	paid INTEGER NOT NULL DEFAULT 0,
	expected_amount INTEGER NULL,

	date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
	expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS services_subscriptions_unique ON services_subscriptions (id_user, id_service, id_fee, date);

CREATE INDEX IF NOT EXISTS su_service ON services_subscriptions (id_service);
CREATE INDEX IF NOT EXISTS su_fee ON services_subscriptions (id_fee);
CREATE INDEX IF NOT EXISTS su_paid ON services_subscriptions (paid);
CREATE INDEX IF NOT EXISTS su_expiry ON services_subscriptions (expiry_date);

CREATE TABLE IF NOT EXISTS services_reminders
-- Reminders for service expiry
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

	delay INTEGER NOT NULL, -- Delay in days before or after expiry date

	subject TEXT NOT NULL,
	body TEXT NOT NULL,

	not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Records of sent reminders, to keep track
(
	id INTEGER NOT NULL PRIMARY KEY,

	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
	id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL,

	sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
	due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- Accounting
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Accounting charts (plans comptables)
(
	id INTEGER NOT NULL PRIMARY KEY,
	country TEXT NOT NULL,
	code TEXT NULL, -- the code is NULL if the chart is user-created or imported
	label TEXT NOT NULL,
	archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Accounts of the charts (comptes)
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE,

	code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B...

	label TEXT NOT NULL,
	description TEXT NULL,

	position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit)
	type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc.
	user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user
	bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);
CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code);

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
	SELECT id_year, id, label, code, type, debit, credit, bookmark,
		CASE -- 3 = dynamic asset or liability depending on balance
			WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs
			WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs
			ELSE position
		END AS position,
		CASE
			WHEN position IN (1, 4) -- 1 = asset, 4 = expense
				OR (position = 3 AND (debit - credit) > 0)
			THEN
				debit - credit
			ELSE
				credit - debit
		END AS balance,
		CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt
	FROM (
		SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark,
			SUM(l.credit) AS credit,
			SUM(l.debit) AS debit
		FROM acc_accounts a
		INNER JOIN acc_transactions_lines l ON l.id_account = a.id
		INNER JOIN acc_transactions t ON t.id = l.id_transaction
		GROUP BY t.id_year, a.id
	);

CREATE TABLE IF NOT EXISTS acc_projects
-- Analytical projects
(
	id INTEGER NOT NULL PRIMARY KEY,

	code TEXT NULL,

	label TEXT NOT NULL,
	description TEXT NULL,

	archived INTEGER NOT NULL DEFAULT 0
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code);
CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code);

CREATE TABLE IF NOT EXISTS acc_years
-- Years (exercices)
(
	id INTEGER NOT NULL PRIMARY KEY,

	label TEXT NOT NULL,

	start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
	end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),

	closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed

	id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
	UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

CREATE TABLE IF NOT EXISTS acc_transactions
-- Transactions (écritures comptables)
(
	id INTEGER PRIMARY KEY NOT NULL,

	type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced
	status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask)

	label TEXT NOT NULL,
	notes TEXT NULL,
	reference TEXT NULL, -- N° de pièce comptable

	date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),

	hash TEXT NULL,
	prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL,
	prev_hash TEXT NULL,

	id_year INTEGER NOT NULL REFERENCES acc_years(id),
	id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);
CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash);
CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference);

CREATE TABLE IF NOT EXISTS acc_transactions_lines
-- Transactions lines (lignes des écritures)
(
	id INTEGER PRIMARY KEY NOT NULL,

	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_account INTEGER NOT NULL REFERENCES acc_accounts (id),

	credit INTEGER NOT NULL,
	debit INTEGER NOT NULL,

	reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque)
	label TEXT NULL,

	reconciled INTEGER NOT NULL DEFAULT 0,

	id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL,

	CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
	CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_links
(
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE,
	id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related),
	PRIMARY KEY (id_transaction, id_related)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Linking transactions and users
(
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_subscription INTEGER NULL REFERENCES services_subscriptions (id) ON DELETE CASCADE,

	PRIMARY KEY (id_transaction, id_user, id_subscription)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_transaction ON acc_transactions_users (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_user ON acc_transactions_users (id_user);
CREATE INDEX IF NOT EXISTS acc_transactions_subscription ON acc_transactions_users (id_subscription);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
	id INTEGER NOT NULL PRIMARY KEY,
	path TEXT NOT NULL,
	parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE,
	name TEXT NOT NULL, -- File name
	type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory
	mime TEXT NULL,
	size INT NULL,
	modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
	image INT NOT NULL DEFAULT 0,
	md5 TEXT NULL,
	trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash),

	CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL))
);

-- Unique index as this is used to make up a file path
CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path);
CREATE INDEX IF NOT EXISTS files_parent ON files (parent);
CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path);
CREATE INDEX IF NOT EXISTS files_name ON files (name);
CREATE INDEX IF NOT EXISTS files_modified ON files (modified);
CREATE INDEX IF NOT EXISTS files_trash ON files (trash);
CREATE INDEX IF NOT EXISTS files_size ON files (size);

CREATE TABLE IF NOT EXISTS files_contents
-- Files contents (empty if using another storage backend)
(
	id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	content BLOB NOT NULL
);

CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4
-- Search inside files content
(
	tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012)
	path TEXT NOT NULL,
	title TEXT NOT NULL,
	content TEXT NULL, -- Text content
	notindexed=path
);

-- Delete/insert search item when item is deleted/inserted from files
CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN
	DELETE FROM files_search WHERE docid = OLD.rowid;
END;

CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN
	INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL);
END;

CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN
	UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid;
END;

CREATE TABLE IF NOT EXISTS acc_transactions_files
-- Link between transactions and files
(
	id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction);

CREATE TABLE IF NOT EXISTS users_files
-- Link between users and files
(
	id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
	id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user);
CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field);

CREATE TABLE IF NOT EXISTS web_pages
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE,
	uri TEXT NOT NULL, -- Page identifier
	type INTEGER NOT NULL, -- 1 = Category, 2 = Page
	status TEXT NOT NULL,
	format TEXT NOT NULL,
	published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published),
	modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified),
	title TEXT NOT NULL,
	content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent);
CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published);
CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title);

CREATE TABLE IF NOT EXISTS web_pages_versions
(
	id INTEGER NOT NULL PRIMARY KEY,
	id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE,
	id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL,
	date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),
	size INTEGER NOT NULL,
	changes INTEGER NOT NULL,
	content TEXT NOT NULL
);

Modified src/templates/_head.tpl from [d03c159649] to [94d4befe67].

87
88
89
90
91
92
93
94

95
96
97
98
99
100
101
87
88
89
90
91
92
93

94
95
96
97
98
99
100
101







-
+







			<li class="{if $current == 'users'} current{elseif $current_parent == 'users'} current_parent{/if}"><h3><a href="{$admin_uri}users/" accesskey="U">{icon shape="users"}<b>Membres</b></a></h3>
			<ul>
			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				<li{if $current == 'users/new'} class="current"{/if}><a href="{$admin_uri}users/new.php" accesskey="A">Ajouter</a></li>
			{/if}
				<li{if $current == 'users/services'} class="current"{/if}><a href="{$admin_uri}services/">Activités &amp; cotisations</a></li>
			{if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				<li{if $current == 'users/mailing'} class="current"{/if}><a href="{$admin_uri}users/mailing/">Messages collectifs</a></li>
				<li{if $current == 'users/mailing'} class="current"{/if}><a href="{$admin_uri}users/email/mailing/">Messages collectifs</a></li>
			{/if}
			</ul>
			</li>
		{/if}
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
			<li class="{if $current == 'acc'} current{elseif $current_parent == 'acc'} current_parent{/if}"><h3><a href="{$admin_uri}acc/">{icon shape="money"}<b>Comptabilité</b></a></h3>
			<ul>

Modified src/templates/acc/transactions/details.tpl from [f4d950d530] to [b90ac4724c].

220
221
222
223
224
225
226

227

228
229
230
231
232
233
234
220
221
222
223
224
225
226
227

228
229
230
231
232
233
234
235







+
-
+







		<table class="list">
			<caption>Inscriptions liées</caption>
			<tbody>
			{foreach from=$linked_subscriptions item="s"}
				<tr>
					<td class="num">{link href="!users/details.php?id=%d"|args:$s.id_user label=$s.user_number}</td>
					<td>{$s.user_identity}</td>
					<td><small>{$s.label}</small></td>
					<td class="actions">{linkbutton href="!services/user/?id=%d&only=%s"|args:$s.id_user:$s.id_subscription label="Inscription" shape="eye"}</td>
					<td class="actions">{linkbutton href="!users/subscriptions.php?id=%d&only=%s"|args:$s.id_user:$s.id_subscription label="Inscription" shape="eye"}</td>
				</tr>
			{/foreach}
			</tbody>
		</table>
	{/if}

	{if count($linked_transactions)}

Deleted src/templates/acc/transactions/service_user.tpl version [6640beb19f].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38






































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}

<nav class="tabs">
	{linkbutton href="!users/details.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="user"}
	{linkbutton href="!services/user/payment.php?id=%d"|args:$service_user_id label="Nouveau règlement" shape="plus" target="_dialog"}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
	{linkbutton href="!services/user/link.php?id=%d"|args:$service_user_id label="Lier à une écriture" shape="check" target="_dialog"}
	{/if}
</nav>

{if empty($balance)}
	<p class="alert block">Aucune écriture n'est liée à cette inscription.</p>
{else}
	{include file="acc/reports/_journal.tpl"}

	<h2 class="ruler">Solde des comptes</h2>

	<table class="list">
		<thead>
			<tr>
				<td>Numéro</td>
				<th>Compte</th>
				<td class="money">Solde</td>
			</tr>
		</thead>
		<tbody>
		{foreach from=$balance item="account"}
			<tr>
				<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
				<th>{$account.label}</th>
				<td class="money">{$account.balance|raw|money:false}</td>
			</tr>
		{/foreach}
		</tbody>
	</table>
{/if}

{include file="_foot.tpl"}

Added src/templates/acc/transactions/subscription.tpl version [970268d6e6].







































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}

<nav class="tabs">
	{linkbutton href="!users/details.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="user"}
	{linkbutton href="!services/subscription/payment.php?id=%d"|args:$subscription_id label="Nouveau règlement" shape="plus" target="_dialog"}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
	{linkbutton href="!services/subscription/link.php?id=%d"|args:$subscription_id label="Lier à une écriture" shape="check" target="_dialog"}
	{/if}
</nav>

{if empty($balance)}
	<p class="alert block">Aucune écriture n'est liée à cette inscription.</p>
{else}
	{include file="acc/reports/_journal.tpl"}

	<h2 class="ruler">Solde des comptes</h2>

	<table class="list">
		<thead>
			<tr>
				<td>Numéro</td>
				<th>Compte</th>
				<td class="money">Solde</td>
			</tr>
		</thead>
		<tbody>
		{foreach from=$balance item="account"}
			<tr>
				<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td>
				<th>{$account.label}</th>
				<td class="money">{$account.balance|raw|money:false}</td>
			</tr>
		{/foreach}
		</tbody>
	</table>
{/if}

{include file="_foot.tpl"}

Modified src/templates/config/_menu.tpl from [8e80c00714] to [e3a976c730].

1
2
3
4
5
6
7
8
9
10
11
12
13
14


15
16
17
18
19
20
21
22
23




24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12


13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34












-
-
+
+









+
+
+
+







{if !$dialog}
<?php $sub_current ??= null; ?>
<nav class="tabs">
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Configuration</a></li>
		<li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}config/users/">Membres</a></li>
		<li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li>
		<li{if $current == 'ext'} class="current"{/if}><a href="{$admin_url}config/ext/">Extensions</a></li>
		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'users'}
		{if $sub_current == 'fields'}
	{if $current === 'users'}
		{if $sub_current === 'fields'}
			<aside>{linkbutton shape="plus" label="Ajouter un champ" href="new.php"}</aside>
		{/if}

		<ul class="sub">
			<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/users/">Préférences</a></li>
			<li{if $sub_current == 'fields'} class="current"{/if}><a href="{$admin_url}config/fields/">Fiche des membres</a></li>
			<li{if $sub_current == 'categories'} class="current"{/if}><a href="{$admin_url}config/categories/">Catégories &amp; droits des membres</a></li>
		</ul>
	{elseif $current == 'advanced'}
		{if $sub_current === 'api'}
			<aside>{linkbutton shape="help" label="Documentation de l'API" href=$api_doc_url target="_dialog"}</aside>
		{/if}

		<ul class="sub">
			<li{if $sub_current == 'audit'} class="current"{/if}><a href="{$admin_url}config/advanced/audit.php">Journal d'audit</a></li>
			<li{if $sub_current == 'api'} class="current"{/if}><a href="{$admin_url}config/advanced/api.php">Accès à l'API</a></li>
			<li{if $sub_current == 'sql'} class="current"{/if}><a href="{$admin_url}config/advanced/sql.php">SQL</a></li>
			{if ENABLE_TECH_DETAILS}
				<li{if $sub_current == 'errors'} class="current"{/if}><a href="{$admin_url}config/advanced/errors.php">Erreurs système</a></li>
				{if SQL_DEBUG}

Modified src/templates/config/advanced/api.tpl from [d8e3b6d3d4] to [2a2af2a699].

44
45
46
47
48
49
50
51

52
53
54
55
56
57
58
59
44
45
46
47
48
49
50

51

52
53
54
55
56
57
58







-
+
-







</form>
{/if}

<form method="post" action="">
	<fieldset>
		<legend>Créer un nouvel identifiant</legend>
		<p class="help">
			Cet identifiant vous permettra de faire des requêtes vers l'API, pour modifier ou récupérer les informations de votre association.<br />
			Cet identifiant vous permettra de faire des requêtes vers <a href="{$api_doc_url}" target="_dialog">l'API</a>, pour modifier ou récupérer les informations de votre association.<br />
			{linkbutton shape="help" label="Documentation de l'API" href="%swiki?name=API"|args:$website}
		</p>
		<dl>
			{input type="text" name="label" label="Description" required=true}
			{input type="text" name="key" label="Identifiant" help="Seules les lettres minuscules, chiffres et tirets bas sont acceptés." pattern="[a-z0-9_]+" required=true default=$default_key}
			{input type="text" label="Mot de passe" default=$secret readonly="readonly" help="Ce mot de passe ne sera plus affiché, il est conseillé de le copier/coller et l'enregistrer de votre côté." name="secret" copy=true}
			{input type="select" required=true label="Autorisation d'accès" options=$access_levels name="access_level"}
		</dl>

Modified src/templates/services/_nav.tpl from [1d93ffcca6] to [289e6f2205].

1
2
3
4
5
6
7









8
9
10
11
12

13
14
15

16
17
18
19

20
21
22


23
24
25
26
27
28
29
1
2
3




4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


20
21
22
23

24
25


26
27
28
29
30
31
32
33
34



-
-
-
-
+
+
+
+
+
+
+
+
+





+

-
-
+



-
+

-
-
+
+







{if !$dialog}
<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current != 'reminders'}
			{linkbutton href="!services/user/add.php" label="Inscrire à une activité" shape="plus"}
		{elseif $current == 'reminders'}
			{linkbutton href="!services/reminders/new.php" label="Nouveau rappel automatique" shape="plus" target="_dialog"}
		{if $current === 'history' || $current === 'import'}
			{linkbutton href="!services/import.php" label="Import" shape="import"}
			{if $current === 'history'}
				{exportmenu right=true}
			{/if}
		{elseif $current === 'reminders'}
			{linkbutton href="!services/reminders/new.php" label="Nouveau rappel automatique" shape="plus"}
		{elseif $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
			{linkbutton href="!services/subscription/select.php" label="Inscrire à une activité" shape="plus"}
		{/if}
	</aside>

	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}services/">Activités et cotisations</a></li>
		<li{if $current == 'history'} class="current"{/if}><a href="{$admin_url}services/history.php">Inscriptions</a></li>
		{if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			<li{if $current == 'import'} class="current"{/if}><a href="{$admin_url}services/import.php">Import</a></li>
			<li{if $current == 'reminders'} class="current"{/if}><a href="{$admin_url}services/reminders/">Gestion des rappels automatiques</a></li>
			<li{if $current == 'reminders'} class="current"{/if}><a href="{$admin_url}services/reminders/">Rappels automatiques</a></li>
		{/if}
	</ul>

	{if !empty($has_old_services)}
	{if !empty($has_archived_services)}
	<ul class="sub">
		<li{if !$show_old_services} class="current"{/if}>{link href="!services/" label="Activités courantes"}</li>
		<li{if $show_old_services} class="current"{/if}>{link href="!services/?old=1" label="Activités passées"}</li>
		<li{if !$show_archived_services} class="current"{/if}>{link href="!services/" label="Activités courantes"}</li>
		<li{if $show_archived_services} class="current"{/if}>{link href="!services/?archived=1" label="Activités archivées"}</li>
	</ul>
	{/if}

	{if isset($current_service)}
	<ul class="sub">
		<li class="title">
			{$current_service->long_label()}

Modified src/templates/services/_service_form.tpl from [78656695f6] to [7e9c55acbd].

1
2
3
4
5
6
7
8
9





10
11
12
13
14
15
16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21









+
+
+
+
+







{form_errors}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input name="label" type="text" required=1 label="Libellé" source=$service}
			{input name="description" type="textarea" label="Description" source=$service}

			{if $service && $service->exists()}
				{input type="checkbox" name="archived" value=1 label="Archiver cette activité" source=$service}
				<dd class="help">Si coché, les inscrits ne recevront plus de rappels, l'activité ne sera plus visible sur la fiche des membres, il ne sera plus possible d'y inscrire des membres.</dd>
			{/if}

			<dt><label for="f_periodicite_jours">Durée de validité</label> <b title="Champ obligatoire">(obligatoire)</b></dt>

			{if $service && $service->exists()}
			<dd class="help">Attention, une modification de la durée renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.</dd>
			{/if}

Modified src/templates/services/details.tpl from [814e435b61] to [37b6dda5e5].

56
57
58
59
60
61
62
63

64
65
66
67
68
69
70
56
57
58
59
60
61
62

63
64
65
66
67
68
69
70







-
+







				{/if}
			</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{$row.fee}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user}
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!users/subscriptions.php?id=%d"|args:$row.id_user}
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>
	{if $can_action}

Modified src/templates/services/fees/details.tpl from [01c98ab39a] to [0040e609a0].

39
40
41
42
43
44
45
46

47
48
49
50
51
52
53
39
40
41
42
43
44
45

46
47
48
49
50
51
52
53







-
+







			<td class="check">{input type="checkbox" name="selected[]" value=$row.id_user}</td>
			{/if}
			<th>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}</th>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td class="money">{if null === $row.paid_amount}<em title="Aucune écriture n'est liée à cette inscription">—</em>{else}{$row.paid_amount|raw|money_currency}{/if}</td>
			<td>{$row.date|date_short}</td>
			<td class="actions">
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!services/user/?id=%d"|args:$row.id_user}
				{linkbutton shape="user" label="Toutes les activités de ce membre" href="!users/subscriptions.php?id=%d"|args:$row.id_user}
				{linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user}
			</td>
		</tr>
	{/foreach}

	</tbody>

Modified src/templates/services/fees/index.tpl from [46e7db2d1b] to [5ace296f44].

1
2
3
4
5
6
7

8





9
10
11
12
13
14
15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21







+

+
+
+
+
+







{include file="_head.tpl" title="%s — Tarifs"|args:$service.label current="users/services"}

{include file="services/_nav.tpl" current="index" current_service=$service service_page="index"}


{if $list->count()}
	{include file="common/dynamic_list_head.tpl"}
			<?php $total = ['nb_users_ok' => 0, 'nb_users_expired' => 0, 'nb_users_unpaid' => 0]; ?>
			{foreach from=$list->iterate() item="row"}
				<?php
				foreach ($total as $key => $v) {
					$total[$key] += $row->$key;
				}
				?>
				<tr>
					<th><a href="details.php?id={$row.id}">{$row.label}</a></th>
					<td>
						{if $row.formula}
							Formule
						{elseif $row.amount}
							{$row.amount|money_currency|raw}
26
27
28
29
30
31
32









33
34
35
36
37
38
39
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54







+
+
+
+
+
+
+
+
+







							{linkbutton shape="edit" label="Modifier" href="!services/fees/edit.php?id=%d"|args:$row.id}
							{linkbutton shape="delete" label="Supprimer" href="!services/fees/delete.php?id=%d"|args:$row.id}
						{/if}
					</td>
				</tr>
			{/foreach}
		</tbody>
		<tfoot>
			<tr>
				<td colspan="2">Total</td>
				<td class="num">{$total.nb_users_ok}</td>
				<td class="num">{$total.nb_users_expired}</td>
				<td class="num">{$total.nb_users_unpaid}</td>
				<td></td>
			</tr>
		</tfoot>
	</table>

	{$list->getHTMLPagination()|raw}
{else}
	<p class="block alert">
		Il n'y a aucun tarif enregistré. Créez un premier tarif pour l'activité «&nbsp;{$service.label}&nbsp;» pour pouvoir y inscrire des membres.
	</p>

Added src/templates/services/history.tpl version [ca94324006].






























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Inscriptions" current="users/services"}

{include file="services/_nav.tpl" current="history" service=null fee=null}

{if $list->count()}
	{include file="common/dynamic_list_head.tpl"}
			{foreach from=$list->iterate() item="row"}
				<tr>
					<td>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.name}</td>
					<th><a href="details.php?id={$row.id_service}">{$row.service}</a></th>
					<th><a href="fees/?id={$row.id_fee}">{$row.fee}</a></th>
					<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
					<td>{$row.left_amount|money_html}</td>
					<td>{$row.date|date_short}</td>
					<td>{$row.expiry_date|date_short}</td>
					<td class="actions">
						{linkbutton href="!users/subscriptions.php?id=%d&only=%d"|args:$row.id_user:$row.id label="Détails" shape="eye"}
					</td>
				</tr>
			{/foreach}
		</tbody>
	</table>

	{$list->getHTMLPagination()|raw}
{else}
	<p class="block alert">Il n'y a aucune inscription enregistrée.</p>
{/if}

{include file="_foot.tpl"}

Modified src/templates/services/import.tpl from [4484803cc7] to [5cf2f4904f].

1

2
3
4
5
6
7
8

1
2
3
4
5
6
7
8
-
+







{include file="_head.tpl" title="Importer des inscriptions" current="users"}
{include file="_head.tpl" title="Importer des inscriptions" current="users/services"}

{include file="services/_nav.tpl" current="import" service=null fee=null}

{form_errors}

{if $_GET.msg == 'OK'}
	<p class="block confirm">
28
29
30
31
32
33
34
35

36
37
38
39
40
41
42
43
44
45
46
47
48
28
29
30
31
32
33
34

35
36
37
38
39
40
41
42
43
44
45
46
47
48







-
+













		Ce formulaire permet d'importer les inscriptions des membres aux activités.
	</p>

	<fieldset>
		<legend>Importer depuis un fichier</legend>
		<dl>
			{input type="file" name="file" label="Fichier à importer" required=true accept="csv"}
			{include file="common/_csv_help.tpl" csv=$csv}
			{include file="common/_csv_help.tpl" csv=$csv more_text="Si le numéro d'inscription est fourni, l'inscription correspondante sera mise à jour."}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="load" label="Charger le fichier" shape="right" class="main"}
	</p>
{/if}


</form>

{include file="_foot.tpl"}

Modified src/templates/services/index.tpl from [1156c1e137] to [74054465a9].

37
38
39
40
41
42
43
44

45
46
47
48
37
38
39
40
41
42
43

44
45
46
47
48







-
+




	</table>

	{$list->getHTMLPagination()|raw}
{else}
	<p class="block alert">Il n'y a aucune activité enregistrée.</p>
{/if}

{if empty($show_old_services) && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
{if empty($show_archived_services) && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
	{include file="services/_service_form.tpl" legend="Ajouter une activité" service=null period=0}
{/if}

{include file="_foot.tpl"}

Modified src/templates/services/reminders/_form.tpl from [a0f419c655] to [24c866413a].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22















23
24
25
26
27
28
29
1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43








-













+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







{form_errors}

<form method="post" action="{$self_url}">

	<fieldset>
		<legend>{$legend}</legend>
		<dl>
			{input type="select" name="id_service" options=$services_list label="Activité associée au rappel" required=1 source=$reminder}
			{input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"}

			<dt><label for="f_delay_type_0">Délai d'envoi</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
			{input type="radio" name="delay_type" value=0 default=$delay_type label="Le jour de l'expiration de l'activité"}
			<dd>
				{input type="radio" name="delay_type" value=1 default=$delay_type}
				{input type="number" name="delay_before" min=1 max=999 default=$delay_before size=4}
				<label for="f_delay_type_1">jours <strong>avant</strong> expiration</label>
			</dd>
			<dd>
				{input type="radio" name="delay_type" value=2 default=$delay_type}
				{input type="number" name="delay_after" min=1 max=999 size=4 default=$delay_after}
				<label for="f_delay_type_2">jours <strong>après</strong> expiration</label>
			</dd>

			{if !$reminder->exists()}
				<?php $yes_before = ($reminder->not_before_date ?? null) === null; ?>
				{input type="radio" name="yes_before" value=1 default=$yes_before prefix_label="Envoyer ce rappel…" prefix_required=true label="À tous les membres" help="Même si leur inscription a expiré il y a longtemps, sauf s'ils ont déjà reçu un rappel pour cette activité"}
				{input type="radio" name="yes_before" value=0 default=$yes_before label="Seulement aux membres dont l'inscription n'a pas encore expiré" help="Seuls les inscriptions expirant dans le futur seront concernées"}
			{else}
				<dt><strong>Restriction d'envoi</strong></dt>
				{if $reminder.not_before_date}
					<dd>Aucun rappel ne sera envoyé aux inscriptions expirant avant le {$reminder.not_before_date|date_short}
				{else}
					<dd>Aucune restriction. Tous les membres recevront ce rappel, selon le délai choisi.</dd>
				{/if}
			{/if}

			{input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"}
			{input type="textarea" name="body" required=1 source=$reminder label="Texte du message envoyé" cols="90" rows="15"}
			<dd class="help">
				Il est possible d'utiliser les mots-clés suivant dans le corps du mail, ils seront remplacés lors de l'envoi&nbsp;:
				{literal}
				<table class="list auto">
					<tr>
						<th>{{$label}}</th>

Modified src/templates/services/reminders/delete.tpl from [5e648894f4] to [95bcd98fcf].

1
2
3
4
5
6
7
8

9
10
1
2
3
4
5
6
7

8
9
10







-
+


{include file="_head.tpl" title="Supprimer un rappel automatique" current="users/services"}

{include file="services/_nav.tpl" current="reminders"}

{include file="common/delete_form.tpl"
	legend="Supprimer ce rappel automatique ?"
	warning="Êtes-vous sûr de vouloir supprimer le rappel « %s » ?"|args:$reminder.subject
	alert="Attention, cela supprimera également l'historique des emails envoyés par ce rappel."}
	confirm="Cocher cette case pour supprimer aussi l'historique des messages envoyés."}

{include file="_foot.tpl"}

Modified src/templates/services/reminders/details.tpl from [9bccdf4d03] to [827126e934].

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

44
45
46
47

48
49
50
51
52
53
54
20
21
22
23
24
25
26



27
28
29
30
31
32
33
34
35
36
37
38
39

40
41


42
43
44
45
46
47
48
49
50







-
-
-













-
+

-
-

+







			{$list->count()}
		</dd>
	{elseif $current_list === 'pending'}
		<dt>Nombre de rappels à envoyer</dt>
		<dd>
			{$list->count()}
		</dd>
		{if USE_CRON && $list->count()}
			<dd class="help">Ces rappels seront envoyés dans les prochaines 24 heures.</dd>
		{/if}
	{/if}
</dl>

{if $list->count()}
	{if $current_list === 'pending'}
		<p class="help">Note : cette liste ne prend pas en compte les membres qui ont une adresse e-mail invalide, ou qui se sont désinscrit des envois de messages.</p>
	{/if}

	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
			<tr>
				<th>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}</th>
				{if $row.expiry_date}
				{if $current_list === 'pending'}
					<td>{$row.expiry_date|date_short}</td>
				{else}
					<td>{$row.date|date_short}</td>
				{/if}
				<td>{$row.reminder_date|date_short}</td>
				<td class="actions">
				{if $current_list === 'pending'}
					{linkbutton href="preview.php?id_user=%d&id_reminder=%d"|args:$row.id_user:$reminder.id shape="eye" label="Prévisualiser" target="_dialog"}
				{/if}
				</td>
			</tr>
		{/foreach}

Modified src/templates/services/reminders/user.tpl from [8f1d568b5d] to [b9222789b3].

8
9
10
11
12
13
14
15

16
17
18
19
20
21
22
8
9
10
11
12
13
14

15
16
17
18
19
20
21
22







-
+








		{foreach from=$list->iterate() item="row"}
			<tr>
				<th>{$row.label}</th>
				<td>{if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}</td>
				<td>{$row.date|date_short}</td>
				<td>
					{linkbutton shape="menu" label="Inscriptions après ce rappel" href="!services/user/?id=%d&after=%s"|args:$user_id,$row.date}
					{linkbutton shape="menu" label="Inscriptions après ce rappel" href="!users/subscriptions.php?id=%d&after=%s"|args:$user_id,$row.date}
				</td>
			</tr>
		{/foreach}

		</tbody>
	</table>

Added src/templates/services/subscription/_choice_form.tpl version [2bdf0b250d].
































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<dl>

	<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>

	{foreach from=$grouped_services item="service"}
		<dd class="radio-btn">
			{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null}
			<label for="f_id_service_{$service.id}">
				<div>
					<h3>{$service.label}</h3>
					<p>
						{if $service.duration}
							{$service.duration} jours
						{elseif $service.start_date}
							du {$service.start_date|date_short} au {$service.end_date|date_short}
						{else}
							ponctuelle
						{/if}
					</p>
					{if $service.description}
					<p class="help">
						{$service.description|escape|nl2br}
					</p>
					{/if}
				</div>
			</label>
		</dd>
	{foreachelse}
		<dd><p class="error block">Aucune activité trouvée</p></dd>
	{/foreach}

</dl>

{foreach from=$grouped_services item="service"}
<?php if (!count($service->fees)) { continue; } ?>
<dl data-service="s{$service.id}">
	<dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt>
	{foreach from=$service.fees key="service_id" item="fee"}
	<dd class="radio-btn">
		{input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null}
		<label for="f_id_fee_{$fee.id}">
			<div>
				<h3>{$fee.label}</h3>
				<p>
					{if !$fee.user_amount}
						prix libre ou gratuit
					{elseif $fee.user_amount && $fee.formula}
						<strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé)
					{elseif $fee.user_amount}
						<strong>{$fee.user_amount|raw|money_currency}</strong>
					{/if}
				</p>
				{if $fee.description}
				<p class="help">
					{$fee.description|escape|nl2br}
				</p>
				{/if}
			</div>
		</label>
	</dd>
	{/foreach}
</dl>
{/foreach}

Added src/templates/services/subscription/_form.tpl version [a7a08ba9d4].



























































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
assert(isset($create) && is_bool($create));
assert(isset($form_url) && is_string($form_url));
assert(isset($today) && $today instanceof \DateTimeInterface);
assert($create === false || isset($account_targets));
assert(isset($grouped_services) && is_array($grouped_services));
?>

<form method="post" action="{$self_url}" data-focus="1" data-create="{$create|escape:json}">

	<fieldset>
		<legend>Inscrire à une activité</legend>

		<dl>
		{if $create && $users}
			<dt>
				Membres à inscrire
			</dt>
			<dd>
				<details>
					<summary>{{%n membre sélectionné.}{%n membres sélectionnés.} n=$users|count}</summary>
					<table>
						{foreach from=$users key="id" item="name"}
						<tr>
							<td>
								<input type="hidden" name="users[{$id}]" value="{$name}" />
								{if !empty($allow_users_edit)}
								{button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"}
								{/if}
							</td>
							<th>
								{$name}
							</th>
						</tr>
						{/foreach}
					</table>
				</details>
			</dd>
		{elseif $create && $copy_service}
			<dt>Recopier depuis l'activité</dt>
			<dd><strong>{$copy_service.label}</strong><input type="hidden" name="copy" value="s{$copy_service.id}" /></dd>
			<dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd>
		{elseif $create && $copy_fee}
			<dt>Recopier depuis le tarif</dt>
			<dd><strong>{$copy_fee->service()->label} — {$copy_fee.label}</strong><input type="hidden" name="copy" value="f{$copy_fee.id}" /></dd>
			<dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd>
		{/if}

			<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>

			{foreach from=$grouped_services item="service"}
				<dd class="radio-btn">
					{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$subscription}
					<label for="f_id_service_{$service.id}">
						<div>
							<h3>{$service.label}</h3>
							<p>
								{if $service.duration}
									{$service.duration} jours
								{elseif $service.start_date}
									du {$service.start_date|date_short} au {$service.end_date|date_short}
								{else}
									ponctuelle
								{/if}
							</p>
							{if $service.description}
							<p class="help">
								{$service.description|escape|nl2br}
							</p>
							{/if}
						</div>
					</label>
				</dd>
			{foreachelse}
				<dd><p class="error block">Aucune activité trouvée</p></dd>
			{/foreach}

		</dl>

		{foreach from=$grouped_services item="service"}
		<?php if (!count($service->fees)) { continue; } ?>
		<dl data-service="s{$service.id}">
			<dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt>
			{foreach from=$service.fees key="service_id" item="fee"}
			<dd class="radio-btn">
				{input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null data-project=$fee.id_project source=$subscription}
				<label for="f_id_fee_{$fee.id}">
					<div>
						<h3>{$fee.label}</h3>
						<p>
							{if $fee.user_amount && $fee.formula}
								<strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé)
							{elseif $fee.formula}
								montant calculé, variable selon les membres
							{elseif $fee.user_amount}
								<strong>{$fee.user_amount|raw|money_currency}</strong>
							{else}
								prix libre ou gratuit
							{/if}
						</p>
						{if $fee.description}
						<p class="help">
							{$fee.description|escape|nl2br}
						</p>
						{/if}
					</div>
				</label>
			</dd>
			{/foreach}
		</dl>
		{/foreach}

	</fieldset>


	</fieldset>

	<fieldset>
		<legend>Détails</legend>
		<dl>
			{input type="date" name="date" required=1 default=$today source=$subscription label="Date d'inscription"}
			{input type="date" name="expiry_date" source=$subscription label="Date d'expiration de l'inscription"}
			{input type="checkbox" name="paid" value="1" source=$subscription default="1" label="Marquer cette inscription comme payée"}
			<dd class="help">Décocher cette case pour pouvoir suivre les règlements de personnes qui payent en plusieurs fois. Il sera possible de cocher cette case lorsque le solde aura été réglé.</dd>
		</dl>
	</fieldset>

	{if $create}
	<fieldset class="accounting">
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>

		<dl>
		{if !empty($users)}
		<dd class="help">Une écriture sera créée pour chaque membre inscrit.</dd>
		{/if}

			{input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=0"|args:$account_targets name="account_selector" label="Compte de règlement" required=true}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{input type="textarea" name="notes" label="Remarques"}
			{if count($projects) > 0}
				{input type="select" options=$projects name="id_project" label="Projet analytique" required=false default_empty="— Aucun —"}
			{/if}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

Added src/templates/services/subscription/delete.tpl version [9a92a85389].










1
2
3
4
5
6
7
8
9
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="%s : Supprimer une inscription"|args:$user_name current="users/services"}

{include file="common/delete_form.tpl"
	legend="Supprimer l'inscription ?"
	warning="Êtes-vous sûr de vouloir supprimer l'inscription ?"
	alert="Les écritures comptables liées à cette inscription ne seront pas supprimées, la comptabilité demeurera inchangée."
	info="%s – à « %s — %s »"|args:$user_name,$service_name,$fee_name}

{include file="_foot.tpl"}

Added src/templates/services/subscription/edit.tpl version [5a188a656c].








1
2
3
4
5
6
7
+
+
+
+
+
+
+
{include file="_head.tpl" title="Modifier une inscription" current="users/services"}

{form_errors}

{include file="services/subscription/_form.tpl" create=false}

{include file="_foot.tpl"}

Added src/templates/services/subscription/link.tpl version [6f582c8e1f].























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Lier à une écriture</legend>

		<dl>
			{input type="number" label="Numéro de l'écriture" name="id_transaction" required=true}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="_foot.tpl"}

Added src/templates/services/subscription/new.tpl version [03b96c821f].












1
2
3
4
5
6
7
8
9
10
11
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Inscrire à une activité" current="users/services"}

{if !$dialog}
{include file="services/_nav.tpl" current="save" fee=null service=null}
{/if}

{form_errors}

{include file="services/subscription/_form.tpl" create=true}

{include file="_foot.tpl"}

Added src/templates/services/subscription/payment.tpl version [d5e1991249].


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Enregistrer un règlement" current="users/services"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Enregistrer un règlement</legend>

		<dl>
			<dt>Membre sélectionné</dt>
			<dd><h3>{$user_name}</h3></dd>
			<dt><strong>Inscription</strong></dt>
			{input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"}
			{input type="date" name="date" label="Date" required=1 source=$su}
			{input type="money" name="amount" label="Montant réglé par le membre" required=1}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$account_targets,$fee.id_year name="account_selector" label="Compte de règlement" required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{if count($projects) > 0}
				{input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"}
			{/if}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="_foot.tpl"}

Added src/templates/services/subscription/select.tpl version [58681029af].































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Inscrire à une activité" current="membres/services"}

{include file="services/_nav.tpl" current="save" fee=null service=null}

{form_errors}

<form method="post" action="new.php" data-focus="button">

	<fieldset>
		<legend>Inscrire à une activité</legend>
		<dl>
			{input type="radio-btn" name="choice" value="1" label="Sélectionner des membres" default=1}
			{input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."}
			{input type="radio-btn" name="choice" value="3" label="Tous les membres d'une catégorie"}
		</dl>
	</fieldset>

	<fieldset class="c1">
		<legend>Inscrire des membres</legend>
		<dl>
			{input type="list" name="users" required=true label="Membres à inscrire" target="!users/selector.php" multiple=true}
		</dl>
	</fieldset>

	<fieldset class="c2">
		<legend>Recopier depuis une activité</legend>
		<dl>
			{input type="select_groups" name="copy" label="Activité à recopier" options=$services required=true default=0}
			{input type="checkbox" name="copy_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"}
		</dl>
	</fieldset>

	<fieldset class="c3">
		<legend>Tous les membres d'une catégorie</legend>
		<dl>
			{input type="select" name="category" label="Catégorie à inscrire" options=$categories required=true}
		</dl>
	</fieldset>

	<p class="submit">
		<input type="hidden" name="paid" value="1" />
		{button type="submit" name="next" label="Continuer" shape="right" class="main"}
	</p>
</form>

<script type="text/javascript">
{literal}
function selectChoice() {
	let choice = $('#f_choice_1').form.choice.value;
	g.toggle('.c1', choice == 1);
	g.toggle('.c2', choice == 2);
	g.toggle('.c3', choice == 3);
}

$('#f_choice_1').onchange = selectChoice;
$('#f_choice_2').onchange = selectChoice;
$('#f_choice_3').onchange = selectChoice;
selectChoice();
{/literal}
</script>

{include file="_foot.tpl"}

Deleted src/templates/services/user/_choice_form.tpl version [2bdf0b250d].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<dl>

	<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>

	{foreach from=$grouped_services item="service"}
		<dd class="radio-btn">
			{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null}
			<label for="f_id_service_{$service.id}">
				<div>
					<h3>{$service.label}</h3>
					<p>
						{if $service.duration}
							{$service.duration} jours
						{elseif $service.start_date}
							du {$service.start_date|date_short} au {$service.end_date|date_short}
						{else}
							ponctuelle
						{/if}
					</p>
					{if $service.description}
					<p class="help">
						{$service.description|escape|nl2br}
					</p>
					{/if}
				</div>
			</label>
		</dd>
	{foreachelse}
		<dd><p class="error block">Aucune activité trouvée</p></dd>
	{/foreach}

</dl>

{foreach from=$grouped_services item="service"}
<?php if (!count($service->fees)) { continue; } ?>
<dl data-service="s{$service.id}">
	<dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt>
	{foreach from=$service.fees key="service_id" item="fee"}
	<dd class="radio-btn">
		{input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null}
		<label for="f_id_fee_{$fee.id}">
			<div>
				<h3>{$fee.label}</h3>
				<p>
					{if !$fee.user_amount}
						prix libre ou gratuit
					{elseif $fee.user_amount && $fee.formula}
						<strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé)
					{elseif $fee.user_amount}
						<strong>{$fee.user_amount|raw|money_currency}</strong>
					{/if}
				</p>
				{if $fee.description}
				<p class="help">
					{$fee.description|escape|nl2br}
				</p>
				{/if}
			</div>
		</label>
	</dd>
	{/foreach}
</dl>
{/foreach}

Deleted src/templates/services/user/_service_user_form.tpl version [3477f32009].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171











































































































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
assert(isset($create) && is_bool($create));
assert(isset($has_past_services) && is_bool($has_past_services));
assert(isset($current_only) && is_bool($current_only));
assert(isset($form_url) && is_string($form_url));
assert(isset($today) && $today instanceof \DateTimeInterface);
assert($create === false || isset($account_targets));
assert(isset($grouped_services) && is_array($grouped_services));
?>

<form method="post" action="{$self_url}" data-focus="1" data-create="{$create|escape:json}">

	<fieldset>
		<legend>Inscrire à une activité</legend>

		<dl>
		{if $create && $users}
			<dt>
				Membres à inscrire
			</dt>
			<dd>
				<details>
					<summary>{{%n membre sélectionné.}{%n membres sélectionnés.} n=$users|count}</summary>
					<table>
						{foreach from=$users key="id" item="name"}
						<tr>
							<td>
								<input type="hidden" name="users[{$id}]" value="{$name}" />
								{if !empty($allow_users_edit)}
								{button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"}
								{/if}
							</td>
							<th>
								{$name}
							</th>
						</tr>
						{/foreach}
					</table>
				</details>
			</dd>
		{elseif $create && $copy_service}
			<dt>Recopier depuis l'activité</dt>
			<dd><strong>{$copy_service.label}</strong><input type="hidden" name="copy" value="s{$copy_service.id}" /></dd>
			<dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd>
		{elseif $create && $copy_fee}
			<dt>Recopier depuis le tarif</dt>
			<dd><strong>{$copy_fee->service()->label} — {$copy_fee.label}</strong><input type="hidden" name="copy" value="f{$copy_fee.id}" /></dd>
			<dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd>
		{/if}

			<dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt>

			{if $has_past_services}
			<dd>
				{* We can't use a button type="submit" here because it would trigger when user presses Enter, instead of the true submit button *}
				<input type="hidden" name="past_services" value="{$current_only}" />
				{if $current_only}
					Seules les activités courantes sont affichées.
					{button value="1" shape="reset" type="button" onclick="this.form.past_services=this.value; this.form.submit();" label="Inscrire à une activité passée"}
				{else}
					Seules les activités passées sont affichées.
					{button value="0" shape="left" type="button"  onclick="this.form.past_services=this.value; this.form.submit();" label="Inscrire à une activité courante"}
				{/if}
			</dd>
			{/if}


			{foreach from=$grouped_services item="service"}
				<dd class="radio-btn">
					{input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$service_user}
					<label for="f_id_service_{$service.id}">
						<div>
							<h3>{$service.label}</h3>
							<p>
								{if $service.duration}
									{$service.duration} jours
								{elseif $service.start_date}
									du {$service.start_date|date_short} au {$service.end_date|date_short}
								{else}
									ponctuelle
								{/if}
							</p>
							{if $service.description}
							<p class="help">
								{$service.description|escape|nl2br}
							</p>
							{/if}
						</div>
					</label>
				</dd>
			{foreachelse}
				<dd><p class="error block">Aucune activité trouvée</p></dd>
			{/foreach}

		</dl>

		{foreach from=$grouped_services item="service"}
		<?php if (!count($service->fees)) { continue; } ?>
		<dl data-service="s{$service.id}">
			<dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt>
			{foreach from=$service.fees key="service_id" item="fee"}
			<dd class="radio-btn">
				{input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null data-project=$fee.id_project source=$service_user }
				<label for="f_id_fee_{$fee.id}">
					<div>
						<h3>{$fee.label}</h3>
						<p>
							{if $fee.user_amount && $fee.formula}
								<strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé)
							{elseif $fee.formula}
								montant calculé, variable selon les membres
							{elseif $fee.user_amount}
								<strong>{$fee.user_amount|raw|money_currency}</strong>
							{else}
								prix libre ou gratuit
							{/if}
						</p>
						{if $fee.description}
						<p class="help">
							{$fee.description|escape|nl2br}
						</p>
						{/if}
					</div>
				</label>
			</dd>
			{/foreach}
		</dl>
		{/foreach}

	</fieldset>


	</fieldset>

	<fieldset>
		<legend>Détails</legend>
		<dl>
			{input type="date" name="date" required=1 default=$today source=$service_user label="Date d'inscription"}
			{input type="date" name="expiry_date" source=$service_user label="Date d'expiration de l'inscription"}
			{input type="checkbox" name="paid" value="1" source=$service_user default="1" label="Marquer cette inscription comme payée"}
			<dd class="help">Décocher cette case pour pouvoir suivre les règlements de personnes qui payent en plusieurs fois. Il sera possible de cocher cette case lorsque le solde aura été réglé.</dd>
		</dl>
	</fieldset>

	{if $create}
	<fieldset class="accounting">
		<legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend>

		<dl>
		{if !empty($users)}
		<dd class="help">Une écriture sera créée pour chaque membre inscrit.</dd>
		{/if}

			{input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=0"|args:$account_targets name="account_selector" label="Compte de règlement" required=true}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{input type="textarea" name="notes" label="Remarques"}
			{if count($projects) > 0}
				{input type="select" options=$projects name="id_project" label="Projet analytique" required=false default_empty="— Aucun —"}
			{/if}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

Deleted src/templates/services/user/add.tpl version [a9b93751c5].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62






























































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Inscrire à une activité" current="membres/services"}

{include file="services/_nav.tpl" current="save" fee=null service=null}

{form_errors}

<form method="post" action="subscribe.php" data-focus="button">

	<fieldset>
		<legend>Inscrire à une activité</legend>
		<dl>
			{input type="radio-btn" name="choice" value="1" label="Sélectionner des membres" default=1}
			{input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."}
			{input type="radio-btn" name="choice" value="3" label="Tous les membres d'une catégorie"}
		</dl>
	</fieldset>

	<fieldset class="c1">
		<legend>Inscrire des membres</legend>
		<dl>
			{input type="list" name="users" required=true label="Membres à inscrire" target="!users/selector.php" multiple=true}
		</dl>
	</fieldset>

	<fieldset class="c2">
		<legend>Recopier depuis une activité</legend>
		<dl>
			{input type="select_groups" name="copy" label="Activité à recopier" options=$services required=true default=0}
			{input type="checkbox" name="copy_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"}
		</dl>
	</fieldset>

	<fieldset class="c3">
		<legend>Tous les membres d'une catégorie</legend>
		<dl>
			{input type="select" name="category" label="Catégorie à inscrire" options=$categories required=true}
		</dl>
	</fieldset>

	<p class="submit">
		<input type="hidden" name="paid" value="1" />
		{button type="submit" name="next" label="Continuer" shape="right" class="main"}
	</p>
</form>

<script type="text/javascript">
{literal}
function selectChoice() {
	let choice = $('#f_choice_1').form.choice.value;
	g.toggle('.c1', choice == 1);
	g.toggle('.c2', choice == 2);
	g.toggle('.c3', choice == 3);
}

$('#f_choice_1').onchange = selectChoice;
$('#f_choice_2').onchange = selectChoice;
$('#f_choice_3').onchange = selectChoice;
selectChoice();
{/literal}
</script>

{include file="_foot.tpl"}

Deleted src/templates/services/user/delete.tpl version [9a92a85389].

1
2
3
4
5
6
7
8
9









-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="%s : Supprimer une inscription"|args:$user_name current="users/services"}

{include file="common/delete_form.tpl"
	legend="Supprimer l'inscription ?"
	warning="Êtes-vous sûr de vouloir supprimer l'inscription ?"
	alert="Les écritures comptables liées à cette inscription ne seront pas supprimées, la comptabilité demeurera inchangée."
	info="%s – à « %s — %s »"|args:$user_name,$service_name,$fee_name}

{include file="_foot.tpl"}

Deleted src/templates/services/user/edit.tpl version [3f7ef103e9].

1
2
3
4
5
6
7







-
-
-
-
-
-
-
{include file="_head.tpl" title="Modifier une inscription" current="users/services"}

{form_errors}

{include file="services/user/_service_user_form.tpl" create=false}

{include file="_foot.tpl"}

Deleted src/templates/services/user/index.tpl version [65f01862e2].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99



































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user_name current="users/services"}

{include file="users/_nav_user.tpl" id=$user_id current="services"}

{form_errors}

{if !$only}
<dl class="cotisation">
	<dt>Statut des inscriptions</dt>
	{foreach from=$services item="service"}
	<dd{if $service.archived} class="disabled"{/if}>
		{$service.label}
		{if $service.archived} <em>(activité passée)</em>{/if}
		{if $service.status == -1 && $service.end_date} — expirée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
	{if !$only && !$after}
	<dt>Nombre d'inscriptions pour ce membre</dt>
	<dd>
		{$list->count()}
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			{exportmenu href="?id=%d"|args:$user_id}
		{/if}
	</dd>
	{/if}
</dl>
{/if}

{if $only}
	<p class="alert block">Cette liste ne montre qu'une seule inscription, liée à l'activité <strong>{$only_service.label}</strong><br />
		{linkbutton shape="right" href="?id=%d"|args:$user_id label="Voir toutes les inscriptions"}
	</p>
{/if}

{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{$row.label}</th>
			<td>{$row.fee}</td>
			<td>{$row.date|date_short}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td class="money">{if $row.expected_amount}{$row.amount|raw|money_currency:false}
				{if $row.amount}<br /><small class="help">(sur {$row.expected_amount|raw|money_currency:false})</small>{/if}
				{/if}
			</td>
			<td class="actions">
			{if !$row.paid}
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $row.id_account}
					{linkbutton shape="plus" label="Nouveau règlement" href="payment.php?id=%d"|args:$row.id}
				{/if}

				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
					{linkbutton shape="plus" label="Saisir une écriture liée"
						href="!acc/transactions/new.php?u[%d]=%d&00=%d&t=1&l=Paiement%%20activité&ar=%s&set_year=%d"|args:$user_id:$row.id:$row.expected_amount:$row.account_code:$row.id_year target="_dialog"}
				{/if}
				<br />
			{/if}

			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
				{linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/service_user.php?id=%d&user=%d"|args:$row.id,$user_id}
			{/if}

			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				{if $row.paid}
					{linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user_id,$row.id}
				{else}
					{linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user_id,$row.id}
				{/if}
				<br />
				{linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$row.id}
				{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id}
			{/if}

			</td>
		</tr>
	{foreachelse}
		<tr>
			<td colspan="7">Aucune inscription trouvée.</td>
		</tr>
	{/foreach}

	</tbody>
</table>

{$list->getHTMLPagination()|raw}


{include file="_foot.tpl"}

Deleted src/templates/services/user/link.tpl version [6f582c8e1f].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22






















-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Lier à une écriture</legend>

		<dl>
			{input type="number" label="Numéro de l'écriture" name="id_transaction" required=true}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="_foot.tpl"}

Deleted src/templates/services/user/payment.tpl version [d5e1991249].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Enregistrer un règlement" current="users/services"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">

	<fieldset>
		<legend>Enregistrer un règlement</legend>

		<dl>
			<dt>Membre sélectionné</dt>
			<dd><h3>{$user_name}</h3></dd>
			<dt><strong>Inscription</strong></dt>
			{input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"}
			{input type="date" name="date" label="Date" required=1 source=$su}
			{input type="money" name="amount" label="Montant réglé par le membre" required=1}
			{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$account_targets,$fee.id_year name="account_selector" label="Compte de règlement" required=1}
			{input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."}
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."}
			{if count($projects) > 0}
				{input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"}
			{/if}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{include file="_foot.tpl"}

Deleted src/templates/services/user/subscribe.tpl version [956f4af913].

1
2
3
4
5
6
7
8
9
10
11











-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Inscrire à une activité" current="users/services"}

{if !$dialog}
{include file="services/_nav.tpl" current="save" fee=null service=null}
{/if}

{form_errors}

{include file="services/user/_service_user_form.tpl" create=true}

{include file="_foot.tpl"}

Modified src/templates/users/_details.tpl from [b3fb59fa84] to [53a6d4782f].

46
47
48
49
50
51
52
53

54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

70



71
72
73

74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

91
92
93
94
95
96
46
47
48
49
50
51
52

53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

71
72
73
74
75

76

















77


78
79
80
81







-
+
















+
-
+
+
+


-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-




			?>
			{include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path:$key}
		{elseif empty($value)}
			<em>(Non renseigné)</em>
		{elseif $field.type == 'email'}
			<a href="mailto:{$value|escape:'url'}">{$value}</a>
			{if !DISABLE_EMAIL && $show_message_button && !$email_button++}
				{linkbutton href="!users/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"}
				{linkbutton href="!users/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail" target="_dialog"}
			{/if}
		{elseif $field.type == 'multiple'}
			<ul>
			{foreach from=$field.options key="b" item="name"}
				{if (int)$value & (0x01 << (int)$b)}
					<li>{$name}</li>
				{/if}
			{/foreach}
			</ul>
		{else}
			{if in_array($key, $id_fields)}<strong>{/if}
			{user_field field=$field value=$value user_id=$user.id}
			{if in_array($key, $id_fields)}</strong>{/if}
		{/if}
	</dd>
		{if $field.type == 'email' && $value}
		<?php
		<?php $email = Email\Emails::getEmail($value); ?>
		$email = Email\Addresses::getOrCreate($value);
		$address = rawurlencode($value);
		?>
		<dt>Statut e-mail</dt>
		<dd>
			{if $email.optout}
			{tag color=$email->getStatusColor() label=$email->getStatusLabel()}
				<b class="alert">{icon shape="alert"}</b> Ne souhaite plus recevoir de messages
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					<?php $value = rawurlencode($value); ?>
					<br/>{linkbutton target="_dialog" label="Rétablir les envois à cette adresse" href="!users/mailing/verify.php?address=%s"|args:$value shape="check"}
				{/if}
			{elseif $email.invalid}
				<b class="error">{icon shape="alert"} Adresse invalide</b>
				{linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
			{elseif $email && $email->hasReachedFailLimit()}
				<b class="error">{icon shape="alert"} Trop d'erreurs</b>
				{linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
			{elseif $email.verified}
				<b class="confirm">{icon shape="check" class="confirm"}</b> Adresse vérifiée
			{else}
				{* Adresse non vérifiée *}
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					{linkbutton target="_dialog" label="Désinscrire de tous les envois" href="!users/mailing/block.php?address=%s"|args:$value shape="delete"}
			{linkbutton target="_dialog" label="Détails" href="!users/email/address.php?address=%s"|args:$address shape="mail"}
				{/if}
			{/if}
		</dd>
		{/if}
	{/foreach}
</dl>

Modified src/templates/users/_nav_user.tpl from [8a253455d6] to [12acdfccd2].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16

17
18
19
1
2
3
4
5
6
7
8
9

10
11
12
13
14
15

16
17
18
19









-
+





-
+



<nav class="tabs">
	<aside>
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'details'}
		{linkbutton href="edit.php?id=%d"|args:$id shape="edit" label="Modifier" accesskey="M"}
	{/if}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $logged_user.id != $id && $current == 'details'}
		{linkbutton href="delete.php?id=%d"|args:$id shape="delete" label="Supprimer" target="_dialog" accesskey="S"}
	{/if}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'services'}
		{linkbutton href="!services/user/subscribe.php?user=%d"|args:$id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="K"}
		{linkbutton href="!services/subscription/new.php?user=%d"|args:$id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="K"}
	{/if}

	</aside>
	<ul>
		<li{if $current == 'details'} class="current"{/if}>{link href="!users/details.php?id=%d"|args:$id label="Fiche membre" accesskey="F"}</li>
		<li{if $current == 'services'} class="current"{/if}>{link href="!services/user/?id=%d"|args:$id label="Inscriptions aux activités" accesskey="I"}</li>
		<li{if $current == 'services'} class="current"{/if}>{link href="!users/subscriptions.php?id=%d"|args:$id label="Inscriptions aux activités" accesskey="I"}</li>
		<li{if $current == 'reminders'} class="current"{/if}>{link href="!services/reminders/user.php?id=%d"|args:$id label="Rappels envoyés" accesskey="R"}</li>
	</ul>
</nav>

Modified src/templates/users/details.tpl from [e2f570012f] to [415d0f4e4a].

13
14
15
16
17
18
19
20

21
22
23
24
25

26
27
28
29
30
31
32
13
14
15
16
17
18
19

20
21
22
23
24

25
26
27
28
29
30
31
32







-
+




-
+







		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est inscrit à aucune activité ou cotisation.
		Ce membre n'est actuellement inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
	<dd>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
			{linkbutton href="!services/user/subscribe.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="V"}
			{linkbutton href="!services/subscription/new.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="V"}
		{/if}
	</dd>
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
		{if !empty($transactions_linked)}
			<dt>Écritures comptables liées</dt>
			<dd><a href="{$admin_url}acc/transactions/user.php?id={$user.id}">{$transactions_linked} écritures comptables liées à ce membre</a></dd>
		{/if}

Added src/templates/users/email/_nav.tpl version [3b9ed2172c].



































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<nav class="tabs">
	{if $current === 'rejected'}
		<aside>
			{exportmenu right=true}
		</aside>
	{elseif $current === 'index'}
		<aside>
			{linkbutton shape="plus" label="Nouveau message" href="edit.php"}
		</aside>
	{elseif $current === 'mailing'}
		<aside>
			{if !$mailing.sent}
				{linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$mailing.id}
			{/if}
			{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id}
		</aside>
	{/if}

	<ul>
		<li{if $current === 'index' || $current === 'mailing'} class="current"{/if}>{link href="!users/email/mailing/" label="Messages collectifs"}</li>
		<li{if $current === 'optout'} class="current"{/if}>{link href="!users/email/optout.php" label="Désinscriptions"}</li>
		<li{if $current === 'rejected'} class="current"{/if}>{link href="!users/email/rejected.php" label="Adresses rejetées"}</li>
		{if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
			<li {if $current === 'queue'}class="current"{/if}>{link href="!users/email/queue.php" label="File d'envoi"}</li>
		{/if}
	</ul>

	{if $current === 'mailing'}
	<ul class="sub">
		<li class="title">{$mailing.subject}</li>
		<li>{link href="recipients.php?id=%d"|args:$mailing.id label="Destinataires"}</li>
	</ul>
	{/if}
</nav>

Added src/templates/users/email/address.tpl version [53622e4c17].








































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Adresse e-mail" current="users/mailing"}

{if $_GET.msg === 'VERIFICATION_SENT'}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé.<br />
	Le destinataire doit désormais cliquer sur le lien dans ce message pour valider son adresse.
</p>
{/if}

<div class="describe">
	<dt>Adresse e-mail</dt>
	<dd>{if $raw_address}{$raw_address}{else}<em>anonymisée</em>{/if}</dd>
	<dt>Statut</dt>
	<dd>{tag label=$address->getStatusLabel() color=$address->getStatusColor()}</dd>
	<dt>Description du statut</dt>
	<dd>
		{if $address.status === $address::STATUS_VERIFIED}
			L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.
		{elseif $address.status === $address::STATUS_INVALID}
			Cette adresse a une erreur de syntaxe, ou le serveur n'existe pas.
		{elseif $address.status === $address::STATUS_HARD_BOUNCE}
			Le serveur existe, mais l'adresse n'existe pas, ou bloque vos messages définitivement.
		{elseif $address.status === $address::STATUS_SOFT_BOUNCE_LIMIT_REACHED}
			L'adresse existe, mais a rencontré plus de {$max_fail_count} erreurs temporaires.<br />
			Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine.<br />
			Cette adresse ne recevra plus de message.
		{elseif $address.status === $address::STATUS_OPTOUT}
			L'adresse existe, mais le destinataire a demandé à ne recevoir de messages de votre part.
		{else}
			Cette adresse n'a pas rencontré de problème jusque là.
		{/if}
	</dd>
	<dt>Nombre de messages envoyés</dt>
	<dd>{$address.sent_count}</dd>
	<dt>Nombre d'erreurs temporaires</dt>
	<dd>{$address.bounce_count}</dd>
</div>

{include file="_foot.tpl"}

Added src/templates/users/email/block.tpl version [d3cb829167].


















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Désinscription d'adresse" current="users/mailing"}

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Désinscrire une adresse</legend>
		<h3 class="warning">Désinscrire l'adresse {$address} ?</h3>
		<p class="alert block">
			Une fois cette adresse désinscrite, elle ne pourra plus recevoir aucun message de votre association (rappels, notifications, messages collectifs, etc.).
		</p>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="send" label="Désinscrire cette adresse" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}

Added src/templates/users/email/mailing/delete.tpl version [287d96382b].









1
2
3
4
5
6
7
8
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Supprimer un envoi de message collectif" current="users/mailing"}

{include file="common/delete_form.tpl"
	legend="Supprimer ce message collectif ?"
	warning="Êtes-vous sûr de vouloir supprimer le message « %s » ?"|args:$mailing.subject
	info="La liste des destinataires sera également supprimée."}

{include file="_foot.tpl"}

Added src/templates/users/email/mailing/details.tpl version [3189d327bf].






























































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing" hide_title=true}

{include file="../_nav.tpl" current="mailing"}

{if $sent}
	<p class="confirm block">L'envoi du message a bien commencé. Il peut prendre quelques minutes avant d'avoir été expédié à tous les destinataires.</p>
{/if}

{form_errors}

<form method="post" action="">
	<dl class="describe">
		{if $mailing.sent}
			<dt>Envoyé le</dt>
			<dd>{$mailing.sent|date_long:true}</dd>
		{else}
			<dt>Statut</dt>
			<dd>
				Brouillon<br />
				{if $mailing.body && $count}
				<br />{button shape="right" label="Envoyer" class="main" name="send" type="submit"}
				{/if}
			</dd>
			<dt>Expéditeur</dt>
			<dd>
				{$mailing->getFrom()}<br/>
			</dd>
		{/if}
		{if $mailing.target_type}
		<dt>Cible</dt>
		<dd>
			{$mailing->getTargetTypeLabel()} — {$mailing.target_label}
		</dd>
		{/if}
		<dt>Destinataires</dt>
		<dd>
		{if $mailing.count}
			{{%n destinataire}{%n destinataires} n=$count}<br />
			{linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id}
			{linkbutton shape="plus" label="Ajouter des destinataires" href="populate.php?id=%d"|args:$mailing.id}
		{else}
			{linkbutton class="main" shape="plus" label="Ajouter des destinataires" href="populate.php?id=%d"|args:$mailing.id}
		{/if}
		</dd>

		<dt>Sujet</dt>
		<dd><strong>{$mailing.subject}</strong></dd>
		<dt>Message</dt>
		<dd><pre class="preview"><code>{$mailing.body}</code></pre></dd>
		{if $count}
			<dt>Prévisualisation</dt>
			<dd>{linkbutton shape="eye" label="Prévisualiser le message" href="?id=%d&preview"|args:$mailing.id target="_dialog"}<br />
				<small class="help">(Un destinataire sera choisi au hasard.)</small></dd>
			<dt></dt>
			<dd class="help">Note : la prévisualisation peut différer du rendu final, selon le logiciel utilisé par vos destinataires pour lire leurs messages.</dd>
		{/if}
	</dl>
	{csrf_field key=$csrf_key}
</form>

{include file="_foot.tpl"}

Added src/templates/users/email/mailing/edit.tpl version [8809e0af4c].






















































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true}

{form_errors}

<form method="post" action="{$self_url}" data-focus="{if $mailing->exists()}textarea{else}1{/if}">

	<fieldset class="header">
		<legend>{if $mailing->exists()}Modifier le message collectif{else}Nouveau message collectif{/if}</legend>
		<p>
			{input type="text" name="subject" required=true class="full-width" placeholder="Sujet du message…" source=$mailing}
		</p>
		<div>
			<p class="sender_default {if $mailing.sender_name}hidden{/if}">
				<strong>Expéditeur&nbsp;:</strong> {$config.org_name} &lt;{$config.org_email}&gt;
				{button label="Modifier" shape="edit" id="f_edit_sender"}
			</p>
			<dl class="sender_custom {if !$mailing.sender_name}hidden{/if}">
				{input type="text" required=true name="sender_name" source=$mailing label="Nom de l'expéditeur" placeholder="Nom de l'expéditeur"} &nbsp;
				{input type="email" required=true name="sender_email" source=$mailing label="Adresse e-mail de l'expéditeur" placeholder="Adresse e-mail de l'expéditeur"}
			</dl>
		</div>
	</fieldset>

	<fieldset class="textEditor">
		{input type="textarea" name="content" cols=35 rows=25 required=true class="full-width"
				data-attachments=0 data-savebtn=0 data-preview-url="!users/email/mailing/edit.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body}
	</fieldset>

	{if !$mailing->exists()}
		<p class="help">Vous pourrez sélectionner les destinataires à l'étape suivante.</p>
	{/if}

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

<script type="text/javascript">
{literal}
$('#f_edit_sender').onclick = () => {
	g.toggle('.sender_default', false);
	g.toggle('.sender_custom', true);
}
{/literal}
{if !$mailing.sender_name}
g.toggle('.sender_custom', false);
{/if}
</script>


{include file="_foot.tpl"}

Added src/templates/users/email/mailing/index.tpl version [90aff71501].





































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Messages collectifs" current="users/mailing"}

{include file="../_nav.tpl" current="index"}

{if $_GET.msg === 'DELETE'}
	<p class="confirm block">Le message a bien été supprimé.</p>
{elseif $_GET.msg === 'FORCED'}
	<p class="confirm block">La file d'attente a été envoyée.</p>
{/if}

{if !$list->count()}
	<p class="alert block">Aucun message collectif n'a été écrit.<br />
		{linkbutton shape="plus" label="Écrire un nouveau message" href="new.php" target="_dialog"}
	</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{link href="details.php?id=%d"|args:$row.id label=$row.subject}</th>
			<td>{$row.nb_recipients}</td>
			<td>{if $row.sent}{$row.sent|relative_date:true}{else}Brouillon{/if}</td>
			<td class="actions">
				{linkbutton shape="eye" label="Ouvrir" href="details.php?id=%d"|args:$row.id}
				{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id target="_dialog"}
			</td>
		</tr>
	{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}
{/if}


{include file="_foot.tpl"}

Added src/templates/users/email/mailing/populate.tpl version [8dc2925045].
















































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"}

{form_errors}

<form method="post" action="" data-focus=1>
{if !$target_type}
	<fieldset>
		<legend>Sujet du message</legend>
		<dl>
			{input type="text" required="true" label="Sujet du message" name="subject" class="full-width"}
		</dl>
	</fieldset>
	<fieldset>
		<legend>Qui doit recevoir ce message&nbsp;?</legend>
		<dl>
			{input type="radio-btn" name="target_type" value="field" label="Membres correspondant à une case à cocher (sauf ceux appartenant à une catégorie cachée)" required=true help="Par exemple les membres inscrits à la lettre d'information."}
			{input type="radio-btn" name="target_type" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true}
			{input type="radio-btn" name="target_type" value="category" label="Membres d'une seule catégorie" required=true}
			{input type="radio-btn" name="target_type" value="service" label="Membres inscrits à une activité, et à jour" required=true help="Les membres dont l'inscription a expiré ne recevront pas de message."}
			{input type="radio-btn" name="target_type" value="search" label="Membres renvoyés par une recherche enregistrée" required=true}
		</dl>
	</fieldset>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="step2" label="Continuer" shape="right" class="main"}
	</p>
{elseif $target_type == 'field'}
	<fieldset>
		<legend>Quel champ de la fiche membre&nbsp;?</legend>
		<dl>
			{foreach from=$list item="field"}
				{input type="radio" name="target_value" value=$field.name label=$field.label help="%d membres"|args:$field.count}
				{input type="hidden" name="labels[%s]"|args:$field.name default=$field.label}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target_type == 'category'}
	<fieldset>
		<legend>Quelle catégorie&nbsp;?</legend>
		<dl>
			{foreach from=$list item="cat"}
				{input type="radio" name="target_value" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count}
				{input type="hidden" name="labels[%s]"|args:$cat.id default=$cat.name}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target_type == 'service'}
	<fieldset>
		<legend>Quelle activité&nbsp;?</legend>
		<dl>
			{foreach from=$list item="service"}
				{input type="radio" name="target_value" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok}
				{input type="hidden" name="labels[%s]"|args:$service.id default=$service.label}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target_type == 'search'}
	<fieldset>
		<legend>Quelle recherche utiliser&nbsp;?</legend>
		<dl>
			{foreach from=$list item="search"}
				{input type="radio" name="target_value" value=$search.id label=$search.label help="%d membres"|args:$search.count}
				{input type="hidden" name="labels[%s]"|args:$search.id default=$search.label}
			{/foreach}
		</dl>
	</fieldset>
{/if}
{if $target_type}
	<p class="help"><small>Note : le nombre de membres affiché ne prend pas en compte les membres qui ne disposent pas d'adresse e-mail, ou qui se sont désinscrits. Le nombre de destinataires réels sera affiché avant envoi.</small></p>
	<p class="submit">
		{input type="hidden" name="subject"}
		{input type="hidden" name="target_type" default=$target_type}
		{csrf_field key=$csrf_key}
		{button type="submit" name="step3" label="Créer" shape="right" class="main"}
	</p>
{/if}
</form>

{include file="_foot.tpl"}

Added src/templates/users/email/mailing/recipient_data.tpl version [d5ad4ec244].
























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Données du destinataire" current="users/mailing"}

<p class="help">Vous pouvez copier la variable (colonne de gauche) dans le corps du message&nbsp;:
	elle sera remplacée dans le message par le contenu (colonne à droite) spécifique à chaque destinataire.</p>

<table class="list auto center">
	<thead>
		<tr>
			<td>Variable</td>
			<td>Contenu</td>
		</tr>
	</thead>
	<tbody>
		{foreach from=$data key="name" item="value"}
		<tr>
			<td><code>{ldelim}{ldelim}${$name}{rdelim}{rdelim}</code></td>
			<td>{$value|escape|nl2br}</td>
		</tr>
		{/foreach}
	</tbody>
</table>

{include file="_foot.tpl"}

Added src/templates/users/email/mailing/recipients.tpl version [f781d16b3b].























































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"}

{include file="../_nav.tpl" current="mailing"}

<p>
	{linkbutton shape="left" label="Retour au message" href="details.php?id=%d"|args:$mailing.id}
	{exportmenu}
</p>

{if $mailing.anonymous}
	<p class="alert block">
		Les informations personnelles des destinataires ont été supprimées automatiquement après un délai de six mois, conformément au RGPD.
	</p>
{else}
	<p class="help">
		Les informations personnelles des destinataires seront supprimées automatiquement après un délai de six mois, conformément au RGPD.
	</p>
{/if}

{form_errors}
<form method="post" action="">
	{include file="common/dynamic_list_head.tpl"}
	{foreach from=$list->iterate() item="r"}
		<tr>
			<td>{$r.email}</td>
			<td>{$r.name}</td>
			<td>
				{if $r.status}
					<span class="error">{$r.status}</span>
				{/if}
			</td>
			<td class="actions">
				{if $r.has_extra_data}
					{linkbutton shape="menu" label="Données" href="recipient_data.php?id=%d&r=%d"|args:$mailing.id:$r.id target="_dialog"}
				{/if}
				{if $r.id_user}
					{linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$r.id_user}
				{/if}
				{if !$mailing.sent}
					{button shape="delete" label="Supprimer" name="delete" value=$r.id type="submit"}
				{/if}
				{if !$mailing.anonymous && $r.email}
					{linkbutton href="details.php?id=%d&preview=%d"|args:$mailing.id:$r.id label="Prévisualiser" shape="eye" target="_dialog"}
				{/if}
			</td>
		</tr>
		{/foreach}
		</tbody>
	</table>
	{csrf_field key=$csrf_key}
	{$list->getHTMLPagination()|raw}
</form>

{include file="_foot.tpl"}

Added src/templates/users/email/optout.tpl version [93bde13c66].









































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Désinscriptions" current="users/mailing"}

{include file="./_nav.tpl" current="optout"}

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{/if}

{if !$list->count()}
	<p class="alert block">Aucune adresse e-mail n'a demandé à être désinscrite pour le moment.</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
		<tr{if $_GET.hl == $row.id} class="highlight"{/if} id="e_{$row.id}">
			<th>{link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}</th>
			<td>{$row.email}</td>
			<td><b class="error">{$row.status}</b></td>
			<td class="num">{$row.sent_count}</td>
			<td>{$row.last_sent|date}</td>
			<td>
				{if $row.email && $row.optout}
					{linkbutton target="_dialog" label="Rétablir" href="!users/email/verify.php?address=%s"|args:$row.email shape="check"}
				{elseif $row.email && $row.target_type}
					{linkbutton target="_dialog" label="Supprimer" href="!users/email/optout_delete.php?address=%s"|args:$row.email shape="delete"}
				{/if}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}

{/if}

{include file="_foot.tpl"}

Added src/templates/users/email/queue.tpl version [ffd1e1f707].

















































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="File d'envoi" current="users/mailing"}

{include file="./_nav.tpl" current="queue"}

{if $_GET.msg === 'EMPTY'}
<p class="confirm block">
	Les messages en attente ont été envoyés.
</p>
{/if}

<p class="help">Cette page affiche les e-mails qui sont en attente d'être envoyés.</p>

{if !$count}
	<p class="alert block">Il n'y a aucun message en attente d'envoi.</p>
{else}
	<p class="help">
		{if USE_CRON}
			Il y a {$count} messages dans la file d'attente, ils seront envoyés dans quelques minutes par une tâche automatique.
		{else}
			Il y a {$count} messages dans la file d'attente, cliquez ici pour envoyer les messages :
			{linkbutton shape="right" label="Envoyer les messages en attente" href="?run=1"}
		{/if}
	</p>

	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
		<tr>
			<td>{$contexts[$row.context]}</td>
			<td>{tag color=$statuses_colors[$row.status] label=$statuses[$row.status]}</td>
			<td>{$row.sender}</td>
			<td>{$row.recipient}</td>
			<td>{$row.subject}</td>
			<td class="actions">
				{linkbutton href="?id=%d"|args:$row.id label="Ouvrir" target="_dialog" shape="eye"}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}


{/if}

{include file="_foot.tpl"}

Added src/templates/users/email/rejected.tpl version [1a476180ac].





































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Adresses rejetées" current="users/mailing"}

{include file="./_nav.tpl" current="rejected"}

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{/if}

{if !$list->count()}
	<p class="alert block">Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
		<tr{if $_GET.hl == $row.id} class="highlight"{/if} id="e_{$row.id}">
			<th>{link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}</th>
			<td>{$row.email}</td>
			<td>{tag label=$labels[$row.status] color=$colors[$row.status]}</td>
			<td class="num">{$row.sent_count}</td>
			<td>{$row.last_sent|date}</td>
			<td>
				{linkbutton target="_dialog" label="Détails" href="!users/email/address.php?id=%d"|args:$row.id shape="eye"}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}

{/if}

{include file="_foot.tpl"}

Added src/templates/users/email/verify.tpl version [648dec2f4d].





























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="Vérification d'adresse" current="users/mailing"}

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Demander la vérification de l'adresse</legend>
		{if $address.optout}
		<p class="help">
			Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour se réinscrire.
		</p>
		{else}
		<p class="help">
			Si l'adresse du membre a rencontré une erreur fatale, ou trop d'erreurs temporaires, il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour valider son adresse.
		</p>
		{/if}
		<p class="alert block">
			Attention, n'utiliser cette procédure qu'à la demande du membre.<br />
			En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires.
		</p>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/block.tpl version [d3cb829167].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

















-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Désinscription d'adresse" current="users/mailing"}

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Désinscrire une adresse</legend>
		<h3 class="warning">Désinscrire l'adresse {$address} ?</h3>
		<p class="alert block">
			Une fois cette adresse désinscrite, elle ne pourra plus recevoir aucun message de votre association (rappels, notifications, messages collectifs, etc.).
		</p>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="send" label="Désinscrire cette adresse" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/delete.tpl version [287d96382b].

1
2
3
4
5
6
7
8








-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Supprimer un envoi de message collectif" current="users/mailing"}

{include file="common/delete_form.tpl"
	legend="Supprimer ce message collectif ?"
	warning="Êtes-vous sûr de vouloir supprimer le message « %s » ?"|args:$mailing.subject
	info="La liste des destinataires sera également supprimée."}

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/details.tpl version [8eb4612382].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

























































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if $sent}
	<p class="confirm block">L'envoi du message a bien commencé. Il peut prendre quelques minutes avant d'avoir été expédié à tous les destinataires.</p>
{/if}

{form_errors}

<form method="post" action="">
	<dl class="describe">
		{if $mailing.sent}
			<dt>Envoyé le</dt>
			<dd>{$mailing.sent|date_long:true}</dd>
		{else}
			<dt>Statut</dt>
			<dd>
				Brouillon<br />
				{linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$mailing.id}
				{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id}
				{if $mailing.body}
				{button shape="right" label="Envoyer" class="main" name="send" type="submit"}
				{/if}
			</dd>
			<dt>Expéditeur</dt>
			<dd>
				{$mailing->getFrom()}<br/>
			</dd>
		{/if}
		<dt>Destinataires</dt>
		<dd>
			{{%n destinataire}{%n destinataires} n=$mailing->countRecipients()}<br />
			{linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id}
		</dd>
		<dt>Sujet</dt>
		<dd><strong>{$mailing.subject}</strong></dd>
		<dt>Message</dt>
		<dd><pre class="preview"><code>{$mailing.body}</code></pre></dd>
		<dt>Prévisualisation</dt>
		<dd>{linkbutton shape="eye" label="Prévisualiser le message" href="?id=%d&preview"|args:$mailing.id target="_dialog"}<br />
		 <small class="help">(Un destinataire sera choisi au hasard.)</small></dd>
		 <dt></dt>
		 <dd class="help">Note : la prévisualisation peut différer du rendu final, selon le logiciel utilisé par vos destinataires pour lire leurs messages.</dd>
	</dl>
	{csrf_field key=$csrf_key}
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/index.tpl version [a078781ae8].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45













































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Messages collectifs" current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li class="current"><a href="{$self_url}">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if $_GET.msg == 'DELETE'}
	<p class="confirm block">Le message a bien été supprimé.</p>
{/if}

{if !$list->count()}
	<p class="alert block">Aucun message collectif n'a été écrit.<br />
		{linkbutton shape="plus" label="Écrire un nouveau message" href="new.php" target="_dialog"}
	</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr>
			<th>{link href="details.php?id=%d"|args:$row.id label=$row.subject}</th>
			<td>{$row.nb_recipients}</td>
			<td>{if $row.sent}{$row.sent|relative_date:true}{else}Brouillon{/if}</td>
			<td class="actions">
				{linkbutton shape="eye" label="Ouvrir" href="details.php?id=%d"|args:$row.id}
				{if !$row.sent}
					{linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$row.id}
				{/if}
				{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id target="_dialog"}
			</td>
		</tr>
	{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}
{/if}


{include file="_foot.tpl"}

Deleted src/templates/users/mailing/new.tpl version [aff296b479].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"}

{form_errors}

<form method="post" action="" data-focus=1>
{if !$target}
	<fieldset>
		<legend>Sujet du message</legend>
		<dl>
			{input type="text" required="true" label="Sujet du message" name="subject" class="full-width"}
		</dl>
	</fieldset>
	<fieldset>
		<legend>Qui doit recevoir ce message&nbsp;?</legend>
		<dl>
			{input type="radio-btn" name="target" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true}
			{input type="radio-btn" name="target" value="category" label="Les membres d'une seule catégorie" required=true}
			{input type="radio-btn" name="target" value="service" label="Les membres inscrits à une activité, et à jour" required=true help="Les membres dont l'inscription a expiré ne recevront pas de message."}
			{input type="radio-btn" name="target" value="search" label="Les membres renvoyés par une recherche enregistrée" required=true}
		</dl>
	</fieldset>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="step2" label="Continuer" shape="right" class="main"}
	</p>
{elseif $target == 'category'}
	<fieldset>
		<legend>Quelle catégorie&nbsp;?</legend>
		<dl>
			{foreach from=$categories item="cat"}
				{input type="radio" name="target_id" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target == 'service'}
	<fieldset>
		<legend>Quelle activité&nbsp;?</legend>
		<dl>
			{foreach from=$services->iterate() item="service"}
				{input type="radio" name="target_id" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target == 'search'}
	<fieldset>
		<legend>Quelle recherche utiliser&nbsp;?</legend>
		<dl>
			{foreach from=$search_list item="search"}
				{input type="radio" name="target_id" value=$search.id label=$search.label help="%d membres"|args:$search.count}
			{/foreach}
		</dl>
	</fieldset>
{/if}
{if $target}
	<p class="help"><small>Note : le nombre de membres affiché ne prend pas en compte les membres qui ne disposent pas d'adresse e-mail, ou qui se sont désinscrits. Le nombre de destinataires réels sera affiché avant envoi.</small></p>
	<p class="submit">
		{input type="hidden" name="subject"}
		{input type="hidden" name="target"}
		{csrf_field key=$csrf_key}
		{button type="submit" name="step3" label="Créer" shape="right" class="main"}
	</p>
{/if}
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/recipient_data.tpl version [d5ad4ec244].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Données du destinataire" current="users/mailing"}

<p class="help">Vous pouvez copier la variable (colonne de gauche) dans le corps du message&nbsp;:
	elle sera remplacée dans le message par le contenu (colonne à droite) spécifique à chaque destinataire.</p>

<table class="list auto center">
	<thead>
		<tr>
			<td>Variable</td>
			<td>Contenu</td>
		</tr>
	</thead>
	<tbody>
		{foreach from=$data key="name" item="value"}
		<tr>
			<td><code>{ldelim}{ldelim}${$name}{rdelim}{rdelim}</code></td>
			<td>{$value|escape|nl2br}</td>
		</tr>
		{/foreach}
	</tbody>
</table>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/recipients.tpl version [4b637ba142].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62






























































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

<p>
	{linkbutton shape="left" label="Retour au message" href="details.php?id=%d"|args:$mailing.id}
	{exportmenu}
</p>

{if $mailing.anonymous}
	<p class="alert block">
		Les informations personnelles des destinataires ont été supprimées automatiquement après un délai de six mois, conformément au RGPD.
	</p>
{else}
	<p class="help">
		Les informations personnelles des destinataires seront supprimées automatiquement après un délai de six mois, conformément au RGPD.
	</p>
{/if}

{form_errors}
<form method="post" action="">
	{include file="common/dynamic_list_head.tpl"}
	{foreach from=$list->iterate() item="r"}
		<tr>
			<td>{$r.email}</td>
			<td>{$r.name}</td>
			<td>
				{if $r.status}
					<span class="error">{$r.status}</span>
				{/if}
			</td>
			<td class="actions">
				{if $r.has_extra_data}
					{linkbutton shape="menu" label="Données" href="recipient_data.php?id=%d&r=%d"|args:$mailing.id:$r.id target="_dialog"}
				{/if}
				{if $r.id_user}
					{linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$r.id_user}
				{/if}
				{if !$mailing.sent}
					{button shape="delete" label="Supprimer" name="delete" value=$r.id type="submit"}
				{/if}
				{if !$mailing.anonymous && $r.email}
					{linkbutton href="details.php?id=%d&preview=%d"|args:$mailing.id:$r.id label="Prévisualiser" shape="eye" target="_dialog"}
				{/if}
			</td>
		</tr>
		{/foreach}
		</tbody>
	</table>
	{csrf_field key=$csrf_key}
	{$list->getHTMLPagination()|raw}
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/rejected.tpl version [6aee1d93eb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84




















































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Adresses rejetées" current="users/mailing"}

<nav class="tabs">
	<aside>
		{exportmenu}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li class="current"><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if isset($_GET['sent'])}
<p class="confirm block">
	Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message.
</p>
{elseif isset($_GET['forced'])}
<p class="confirm block">
	La file d'attente a été envoyée.
</p>
{/if}

<form method="post" action="">
<p class="help">
	{if !$queue_count}
		Il n'y a aucun message en attente d'envoi.
	{else}
		Il y a {$queue_count} messages dans la file d'attente, ils seront envoyés dans quelques instants.
		{if !USE_CRON && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
			{button shape="right" label="Forcer l'envoi des messages en attente" type="submit" name="force_queue"}
		{/if}
	{/if}
</p>
</form>

{if !$list->count()}
	<p class="alert block">Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.</p>
{else}
	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
		<tr{if $_GET.hl == $row.id} class="highlight"{/if} id="e_{$row.id}">
			<th>{link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}</th>
			<td>{$row.email}</td>
			<td>{$row.status}</td>
			<td class="num">{$row.sent_count}</td>
			<td>{$row.fail_log|escape|nl2br}</td>
			<td>{$row.last_sent|date}</td>
			<td>
				{if $row.email && ($row.optout || $row.last_sent < $limit_date)}
					<?php $email = rawurlencode($row->email); ?>
					{linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$email shape="check"}
				{/if}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}

	<div class="block help">
		<h3>Statuts possibles d'une adresse e-mail&nbsp;:</h3>
		<dl class="cotisation">
			{*
			<dt>Vérifiée</dt>
			<dd>L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.</dd>
			*}
			<dt>Désinscription</dt>
			<dd>Le destinataire a demandé à être désinscrit et ne recevra plus de messages.</dd>
			<dt>Invalide</dt>
			<dd>L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.</dd>
			<dt>Trop d'erreurs</dt>
			<dd>Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois.<br />Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.</dd>
		</dl>
		<p class="help">
			Il est possible de rétablir la réception de messages pour les adresses invalides après un délai d'un mois, et les adresses désinscrites immédiatement, en cliquant sur le bouton "Rétablir" qui enverra un message de validation à la personne.
		</p>
	</div>

{/if}

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/verify.tpl version [3380d3fc0b].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28




























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Vérification d'adresse" current="users/mailing"}

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Demander la vérification de l'adresse</legend>
		{if $email.optout}
		<p class="help">
			Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour se réinscrire.
		</p>
		{elseif $email->hasReachedFailLimit()}
		<p class="help">
			Si l'adresse du membre a rencontré trop d'erreurs (boîte mail pleine par exemple), il est possible de rétablir l'envoi des messages.<br />
			Le membre recevra alors un message contenant un lien pour valider son adresse.
		</p>
		{/if}
		<p class="alert block">
			Attention, n'utiliser cette procédure qu'à la demande du membre.<br />
			En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires.
		</p>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}

Deleted src/templates/users/mailing/write.tpl version [a5ee5946c0].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

















































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true}

{form_errors}

<form method="post" action="{$self_url}">

	<fieldset class="header">
		<legend>Modifier le message collectif</legend>
		<p>
			{input type="text" name="subject" required=true class="full-width" placeholder="Sujet du message…" source=$mailing}
		</p>
		<div>
			<p class="sender_default {if $mailing.sender_name}hidden{/if}">
				<strong>Expéditeur&nbsp;:</strong> {$config.org_name} &lt;{$config.org_email}&gt;
				{button label="Modifier" shape="edit" id="f_edit_sender"}
			</p>
			<dl class="sender_custom {if !$mailing.sender_name}hidden{/if}">
				{input type="text" required=true name="sender_name" source=$mailing label="Nom de l'expéditeur" placeholder="Nom de l'expéditeur"} &nbsp;
				{input type="email" required=true name="sender_email" source=$mailing label="Adresse e-mail de l'expéditeur" placeholder="Adresse e-mail de l'expéditeur"}
			</dl>
		</div>
	</fieldset>

	<fieldset class="textEditor">
		{input type="textarea" name="content" cols=35 rows=25 required=true class="full-width"
				data-attachments=0 data-savebtn=0 data-preview-url="!users/mailing/write.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body}
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

<script type="text/javascript">
{literal}
$('#f_edit_sender').onclick = () => {
	g.toggle('.sender_default', false);
	g.toggle('.sender_custom', true);
}
{/literal}
{if !$mailing.sender_name}
g.toggle('.sender_custom', false);
{/if}
</script>


{include file="_foot.tpl"}

Modified src/templates/users/message.tpl from [68d6edcd71] to [a5fc52d86b].

1
2
3
4
5
6
7
8
9
10
11
12
13


14
15
16
17
18
19
20
1
2
3
4
5
6
7
8



9
10
11
12
13
14
15
16
17
18
19








-
-
-


+
+







{include file="_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
	<fieldset class="message">
		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			{input type="radio" name="sender" value="self" default="self" required=true label="Membre : %s"|args:$self->getNameAndEmail()}
			{input type="radio" name="sender" value="org" default="self" required=true label='Association : "%s" <%s>'|args:$config.org_name:$config.org_email}
			<dt>Destinataire</dt>
			<dd>{$recipient->getNameAndEmail()}</dd>
			{input type="radio-btn" name="sender" value="self" default="self" required=true label="Membre" help=$self->getNameAndEmail() prefix_label="Expéditeur" prefix_required=true}
			{input type="radio-btn" name="sender" value="org" default="self" required=true label="Association" help="%s  <%s>"|args:$config.org_name:$config.org_email}
			{input type="text" name="subject" required=true label="Sujet" class="full-width"}
			{input type="textarea" name="message" required=true label="Message" rows=15 class="full-width"}
			{input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"}
		</dl>
	</fieldset>

	<p class="submit">

Added src/templates/users/subscriptions.tpl version [74131c5aed].




































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{include file="_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user_name current="users/services"}

{include file="users/_nav_user.tpl" id=$user_id current="services"}

{form_errors}

{if !$only}
<dl class="cotisation">
	<dt>Statut des inscriptions</dt>
	{foreach from=$services item="service"}
	<dd{if $service.archived} class="disabled"{/if}>
		{$service.label}
		{if $service.archived} <em>(activité passée)</em>{/if}
		{if $service.status == -1 && $service.end_date} — expirée
		{elseif $service.status == -1} — <b class="error">en retard</b>
		{elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b>
		{elseif $service.status == 1} — <b class="confirm">à jour</b>{/if}
		{if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if}
		{if !$service.paid} — <b class="error">À payer&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est actuellement inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
	{if !$only && !$after}
	<dt>Nombre d'inscriptions pour ce membre</dt>
	<dd>
		{$list->count()}
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			{exportmenu href="?id=%d"|args:$user_id}
		{/if}
	</dd>
	{/if}
</dl>
{/if}

{if $only}
	<p class="alert block">Cette liste ne montre qu'une seule inscription, liée à l'activité <strong>{$only_service.label}</strong><br />
		{linkbutton shape="right" href="?id=%d"|args:$user_id label="Voir toutes les inscriptions"}
	</p>
{/if}

{include file="common/dynamic_list_head.tpl"}

	{foreach from=$list->iterate() item="row"}
		<tr{if $row.archived} class="disabled"{/if}>
			<th>{$row.label} {if $row.archived}<em>(archivée)</em>{/if}</th>
			<td>{$row.fee}</td>
			<td>{$row.date|date_short}</td>
			<td>{$row.expiry|date_short}</td>
			<td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td>
			<td class="money">{if $row.expected_amount}{$row.amount|raw|money_currency:false}
				{if $row.amount}<br /><small class="help">(sur {$row.expected_amount|raw|money_currency:false})</small>{/if}
				{/if}
			</td>
			<td class="actions">
			{if !$row.paid}
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $row.id_account}
					{linkbutton shape="plus" label="Nouveau règlement" href="!services/subscription/payment.php?id=%d"|args:$row.id}
				{/if}

				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
					{linkbutton shape="plus" label="Saisir une écriture liée"
						href="!acc/transactions/new.php?u[%d]=%d&00=%d&t=1&l=Paiement%%20activité&ar=%s&set_year=%d"|args:$user_id:$row.id:$row.expected_amount:$row.account_code:$row.id_year target="_dialog"}
				{/if}
				<br />
			{/if}

			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
				{linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/subscription.php?id=%d&user=%d"|args:$row.id,$user_id}
			{/if}

			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				{if $row.paid}
					{linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user_id,$row.id}
				{else}
					{linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user_id,$row.id}
				{/if}
				<br />
				{linkbutton shape="edit" label="Modifier" href="!services/subscription/edit.php?id=%d"|args:$row.id}
				{linkbutton shape="delete" label="Supprimer" href="!services/subscription/delete.php?id=%d"|args:$row.id}
			{/if}

			</td>
		</tr>
	{foreachelse}
		<tr>
			<td colspan="7">Aucune inscription trouvée.</td>
		</tr>
	{/foreach}

	</tbody>
</table>

{$list->getHTMLPagination()|raw}


{include file="_foot.tpl"}

Modified src/www/_route.php from [fbc1235739] to [7f487761ce].

1
2
3
4
5
6

7
8
9
10
11
12
13
1
2
3
4
5

6
7
8
9
10
11
12
13





-
+







<?php

namespace Paheko;

use Paheko\Web\Router;
use Paheko\Email\Emails;
use Paheko\Email\Addresses;

if (empty($_SERVER['REQUEST_URI'])) {
	http_response_code(500);
	die('Appel non supporté');
}

$uri = $_SERVER['REQUEST_URI'];
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
34
35
36
37
38
39
40

41
42
43
44
45
46
47
48







-
+








// Handle __un__subscribe URL: .../?un=XXXX
if ((empty($uri) || $uri === '/') && !empty($_GET['un'])) {
	$params = array_intersect_key($_GET, ['un' => null, 'v' => null]);

	// RFC 8058
	if (!empty($_POST['Unsubscribe']) && $_POST['Unsubscribe'] == 'Yes') {
		$email = Emails::getEmailFromOptout($params['un']);
		$email = Addresses::getFromOptout($params['un']);

		if (!$email) {
			throw new UserException('Adresse email introuvable.');
		}

		$email->setOptout();
		$email->save();

Modified src/www/admin/acc/transactions/details.php from [1a82cb539a] to [b62b8ead45].

29
30
31
32
33
34
35
36

37
38
39
40
41
42
29
30
31
32
33
34
35

36
37
38
39
40
41
42







-
+






	'details'              => $transaction->getDetails(),
	'files'                => $transaction->listFiles(),
	'creator_name'         => $transaction->id_creator ? Users::getName($transaction->id_creator) : null,
	'files_edit'           => $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE),
	'file_parent'          => $transaction->getAttachementsDirectory(),
	'linked_users'         => $transaction->listLinkedUsers(),
	'linked_transactions'  => $transaction->listLinkedTransactions(),
	'linked_subscriptions' => $transaction->listSubscriptionLinks(),
	'linked_subscriptions' => $transaction->listLinkedSubscriptions(),
];

$tpl->assign($variables);
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_TRANSACTION, $variables));

$tpl->display('acc/transactions/details.tpl');

Deleted src/www/admin/acc/transactions/service_user.php version [c55459a2ec].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30






























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Accounting\Reports;
use Paheko\Accounting\Transactions;
use Paheko\Accounting\Years;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$id = (int)qg('id');
$user = (int)qg('user');
$self_url = sprintf('!acc/transactions/service_user.php?id=%d&user=%d', $id, $user);

$form->runIf(qg('unlink') !== null, function () use ($id) {
	$t = Transactions::get((int)qg('unlink'));
	$t->deleteSubscriptionLink($id);
}, null, $self_url);

$criterias = ['subscription' => $id];
$action = ['shape' => 'delete', 'href' => $self_url . '&unlink=%d', 'label' => 'Dé-lier cette écriture'];

$tpl->assign('balance', Reports::getAccountsBalances($criterias));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign('user_id', $user);
$tpl->assign('service_user_id', $id);
$tpl->assign(compact('action'));

$tpl->display('acc/transactions/service_user.tpl');

Added src/www/admin/acc/transactions/subscription.php version [32cde7e24f].































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Accounting\Reports;
use Paheko\Accounting\Transactions;
use Paheko\Accounting\Years;

require_once __DIR__ . '/../../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$id = (int)qg('id');
$user = (int)qg('user');
$self_url = sprintf('!acc/transactions/subscription.php?id=%d&user=%d', $id, $user);

$form->runIf(qg('unlink') !== null, function () use ($id) {
	$t = Transactions::get((int)qg('unlink'));
	$t->deleteSubscriptionLink($id);
}, null, $self_url);

$criterias = ['subscription' => $id];
$action = ['shape' => 'delete', 'href' => $self_url . '&unlink=%d', 'label' => 'Dé-lier cette écriture'];

$tpl->assign('balance', Reports::getAccountsBalances($criterias));
$tpl->assign('journal', Reports::getJournal($criterias));
$tpl->assign('user_id', $user);
$tpl->assign('subscription_id', $id);
$tpl->assign(compact('action'));

$tpl->display('acc/transactions/subscription.tpl');

Modified src/www/admin/config/advanced/api.php from [0867da5451] to [e8b46ed571].

19
20
21
22
23
24
25
26

27
28
29
19
20
21
22
23
24
25

26
27
28
29







-
+



}, $csrf_key, Utils::getSelfURI());

$list = API_Credentials::list();
$default_key = API_Credentials::generateKey();
$secret = API_Credentials::generateSecret();
$access_levels = API_Entity::ACCESS_LEVELS;

$tpl->assign('website', WEBSITE);
$tpl->assign('api_doc_url', Utils::getLocalURL('!static/doc/api.html'));
$tpl->assign(compact('list', 'csrf_key', 'default_key', 'secret', 'access_levels'));

$tpl->display('config/advanced/api.tpl');

Modified src/www/admin/me/services.php from [01519cc713] to [6036b9331b].

1
2
3
4

5
6
7
8
9
10
11
12
13

14
15
16
17
18

19
20
21
22
23
24
25
1
2
3

4
5
6
7
8
9
10
11
12

13
14
15
16
17

18
19
20
21
22
23
24
25



-
+








-
+




-
+







<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Services\Subscriptions;
use Paheko\Accounting\Reports;
use Paheko\Entities\Accounting\Account;
use Paheko\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list = Subscriptions::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$services = Services_User::listDistinctForUser($user->id);
$services = Subscriptions::listDistinctForUser($user->id);
$accounts = Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]);

$variables = compact('list', 'services', 'accounts');
$tpl->assign($variables);
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_SERVICES, $variables));

$tpl->display('me/services.tpl');

Modified src/www/admin/optout.php from [a8e32e6d40] to [992e84897e].

1
2
3
4

5
6
7
8
9
10
11
12
13
14
15

16
17
18
19
20
21
22
1
2
3

4
5
6
7
8
9
10
11
12
13
14

15
16
17
18
19
20
21
22



-
+










-
+







<?php
namespace Paheko;

use Paheko\Email\Emails;
use Paheko\Email\Addresses;

const LOGIN_PROCESS = true;

require_once __DIR__ . '/_inc.php';

if (empty($_GET['un'])) {
	throw new UserException('Demande de désinscription incomplète.');
}

$code = $_GET['un'];
$email = Emails::getEmailFromOptout($code);
$email = Addresses::getFromOptout($code);
$verify = null;

if (!$email) {
	throw new UserException('Adresse email introuvable.');
}

if (!empty($_GET['v'])) {

Added src/www/admin/services/history.php version [c26070860d].















1
2
3
4
5
6
7
8
9
10
11
12
13
14
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko;

use Paheko\Services\Subscriptions;

require_once __DIR__ . '/_inc.php';

$list = Subscriptions::getList();
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$tpl->display('services/history.tpl');

Modified src/www/admin/services/import.php from [8b45124880] to [eb312706d4].

1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
16
17
18
19


20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

40
41
42
43
44
45
46
47
48
1
2
3
4
5
6
7

8
9
10
11
12
13
14
15
16
17


18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

39
40
41
42
43
44
45
46
47
48







-
+









-
-
+
+



















-
+









<?php

namespace Paheko;

use Paheko\CSV_Custom;
use Paheko\Users\Session;
use Paheko\Users\Users;
use Paheko\Services\Services_User;
use Paheko\Services\Subscriptions;

require_once __DIR__ . '/_inc.php';

$session = Session::getInstance();
$session->requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN);

$csrf_key = 'su_import';
$csv = new CSV_Custom($session, 'su_import');

$csv->setColumns(Services_User::listImportColumns());
$csv->setMandatoryColumns(Services_User::listMandatoryImportColumns());
$csv->setColumns(Subscriptions::listImportColumns());
$csv->setMandatoryColumns(Subscriptions::listMandatoryImportColumns());

$form->runIf('cancel', function() use ($csv) {
	$csv->clear();
}, $csrf_key, Utils::getSelfURI());

$form->runIf(f('load') && isset($_FILES['file']['tmp_name']), function () use ($csv) {
	$csv->load($_FILES['file']);
}, $csrf_key, Utils::getSelfURI());

$form->runIf(f('import') && $csv->loaded(), function () use (&$csv) {
	$csv->skip((int)f('skip_first_line'));
	$csv->setTranslationTable(f('translation_table'));

	try {
		if (!$csv->ready()) {
			$csv->clear();
			throw new UserException('Erreur dans le chargement du CSV');
		}

		Services_User::import($csv);
		Subscriptions::import($csv);
	}
	finally {
		$csv->clear();
	}
}, $csrf_key, '!services/import.php?msg=OK');

$tpl->assign(compact('csv', 'csrf_key'));

$tpl->display('services/import.tpl');

Modified src/www/admin/services/index.php from [0d2c08a2dd] to [1c9db068eb].

11
12
13
14
15
16
17
18
19


20




21



22
23
24

25
26
11
12
13
14
15
16
17


18
19
20
21
22
23
24

25
26
27
28
29

30
31
32







-
-
+
+

+
+
+
+
-
+
+
+


-
+


$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () {
	$service = new Service;
	$service->importForm();
	$service->save();
	Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id());
}, $csrf_key);

$has_old_services = Services::countOldServices();
$show_old_services = $_GET['old'] ?? false;
$has_archived_services = Services::hasArchivedServices();
$show_archived_services = $_GET['archived'] ?? false;

if ($show_archived_services) {
	$list = Services::listArchivedWithStats();
}
else {
$list = Services::listWithStats(!$show_old_services);
	$list = Services::listWithStats();
}

$list->loadFromQueryString();

$tpl->assign(compact('csrf_key', 'has_old_services', 'show_old_services', 'list'));
$tpl->assign(compact('csrf_key', 'has_archived_services', 'show_archived_services', 'list'));

$tpl->display('services/index.tpl');

Modified src/www/admin/services/reminders/delete.php from [32c8f1317f] to [7fa839c1bc].

14
15
16
17
18
19
20




21
22
23
24
25
26
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30







+
+
+
+






if (!$reminder) {
	throw new UserException("Ce rappel n'existe pas");
}

$csrf_key = 'reminder_delete_' . $reminder->id();

$form->runIf('delete', function () use ($reminder) {
	if (f('confirm_delete')) {
		$reminder->deleteHistory();
	}

	$reminder->delete();
}, $csrf_key, ADMIN_URL . 'services/reminders/');

$tpl->assign(compact('reminder', 'csrf_key'));

$tpl->display('services/reminders/delete.tpl');

Added src/www/admin/services/subscription/_form.php version [6c01868431].



































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko;

use Paheko\Accounting\Projects;
use Paheko\Services\Services;


if (!defined('\Paheko\ROOT')) {
	die();
}

assert(isset($tpl, $form_url, $create));

// If there is only one user selected we can calculate the amount
$single_user_id = isset($users) && count($users) == 1 ? key($users) : null;
$copy_service ??= null;
$copy_service_only_paid ??= null;
$users ??= null;

$grouped_services = Services::listGroupedWithFees($single_user_id);

$today = new \DateTime;

$tpl->assign([
	'custom_js' => ['service_form.js'],
]);

$tpl->assign(compact('form_url', 'today', 'grouped_services',
	'create', 'copy_service', 'copy_service_only_paid'));

$tpl->assign_by_ref('users', $users);

$tpl->assign('projects', Projects::listAssoc());

Added src/www/admin/services/subscription/delete.php version [c6518954dd].
































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Subscriptions;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Subscriptions::get((int) qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'su_delete_' . $su->id();
$user_id = $su->id_user;

$form->runIf('delete', function () use ($su) {
	$su->delete();
}, $csrf_key, ADMIN_URL . 'users/subscriptions.php?id=' . $user_id);

$user_name = Users::getName($su->id_user);

$service_name = $su->service()->label;
$fee_name = $su->id_fee ? $su->fee()->label : null;

$tpl->assign(compact('csrf_key', 'user_name', 'fee_name', 'service_name'));

$tpl->display('services/subscription/delete.tpl');

Added src/www/admin/services/subscription/edit.php version [769c92870f].


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Subscriptions;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$subscription = Subscriptions::get((int) qg('id'));

if (!$subscription) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'subscription_edit_' . $subscription->id();
$users = [$subscription->id_user => Users::getName($subscription->id_user)];
$form_url = sprintf('edit.php?id=%d&', $subscription->id());
$create = false;

require __DIR__ . '/_form.php';

$form->runIf('save', function () use ($subscription) {
	$subscription->importForm();
	$subscription->importForm(['paid' => (bool)f('paid')]);
	$subscription->updateExpectedAmount();
	$subscription->save();
}, $csrf_key, ADMIN_URL . 'users/subscriptions.php?id=' . $subscription->id_user);

$tpl->assign(compact('csrf_key', 'subscription'));

$tpl->display('services/subscription/edit.tpl');

Added src/www/admin/services/subscription/link.php version [9079fc0131].


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Subscriptions;
use Paheko\Accounting\Transactions;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$subscription = Subscriptions::get((int)qg('id'));

if (!$subscription) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'service_link';

$form->runIf('save', function () use ($subscription) {
	$id = (int)f('id_transaction');
	$transaction = Transactions::get($id);

	if (!$transaction) {
		throw new UserException('Impossible de trouver l\'écriture #' . $id);
	}

	$transaction->linkToSubscription($subscription->id);
}, $csrf_key, '!acc/transactions/subscription.php?id=' . $subscription->id . '&user=' . $subscription->id_user);

$tpl->assign(compact('csrf_key'));

$tpl->display('services/subscription/link.tpl');

Added src/www/admin/services/subscription/new.php version [9be2c31d95].

















































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Fees;
use Paheko\Services\Services;
use Paheko\Users\Categories;
use Paheko\Users\Users;
use Paheko\Accounting\Projects;
use Paheko\Entities\Services\Subscription;
use Paheko\Entities\Accounting\Account;
use Paheko\Entities\Accounting\Transaction;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

// This controller allows to either select a user if none has been provided in the query string
// or subscribe a user to an activity (create a new Subscription entity)
// If $user_id is null then the form is just a select to choose a user

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$users = null;
$copy_service = null;
$copy_fee = null;
$copy_only_paid = null;
$allow_users_edit = true;
$copy = substr((string) f('copy'), 0, 1);
$copy_id = (int) substr((string) f('copy'), 1);

if (qg('user') && ($name = Users::getName((int)qg('user')))) {
	$users = [(int)qg('user') => $name];
	$allow_users_edit = false;
}
elseif (f('users') && is_array(f('users')) && count(f('users'))) {
	$users = f('users');
	$users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY);
}
elseif (($copy == 's' && ($copy_service = Services::get($copy_id)))
	|| ($copy == 'f' && ($copy_fee = Fees::get($copy_id)))) {
	$copy_only_paid = (bool) f('copy_only_paid');
}
elseif (f('category')) {
	$category = Categories::get((int)f('category'));

	if (!$category) {
		throw new UserException('Catégorie inconnue.');
	}

	$users = iterator_to_array(Users::iterateAssocByCategory($category->id));
}
elseif (qg('users')) {
	$users = explode(',', qg('users'));
	$users = array_map('intval', $users);
	$users = Users::getNames($users);
}
else {
	throw new UserException('Aucun membre n\'a été sélectionné');
}

if (null !== $users) {
	natcasesort($users);
}

$form_url = '?';
$csrf_key = 'service_save';
$create = true;

// Only load the form if a user has been selected
require __DIR__ . '/_form.php';

$form->runIf('save', function () use ($session, &$users, $copy_service, $copy_fee, $copy_only_paid) {
	if ($copy_service) {
		$users = $copy_service->getUsers($copy_only_paid);
	}
	elseif ($copy_fee) {
		$users = $copy_fee->getUsers($copy_only_paid);
	}

	$su = Subscription::createFromForm($users, $session->getUser()->id, $copy_service ? true : false);

	Utils::reloadParentFrameIfDialog();

	if (count($users) > 1) {
		$url = ADMIN_URL . 'services/details.php?id=' . $su->id_service;
	}
	else {
		$url = ADMIN_URL . 'users/subscriptions.php?id=' . $su->id_user;
	}

	Utils::redirect($url);
}, $csrf_key);

if (null !== $users && !count($users)) {
	throw new ValidationException('Aucun membre sélectionné ne peut être inscrit, car ils sont tous déjà inscrits à cette activité et à la date indiquée.');
}

$t = new Transaction;
$t->type = $t::TYPE_REVENUE;
$types_details = $t->getTypesDetails();
$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string;

$subscription = null;

$tpl->assign(compact('csrf_key', 'users', 'account_targets', 'subscription', 'allow_users_edit', 'copy_service', 'copy_fee', 'copy_only_paid'));
$tpl->assign('projects', Projects::listAssoc());

$tpl->display('services/subscription/new.tpl');

Added src/www/admin/services/subscription/payment.php version [856f266536].




















































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Subscriptions;
use Paheko\Accounting\Accounts;
use Paheko\Accounting\Projects;
use Paheko\Accounting\Years;
use Paheko\Entities\Accounting\Account;
use Paheko\Entities\Accounting\Transaction;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Subscriptions::get((int)qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$fee = $su->fee();

if (!$fee || !$fee->id_year) {
	throw new UserException('Cette inscription n\'est pas liée à un tarif relié à la comptabilité, il n\'est pas possible de saisir un règlement.');
}

$user_name = Users::getName($su->id_user);

$csrf_key = 'service_pay';

$form->runIf(f('save') || f('save_and_add_payment'), function () use ($su, $session) {
	$su->addPayment($session->getUser()->id);

	if ($su->paid != (bool) f('paid')) {
		$su->paid = (bool) f('paid');
		$su->save();
	}
}, $csrf_key, '!users/subscriptions.php?id=' . $su->id_user);

$t = new Transaction;
$t->type = $t::TYPE_REVENUE;
$types_details = $t->getTypesDetails();

$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string;

$tpl->assign('projects', Projects::listAssoc());

$tpl->assign(compact('csrf_key', 'account_targets', 'user_name', 'su', 'fee'));

$tpl->display('services/subscription/payment.tpl');

Added src/www/admin/services/subscription/select.php version [aa7c499a9e].




























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Services\Services;
use Paheko\Users\Categories;
use Paheko\Users\Session;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

// This controller allows to either select a user if none has been provided in the query string
// or subscribe a user to an activity (create a new Subscription entity)
// If $user_id is null then the form is just a select to choose a user

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$services = Services::listAssocWithFees();
$categories = Categories::listAssoc();

$tpl->assign(compact('services', 'categories'));

$tpl->display('services/subscription/select.tpl');

Deleted src/www/admin/services/user/_form.php version [0472d83149].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47















































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko;

use Paheko\Accounting\Projects;
use Paheko\Services\Services;


if (!defined('\Paheko\ROOT')) {
	die();
}

assert(isset($tpl, $form_url, $create));

$current_only = !f('past_services');

// If there is only one user selected we can calculate the amount
$single_user_id = isset($users) && count($users) == 1 ? key($users) : null;
$copy_service ??= null;
$copy_service_only_paid ??= null;
$users ??= null;

$grouped_services = Services::listGroupedWithFees($single_user_id, (int)$current_only);

if (!count($grouped_services)) {
	$current_only = false;
	$grouped_services = Services::listGroupedWithFees($single_user_id, (int)$current_only);
}

if (!isset($count_all)) {
	$count_all = Services::count();
}

$has_past_services = count($grouped_services) != $count_all;

$today = new \DateTime;

$tpl->assign([
	'custom_js' => ['service_form.js'],
]);

$tpl->assign(compact('form_url', 'today', 'grouped_services', 'current_only', 'has_past_services',
	'create', 'copy_service', 'copy_service_only_paid'));

$tpl->assign_by_ref('users', $users);

$tpl->assign('projects', Projects::listAssoc());

Deleted src/www/admin/services/user/add.php version [f211c6b3cd].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27



























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Services;
use Paheko\Users\Categories;
use Paheko\Users\Session;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

// This controller allows to either select a user if none has been provided in the query string
// or subscribe a user to an activity (create a new Service_User entity)
// If $user_id is null then the form is just a select to choose a user

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$services = Services::listAssocWithFees();
$categories = Categories::listAssoc();

$tpl->assign(compact('services', 'categories'));

$tpl->display('services/user/add.tpl');

Deleted src/www/admin/services/user/delete.php version [258ad809c7].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Services_User::get((int) qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'su_delete_' . $su->id();
$user_id = $su->id_user;

$form->runIf('delete', function () use ($su) {
	$su->delete();
}, $csrf_key, ADMIN_URL . 'services/user/?id=' . $user_id);

$user_name = Users::getName($su->id_user);

$service_name = $su->service()->label;
$fee_name = $su->id_fee ? $su->fee()->label : null;

$tpl->assign(compact('csrf_key', 'user_name', 'fee_name', 'service_name'));

$tpl->display('services/user/delete.tpl');

Deleted src/www/admin/services/user/edit.php version [ac51e77e6c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35



































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Services_User::get((int) qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'su_edit_' . $su->id();
$users = [$su->id_user => Users::getName($su->id_user)];
$form_url = sprintf('edit.php?id=%d&', $su->id());
$create = false;

require __DIR__ . '/_form.php';

$form->runIf('save', function () use ($su) {
	$su->importForm();
	$su->importForm(['paid' => (bool)f('paid')]);
	$su->updateExpectedAmount();
	$su->save();
}, $csrf_key, ADMIN_URL . 'services/user/?id=' . $su->id_user);

$service_user = $su;

$tpl->assign(compact('csrf_key', 'service_user'));

$tpl->display('services/user/edit.tpl');

Deleted src/www/admin/services/user/index.php version [f8e9112ec8].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46














































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko;

use Paheko\Services\Services;
use Paheko\Services\Services_User;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_READ);

$user_id = (int) qg('id');
$user_name = Users::getName($user_id);

if (!$user_name) {
	throw new UserException("Ce membre est introuvable");
}

$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && null !== qg('paid') && qg('su_id'), function () {
	$su = Services_User::get((int) qg('su_id'));

	if (!$su) {
		throw new UserException("Cette inscription est introuvable");
	}

	$su->paid = (bool)qg('paid');
	$su->save();
}, null, ADMIN_URL . 'services/user/?id=' . $user_id);

$only = (int)qg('only') ?: null;

if ($after = qg('after')) {
	$after = \DateTime::createFromFormat('!Y-m-d', $after) ?: null;
}

$only_service = !$only ? null : Services::get($only);

$list = Services_User::perUserList($user_id, $only, $after);
$list->setTitle(sprintf('Inscriptions — %s', $user_name));
$list->loadFromQueryString();

$tpl->assign('services', Services_User::listDistinctForUser($user_id));
$tpl->assign(compact('list', 'user_id', 'user_name', 'only', 'only_service', 'after'));

$tpl->display('services/user/index.tpl');

Deleted src/www/admin/services/user/link.php version [fadf825ef4].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Accounting\Transactions;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ);

$su = Services_User::get((int)qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'service_link';

$form->runIf('save', function () use ($su) {
	$id = (int)f('id_transaction');
	$transaction = Transactions::get($id);

	if (!$transaction) {
		throw new UserException('Impossible de trouver l\'écriture #' . $id);
	}

	$transaction->linkToSubscription($su->id);
}, $csrf_key, '!acc/transactions/service_user.php?id=' . $su->id . '&user=' . $su->id_user);

$tpl->assign(compact('csrf_key'));

$tpl->display('services/user/link.tpl');

Deleted src/www/admin/services/user/payment.php version [ffae6c1087].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51



















































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Accounting\Accounts;
use Paheko\Accounting\Projects;
use Paheko\Accounting\Years;
use Paheko\Entities\Accounting\Account;
use Paheko\Entities\Accounting\Transaction;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Services_User::get((int)qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$fee = $su->fee();

if (!$fee || !$fee->id_year) {
	throw new UserException('Cette inscription n\'est pas liée à un tarif relié à la comptabilité, il n\'est pas possible de saisir un règlement.');
}

$user_name = Users::getName($su->id_user);

$csrf_key = 'service_pay';

$form->runIf(f('save') || f('save_and_add_payment'), function () use ($su, $session) {
	$su->addPayment($session->getUser()->id);

	if ($su->paid != (bool) f('paid')) {
		$su->paid = (bool) f('paid');
		$su->save();
	}
}, $csrf_key, '!services/user/?id=' . $su->id_user);

$t = new Transaction;
$t->type = $t::TYPE_REVENUE;
$types_details = $t->getTypesDetails();

$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string;

$tpl->assign('projects', Projects::listAssoc());

$tpl->assign(compact('csrf_key', 'account_targets', 'user_name', 'su', 'fee'));

$tpl->display('services/user/payment.tpl');

Deleted src/www/admin/services/user/subscribe.php version [f757166bd5].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
















































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Services\Fees;
use Paheko\Services\Services;
use Paheko\Users\Categories;
use Paheko\Users\Users;
use Paheko\Accounting\Projects;
use Paheko\Entities\Services\Service_User;
use Paheko\Entities\Accounting\Account;
use Paheko\Entities\Accounting\Transaction;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

// This controller allows to either select a user if none has been provided in the query string
// or subscribe a user to an activity (create a new Service_User entity)
// If $user_id is null then the form is just a select to choose a user

$count_all = Services::count();

if (!$count_all) {
	Utils::redirect(ADMIN_URL . 'services/?CREATE');
}

$users = null;
$copy_service = null;
$copy_fee = null;
$copy_only_paid = null;
$allow_users_edit = true;
$copy = substr((string) f('copy'), 0, 1);
$copy_id = (int) substr((string) f('copy'), 1);

if (qg('user') && ($name = Users::getName((int)qg('user')))) {
	$users = [(int)qg('user') => $name];
	$allow_users_edit = false;
}
elseif (f('users') && is_array(f('users')) && count(f('users'))) {
	$users = f('users');
	$users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY);
}
elseif (($copy == 's' && ($copy_service = Services::get($copy_id)))
	|| ($copy == 'f' && ($copy_fee = Fees::get($copy_id)))) {
	$copy_only_paid = (bool) f('copy_only_paid');
}
elseif (f('category')) {
	$category = Categories::get((int)f('category'));

	if (!$category) {
		throw new UserException('Catégorie inconnue.');
	}

	$users = iterator_to_array(Users::iterateAssocByCategory($category->id));
}
elseif (qg('users')) {
	$users = explode(',', qg('users'));
	$users = array_map('intval', $users);
	$users = Users::getNames($users);
}
else {
	throw new UserException('Aucun membre n\'a été sélectionné');
}

if (null !== $users) {
	natcasesort($users);
}

$form_url = '?';
$csrf_key = 'service_save';
$create = true;

// Only load the form if a user has been selected
require __DIR__ . '/_form.php';

$form->runIf('save', function () use ($session, &$users, $copy_service, $copy_fee, $copy_only_paid) {
	if ($copy_service) {
		$users = $copy_service->getUsers($copy_only_paid);
	}
	elseif ($copy_fee) {
		$users = $copy_fee->getUsers($copy_only_paid);
	}

	$su = Service_User::createFromForm($users, $session->getUser()->id, $copy_service ? true : false);

	Utils::reloadParentFrameIfDialog();

	if (count($users) > 1) {
		$url = ADMIN_URL . 'services/details.php?id=' . $su->id_service;
	}
	else {
		$url = ADMIN_URL . 'services/user/?id=' . $su->id_user;
	}

	Utils::redirect($url);
}, $csrf_key);

if (null !== $users && !count($users)) {
	throw new ValidationException('Aucun membre sélectionné ne peut être inscrit, car ils sont tous déjà inscrits à cette activité et à la date indiquée.');
}

$t = new Transaction;
$t->type = $t::TYPE_REVENUE;
$types_details = $t->getTypesDetails();
$account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string;

$service_user = null;

$tpl->assign(compact('csrf_key', 'users', 'account_targets', 'service_user', 'allow_users_edit', 'copy_service', 'copy_fee', 'copy_only_paid'));
$tpl->assign('projects', Projects::listAssoc());

$tpl->display('services/user/subscribe.tpl');

Modified src/www/admin/static/doc/api.html from [d4bb706a70] to [3d95d98e16].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/api.md</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/api.md</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72


































































73
74
75
76
77
78
79























80
81
82
83
84
85
86
87
88
89















































90
91
92
93





















94
95
96




97
98
99









100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120









































121
122


























123
124
125
126
127






















128
129
130










131
132
133


134
135
136


















137
138
139
140
141
142
143
144
145
146
147
148
































149
150
151
152
153

























154
155
156
157
158
159
160
161
162
163
164



































165
166
167
168
169
170
171
172
173
174
175
176
177
















































178
179

180
181
182
183
184























































185
186
187



188
189








190
191



192
193
194
195
196


197
198
199
200
201





























202

203
204
205
206









207
208
209
210
211
212
213
214
215
216
217
218



























219
220
221



















222
223









224
225
226

227
228
229
230
231
232
233
234
235
236

237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252

253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275

276
277
278
279
280

281
282
283
284
285
286
287



288
289

290
291
292
293
294
295
296
297
298
44
45
46
47
48
49
50























51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116







117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139










140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186




187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207



208
209
210
211



212
213
214
215
216
217
218
219
220





















221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261


262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287





288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309



310
311
312
313
314
315
316
317
318
319



320
321



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339












340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371





372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396











397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431













432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479


480





481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535



536
537
538


539
540
541
542
543
544
545
546


547
548
549





550
551


552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583

584




585
586
587
588
589
590
591
592
593












594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620



621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639


640
641
642
643
644
645
646
647
648
649
650

651
652
653
654
655
656
657
658
659
660

661


662
663
664
665
666
667
668
669
670
671
672
673


674



675



















676





677



678



679
680
681


682


683
684
685
686
687
688
689







-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
+
+
-
-



+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+


-
+









-
+
-
-












-
-
+
-
-
-

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
-
-
-

-
-
-
+
+
+
-
-
+
-
-







		.web-content .nav strong a {
			color: darkred;
			box-shadow: 0px 0px 5px orange;
		}
		</style>
		<link rel="stylesheet" type="text/css" href="../../../content.css" />
	</head>
	<body><div class="web-content"><p>Une API de type REST est disponible dans Paheko.</p>
<p>Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu <mark>Configuration</mark>, onglet <mark>Fonctions avancées</mark>, puis <mark>API</mark>.</p>
<p>L'API peut ensuite recevoir des requêtes REST sur l'URL <code>https://adresse_association/api/{chemin}/</code>.</p>
<p>Remplacer <mark>{chemin}</mark> par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin.</p>
<p>Pour les requêtes de type <code>POST</code>, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (<code>application/x-www-form-urlencoded</code>) ou sous forme d'objet JSON. Dans ce cas le <code>Content-Type</code> doit être positionné sur <code>application/json</code>.</p>
<p>Les réponses sont faites en JSON par défaut.</p><div class="toc">
	<ol>
		<li><a href="#utiliser-l-api">Utiliser l'API</a></li>
		<li><a href="#authentification">Authentification</a></li>
		<li><a href="#erreurs">Erreurs</a></li>
		<li><a href="#chemins">Chemins</a>
		<ol>
			<li><a href="#sql-post">sql (POST)</a></li>
			<li><a href="#telechargements">Téléchargements</a>
			<ol>
				<li><a href="#download-get">download (GET)</a></li>
				<li><a href="#download-files-get">download/files (GET)</a>
			</ol></li>
			<li><a href="#site-web">Site web</a>
			<ol>
				<li><a href="#web-list-get">web/list (GET)</a></li>
				<li><a href="#web-attachment-page_uri-filename-get">web/attachment/{PAGE_URI}/{FILENAME} (GET)</a></li>
				<li><a href="#web-page-page_uri-get">web/page/{PAGE_URI} (GET)</a></li>
	<body><div class="web-content"><style type="text/css">
		details.api {
			clear: both;
			list-style: none;
			padding: 0.2em 0.5em;
			transition: background-color .2s;
			background: #fff;
			padding: 0;
			border: 1px solid #ccc;
			margin-bottom: .7em;
			border-radius: .5rem;
		}

		details.api summary {
			cursor: pointer;
			display: flex;
			align-items: center;
			gap: .8rem;
			font-size: 1.2em;
			position: relative;
			padding: .5rem;
			padding-right: 2em;
			flex-wrap: wrap;
		}

		details.api summary::after {
			content: "⌄";
			position: absolute;
			right: .5rem;
			bottom: .5em;
			font-size: 2em;
			line-height: .5em;
			transition: top .2s, transform .4s, color .2s;
		}

		details.api summary:hover::after {
			color: darkred;
			text-shadow: 0px 0px 5px orange;
		}

		details.api:not([open]):hover {
			background: #eee;
			box-shadow: 0px 0px 5px orange;
		}

		details.api[open] summary::after {
			transform: rotate(180deg);
			top: .75em;
			right: 0;
		}

		details.api[open] {
			padding: .5rem;
		}

		details.api[open] summary {
			margin-bottom: 1em;
			padding: 0;
			padding-right: 2em;
		}

		details.api summary b {
			display: block;
			border-radius: .3em;
			background: #333;
			padding: .1rem .4rem;
				<li><a href="#web-html-page_uri-get">web/html/{PAGE_URI} (GET)</a>
			</ol></li>
			<li><a href="#membres">Membres</a>
			<ol>
				<li><a href="#user-categories-get">user/categories (GET)</a></li>
				<li><a href="#user-category-id-format-get">user/category/{ID}.{FORMAT} (GET)</a></li>
				<li><a href="#user-new-post">user/new (POST)</a></li>
			color: #fff;
			width: 8ch;
			text-align: center;
		}

		details.api summary code {
			background: none;
			font-weight: bold;
			word-break: keep-all;
		}

		details.api summary code u {
			text-decoration: none;
			border: 1px dashed #999;
			color: darkblue;
			border-radius: .5rem;
			padding: .2rem;
		}

		details.api summary span {
			font-size: 1rem;
		}

				<li><a href="#user-id-get">user/{ID} (GET)</a></li>
				<li><a href="#user-id-delete">user/{ID} (DELETE)</a></li>
				<li><a href="#user-id-post">user/{ID} (POST)</a></li>
				<li><a href="#user-import-put">user/import (PUT)</a></li>
				<li><a href="#user-import-post">user/import (POST)</a></li>
				<li><a href="#user-import-preview-put">user/import/preview (PUT)</a></li>
				<li><a href="#user-import-preview-post">user/import/preview (POST)</a>
			</ol></li>
			<li><a href="#activites">Activités</a>
			<ol>
		details.api summary b.method-GET {
			background: #8fbc8f;
		}
		details.api summary b.method-POST {
			background: #4682b4;
		}
		details.api summary b.method-PUT {
			background: #9370db;
		}
		details.api summary b.method-DELETE {
			background: #cd5c5c;
		}

		details.api summary h3 {
			margin: 0;
		}

		details.api.all {
			float: right;
		}

		details.api.all summary {
			margin: 0;
			font-size: .9rem;
		}

		@media screen and (max-width: 800px) {
			details.api summary {
				flex-direction: column;
				align-items: start;
			}

			details.api.all {
				float: none;
			}
		}
		</style>
		<details class="api all"><summary onclick="var open = !this.parentNode.hasAttribute('open'); document.querySelectorAll('details').forEach(elm => elm.open = open); return false;">Tout déplier / replier</summary></details><?xml encoding="utf-8" ?><h1 id="introduction">Introduction</h1><details class="api"><summary id="debuter" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>D&eacute;buter</h3></summary><p>Une API de type REST est disponible dans Paheko.</p><p>Pour acc&eacute;der &agrave; l'API il faut un identifiant et un mot de passe, &agrave; cr&eacute;er dans le menu <mark>Configuration</mark>, onglet <mark>Fonctions avanc&eacute;es</mark>, puis <mark>API</mark>.</p><p>L'API peut ensuite recevoir des requ&ecirc;tes REST sur l'URL <code>https://adresse_association/api{route}</code>.</p><p>Remplacer <mark>{route}</mark> par une des routes de l'API (voir ci-dessous).</p><p>La m&eacute;thode HTTP (<code>GET</code>, <code>POST</code>, etc.) &agrave; utiliser est sp&eacute;cifi&eacute;e pour chaque route.</p><p>Des exemples sont donn&eacute;s pour l'utilisation de l'outil <code>curl</code> en ligne de commande, si vous souhaitez utiliser un autre langage de programmation il faudra adapter votre code.</p></details><details class="api"><summary id="formats-des-requetes-et-reponses" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Formats des requ&ecirc;tes et r&eacute;ponses</h3></summary><p>Les param&egrave;tres peuvent &ecirc;tre fournis sous les formes suivantes :</p><ul>
<li>dans les param&egrave;tres de l'URL (query string) : pour toutes les m&eacute;thodes</li>
<li>formulaire HTTP classique pour les requ&ecirc;tes <code>POST</code> :<ul>
<li><code>Content-Type: application/x-www-form-urlencoded</code></li>
<li>ou <code>Content-Type: multipart/form-data</code></li>
</ul>
</li>
<li>objet JSON pour les requ&ecirc;tes POST :<ul>
<li><code>Content-Type: application/json</code></li>
</ul>
				<li><a href="#services-subscriptions-import-put">services/subscriptions/import (PUT)</a>
			</ol></li>
			<li><a href="#erreurs">Erreurs</a>
			<ol>
</li>
</ul><p>Les r&eacute;ponses sont renvoy&eacute;es en JSON par d&eacute;faut, sauf quand la route permet de choisir un autre format.</p><p>Les formats ODS et XLSX ne sont disponibles &agrave; l'import que si le serveur est configur&eacute; pour convertir ces formats.</p><p>De la m&ecirc;me mani&egrave;re, le format XLSX n'est disponible que si le serveur est configur&eacute; pour g&eacute;n&eacute;rer ce format.</p></details><details class="api"><summary id="utiliser-l-api" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Utiliser l'API</h3></summary><p>N'importe quel client HTTP capable de g&eacute;rer TLS (HTTPS) et l'authentification basique fonctionnera.</p><p>En ligne de commande il est possible d'utiliser <code>curl</code>. Exemple pour t&eacute;l&eacute;charger la base de donn&eacute;es :</p><pre><code>curl -u test:secret https://[identifiant_association].paheko.cloud/api/download -o association.sqlite</code></pre><p>On peut aussi utiliser <code>wget</code> en n'oubliant pas l'option <code>--auth-no-challenge</code> sinon l'authentification ne fonctionnera pas :</p><pre><code>wget https://test:secret@[identifiant_association].paheko.cloud/api/download \
  --auth-no-challenge \
  -O association.sqlite</code></pre><p>Exemple pour cr&eacute;er une &eacute;criture sous forme de formulaire :</p><pre><code>curl -v -u test:secret \
  https://[identifiant_association].paheko.cloud/api/accounting/transaction \
  -F id_year=1 \
  -F label=Test \
  -F "date=01/02/2023"
  &hellip;</code></pre><p>Ou sous forme d'objet JSON :</p><pre><code>curl -v -u test:secret \
  https://[identifiant_association].paheko.cloud/api/accounting/transaction \
  -H 'Content-Type: application/json' \
  -d '{"id_year":1, "label": "Test &eacute;criture", "date": "01/02/2023", &hellip;}'</code></pre></details><details class="api"><summary id="authentification" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Authentification</h3></summary><p>L'API utilise l'authentification <a href="https://fr.wikipedia.org/wiki/Authentification_HTTP#M%C3%A9thode_%C2%AB_Basic_%C2%BB" rel="noreferrer noopener external" target="_blank"><code>Basic</code> de HTTP</a>.</p></details><details class="api"><summary id="erreurs" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Erreurs</h3></summary><p>En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une cl&eacute; <code>error</code> contenant le message d'erreur.</p></details><h1 id="routes">Routes</h1><h2 id="requetes-sql">Requ&ecirc;tes SQL</h2><details class="api"><summary id="post-sql-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/sql.<u>{FORMAT}</u></code> <span>Ex&eacute;cute une requ&ecirc;te SQL en lecture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
				<li><a href="#errors-report-post">errors/report (POST)</a></li>
				<li><a href="#errors-log-get">errors/log (GET)</a>
			</ol></li>
<td style="text-align: left;"><code>FORMAT</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Format de retour : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td>
</tr>
			<li><a href="#comptabilite">Comptabilité</a>
			<ol>
				<li><a href="#accounting-years-get">accounting/years (GET)</a></li>
<tr>
<td style="text-align: left;"><code>sql</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Requ&ecirc;te SQL &agrave; ex&eacute;cuter.</td>
</tr>
</tbody>
</table><p>Si aucun format n'est pass&eacute; (exemple : <code>&hellip;/api/sql</code>, sans point ni extension), <code>json</code> sera utilis&eacute;.</p><p>Permet d'ex&eacute;cuter une requ&ecirc;te SQL <code>SELECT</code> (uniquement, pas de requ&ecirc;te <code>UPDATE</code>, <code>DELETE</code>, <code>INSERT</code>, etc.) sur la base de donn&eacute;es. La requ&ecirc;te SQL doit &ecirc;tre pass&eacute;e dans le corps de la requ&ecirc;te HTTP, ou dans le param&egrave;tre <code>sql</code>.</p><p>S'il n'y a pas de limite &agrave; la requ&ecirc;te, une limite &agrave; 1000 r&eacute;sultats sera ajout&eacute;e obligatoirement.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/sql \
  -d 'SELECT nom, code_postal FROM users LIMIT 2;'</code></pre><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
    "count": 65,
				<li><a href="#accounting-charts-get">accounting/charts (GET)</a></li>
				<li><a href="#accounting-charts-id_chart-accounts-get">accounting/charts/{ID_CHART}/accounts (GET)</a></li>
				<li><a href="#accounting-years-id_year-journal-get">accounting/years/{ID_YEAR}/journal (GET)</a></li>
				<li><a href="#accounting-years-id_year-export-format-extension-get">accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)</a></li>
				<li><a href="#accounting-years-id_year-account-journal-get">accounting/years/{ID_YEAR}/account/journal (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-get">accounting/transaction/{ID_TRANSACTION} (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-post">accounting/transaction/{ID_TRANSACTION} (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-get">accounting/transaction/{ID_TRANSACTION}/users (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-post">accounting/transaction/{ID_TRANSACTION}/users (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-delete">accounting/transaction/{ID_TRANSACTION}/users (DELETE)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-get">accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-post">accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-delete">accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)</a></li>
				<li><a href="#accounting-transaction-post">accounting/transaction (POST)</a>
</li></ol></li></ol></li></ol></div><h1 id="utiliser-l-api">Utiliser l'API</h1>
<p>N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera.</p>
<p>En ligne de commande il est possible d'utiliser <code>curl</code>. Exemple pour télécharger la base de données :</p>
<pre><code>curl https://test:coucou@[identifiant_association].paheko.cloud/api/download -o association.sqlite</code></pre>
<p>On peut aussi utiliser <code>wget</code> en n'oubliant pas l'option <code>--auth-no-challenge</code> sinon l'authentification ne fonctionnera pas :</p>
<pre><code>wget https://test:coucou@[identifiant_association].paheko.cloud/api/download  --auth-no-challenge -O association.sqlite</code></pre>
<p>Exemple pour créer une écriture sous forme de formulaire :</p>
    "results":
    [
        {
            "nom": "Ada Lovelace",
            "code_postal": null
        },
        {
            "nom": "James Coincoin",
            "code_postal": "78990"
        }
    ]
}</code></pre><p><strong>Attention :</strong> Les requ&ecirc;tes en &eacute;criture (<code>INSERT, DELETE, UPDATE, CREATE TABLE</code>, etc.) ne sont pas accept&eacute;es, il n'est pas possible de modifier la base de donn&eacute;es directement via Paheko, afin d'&eacute;viter les soucis de donn&eacute;es corrompues.</p></details><h2 id="telechargements">T&eacute;l&eacute;chargements</h2><details class="api"><summary id="get-download" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/download</code> <span>T&eacute;l&eacute;charger la base de donn&eacute;es</span></summary><p>Renvoie directement le fichier SQLite de la base de donn&eacute;es.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/download -o db.sqlite</code></pre></details><details class="api"><summary id="get-download-files" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/download/files</code> <span>T&eacute;l&eacute;charger un fichier ZIP contenant tous les fichiers</span></summary><p><em>(Depuis la version 1.3.4)</em></p><p>Les fichiers inclus sont :</p><ul>
<li>documents</li>
<li>fichiers li&eacute;s aux &eacute;critures,</li>
<li>fichiers li&eacute;s des membres,</li>
<li>fichiers joints aux pages du site web</li>
<li>code des modules modifi&eacute;s</li>
<li>corbeille</li>
<li>configuration : logo, ic&ocirc;nes, etc.</li>
<li>anciennes versions des fichiers</li>
</ul><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/download/files -o backup_files.zip</code></pre></details><h2 id="site-web">Site web</h2><p><em>(Depuis la version 1.4.0)</em></p><details class="api"><summary id="get-web" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web</code> <span>Liste de toutes les pages du site web</span></summary></details><details class="api"><summary id="get-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u></code> <span>M&eacute;tadonn&eacute;es de la page du site web</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
<tr>
<td style="text-align: left;"><code>html</code></td>
<td style="text-align: left;"><code>bool</code></td>
<td style="text-align: left;">Si <code>true</code> ou <code>1</code>, une cl&eacute; <code>html</code> sera ajout&eacute;e &agrave; la r&eacute;ponse avec le contenu de la page au format HTML.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">[
<pre><code>curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -F id_year=1 -F label=Test -F "date=01/02/2023" …</code></pre>
<p>Ou sous forme d'objet JSON :</p>
    {
        "id": 13,
        "uri": "actualite",
        "title": "Actualit\u00e9",
        "path": null,
        "draft": 0,
        "published": "2019-04-22 18:00:00",
        "modified": "2023-09-12 15:44:55"
    },
    {
        "id": 66,
        "uri": "Affiches-des-bourses-aux-velos",
        "title": "Affiches des bourses aux v\u00e9los",
        "path": "Nos activit\u00e9s",
        "draft": 0,
        "published": "2019-07-18 19:05:00",
        "modified": "2023-04-04 14:44:04"
    },
    &hellip;
]</code></pre></details><details class="api"><summary id="put-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Modifie le contenu de la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
<pre><code>curl -v "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -H 'Content-Type: application/json' -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023"}'</code></pre>
<h1 id="authentification">Authentification</h1>
<p>Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP :</p>
<pre><code>curl http://test:abcd@paheko.monasso.tld/api/download/</code></pre>
<h1 id="erreurs">Erreurs</h1>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X PUT -d 'La bourse aura lieu le 28 septembre'</code></pre></details><details class="api"><summary id="post-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Modifie les m&eacute;tadonn&eacute;es de la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
<p>En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé <code>error</code> contenant le message d'erreur.</p>
<h1 id="chemins">Chemins</h1>
<h2 id="sql-post">sql (POST)</h2>
<tr>
<td style="text-align: left;"><code>id_parent</code></td>
<td style="text-align: left;"><code>int|null</code></td>
<td style="text-align: left;">Num&eacute;ro de la cat&eacute;gorie parente de cette page.</td>
</tr>
<tr>
<td style="text-align: left;"><code>uri</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Nouvelle adresse unique de la page.</td>
</tr>
<p>Permet d'exécuter une requête SQL <code>SELECT</code> (uniquement, pas de requête UPDATE, DELETE, INSERT, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre <code>sql</code>. Le résultat est retourné dans la clé <code>results</code> de l'objet JSON.</p>
<p>S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;'</code></pre>
<tr>
<td style="text-align: left;"><code>title</code></td>
<p><strong>ATTENTION :</strong> Les requêtes en écriture (<code>INSERT, DELETE, UPDATE, CREATE TABLE</code>, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.</p>
<p>Depuis la version 1.2.8, il est possible d'utiliser le paramètre <code>format</code> pour choisir le format renvoyé :</p>
<ul>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Titre de la page.</td>
</tr>
<tr>
<td style="text-align: left;"><code>type</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Type de page. <code>1</code> pour les cat&eacute;gories, <code>2</code> pour les pages simples.</td>
</tr>
<tr>
<td style="text-align: left;"><code>status</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Statut de la page. <code>online</code> si la page est en ligne, <code>draft</code> si la page est en brouillon.</td>
</tr>
<tr>
<td style="text-align: left;"><code>format</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Format de la page : <code>markdown</code>, <code>encrypted</code> ou <code>skriv</code></td>
</tr>
<li><code>json</code> (défaut) : renvoie un objet JSON, dont la clé est <code>"results"</code> et contient un tableau de la liste des membres trouvés</li>
<li><code>csv</code> : renvoie un fichier CSV</li>
<li><code>ods</code> : renvoie un tableau LibreOffice Calc (ODS)</li>
<li><code>xlsx</code> : renvoie un tableau Excel (XLSX)</li>
</ul>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv</code></pre>
<h2 id="telechargements">Téléchargements</h2>
<h3 id="download-get">download (GET)</h3>
<p>Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données.</p>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite</code></pre>
<tr>
<td style="text-align: left;"><code>published</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Date et heure de publication au format <code>YYYY-MM-DD HH:mm:ss</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>modified</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Date et heure de modification au format <code>YYYY-MM-DD HH:mm:ss</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>content</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Contenu.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -F title="Bourse aux v&eacute;los du 28 septembre"</code></pre></details><details class="api"><summary id="delete-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Supprime la page et ses fichiers joints</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X DELETE</code></pre></details><details class="api"><summary id="get-web-page_uri-html" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>.html</code> <span>Contenu de la page web au format HTML</span></summary><table>
<h3 id="download-files-get">download/files (GET)</h3>
<p><em>(Depuis la version 1.3.4)</em></p>
<p>Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.).</p>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip</code></pre>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre.html</code></pre></details><details class="api"><summary id="get-web-page_uri-children" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/children</code> <span>Liste des pages et sous-cat&eacute;gories dans cette cat&eacute;gorie</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<h2 id="site-web">Site web</h2>
<h3 id="web-list-get">web/list (GET)</h3>
<p>Renvoie la liste des pages du site web.</p>
<h3 id="web-attachment-page_uri-filename-get">web/attachment/{PAGE_URI}/{FILENAME} (GET)</h3>
<p>Renvoie le fichier joint correspondant à la page et nom de fichier indiqués.</p>
<h3 id="web-page-page_uri-get">web/page/{PAGE_URI} (GET)</h3>
<p>Renvoie un objet JSON avec toutes les infos de la page donnée.</p>
<p>Rajouter le paramètre <code>?html</code> à l'URL pour obtenir en plus une clé <code>html</code> dans l'objet JSON qui contiendra la page au format HTML.</p>
<h3 id="web-html-page_uri-get">web/html/{PAGE_URI} (GET)</h3>
<p>Renvoie uniquement le contenu de la page au format HTML.</p>
<h2 id="membres">Membres</h2>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/actualite/children</code></pre><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
    "categories": [],
    "pages": [
        {
            "id": 86,
            "id_parent": 13,
            "uri": "bourse-aux-velos-le-30-septembre-et-1er-octobre",
            "title": "Bourse aux v\u00e9los 30 septembre et 1er octobre",
            "type": 2,
            "status": "online",
            "format": "skriv",
            "published": "2023-10-01 18:00:00",
            "modified": "2023-09-11 23:41:41",
            "content": "&hellip;"
        },
        &hellip;
    ]
}</code></pre></details><details class="api"><summary id="get-web-page_uri-attachments" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/attachments</code> <span>Liste des fichiers joints &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
<h3 id="user-categories-get">user/categories (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Renvoie la liste des catégories de membres, triée par nom, et incluant le nombre de membres de la catégorie (dans la clé <code>count</code>).</p>
<h3 id="user-category-id-format-get">user/category/{ID}.{FORMAT} (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Exporte la liste des membres d'une catégorie correspondant à l'ID demandé, au format indiqué :</p>
<ul>
<li><code>json</code></li>
<li><code>csv</code></li>
<li><code>ods</code></li>
<li><code>xlsx</code></li>
</ul>
<h3 id="user-new-post">user/new (POST)</h3>
</tbody>
</table></details><details class="api"><summary id="get-web-page_uri-file_name" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/<u>{FILE_NAME}</u></code> <span>R&eacute;cup&eacute;rer le fichier joint &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
<tr>
<td style="text-align: left;"><code>FILENAME</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Nom du fichier.</td>
</tr>
</tbody>
</table></details><details class="api"><summary id="delete-web-page_uri-file_name" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/web/<u>{PAGE_URI}</u>/<u>{FILE_NAME}</u></code> <span>Supprime le fichier joint &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>PAGE_URI</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Adresse unique de la page.</td>
</tr>
<tr>
<td style="text-align: left;"><code>FILENAME</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Nom du fichier.</td>
</tr>
</tbody>
</table></details><h2 id="membres">Membres</h2><details class="api"><summary id="get-user-categories" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/categories</code> <span>Liste des cat&eacute;gories de membres</span></summary><p><em>(Depuis la version 1.4.0)</em></p><p>La liste est tri&eacute;e par nom, et inclue le nombre de membres de la cat&eacute;gorie dans la cl&eacute; <code>count</code>.</p><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
    "12": {
        "id": 12,
        "name": "Administration technique",
        "perm_web": 9,
        "perm_documents": 9,
        "perm_users": 9,
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Permet de créer un nouveau membre.</p>
        "perm_accounting": 9,
<p>Attention, cette méthode comporte des restrictions :</p>
<ul>
<li>il n'est pas possible de créer un membre dans une catégorie ayant accès à la configuration</li>
<li>il n'est pas possible de définir l'OTP ou la clé PGP du membre créé</li>
<li>seul un identifiant API ayant le droit "Administration" pourra créer des membres administrateurs</li>
        "perm_subscribe": 0,
        "perm_connect": 1,
        "perm_config": 9,
        "hidden": 0,
        "count": 1
    }
}</code></pre></details><details class="api"><summary id="get-user-category-id-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/category/<u>{ID}</u>.<u>{FORMAT}</u></code> <span>Exporte la liste des membres d'une cat&eacute;gorie</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant unique de la cat&eacute;gorie.</td>
</tr>
<tr>
<td style="text-align: left;"><code>FORMAT</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Format de sortie : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="post-user-new" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/new</code> <span>Cr&eacute;er un nouveau membre</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>id_category</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant de la cat&eacute;gorie. Si absent, la cat&eacute;gorie par d&eacute;faut sera utilis&eacute;e.</td>
</tr>
<tr>
<td style="text-align: left;"><code>password</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Mot de passe du membre.</td>
</tr>
<tr>
<td style="text-align: left;"><code>force_duplicate</code></td>
<td style="text-align: left;"><code>bool</code></td>
<td style="text-align: left;">Si <code>true</code> ou <code>1</code>, alors aucune erreur ne sera renvoy&eacute;e si le nom du membre correspond &agrave; un membre d&eacute;j&agrave; existant.</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Attention, cette m&eacute;thode comporte des restrictions :</p><ul>
<li>il n'est pas possible de cr&eacute;er un membre dans une cat&eacute;gorie ayant acc&egrave;s &agrave; la configuration</li>
<li>il n'est pas possible de d&eacute;finir l'OTP ou la cl&eacute; PGP du membre cr&eacute;&eacute;</li>
<li>seul un identifiant API ayant le droit "Administration" pourra cr&eacute;er des membres administrateurs</li>
</ul>
<p>Il est possible d'utiliser tous les champs de la fiche membre en utilisant leur clé unique, ainsi que les clés suivantes :</p>
<ul>
</ul><p>Il est possible d'utiliser tous les champs de la fiche membre en utilisant la cl&eacute; unique du champ.</p><p>Sera renvoy&eacute;e la liste des infos de la fiche membre.</p><p>Si un membre avec le m&ecirc;me nom existe d&eacute;j&agrave; (et que <code>force_duplicate</code> n'est pas utilis&eacute;), une erreur <code>409</code> sera renvoy&eacute;e.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new</code></pre></details><details class="api"><summary id="get-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/<u>{ID}</u></code> <span>Informations de la fiche d'un membre</span></summary><table>
<thead>
<tr>
<li><code>id_category</code> : indique l'ID d'une catégorie, si absent la catégorie par défaut sera utilisée</li>
<li><code>password</code> : mot de passe du membre</li>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID</code></td>
<li><code>force_duplicate=1</code> : ne pas renvoyer une erreur si le nom du membre à ajouter est identique au nom d'un membre existant.</li>
</ul>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant unique du membre (diff&eacute;rent du num&eacute;ro).</td>
</tr>
<p>Sera renvoyée la liste des infos de la fiche membre.</p>
<p>Si un membre avec le même nom existe déjà (et que <code>force_duplicate</code> n'est pas utilisé), une erreur <code>409</code> sera renvoyée.</p>
<pre><code>curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new</code></pre>
<h3 id="user-id-get">user/{ID} (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Plusieurs cl&eacute;s suppl&eacute;mentaires sont retourn&eacute;es, en plus des champs de la fiche membre :</p><ul>
<p>Renvoie les infos de la fiche d'un membre à partir de son ID, ainsi que 3 autres clés :</p>
<ul>
<li><code>has_password</code></li>
<li><code>has_pgp_key</code></li>
<li><code>has_otp</code></li>
</ul><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
    "has_password": true,
    "has_otp": false,
    "has_pgp_key": false,
    "id": 1,
    "id_category": 8,
    "date_login": "2021-06-06 09:17:39",
    "date_updated": null,
    "id_parent": null,
    "is_parent": false,
    "preferences": null,
    "numero": 1,
    "nom": "Ada Lovelace",
    "notes": null,
    "groupe_information": true,
    "groupe_benevoles": false,
    "email": "ada@lovelace.org",
    "telephone": "010101010101",
    "adresse": null,
    "code_postal": "21000",
    "ville": "DIJON",
    "pays": "FR",
    "date_inscription": "2012-02-25"
}</code></pre></details><details class="api"><summary id="delete-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/user/<u>{ID}</u></code> <span>Supprime un membre</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</ul>
</tr>
<h3 id="user-id-delete">user/{ID} (DELETE)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Supprime un membre à partir de son ID.</p>
<p>Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres.</p>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant unique du membre (diff&eacute;rent du num&eacute;ro).</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres.</p><p>Note : il n'est pas possible de supprimer via l'API un membre appartenant &agrave; une cat&eacute;gorie ayant acc&egrave;s &agrave; la configuration.</p></details><details class="api"><summary id="post-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/<u>{ID}</u></code> <span>Modifie les infos de la fiche d'un membre</span></summary><table>
<p>Note : il n'est pas possible de supprimer un membre appartenant à une catégorie ayant accès à la configuration.</p>
<h3 id="user-id-post">user/{ID} (POST)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Modifie les infos de la fiche d'un membre à partir de son ID.</p>
<p>Notes :</p>
<ul>
<li>il n'est pas possible de modifier la catégorie d'un membre</li>
<li>il n'est pas possible de modifier un membre appartenant à une catégorie ayant accès à la configuration.</li>
<li>il n'est pas possible de modifier le mot de passe, l'OTP ou la clé PGP du membre créé</li>
<li>il n'est pas possible de modifier des membres ayant accès à la configuration</li>
<li>seul un identifiant d'API ayant l'accès en "Administartion" pourra modifier un membre administrateur</li>
</ul>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant unique du membre (diff&eacute;rent du num&eacute;ro).</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Notes :</p><ul>
<li>il n'est pas possible de modifier la cat&eacute;gorie d'un membre</li>
<li>il n'est pas possible de modifier un membre appartenant &agrave; une cat&eacute;gorie ayant acc&egrave;s &agrave; la configuration.</li>
<li>il n'est pas possible de modifier le mot de passe, l'OTP ou la cl&eacute; PGP du membre cr&eacute;&eacute;</li>
<li>il n'est pas possible de modifier des membres ayant acc&egrave;s &agrave; la configuration</li>
<li>seul un identifiant d'API ayant l'acc&egrave;s en "Administration" pourra modifier un membre administrateur</li>
</ul></details><details class="api"><summary id="post-user-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/import</code> <span>Importer un fichier de tableur de la liste des membres</span></summary><p>Formats de fichiers accept&eacute;s : CSV, ODS, XLSX.</p><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
<h3 id="user-import-put">user/import (PUT)</h3>
<p>Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko.</p>
<p>Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration.</p>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>mode</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Mode d'import du fichier. Voir ci-dessous pour les d&eacute;tails. <em>(Depuis la version 1.2.8)</em></td>
</tr>
<tr>
<td style="text-align: left;"><code>skip_lines</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Nombre de lignes &agrave; ignorer. D&eacute;faut : <code>1</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>column</code></td>
<td style="text-align: left;"><code>array</code></td>
<td style="text-align: left;">Correspondance entre la colonne (cl&eacute;, commence &agrave; z&eacute;ro) et le champ de la fiche membre (valeur).</td>
</tr>
</tbody>
</table><p>Cette route n&eacute;cessite une cl&eacute; d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'acc&egrave;s &agrave; l'interface d'administration.</p><p>Le param&egrave;tre <code>mode</code> permet d'utiliser une de ces options pour sp&eacute;cifier le mode d'import :</p><ul>
<p>Paheko s'attend à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs <em>Nom et prénom</em> et <em>Adresse postale</em>, alors le fichier fourni devra ressembler à ceci :</p>
<table>
<li><code>auto</code> (d&eacute;faut si le mode n'est pas sp&eacute;cifi&eacute;) : met &agrave; jour la fiche d'un membre si son num&eacute;ro existe, sinon cr&eacute;e un membre si le num&eacute;ro de membre indiqu&eacute; n'existe pas ou n'est pas renseign&eacute;</li>
<li><code>create</code> : ne fait que cr&eacute;er de nouvelles fiches de membre, si le num&eacute;ro de membre existe d&eacute;j&agrave; une erreur sera produite</li>
<li><code>update</code> : ne fait que mettre &agrave; jour les fiches de membre en utilisant le num&eacute;ro de membre comme r&eacute;f&eacute;rence, si le num&eacute;ro de membre n'existe pas une erreur sera produite</li>
</ul><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import \
  -F mode=create \
  -F 'column[0]=nom_prenom' \
  -F 'column[1]=code_postal' \
  -F skip_lines=0 \
  -F file=@membres.csv</code></pre><p>Si aucun param&egrave;tre <code>column</code> n'est fourni, Paheko s'attend alors &agrave; ce que la premi&egrave;re est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou &agrave; leur nom unique). Par exemple si votre fiche membre contient les champs <em>Nom et pr&eacute;nom</em> et <em>Adresse postale</em>, alors le fichier fourni devra ressembler &agrave; ceci :</p><table>
<thead>
<tr>
<th style="text-align: left;">Nom et prénom</th>
<th style="text-align: left;">Nom et pr&eacute;nom</th>
<th style="text-align: left;">Adresse postale</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">Ada Lovelace</td>
<td style="text-align: left;">42 rue du binaire, 21000 DIJON</td>
</tr>
</tbody>
</table>
</table><p>Ou &agrave; ceci :</p><table>
<p>Ou à ceci :</p>
<table>
<thead>
<tr>
<th style="text-align: left;">nom_prenom</th>
<th style="text-align: left;">adresse_postale</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">Ada Lovelace</td>
<td style="text-align: left;">42 rue du binaire, 21000 DIJON</td>
</tr>
</tbody>
</table>
<p>La méthode renvoie un code HTTP <code>200 OK</code> si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse.</p>
</table><p>La m&eacute;thode renvoie un code HTTP <code>200 OK</code> si l'import s'est bien pass&eacute;, sinon un code 400 et un message d'erreur JSON dans le corps de la r&eacute;ponse.</p><p>Utilisez la route <code>user/import/preview</code> avant pour v&eacute;rifier que l'import correspond &agrave; ce que vous attendez.</p><p>Exemple pour modifier le nom du membre n&deg;42 :</p><pre><code>echo 'numero,nom' &gt; membres.csv
<p>Utilisez la route <code>user/import/preview</code> avant pour vérifier que l'import correspond à ce que vous attendez.</p>
<p>Exemple pour modifier le nom du membre n°42 :</p>
<pre><code>echo 'numero,nom' &gt; membres.csv
echo '42,"Nouveau nom"' &gt;&gt; membres.csv
curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv</code></pre>
<h4 id="parametres">Paramètres</h4>
<p>Les paramètres sont à spécifier dans l'URL, dans la query string.</p>
<p>Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire <code>mode</code> contenant une de ces options pour spécifier le mode d'import :</p>
<ul>
<li><code>auto</code> (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné</li>
<li><code>create</code> : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite</li>
<li><code>update</code> : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite</li>
</ul>
<p><em>Depuis la version 1.3.0 il est possible de spécifier :</em></p>
<ul>
<li>le nombre de lignes à ignorer avec le paramètre <code>skip_lines=X</code> : elles ne seront pas importées. Par défaut la première ligne est ignorée.</li>
<li>la correspondance des colonnes avec des paramètres <code>column[x]</code> ou <code>x</code> est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre.</li>
</ul>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&amp;column[0]=nom_prenom&amp;column[1]=code_postal&amp;skip_lines=0 -T membres.csv</code></pre>
<h3 id="user-import-post">user/import (POST)</h3>
<p>Identique à la même méthode en <code>PUT</code>, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors <code>file</code>.</p>
<pre><code>curl https://test:abcd@monpaheko.tld/api/user/import \
curl -u test:abcd https://monpaheko.tld/api/user/import -F file=@membres.csv</code></pre></details><details class="api"><summary id="put-user-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/user/import</code> <span>Importer un fichier de tableur de la liste des membres</span></summary><p>Formats de fichiers accept&eacute;s : CSV, ODS, XLSX.</p><p>Identique &agrave; la m&ecirc;me m&eacute;thode en <code>POST</code>, mais les param&egrave;tres sont pass&eacute;s dans l'URL, et le fichier en contenu de la requ&ecirc;te.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import?mode=create&amp;column[0]=nom_prenom&amp;skip_lines=0 \
  -F mode=create \
  -F 'column[0]=nom_prenom' \
  -F 'column[1]=code_postal' \
  -F skip_lines=0 \
  -F file=@membres.csv</code></pre>
  -T membres.csv</code></pre></details><details class="api"><summary id="post-user-import-preview" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/import/preview</code> <span>Pr&eacute;visualise un import de membres, sans modifier les membres</span></summary><p>Identique &agrave; <code>user/import</code>, mais l'import n'est pas enregistr&eacute;. &Agrave; la place l'API indique les modifications qui seraient apport&eacute;es.</p><p>Renvoie un objet JSON comme ceci :</p><ul>
<h3 id="user-import-preview-put">user/import/preview (PUT)</h3>
<p>Identique à <code>user/import</code>, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier :</p>
<ul>
<li><code>errors</code> : liste des erreurs d'import</li>
<li><code>created</code> : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée</li>
<li><code>modified</code> : liste des membres modifiés, chaque membre aura une clé <code>id</code> et une clé <code>name</code>, ainsi qu'un objet <code>changed</code> contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés <code>old</code> et <code>new</code>, contenant respectivement l'ancienne valeur du champ et la nouvelle.</li>
<li><code>unchanged</code> : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé <code>name</code> et une clé <code>id</code> indiquant le nom et l'identifiant unique numérique du membre</li>
<li><code>created</code> : liste des membres ajout&eacute;s, chaque objet contenant tous les champs de la fiche membre qui serait cr&eacute;&eacute;e</li>
<li><code>modified</code> : liste des membres modifi&eacute;s, chaque membre aura une cl&eacute; <code>id</code> et une cl&eacute; <code>name</code>, ainsi qu'un objet <code>changed</code> contenant la liste des champs modifi&eacute;s. Chaque champ modifi&eacute; aura 2 propri&eacute;t&eacute;s <code>old</code> et <code>new</code>, contenant respectivement l'ancienne valeur du champ et la nouvelle.</li>
<li><code>unchanged</code> : liste des membres mentionn&eacute;s dans l'import, mais qui ne seront pas affect&eacute;s. Pour chaque membre une cl&eacute; <code>name</code> et une cl&eacute; <code>id</code> indiquant le nom et l'identifiant unique num&eacute;rique du membre</li>
</ul>
<p>Note : si <code>errors</code> n'est pas vide, alors il sera impossible d'importer le fichier avec <code>user/import</code>.</p>
</ul><p>Note : si <code>errors</code> n'est pas vide, alors il sera impossible d'importer le fichier avec <code>user/import</code>.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import/preview -F mode=update -F file=@/tmp/membres.csv</code></pre><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
<p>Exemple de retour :</p>
<pre><code>{
    "created": [
        {
            "numero": 3434351,
            "nom": "Bla Bli Blu"
        }
    ],
    "modified": [
309
310
311
312
313
314
315
316

317
318
319
320
321
322
323
324
325
326


327
328
329
330
331


332
333

334
335
336
337
338
339
340
341
342
343
344
345
346
347
































































348
349
350
351
352
353
354
355
356
357
358
359
360
361
362













































































































363
364
365
366








367
368
369







370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395




































































































396
397
398
399
400
401
402






















































































403
404
405
406



407
408

409
410
411
































412
413
414
415
416
417
418
419
420
421

































































422
423
424
425
426
427
428

























700
701
702
703
704
705
706

707










708
709
710
711
712


713
714


715














716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779















780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888




889
890
891
892
893
894
895
896



897
898
899
900
901
902
903


























904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003







1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089




1090
1091
1092


1093



1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125










1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190







1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215







-
+
-
-
-
-
-
-
-
-
-
-
+
+



-
-
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
    ],
    "unchanged": [
        {
            "id": 2,
            "name": "Paul Muad'Dib"
        }
    ]
}</code></pre>
}</code></pre></details><details class="api"><summary id="put-user-import-preview" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/user/import/preview</code> <span>Pr&eacute;visualise un import de membres, sans modifier les membres</span></summary><p>Idem quel la m&eacute;thode en <code>POST</code> mais les param&egrave;tres doivent &ecirc;tre pass&eacute;s dans l'URL, et le fichier dans le corps de la requ&ecirc;te.</p></details><h2 id="activites">Activit&eacute;s</h2><details class="api"><summary id="put-services-subscriptions-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/services/subscriptions/import</code> <span>Importer les inscriptions des membres aux activit&eacute;s</span></summary><p>Fichiers accept&eacute;s : CSV, XLSX, ODS.</p><p><em>(Depuis Paheko 1.3.2)</em></p><p>Les activit&eacute;s et tarifs doivent d&eacute;j&agrave; exister avant l'import.</p><p>Les colonnes suivantes peuvent &ecirc;tre utilis&eacute;es :</p><ul>
<h3 id="user-import-preview-post">user/import/preview (POST)</h3>
<p>Idem quel la méthode en <code>PUT</code> mais accepte les paramètres dans le corps de la requête (voir ci-dessus).</p>
<h2 id="activites">Activités</h2>
<h3 id="services-subscriptions-import-put">services/subscriptions/import (PUT)</h3>
<p><em>(Depuis Paheko 1.3.2)</em></p>
<p>Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. Les activités et tarifs doivent déjà exister avant l'import.</p>
<p>Les colonnes suivantes peuvent être utilisées :</p>
<ul>
<li>Numéro de membre<code>**</code></li>
<li>Activité<code>**</code></li>
<li>Num&eacute;ro de membre<code>**</code></li>
<li>Activit&eacute;<code>**</code></li>
<li>Tarif</li>
<li>Date d'inscription<code>**</code></li>
<li>Date d'expiration</li>
<li>Montant à régler</li>
<li>Payé ?</li>
<li>Montant &agrave; r&eacute;gler</li>
<li>Pay&eacute; ?</li>
</ul>
<p>Les colonnes suivies de deux astérisques (<code>**</code>) sont obligatoires.</p>
</ul><p>Les colonnes suivies de deux ast&eacute;risques (<code>**</code>) sont obligatoires.</p><p>Exemple :</p><pre><code>echo '"Num&eacute;ro de membre","Activit&eacute;","Tarif","Date d'inscription","Date d'expiration","Montant &agrave; r&eacute;gler","Pay&eacute; ?"' &gt; /tmp/inscriptions.csv
<p>Exemple :</p>
<pre><code>echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' &gt; /tmp/inscriptions.csv
echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' &gt;&gt; /tmp/inscriptions.csv
curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv</code></pre>
<h2 id="erreurs">Erreurs</h2>
<p>Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.</p>
<h3 id="errors-report-post">errors/report (POST)</h3>
<p>Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale.</p>
<h3 id="errors-log-get">errors/log (GET)</h3>
<p>Renvoie le log d'erreurs système, au format airbrake/errbit (<a href="https://airbrake.io/docs/api/#create-notice-v3" rel="noreferrer noopener external" target="_blank">voir la doc AirBrake pour un exemple du format</a>)</p>
<h2 id="comptabilite">Comptabilité</h2>
<h3 id="accounting-years-get">accounting/years (GET)</h3>
<p>Renvoie la liste des exercices.</p>
<h3 id="accounting-charts-get">accounting/charts (GET)</h3>
echo '42,"Cours de th&eacute;&acirc;tre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' &gt;&gt; /tmp/inscriptions.csv
curl -u test:abcd https://monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv</code></pre></details><h2 id="erreurs">Erreurs</h2><p>Paheko dispose d'un syst&egrave;me d&eacute;di&eacute; &agrave; la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.</p><details class="api"><summary id="post-errors-report" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/errors/report</code> <span>Ajouter un rapport d'erreur au log</span></summary><p>Cette route permet d'ajouter une erreur au log de l'instance. Utile pour centraliser les erreurs de plusieurs instances.</p><p>Paheko utilise le format d'erreurs de <a href="https://docs.airbrake.io/docs/devops-tools/api/#post-data-schema-v3" rel="noreferrer noopener external" target="_blank">AirBrake</a> et errbit.</p></details><details class="api"><summary id="get-errors-log" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/errors/log</code> <span>Log d'erreurs de l'instance</span></summary></details><h2 id="comptabilite">Comptabilit&eacute;</h2><details class="api"><summary id="get-accounting-years" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years</code> <span>Liste des exercices</span></summary><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">[
    {
        "id": 1,
        "label": "Premier exercice",
        "start_date": "2011-11-01",
        "end_date": "2013-01-31",
        "closed": 1,
        "id_chart": 1,
        "nb_transactions": 1194,
        "chart_name": "Plan comptable associatif 1999"
    },
    &hellip;
]</code></pre></details><details class="api"><summary id="get-accounting-charts" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/charts</code> <span>Liste des plans comptables</span></summary><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">[
    {
        "id": 2,
        "label": "Plan comptable associatif 2018",
        "country": "FR",
        "code": "PCA_2018",
        "archived": false
    }
]</code></pre></details><details class="api"><summary id="get-accounting-charts-id_chart-accounts" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/charts/<u>{ID_CHART}</u>/accounts</code> <span>Liste des comptes pour le plan comptable indiqu&eacute;</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_CHART</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID du plan comptable.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">[
    {
        "id": 312,
        "id_chart": 2,
        "code": "1",
        "label": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)",
        "description": null,
        "position": 2,
        "type": 0,
        "user": false,
        "bookmark": false
    },
    &hellip;
]</code></pre></details><details class="api"><summary id="get-accounting-years-id_year-journal" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal</code> <span>Journal g&eacute;n&eacute;ral des &eacute;critures de l'exercice indiqu&eacute;</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_YEAR</code></td>
<td style="text-align: left;"><code>int|string</code></td>
<td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td>
</tr>
</tbody>
<p>Renvoie la liste des plans comptables.</p>
<h3 id="accounting-charts-id_chart-accounts-get">accounting/charts/{ID_CHART}/accounts (GET)</h3>
<p>Renvoie la liste des comptes pour le plan comptable indiqué (voir <code>id_chart</code> dans la liste des exercices).</p>
<h3 id="accounting-years-id_year-journal-get">accounting/years/{ID_YEAR}/journal (GET)</h3>
<p>Renvoie le journal général des écritures de l'exercice indiqué. </p>
<p>Note : il est possible d'utiliser <code>current</code> comme paramètre pour <code>{ID_YEAR}</code> pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.</p>
<h3 id="accounting-years-id_year-export-format-extension-get">accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Exporte l'exercice indiqué au format indiqué. Les formats suivants sont disponibles :</p>
<ul>
<li><code>full</code> : complet</li>
<li><code>grouped</code> : complet groupé</li>
<li><code>simple</code> : simple (ne comporte pas les saisies avancées)</li>
<li><code>fec</code> : format FEC (Fichier des Écritures Comptables)</li>
</ul>
</table><p>Note : il est possible d'utiliser <code>current</code> comme param&egrave;tre pour <code>{ID_YEAR}</code> pour d&eacute;signer l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilis&eacute;.</p></details><details class="api"><summary id="get-accounting-years-id_year-export-type-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/export/<u>{TYPE}</u>.<u>{FORMAT}</u></code> <span>Export d'un exercice</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_YEAR</code></td>
<td style="text-align: left;"><code>int|string</code></td>
<td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>TYPE</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Type d'export : <code>full</code>, <code>grouped</code>, <code>simple</code> ou <code>fec</code>. <code>simple</code> ne contient pas les &eacute;critures avanc&eacute;es.</td>
</tr>
<tr>
<td style="text-align: left;"><code>FORMAT</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Format d'export : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="get-accounting-years-id_year-journal-code" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal/<u>{CODE}</u></code> <span>Journal des &eacute;critures d'un compte</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_YEAR</code></td>
<td style="text-align: left;"><code>int|string</code></td>
<td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>CODE</code></td>
<td style="text-align: left;"><code>int|string</code></td>
<td style="text-align: left;">Code du compte.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">[
    {
        "id": 9297,
        "id_line": 22401,
        "date": "2022-02-08",
        "debit": 0,
        "credit": 850,
        "change": 850,
        "sum": 850,
        "reference": "POS-SESSION-434",
        "type": 0,
        "label": "Session de caisse n\u00b0434",
        "line_label": null,
        "line_reference": null,
        "id_project": null,
        "project_code": null,
        "files": 1,
        "status": 0
    },
    &hellip;
]</code></pre></details><details class="api"><summary id="get-accounting-years-id_year-journal-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal/=<u>{ID}</u></code> <span>Journal des &eacute;critures d'un compte</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_YEAR</code></td>
<td style="text-align: left;"><code>int|string</code></td>
<td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td>
</tr>
<tr>
<td style="text-align: left;"><code>ID</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID du compte.</td>
</tr>
</tbody>
</table></details><details class="api"><summary id="post-accounting-transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction</code> <span>Cr&eacute;er une nouvelle &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>id_year</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant de l'exercice.</td>
</tr>
<tr>
<td style="text-align: left;"><code>date</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Date au format <code>YYYY-MM-DD</code> ou <code>DD/MM/YYYY</code></td>
</tr>
<tr>
<td style="text-align: left;"><code>type</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Type d'&eacute;criture.</td>
</tr>
<p>L'extension indique le type de fichier :</p>
<ul>
<li><code>csv</code> : Tableur CSV</li>
<li><code>ods</code> : LibreOffice Calc</li>
<tr>
<td style="text-align: left;"><code>reference</code></td>
<td style="text-align: left;"><code>string|null</code></td>
<td style="text-align: left;">Num&eacute;ro de pi&egrave;ce comptable</td>
</tr>
<tr>
<td style="text-align: left;"><code>notes</code></td>
<td style="text-align: left;"><code>string|null</code></td>
<li><code>xlsx</code> : Microsoft OOXML (Excel) - seulement disponible si l'instance le permet</li>
<li><code>json</code> : Texte JSON</li>
</ul>
<td style="text-align: left;">Remarques (texte multi ligne)</td>
</tr>
<tr>
<td style="text-align: left;"><code>linked_transactions</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des &eacute;critures &agrave; lier &agrave; l'&eacute;criture <em>(depuis 1.3.5)</em></td>
</tr>
<p>Note : il est possible d'utiliser <code>current</code> comme paramètre pour <code>{ID_YEAR}</code> pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.</p>
<h3 id="accounting-years-id_year-account-journal-get">accounting/years/{ID_YEAR}/account/journal (GET)</h3>
<p>Renvoie le journal des écritures d'un compte pour l'exercice indiqué.</p>
<p>Le compte est spécifié soit via le paramètre <code>code</code>, soit via le paramètre <code>id</code>. Exemple :  <code>/accounting/years/4/account/journal?code=512A</code></p>
<p>Note : il est possible d'utiliser <code>current</code> comme paramètre pour <code>{ID_YEAR}</code> pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.</p>
<h3 id="accounting-transaction-id_transaction-get">accounting/transaction/{ID_TRANSACTION} (GET)</h3>
<p>Renvoie les détails de l'écriture indiquée.</p>
<h3 id="accounting-transaction-id_transaction-post">accounting/transaction/{ID_TRANSACTION} (POST)</h3>
<p>Modifie l'écriture indiquée. Voir plus bas le format attendu.</p>
<h3 id="accounting-transaction-id_transaction-users-get">accounting/transaction/{ID_TRANSACTION}/users (GET)</h3>
<p>Renvoie la liste des membres liés à une écriture.</p>
<h3 id="accounting-transaction-id_transaction-users-post">accounting/transaction/{ID_TRANSACTION}/users (POST)</h3>
<p>Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé <code>users</code>.</p>
<pre><code> curl -v "http://…/api/accounting/transaction/9337/users"  -F 'users[]=2'</code></pre>
<h3 id="accounting-transaction-id_transaction-users-delete">accounting/transaction/{ID_TRANSACTION}/users (DELETE)</h3>
<p>Efface la liste des membres liés à une écriture.</p>
<h3 id="accounting-transaction-id_transaction-subscriptions-get">accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Renvoie la liste des inscriptions (aux activités) liées à une écriture.</p>
<h3 id="accounting-transaction-id_transaction-subscriptions-post">accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé <code>subscriptions</code>.</p>
<pre><code> curl -v "http://…/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'</code></pre>
<h3 id="accounting-transaction-id_transaction-subscriptions-delete">accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Efface la liste des inscriptions liées à une écriture.</p>
<tr>
<td style="text-align: left;"><code>linked_users</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des membres &agrave; lier &agrave; l'&eacute;criture <em>(depuis 1.3.3)</em></td>
</tr>
<tr>
<td style="text-align: left;"><code>linked_subscriptions</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des inscriptions &agrave; lier &agrave; l'&eacute;criture <em>(depuis 1.4.0)</em></td>
</tr>
</tbody>
</table><h4 id="types-d-ecriture">Types d'&eacute;criture</h4><table>
<thead>
<tr>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>expense</code></td>
<td style="text-align: left;">D&eacute;pense</td>
</tr>
<tr>
<td style="text-align: left;"><code>revenue</code></td>
<td style="text-align: left;">Recette</td>
</tr>
<tr>
<td style="text-align: left;"><code>transfer</code></td>
<td style="text-align: left;">Virement</td>
</tr>
<tr>
<td style="text-align: left;"><code>debt</code></td>
<td style="text-align: left;">Dette</td>
</tr>
<tr>
<td style="text-align: left;"><code>credit</code></td>
<td style="text-align: left;">Cr&eacute;ance</td>
</tr>
<tr>
<td style="text-align: left;"><code>advanced</code></td>
<td style="text-align: left;">Saisie avanc&eacute;e</td>
</tr>
</tbody>
</table><p>Les &eacute;critures avanc&eacute;es (multi-lignes) ont obligatoirement le type <code>advanced</code>. Les autres &eacute;critures sont appel&eacute;es <em>"&eacute;critures simplifi&eacute;es"</em> et ne peuvent comporter que deux lignes.</p><h4 id="parametres-des-ecritures-simplifiees">Param&egrave;tres des &eacute;critures simplifi&eacute;es</h4><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>amount</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Montant de l'&eacute;criture, au format flottant (<code>53,34</code>)</td>
</tr>
<tr>
<td style="text-align: left;"><code>credit</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Code du compte port&eacute; au cr&eacute;dit</td>
</tr>
<tr>
<td style="text-align: left;"><code>debit</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Code du compte port&eacute; au d&eacute;bit</td>
</tr>
<tr>
<td style="text-align: left;"><code>id_project</code></td>
<td style="text-align: left;"><code>int|null</code></td>
<td style="text-align: left;">ID du projet &agrave; affecter</td>
</tr>
<tr>
<td style="text-align: left;"><code>payment_reference</code></td>
<td style="text-align: left;"><code>int|null</code></td>
<td style="text-align: left;">r&eacute;f&eacute;rence de paiement</td>
</tr>
</tbody>
</table><h4 id="parametres-des-ecritures-avancees">Param&egrave;tres des &eacute;critures avanc&eacute;es</h4><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>lines</code></td>
<td style="text-align: left;"><code>array(object, &hellip;)</code></td>
<td style="text-align: left;">un tableau dont chaque &eacute;l&eacute;ment est un objet correspondant &agrave; une ligne de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Structure de l'objet repr&eacute;sentant une ligne de l'&eacute;criture :</p><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
<h3 id="accounting-transaction-post">accounting/transaction (POST)</h3>
<p>Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu.</p>
<h4 id="structure-pour-creer-modifier-une-ecriture">Structure pour créer / modifier une écriture</h4>
<p>Les champs à spécifier pour créer ou modifier une écriture sont les suivants :</p>
<ul>
<li><code>id_year</code></li>
<li><code>date</code> (format YYYY-MM-DD)</li>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>account</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Code du compte</td>
</tr>
<tr>
<td style="text-align: left;"><code>id_account</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">Identifiant du compte (ID). Peut &ecirc;tre utilis&eacute; en alternative au code du compte.</td>
</tr>
<tr>
<td style="text-align: left;"><code>credit</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">Montant &agrave; inscrire au cr&eacute;dit (doit &ecirc;tre z&eacute;ro ou non renseign&eacute; si <code>debit</code> est renseign&eacute;, et vice-versa)</td>
</tr>
<tr>
<td style="text-align: left;"><code>debit</code></td>
<td style="text-align: left;"><code>string</code></td>
<td style="text-align: left;">montant &agrave; inscrire au d&eacute;bit</td>
</tr>
<tr>
<td style="text-align: left;"><code>label</code></td>
<td style="text-align: left;"><code>string|null</code></td>
<td style="text-align: left;">libell&eacute; de la ligne</td>
</tr>
<tr>
<td style="text-align: left;"><code>reference</code></td>
<td style="text-align: left;"><code>string|null</code></td>
<td style="text-align: left;">r&eacute;f&eacute;rence de la ligne (aussi appel&eacute; r&eacute;f&eacute;rence du paiement pour les &eacute;critures simplifi&eacute;es)</td>
</tr>
<tr>
<td style="text-align: left;"><code>id_project</code></td>
<td style="text-align: left;"><code>int|null</code></td>
<td style="text-align: left;">ID du projet &agrave; affecter &agrave; cette ligne</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -F 'id_year=12' \
  -F 'label=Test' \
  -F 'date=01/02/2022' \
  -F 'type=expense' \
  -F 'amount=42,45' \
  -F 'debit=512A' \
  -F 'credit=601'</code></pre></details><details class="api"><summary id="get-accounting-transaction-id_transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u></code> <span>D&eacute;tails de l'&eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;ponse :</p><pre><code class="language-response">{
  "id": 9302,
  "type": 0,
  "status": 0,
  "label": "Session de caisse n\u00b0439",
  "notes": null,
  "reference": "POS-SESSION-439",
  "date": "2022-02-12",
  "hash": null,
  "prev_id": null,
  "prev_hash": null,
  "id_year": 12,
  "id_creator": 5883,
  "url": "http:\/\/dev.paheko.localhost\/admin\/acc\/transactions\/details.php?id=9302",
  "lines": [
    {
      "id": 22421,
      "id_transaction": 9302,
      "id_account": 542,
      "credit": 0,
      "debit": 3000,
      "reference": "CE342",
      "label": null,
      "reconciled": false,
      "id_project": null,
      "account_code": "5112",
<li><code>type</code> peut être un type d'écriture simplifié (2 lignes) : <code>EXPENSE</code> (dépense), <code>REVENUE</code> (recette), <code>TRANSFER</code> (virement), <code>DEBT</code> (dette), <code>CREDIT</code> (créance), ou <code>ADVANCED</code> pour une écriture multi-ligne</li>
<li><code>amount</code> (uniquement pour les écritures simplifiées) : contient le montant de l'écriture</li>
<li><code>credit</code> (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit</li>
<li><code>debit</code> (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit</li>
      "account_label": "Ch\u00e8ques \u00e0 encaisser",
      "account_position": 3,
      "project_name": null,
<li><code>lines</code> (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :<ul>
<li><code>account</code> (numéro du compte) ou <code>id_account</code> (ID unique du compte)</li>
      "account_selector": {
<li><code>credit</code> : montant à inscrire au crédit (doit être zéro ou non renseigné si <code>debit</code> est renseigné, et vice-versa)</li>
<li><code>debit</code> : montant à inscrire au débit</li>
<li><code>label</code> (facultatif) : libellé de la ligne</li>
        "542": "5112 \u2014 Ch\u00e8ques \u00e0 encaisser"
      }
    },
    &hellip;
  ]
}</code></pre></details><details class="api"><summary id="post-accounting-transaction-id_transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u></code> <span>Modifier l'&eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Si l'&eacute;criture est verrouill&eacute;e, ou dans un exercice cl&ocirc;tur&eacute;, la modification sera impossible.</p><p>Voir la route <code>POST accounting/transaction</code> (cr&eacute;ation d'une &eacute;criture) pour la liste des param&egrave;tres.</p></details><details class="api"><summary id="get-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Liste des membres li&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<li><code>reference</code> (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)</li>
<li><code>id_project</code> : ID unique du projet à affecter</li>
</ul>
</li>
</ul>
<p>Champs optionnels :</p>
<ul>
<li><code>reference</code> : numéro de pièce comptable</li>
<li><code>notes</code> : remarques (texte multi ligne)</li>
<li><code>id_project</code> : ID unique du projet à affecter (pour les écritures simplifiées uniquement)</li>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table></details><details class="api"><summary id="post-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Met &agrave; jour la liste des membres li&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
<tr>
<td style="text-align: left;"><code>users</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)</code></td>
<td style="text-align: left;">ID des membres.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code> curl -v "https://&hellip;/api/accounting/transaction/9337/users"  -F 'users[]=2'</code></pre></details><details class="api"><summary id="delete-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Efface la liste des membres li&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table></details><details class="api"><summary id="get-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Liste des inscriptions (aux activit&eacute;s) li&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="post-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Met &agrave; jour la liste des inscriptions li&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<li><code>payment_reference</code> (uniquement pour les écritures simplifiées) : référence de paiement</li>
<li><code>linked_users</code> : Tableau des IDs des membres à lier à l'écriture <em>(depuis 1.3.3)</em></li>
<li><code>linked_transactions</code> : Tableau des IDs des écritures à lier à l'écriture <em>(depuis 1.3.5)</em></li>
<li><code>linked_subscriptions</code> : Tableau des IDs des inscriptions à lier à l'écriture <em>(depuis 1.3.6)</em></li>
</ul>
<p>Exemple :</p>
<pre><code>curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …</code></pre></div></body></html>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
<tr>
<td style="text-align: left;"><code>subscriptions</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)</code></td>
<td style="text-align: left;">ID des inscriptions.</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Exemple de requ&ecirc;te :</p><pre><code> curl -v "https://&hellip;/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'</code></pre></details><details class="api"><summary id="delete-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Efface la liste des inscriptions li&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;tre</th>
<th style="text-align: left;">Type</th>
<th style="text-align: left;">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><code>ID_TRANSACTION</code></td>
<td style="text-align: left;"><code>int</code></td>
<td style="text-align: left;">ID de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p></details>
</div></body></html>

Modified src/www/admin/static/doc/brindille.html from [175273e804] to [c516582898].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Documentation du langage Brindille dans Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Documentation du langage Brindille dans Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/brindille_functions.html from [c705f71df0] to [b17568f66f].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des fonctions Brindille</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence des fonctions Brindille</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/brindille_modifiers.html from [e69ee88290] to [ba0150af03].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des filtres Brindille</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence des filtres Brindille</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/brindille_sections.html from [a6db77203f] to [9e7ca46886].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des sections Brindille</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence des sections Brindille</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/keyboard.html from [55dd27ea5d] to [4e8ee2ed49].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Raccourcis claviers dans l&#039;édition de texte — Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Raccourcis claviers dans l&#039;édition de texte — Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/markdown.html from [450ad76210] to [4ac111b36a].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence complète MarkDown — Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence complète MarkDown — Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/markdown_quickref.html from [a03b76328e] to [8f7105212d].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence rapide MarkDown — Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence rapide MarkDown — Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/modules.html from [c143ed67d2] to [bd8e02b75f].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Développer des modules pour Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Développer des modules pour Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/skriv.html from [1fdf3f7ccd] to [86819c22d1].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Référence rapide SkrivML - Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Référence rapide SkrivML - Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/sql.html from [07fb557ea8] to [7e1714ce22].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/sql.md</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/sql.md</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/doc/web.html from [fe4a7a6059] to [4858b076a5].

1
2
3
4
5


6
7
8
9
10
11
12
1
2
3

4
5
6
7
8
9
10
11
12
13



-

+
+







<!DOCTYPE html>
	<html>
	<head>
		<title>Squelettes du site web dans Paheko</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Squelettes du site web dans Paheko</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;

Modified src/www/admin/static/styles/02-common.css from [e5d5ad0c0a] to [c21c2abd52].

901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
901
902
903
904
905
906
907




908
909
910
911
912
913
914







-
-
-
-







pre code {
    background: var(--gLightBackgroundColor);
    display: block;
    padding: .5em;
    border-radius: .3em;
}

fieldset.message {
    max-width: 40em;
}

img.broken {
    font-size: 1.2rem;
    text-align: center;
    background: #977;
    border-radius: .3rem;
    color: #fff;
    text-decoration: none;
933
934
935
936
937
938
939









929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944







+
+
+
+
+
+
+
+
+
    -ms-hyphens: auto;
    -moz-hyphens: auto;
    -webkit-hyphens: auto;
    hyphens: auto;

    white-space: pre-wrap;
}

span.tag {
    display: inline-block;
    color: #fff;
    text-shadow: 0px 0px 5px #000;
    padding: .2rem .4rem;
    background: var(--tag-color);
    border-radius: .3rem;
}

Modified src/www/admin/users/action.php from [ee26f54d10] to [37a560db21].

19
20
21
22
23
24
25
26

27
28
29
30
31
32
33
19
20
21
22
23
24
25

26
27
28
29
30
31
32
33







-
+







$csrf_key = 'users_actions';

if ($action === 'ods' || $action === 'csv' || $action === 'xlsx') {
	Users::exportSelected($action, $list);
	return;
}
elseif ($action === 'subscribe') {
	Utils::redirect('!services/user/subscribe.php?users=' . implode(',', $list));
	Utils::redirect('!services/subscription/new.php?users=' . implode(',', $list));
}
elseif ($action === 'move' || $action === 'delete' || $action === 'delete_files') {
	$logged_user_id = Session::getUserId();

	// Don't allow to change or delete the currently logged-in user
	// to avoid shooting yourself in the foot
	$list = array_filter($list, fn ($a) => $a != $logged_user_id);

Modified src/www/admin/users/details.php from [c1d42dd32c] to [b26703703f].

1
2
3
4
5

6
7
8
9
10
11
12
1
2
3
4

5
6
7
8
9
10
11
12




-
+







<?php
namespace Paheko;

use Paheko\Accounting\Transactions;
use Paheko\Services\Services_User;
use Paheko\Services\Subscriptions;
use Paheko\Users\Categories;
use Paheko\Users\Users;

use Paheko\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

38
39
40
41
42
43
44
45

46
47
48
49
50
51
52
38
39
40
41
42
43
44

45
46
47
48
49
50
51
52







-
+








	$session->logout();
	$session->forceLogin($user->id);
	Log::add(Log::LOGIN_AS, ['admin' => $logged_user->name()]);

}, $csrf_key, '!?login_as=1');

$services = Services_User::listDistinctForUser($user->id);
$services = Subscriptions::listDistinctForUser($user->id);

$variables = [];

if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) {
	$variables['transactions_linked'] = Transactions::countForUser($user->id);
	$variables['transactions_created'] = Transactions::countForCreator($user->id);
}

Added src/www/admin/users/email/address.php version [1de4509d63].























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Addresses;
use Paheko\Entities\Email\Address;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_READ);

$address = Addresses::getByID((int)qg('id'));

if (!$address) {
	throw new UserException('Adresse e-mail inconnue');
}

$limit_date = Addresses::getVerificationLimitDate();
$max_fail_count = Address::FAIL_LIMIT;

$tpl->assign(compact('address', 'max_fail_count', 'limit_date'));

$tpl->display('users/email/address.tpl');

Added src/www/admin/users/email/block.php version [47036cc98f].


























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Addresses;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$address = qg('address');
$email = Addresses::get($address);

if (!$email) {
    throw new UserException('Adresse invalide ou inconnue');
}

$csrf_key = 'block_email';

$form->runIf('send', function () use ($email) {
    $email->setOptout('Désinscription manuelle par un administrateur');
    $email->save();
}, $csrf_key, '!users/');

$tpl->assign(compact('csrf_key', 'email', 'address'));
$tpl->display('users/email/block.tpl');

Added src/www/admin/users/email/mailing/_inc.php version [1f2037bdd9].











1
2
3
4
5
6
7
8
9
10
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko;

use Paheko\Users\Session;

require_once __DIR__ . '/../../_inc.php';

$session = Session::getInstance();
$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

Added src/www/admin/users/email/mailing/delete.php version [9488cf7f5d].























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$csrf_key = 'mailing_delete';

$form->runIf('delete', function () use ($mailing) {
	$mailing->delete();
}, $csrf_key, '!users/email/mailing/?msg=DELETE');

$tpl->assign(compact('mailing', 'csrf_key'));
$tpl->display('users/email/mailing/delete.tpl');

Added src/www/admin/users/email/mailing/details.php version [5286ebd205].


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

if (qg('preview') !== null) {
	echo $mailing->getHTMLPreview((int)qg('preview') ?: null, true);
	return;
}

$csrf_key = 'mailing_details';

$form->runIf('send', function() use ($mailing) {
	$mailing->send();
}, $csrf_key, '!users/email/mailing/details.php?sent&id=' . $mailing->id);

$count = $mailing->countRecipients();

$tpl->assign(compact('mailing', 'csrf_key', 'count'));

$tpl->assign('custom_css', [BASE_URL . 'content.css']);
$tpl->assign('sent', null !== qg('sent'));

$tpl->display('users/email/mailing/details.tpl');

Added src/www/admin/users/email/mailing/edit.php version [9b79a6b403].
























































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;
use Paheko\Entities\Email\Mailing;

require_once __DIR__ . '/_inc.php';

$id = intval($_GET['id'] ?? 0);

if ($id !== 0) {
	$mailing = Mailings::get($id);

	if (!$mailing) {
		throw new UserException('Invalid mailing ID');
	}
}
else {
	$mailing = new Mailing;
}

$csrf_key = 'mailing_edit';

$form->runIf('save', function () use ($mailing) {
	$mailing->importForm();
	$mailing->set('body', trim(f('content') ?? ''));
	$mailing->save();

	$js = false !== strpos($_SERVER['HTTP_ACCEPT'] ?? '', '/json');

	$url = '!users/email/mailing/details.php?id=' . $mailing->id;
	$url = Utils::getLocalURL($url);

	if ($js) {
		die(json_encode(['success' => true, 'modified' => time(), 'redirect' => $url]));
	}

	Utils::redirect($url);
}, $csrf_key);

if (!$form->hasErrors()) {
	$form->runIf('content', function() use ($mailing) {
		$mailing->set('body', trim(f('content') ?? ''));
		echo $mailing->getHTMLPreview(null, true);
		exit;
	});
}

$tpl->assign(compact('mailing', 'csrf_key'));

$tpl->assign('custom_js', ['web_editor.js']);
$tpl->assign('custom_css', ['web.css', BASE_URL . 'content.css']);

$tpl->display('users/email/mailing/edit.tpl');

Added src/www/admin/users/email/mailing/index.php version [8814739c31].

















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

Mailings::anonymize();

$list = Mailings::getList();
$list->loadFromQueryString();


$tpl->assign(compact('list'));

$tpl->display('users/email/mailing/index.tpl');

Added src/www/admin/users/email/mailing/populate.php version [5258f173ec].



































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$csrf_key = 'create_mailing';

$target_type = f('target_type');

$form->runIf($target_type == 'all' || f('step3'), function () {
	$target_type = f('target_type');
	$target_value = f('target_value');
	$target_label = $_POST['labels'][$target_value] ?? null;

	if ($target_type !== 'all' && empty($target_value)) {
		throw new UserException('Aucune cible n\'a été sélectionnée.');
	}

	$m = Mailings::create(f('subject'), $target_type, $target_value, $target_label);
	Utils::redirectDialog('!users/email/mailing/write.php?id=' . $m->id());
}, $csrf_key);

$list = null;

if ($target_type) {
	$list = Mailings::listTargets($target_type);
}

$tpl->assign(compact('csrf_key', 'target_type', 'list'));

$tpl->display('users/email/mailing/new.tpl');

Added src/www/admin/users/email/mailing/recipient_data.php version [baac793801].
























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$data = $mailing->getRecipientExtraData((int)qg('r'));

if (!$data) {
	throw new UserException('Ce destinataire n\'a aucune donnée.');
}

$tpl->assign(compact('mailing', 'data'));

$tpl->display('users/email/mailing/recipient_data.tpl');

Added src/www/admin/users/email/mailing/recipients.php version [1e8f8312fa].





























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$csrf_key = 'mailing';

if (!$mailing->sent) {
	$form->runIf('delete', function () use ($mailing) {
		$mailing->deleteRecipient((int)f('delete'));
	}, $csrf_key, '!users/email/mailing/recipients.php?id=' . $mailing->id);
}

$list = $mailing->getRecipientsList();
$list->loadFromQueryString();

$tpl->assign(compact('mailing', 'list', 'csrf_key'));

$tpl->display('users/email/mailing/recipients.tpl');

Added src/www/admin/users/email/optout.php version [715868cbde].



















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Mailings;
use Paheko\Email\Addresses;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$limit_date = Addresses::getVerificationLimitDate();

$list = Mailings::getOptoutUsersList();
$list->loadFromQueryString();

$tpl->assign(compact('list', 'limit_date'));

$tpl->display('users/email/optout.tpl');

Added src/www/admin/users/email/queue.php version [ea8b44d27f].


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Queue;
use Paheko\Email\Emails;
use Paheko\Entities\Email\Message;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);

$form->runIf(qg('run') && !USE_CRON, function () use ($session) {
	$i = (int) qg('run');

	Queue::run(100);

	$i++;

	// Continue sending by batch as long as there is something in the queue
	if ($i < 20 && Queue::count()) {
		Utils::redirect('!users/email/queue.php?run=' . $i);
	}
}, null, '!users/email/queue.php?msg=EMPTY');

$count = Queue::count();
$list = Queue::getList();
$statuses = Message::STATUS_LIST;
$statuses_colors = Message::STATUS_COLORS;
$contexts = Message::CONTEXT_LIST;

$tpl->assign(compact('list', 'count', 'statuses', 'statuses_colors', 'contexts'));

$tpl->display('users/email/queue.tpl');

Added src/www/admin/users/email/rejected.php version [5ac573a167].




















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Addresses;
use Paheko\Entities\Email\Address;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$list = Addresses::listRejectedUsers();
$list->loadFromQueryString();

$labels = Address::STATUS_LIST;
$colors = Address::STATUS_COLORS;

$tpl->assign(compact('list', 'labels', 'colors'));

$tpl->display('users/email/rejected.tpl');

Added src/www/admin/users/email/verify.php version [116b91acd0].




























1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php
namespace Paheko;

use Paheko\Email\Addresses;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$raw_address = qg('address');
Addresses::validate($raw_address);

$address = Addresses::getOrCreate($raw_address);

if (!$address->canSendVerificationAfterFail()) {
	$message = sprintf('Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre %d jours.', $address->getVerificationDelay());
    throw new UserException($message);
}

$csrf_key = 'send_verification';

$form->runIf('send', function () use ($address, $raw_address) {
    $address->sendVerification($raw_address);
}, $csrf_key, '!users/email/address.php?msg=VERIFICATION_SENT', true);

$tpl->assign(compact('csrf_key', 'address'));
$tpl->display('users/email/verify.tpl');

Deleted src/www/admin/users/mailing/_inc.php version [57578032ce].

1
2
3
4
5
6
7
8
9
10










-
-
-
-
-
-
-
-
-
-
<?php

namespace Paheko;

use Paheko\Users\Session;

require_once __DIR__ . '/../_inc.php';

$session = Session::getInstance();
$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

Deleted src/www/admin/users/mailing/block.php version [0a3c768560].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Email\Emails;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$address = qg('address');
$email = Emails::getOrCreateEmail($address);

if (!$email) {
    throw new UserException('Adresse invalide');
}

$csrf_key = 'block_email';

$form->runIf('send', function () use ($email) {
    $email->setOptout('Désinscription manuelle par un administrateur');
    $email->save();
}, $csrf_key, '!users/');

$tpl->assign(compact('csrf_key', 'email', 'address'));
$tpl->display('users/mailing/block.tpl');

Deleted src/www/admin/users/mailing/delete.php version [829abccebc].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22






















-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$csrf_key = 'mailing_delete';

$form->runIf('delete', function () use ($mailing) {
	$mailing->delete();
}, $csrf_key, '!users/mailing/?msg=DELETE');

$tpl->assign(compact('mailing', 'csrf_key'));
$tpl->display('users/mailing/delete.tpl');

Deleted src/www/admin/users/mailing/details.php version [4d90651838].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

if (qg('preview') !== null) {
	echo $mailing->getHTMLPreview((int)qg('preview') ?: null, true);
	return;
}

$csrf_key = 'mailing_details';

$form->runIf('send', function() use ($mailing) {
	$mailing->send();
}, $csrf_key, '!users/mailing/details.php?sent&id=' . $mailing->id);

$tpl->assign(compact('mailing', 'csrf_key'));

$tpl->assign('custom_css', [BASE_URL . 'content.css']);
$tpl->assign('sent', null !== qg('sent'));

$tpl->display('users/mailing/details.tpl');

Deleted src/www/admin/users/mailing/index.php version [1da23b3440].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
















-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

Mailings::anonymize();

$list = Mailings::getList();
$list->loadFromQueryString();


$tpl->assign(compact('list'));

$tpl->display('users/mailing/index.tpl');

Deleted src/www/admin/users/mailing/new.php version [0ae0ecf4fb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
















































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Categories;
use Paheko\Users\Session;
use Paheko\Search;
use Paheko\UserException;
use Paheko\Services\Services;
use Paheko\Entities\Search as SearchEntity;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$csrf_key = 'create_mailing';

$target = f('target');

$form->runIf($target == 'all' || f('step3'), function () {
	$target = f('target');
	$target_id = f('target_id');

	if ($target !== 'all' && empty($target_id)) {
		throw new UserException('Aucune cible n\'a été sélectionnée.');
	}

	$m = Mailings::create(f('subject'), $target, $target_id);
	Utils::redirectDialog('!users/mailing/write.php?id=' . $m->id());
}, $csrf_key);

if ($target == 'category') {
	$tpl->assign('categories', Categories::listWithStats(Categories::WITHOUT_HIDDEN));
}
elseif ($target == 'service') {
	$tpl->assign('services', Services::listWithStats(true));
}
elseif ($target == 'search') {
	$search_list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId());
	$search_list = array_filter($search_list, fn($s) => $s->hasUserId());
	array_walk($search_list, function (&$s) {
		$s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()];
	});

	$tpl->assign(compact('search_list'));
}

$tpl->assign(compact('csrf_key', 'target'));

$tpl->display('users/mailing/new.tpl');

Deleted src/www/admin/users/mailing/recipient_data.php version [7eb9cf0ebd].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$data = $mailing->getRecipientExtraData((int)qg('r'));

if (!$data) {
	throw new UserException('Ce destinataire n\'a aucune donnée.');
}

$tpl->assign(compact('mailing', 'data'));

$tpl->display('users/mailing/recipient_data.tpl');

Deleted src/www/admin/users/mailing/recipients.php version [0eab354cfc].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28




























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$csrf_key = 'mailing';

if (!$mailing->sent) {
	$form->runIf('delete', function () use ($mailing) {
		$mailing->deleteRecipient((int)f('delete'));
	}, $csrf_key, '!users/mailing/recipients.php?id=' . $mailing->id);
}

$list = $mailing->getRecipientsList();
$list->loadFromQueryString();

$tpl->assign(compact('mailing', 'list', 'csrf_key'));

$tpl->display('users/mailing/recipients.tpl');

Deleted src/www/admin/users/mailing/rejected.php version [65ed7e261c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26


























-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Email\Emails;
use Paheko\Entities\Email\Email;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$limit_date = new \DateTime(Email::RESEND_VERIFICATION_DELAY);

$form->runIf(f('force_queue') && !USE_CRON, function () use ($session) {
	$session->requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);

	Emails::runQueue();
}, null, '!membres/emails.php?forced');

$list = Emails::listRejectedUsers();
$list->loadFromQueryString();

$max_fail_count = Emails::FAIL_LIMIT;
$queue_count = Emails::countQueue();
$tpl->assign(compact('list', 'max_fail_count', 'queue_count', 'limit_date'));

$tpl->display('users/mailing/rejected.tpl');

Deleted src/www/admin/users/mailing/verify.php version [0d8c005324].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35



































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Email\Emails;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$address = qg('address');
$email = Emails::getOrCreateEmail($address);

if (!$email) {
    throw new UserException('Adresse invalide');
}

if (!$email->canSendVerificationAfterFail()) {
	if ($email->optout) {
		$message = 'Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre 3 jours.';
	}
	else {
		$message = 'Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre un mois.';
	}

    throw new UserException($message);
}

$csrf_key = 'send_verification';

$form->runIf('send', function () use ($email, $address) {
    $email->sendVerification($address);
}, $csrf_key, '!users/mailing/rejected.php?sent', true);

$tpl->assign(compact('csrf_key', 'email'));
$tpl->display('users/mailing/verify.tpl');

Deleted src/www/admin/users/mailing/write.php version [70659ff0aa].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47















































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
<?php
namespace Paheko;

use Paheko\Users\Session;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$mailing = Mailings::get((int)qg('id'));

if (!$mailing) {
	throw new UserException('Invalid mailing ID');
}

$csrf_key = 'mailing_write';

$form->runIf('save', function () use ($mailing) {
	$mailing->importForm();
	$mailing->set('body', trim(f('content') ?? ''));
	$mailing->save();

	$js = false !== strpos($_SERVER['HTTP_ACCEPT'] ?? '', '/json');

	$url = '!users/mailing/details.php?id=' . $mailing->id;
	$url = Utils::getLocalURL($url);

	if ($js) {
		die(json_encode(['success' => true, 'modified' => time(), 'redirect' => $url]));
	}

	Utils::redirect($url);
}, $csrf_key);

if (!$form->hasErrors()) {
	$form->runIf('content', function() use ($mailing) {
		$mailing->set('body', trim(f('content') ?? ''));
		echo $mailing->getHTMLPreview(null, true);
		exit;
	});
}

$tpl->assign(compact('mailing', 'csrf_key'));

$tpl->assign('custom_js', ['web_editor.js']);
$tpl->assign('custom_css', ['web.css', BASE_URL . 'content.css']);

$tpl->display('users/mailing/write.tpl');

Added src/www/admin/users/subscriptions.php version [d508c9b779].















































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<?php

namespace Paheko;

use Paheko\Services\Services;
use Paheko\Services\Subscriptions;
use Paheko\Users\Users;

require_once __DIR__ . '/_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_READ);

$user_id = (int) qg('id');
$user_name = Users::getName($user_id);

if (!$user_name) {
	throw new UserException("Ce membre est introuvable");
}

$form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && null !== qg('paid') && qg('su_id'), function () {
	$su = Subscriptions::get((int) qg('su_id'));

	if (!$su) {
		throw new UserException("Cette inscription est introuvable");
	}

	$su->paid = (bool)qg('paid');
	$su->save();
}, null, '!users/subscriptions.php?id=' . $user_id);

$only = (int)qg('only') ?: null;

if ($after = qg('after')) {
	$after = \DateTime::createFromFormat('!Y-m-d', $after) ?: null;
}

$only_service = !$only ? null : Services::get($only);

$list = Subscriptions::perUserList($user_id, $only, $after);
$list->setTitle(sprintf('Inscriptions — %s', $user_name));
$list->loadFromQueryString();

$tpl->assign('services', Subscriptions::listDistinctForUser($user_id));
$tpl->assign(compact('list', 'user_id', 'user_name', 'only', 'only_service', 'after'));

$tpl->display('users/subscriptions.tpl');

Modified tools/doc_md_to_html.php from [72736f48da] to [df2497a62a].

1
2
3

4
5

6
7
8
9
10
11
12
1
2
3
4
5
6
7
8
9
10
11
12
13
14



+


+







<?php

use KD2\HTML\Markdown;
use KD2\HTMLDocument;
use KD2\HTML\Markdown_Extensions;

require_once __DIR__ . '/../src/include/lib/KD2/HTMLDocument.php';
require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown.php';
require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown_Extensions.php';

$md = new Markdown;

// Allow extra tags for Markdown quickref
$extra_tags = [
46
47
48
49
50
51
52





































































































































































































53
54
55
56
57
58
59
60


61
62
63
64
65
66
67
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257

258
259
260
261
262
263
264
265
266
267







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+






-

+
+








	if (preg_match('/^Title: (.*)/', $t, $match)) {
		$t = substr($t, strlen($match[0]));
	}

	$t = $md->text($t);
	$t = preg_replace('!(<a\s+[^>]+external[^>]+)>!', '$1 target="_blank">', $t);

	// rewrite API HTML to make it better
	if (basename($file) === 'api.md') {
		$dom = new HTMLDocument;
		$dom->loadHTML($t);

		foreach ($dom->querySelectorAll('h3') as $route) {
			$label = null;
			$content = [];

			$next = $route;

			while ($next = $next->nextElementSibling) {
				if ($next->tagName === 'h3' || $next->tagName === 'h2' || $next->tagName === 'h1') {
					break;
				}

				if ($next->tagName === 'p' && null === $label) {
					$label = $next;
				}
				else {
					$content[] = $next;
				}
			}

			foreach ($content as $key => $elm) {
				$content[$key] = $elm->cloneNode(true);
				$elm->parentNode->removeChild($elm);
			}

			$parent = $dom->createElement('details');
			$parent->setAttribute('class', 'api');
			$title = $route->cloneNode(true);
			$method = strtok($title->textContent, ' ');
			$path = strtok('');

			$summary = $dom->createElement('summary');
			$summary->setAttribute('id', $route->getAttribute('id'));
			$summary->setAttribute('onclick', 'if (!this.parentNode.open) window.history.replaceState(null, \'\', \'#\' + this.id); return true;');

			if (in_array($method, ['GET', 'POST', 'PUT', 'DELETE'])) {
				$label->parentNode->removeChild($label);

				$path = '/' . trim($path, '/');
				$path = preg_replace('/(\{[^}]+\})/', '<u>$1</u>', $path);
				$fragment = $dom->createDocumentFragment();
				$fragment->appendXML($path);
				$path = $dom->createElement('code');
				$path->appendChild($fragment);

				$m = $dom->createElement('b', $method);
				$m->setAttribute('class', 'method-' . $method);
				$summary->replaceChildren($m, ' ', $path, ' ', $dom->createElement('span', $label->textContent));
			}
			else {
				array_unshift($content, $label);
				$summary->replaceChildren($dom->createElement($title->tagName, $title->textContent));
			}

			$parent->appendChild($summary);

			foreach ($content as $elm) {
				$parent->appendChild($elm);
			}

			$route->replaceWith($parent);
		}

		$t = '<style type="text/css">
		details.api {
			clear: both;
			list-style: none;
			padding: 0.2em 0.5em;
			transition: background-color .2s;
			background: #fff;
			padding: 0;
			border: 1px solid #ccc;
			margin-bottom: .7em;
			border-radius: .5rem;
		}

		details.api summary {
			cursor: pointer;
			display: flex;
			align-items: center;
			gap: .8rem;
			font-size: 1.2em;
			position: relative;
			padding: .5rem;
			padding-right: 2em;
			flex-wrap: wrap;
		}

		details.api summary::after {
			content: "⌄";
			position: absolute;
			right: .5rem;
			bottom: .5em;
			font-size: 2em;
			line-height: .5em;
			transition: top .2s, transform .4s, color .2s;
		}

		details.api summary:hover::after {
			color: darkred;
			text-shadow: 0px 0px 5px orange;
		}

		details.api:not([open]):hover {
			background: #eee;
			box-shadow: 0px 0px 5px orange;
		}

		details.api[open] summary::after {
			transform: rotate(180deg);
			top: .75em;
			right: 0;
		}

		details.api[open] {
			padding: .5rem;
		}

		details.api[open] summary {
			margin-bottom: 1em;
			padding: 0;
			padding-right: 2em;
		}

		details.api summary b {
			display: block;
			border-radius: .3em;
			background: #333;
			padding: .1rem .4rem;
			color: #fff;
			width: 8ch;
			text-align: center;
		}

		details.api summary code {
			background: none;
			font-weight: bold;
			word-break: keep-all;
		}

		details.api summary code u {
			text-decoration: none;
			border: 1px dashed #999;
			color: darkblue;
			border-radius: .5rem;
			padding: .2rem;
		}

		details.api summary span {
			font-size: 1rem;
		}

		details.api summary b.method-GET {
			background: #8fbc8f;
		}
		details.api summary b.method-POST {
			background: #4682b4;
		}
		details.api summary b.method-PUT {
			background: #9370db;
		}
		details.api summary b.method-DELETE {
			background: #cd5c5c;
		}

		details.api summary h3 {
			margin: 0;
		}

		details.api.all {
			float: right;
		}

		details.api.all summary {
			margin: 0;
			font-size: .9rem;
		}

		@media screen and (max-width: 800px) {
			details.api summary {
				flex-direction: column;
				align-items: start;
			}

			details.api.all {
				float: none;
			}
		}
		</style>
		<details class="api all"><summary onclick="var open = !this.parentNode.hasAttribute(\'open\'); document.querySelectorAll(\'details\').forEach(elm => elm.open = open); return false;">Tout déplier / replier</summary></details>';
		$t .= $dom->saveHTML();
	}

	$title = $match[1] ?? $file;

	$out = '<!DOCTYPE html>
	<html>
	<head>
		<title>' . htmlspecialchars($title) . '</title>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>' . htmlspecialchars($title) . '</title>
		<style type="text/css">
		body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 {
			margin: 0;
			padding: 0;
		}
		body {
			font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif;