Overview
Comment:Merge with trunk
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | dev
Files: files | file ages | folders
SHA3-256: 3ca70608d95e21f3ed6daf06c8922b809c4ecb33516f2b0f59ab4c8e685f3c81
User & Date: bohwaz on 2022-11-08 00:42:36
Other Links: branch diff | manifest | tags
Context
2022-11-08
00:43
Merge missing file check-in: a52d9558be user: bohwaz tags: dev
00:42
Merge with trunk check-in: 3ca70608d9 user: bohwaz tags: dev
00:25
Move migrations check-in: 443e5220c8 user: bohwaz tags: dev
2022-11-07
23:52
Don't allow to change the country of a chart unless it's NULL check-in: 0d3c679467 user: bohwaz tags: trunk, stable, 1.2.1
Changes

Modified src/VERSION from [988e9a1ff9] to [98db9e6a5b].

Added src/include/data/charts/ch_asso.csv version [e384d6a130].















































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
Numéro,Libellé,Description,Position,Favori
1,Actifs,,Actif,
10,Liquidités,,Actif,
100,Caisses,,Actif,
1000,Caisse,,Actif,Favori
1005,Caisse Euro,,Actif,Favori
1010,CCP,,Actif,
102,Banques,,Actif,
1020,Compte bancaire,,Actif,Favori
1030,Dépôts à court terme (< 3 mois),,Actif,
109,Comptes d’attente,,Actif,
1090,Dépôts en attente,,Actif,Favori
11,Titres cotés en bourse et détenus à court terme,,Actif,
1100,Titres,,Actif,
12,Débiteurs,,Actif,
120,Débiteurs résultant de la vente de biens et de prestations de services,,Actif,
1200,Débiteurs résultant de la vente de biens et de prestations de services,,Actif,
125,Autres débiteurs,,Actif,
1250,Autres débiteurs,,Actif,
13,Stocks,,Actif,
1300,Stocks divers,,Actif,
14,Actifs transitoires (comptes de régularisation d’actifs),,Actif,
1400,Impôts anticipés,,Actif,
1410,Produits à recevoir,,Actif,
1420,Charges payées d'avance,,Actif,
15,Immobilisations financières,,Actif,
1500,Cautions loyers,,Actif,
16,Participations,,Actif,
1600,Participations dans d’autres entités,,Actif,
17,Immobilisations corporelles,,Actif,
1700,Mobilier,,Actif,
1710,Informatique,,Actif,
18,Immobilisations incorporelles,,Actif,
1800,Licences informatiques,,Actif,
2,Passifs,,Passif,
20,Créanciers,,Passif,
2000,Fournisseurs,,Passif,
2050,Autres dettes à court terme,,Passif,
21,Dettes à court terme liées aux charges de personnel,,Passif,
2100,Créanciers caisse AVS,,Passif,
2110,Créanciers LAA,,Passif,
2120,Créanciers LPP,,Passif,
2130,Créanciers impôt source,,Passif,
2140,Créanciers assurance indemnités journalières maladie,,Passif,
2180,Salaires à payer,,Passif,
22,Dettes financières à court terme,,Passif,
2200,Emprunts bancaires à rembourser dans l'année,,Passif,
23,Passifs transitoires (comptes de régularisation de passifs),,Passif,
2310,Produits constatés d'avance,,Passif,
2320,Charges à payer,,Passif,
24,Dettes à long terme,,Passif,
2400,Emprunts bancaires à rembourser à plus d'une année,,Passif,
2410,Autres dettes à long terme,,Passif,
26,Provisions,,Passif,
28,Fonds affectés (Swiss GAAP RPC),À subdiviser par projet,Passif,
29,Fonds propres,,Passif,
2900,Capital initial versé (ou capital de fondation),,Passif,
2910,Réserve associative,,Passif,
2990,Résultats reportés (fonds libres),,Passif,
2999,Résultat de l'exercice,,Passif,
29991,Résultat positif,,Passif,
29999,Résultat négatif,,Passif,
3,Charges,,Charge,
30,Charges directes de projets,À subdiviser par projet,Charge,
31,Charges de personnel,,Charge,
310,Salaires,,Charge,
3100,Salaires,,Charge,Favori
311,Charges sociales,,Charge,
3110,AVS-AI-AC-AMAT,,Charge,
3111,LAA professionnelle,,Charge,
3112,LAA complémentaire,,Charge,
3113,Assurance indemnités journalières maladie,,Charge,
3114,LPP,,Charge,
312,Autres charges de personnel,,Charge,
3120,Frais de formation,,Charge,Favori
3128,Autres frais de personnel,,Charge,
315,Compensations charges de personnel (revenus présentés en déduction des charges de personnel),,Charge,
3150,Indemnités d'assurance pour charges du personnel,,Charge,
3151,Mise à disposition de personnes,,Charge,
3152,Commission perception IS,,Charge,
3159,Autres compensations charges salariales,,Charge,
32,Charges de locaux,,Charge,
3200,Loyers,,Charge,Favori
3210,"Charges – Eau, gaz, électricité ",,Charge,Favori
3220,Frais d'entretiens locaux,,Charge,Favori
3230,Assurance RC et choses,,Charge,
3280,Frais de location de salles,,Charge,Favori
33,Administration et informatique,,Charge,
330,Administration,,Charge,
3300,Fournitures de bureau,,Charge,Favori
3301,Télécommunications,,Charge,Favori
3302,Frais de port,,Charge,Favori
3303,Documents et abonnements,,Charge,
3304,Frais de cotisations,,Charge,
3306,Frais de réunion,,Charge,
335,Informatique,,Charge,
3350,Frais de licence,Par exemple pour décompter la contribution à un logiciel comptable ;-),Charge,Favori
3351,Frais de maintenance,,Charge,
3352,Petit matériel informatique,,Charge,Favori
34,Frais de promotion et de représentation,,Charge,
340,Matériel de promotion,,Charge,
3400,Impression de matériel de promotion,,Charge,
3401,Conception de matériel de promotion,,Charge,
3409,Autre matériel de promotion,,Charge,
341,Site internet et communication en ligne,,Charge,
3410,"Frais de maintenance de site internet, plateforme web, outils de communication en ligne",,Charge,
3411,"Conception de site internet, plateforme web, outils de communication en ligne",,Charge,
3419,Autres frais liés à la communication en ligne,,Charge,
346,Frais de représentation,,Charge,
3460,Frais de voyage,,Charge,
3461,Frais de repas,,Charge,Favori
3469,Autres frais de représentation,,Charge,
35,Mises à disposition gratuites (contrepartie : subventions non monétaires en 45),,Charge,
36,Autres charges d’exploitation,,Charge,
360,Amortissements et dépréciations d’actifs,,Charge,
3600,Amortissements,,Charge,
3601,Dépréciations d’actifs,,Charge,
361,Charges sur débiteurs douteux,,Charge,
3610,Variation provision sur débiteurs douteux,,Charge,
3620,Pertes sur débiteurs douteux,,Charge,
369,Autres charges d’exploitation,,Charge,
3690,Autres charges d’exploitation,,Charge,
37,"Charges hors exploitation, exceptionnelles, uniques ou hors périodes",,Charge,
370,Charges hors exploitation,,Charge,
3700,Charges hors exploitation,,Charge,
371,Charges exceptionnelles ou uniques,,Charge,
3710,Charges exceptionnelles ou uniques,,Charge,
372,Charges hors périodes,,Charge,
3720,Charges liées aux exercices précédents,,Charge,
38,Charges financières,,Charge,
3800,Charges d'intérêts,,Charge,
3810,Frais bancaires,,Charge,Favori
3820,Pertes de change,,Charge,
39,Variation des fonds affectés (Swiss GAAP RPC),,Charge,
3900,Attribution aux fonds affectés,,Charge,
3910,Produits internes de fonds affectés,,Produit,
4,Revenus,,Produit,
40,Revenus de ventes et de prestations,,Produit,
4000,Revenu de prestations,,Produit,Favori
4010,Revenu de ventes,,Produit,Favori
41,Revenus des fonds affectés,,Produit,
410,Subventions,,Produit,Favori
4130,Donateurs privés – fonds affectés,,Produit,
42,Dons non affectés,,Produit,
4220,Donateurs privés – non affectés,,Produit,
43,Cotisations de membres,,Produit,
4400,Cotisations de membres,,Produit,Favori
45,Subventions non monétaires (contrepartie : mises à disposition gratuites en 35),,Produit,
46,Autres produits d’exploitation,,Produit,
4600,Dissolutions de provisions,,Produit,
47,"Produits hors exploitation,  exceptionnels, uniques ou hors périodes ",,Produit,
470,Produits hors exploitation,,Produit,
4700,Produits hors exploitation,,Produit,
471,Produits exceptionnels ou uniques,,Produit,
4710,Produits exceptionnels ou uniques,,Produit,
472,Produits hors période,,Produit,
4720,Produits liés aux exercices précédents,,Produit,
48,Revenus financiers,,Produit,
4800,Revenus d'intérêts,,Produit,
4820,Gains de change,,Produit,
49,Variation des fonds affectés (Swiss GAAP RPC),,Produit,
4900,Utilisation des fonds affectés,,Produit,
4910,Charges internes de fonds affectés,,Charge,
5,Comptes de tiers,,Actif ou passif,
9,Bilan,,,
9100,Bilan d'ouverture,,,
9101,Bilan de clôture,,,

Modified src/include/lib/Garradin/Accounting/Accounts.php from [b8a87f7a26] to [8a6afef628].

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
			'description' => [
				'label' => '',
				'order' => null,
			],
			'level' => [
				'select' => 'CASE WHEN LENGTH(code) >= 6 THEN 6 ELSE LENGTH(code) END',
			],
			'bookmark' => [
				'label' => 'Favori',




			],
			'user' => [
				'label' => 'Ajouté',
			],



		];

		$tables = 'acc_accounts';
		$conditions = 'id_chart = ' . $this->chart_id;

		if (!empty($types)) {
			$types = array_map('intval', $types);
			$conditions .= ' AND ' . DB::getInstance()->where('type', $types);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('code', false);
		$list->setPageSize(null);





		return $list;
	}

	public function listAll(array $types = null): array
	{
		$condition = '';







|
|
>
>
>
>




>
>
>













>
>
>
>







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
			'description' => [
				'label' => '',
				'order' => null,
			],
			'level' => [
				'select' => 'CASE WHEN LENGTH(code) >= 6 THEN 6 ELSE LENGTH(code) END',
			],
			'report' => [
				'label' => ' ',
				'select' => null,
			],
			'position' => [
				'label' => 'Position',
			],
			'user' => [
				'label' => 'Ajouté',
			],
			'bookmark' => [
				'label' => 'Favori',
			],
		];

		$tables = 'acc_accounts';
		$conditions = 'id_chart = ' . $this->chart_id;

		if (!empty($types)) {
			$types = array_map('intval', $types);
			$conditions .= ' AND ' . DB::getInstance()->where('type', $types);
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('code', false);
		$list->setPageSize(null);
		$list->setModifier(function (&$row) {
			$row->position_report = !$row->position ? '' : ($row->position <= Account::ASSET_OR_LIABILITY ? 'Bilan' : 'Résultat');
			$row->position_name = Account::POSITIONS_NAMES[$row->position];
		});

		return $list;
	}

	public function listAll(array $types = null): array
	{
		$condition = '';
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
	/**
	 * List common accounts, grouped by type
	 * @return array
	 */
	public function listCommonGrouped(array $types = null): array
	{
		if (null === $types) {


			$types = Account::COMMON_TYPES;



		}

		$out = [];

		foreach ($types as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}











		$sql = sprintf('SELECT a.* FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = %d AND a.%s AND (a.bookmark = 1 OR b.id IS NOT NULL)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->chart_id,
			$this->em->DB()->where('type', $types)

		);

		$query = $this->em->iterate($sql);

		foreach ($query as $row) {

			$out[$row->type]->accounts[] = $row;






		}

		return $out;
	}




	public function listMissing(int $type): array
	{
		if ($type != Account::TYPE_EXPENSE && $type != Account::TYPE_REVENUE && $type != Account::TYPE_THIRD_PARTY) {
			return [];
		}

		return $this->em->DB()->get($this->em->formatQuery('SELECT a.*, CASE WHEN LENGTH(a.code) >= 6 THEN 6 ELSE LENGTH(a.code) END AS level
			FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = ? AND a.type = ? AND NOT (a.bookmark = 1 OR a.user = 1 OR b.id IS NOT NULL)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;'), $this->chart_id, $type);
	}

	public function countByType(int $type)
	{
		return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
	}

	public function getSingleAccountForType(int $type)
	{
		return DB::getInstance()->first('SELECT * FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}






	public function getOpeningAccountId(): ?int
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_OPENING, $this->chart_id) ?: null;
	}

	public function getClosingAccountId()
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_CLOSING, $this->chart_id);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$columns = [
			'id' => [
				'select' => 'u.id',







>
>
|
>
>
>




|






>
>
>
>
>
>
>
>
>
>



|



|
>





>
|
>
>
>
>
>
>





>
>
>














|








>
>
>
>
>



|


|

|







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
	/**
	 * List common accounts, grouped by type
	 * @return array
	 */
	public function listCommonGrouped(array $types = null): array
	{
		if (null === $types) {
			// If we want all types, then we will get used or bookmarked accounts in common types
			// and only bookmarked accounts for other types, grouped in "Others"
			$target = Account::COMMON_TYPES;
		}
		else {
			$target = $types;
		}

		$out = [];

		foreach ($target as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}

		if (null === $types) {
			$out[0] = (object) [
				'label'    => 'Autres',
				'type'     => 0,
				'accounts' => [],
			];
		}

		$db = $this->em->DB();

		$sql = sprintf('SELECT a.* FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR b.id IS NOT NULL)) %s)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->chart_id,
			$db->where('type', $target),
			(null === $types) ? 'OR (a.bookmark = 1)' : ''
		);

		$query = $this->em->iterate($sql);

		foreach ($query as $row) {
			$t = in_array($row->type, $target) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}

		foreach ($out as $key => $v) {
			if (!count($v->accounts)) {
				unset($out[$key]);
			}
		}

		return $out;
	}

	/**
	 * List accounts from this type that are missing in current "usual" accounts list
	 */
	public function listMissing(int $type): array
	{
		if ($type != Account::TYPE_EXPENSE && $type != Account::TYPE_REVENUE && $type != Account::TYPE_THIRD_PARTY) {
			return [];
		}

		return $this->em->DB()->get($this->em->formatQuery('SELECT a.*, CASE WHEN LENGTH(a.code) >= 6 THEN 6 ELSE LENGTH(a.code) END AS level
			FROM @TABLE a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			WHERE a.id_chart = ? AND a.type = ? AND NOT (a.bookmark = 1 OR a.user = 1 OR b.id IS NOT NULL)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;'), $this->chart_id, $type);
	}

	public function countByType(int $type): int
	{
		return DB::getInstance()->count(Account::TABLE, 'id_chart = ? AND type = ?', $this->chart_id, $type);
	}

	public function getSingleAccountForType(int $type)
	{
		return DB::getInstance()->first('SELECT * FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}

	public function getIdForType(int $type): ?int
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ? LIMIT 1;', $type, $this->chart_id);
	}

	public function getOpeningAccountId(): ?int
	{
		return $this->getIdForType(Account::TYPE_OPENING);
	}

	public function getClosingAccountId(): ?int
	{
		return $this->getIdForType(Account::TYPE_CLOSING);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$columns = [
			'id' => [
				'select' => 'u.id',

Modified src/include/lib/Garradin/Accounting/AdvancedSearch.php from [b700397268] to [ab39cd6bf3].

221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
			'desc' => true,
		];
	}

	public function schema(): array
	{
		$db = DB::getInstance();
		$sql = sprintf('SELECT name, sql FROM sqlite_master WHERE %s ORDER BY name;', $db->where('name', ['acc_transactions', 'acc_transactions_lines', 'acc_accounts', 'acc_years']));
		return $db->getAssoc($sql);
	}

	public function make(string $query): DynamicList
	{
		$tables = 'acc_transactions AS t
			INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id







|







221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
			'desc' => true,
		];
	}

	public function schema(): array
	{
		$db = DB::getInstance();
		$sql = sprintf('SELECT name, sql FROM sqlite_master WHERE %s ORDER BY name;', $db->where('name', ['acc_transactions', 'acc_transactions_lines', 'acc_accounts', 'acc_years', 'acc_projects']));
		return $db->getAssoc($sql);
	}

	public function make(string $query): DynamicList
	{
		$tables = 'acc_transactions AS t
			INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id

Modified src/include/lib/Garradin/Accounting/Charts.php from [9d142b0f30] to [4110a572c9].

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
	const BUNDLED_CHARTS = [
		'fr_pca_1999' => 'Plan comptable associatif 1999',
		'fr_pca_2018' => 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)',
		'fr_pcg_2014' => 'Plan comptable général, pour entreprises (Règlement ANC n° 2014-03, consolidé 1er janvier 2019)',
		'fr_cse_2015' => 'Plan comptable des CSE (Comité Social et Économique) (Règlement ANC n°2015-01)',
		'fr_pcc_2020' => 'Plan comptable des copropriétés (2005 révisé en 2020)',
		'be_pcmn_2019' => 'Plan comptable minimum normalisé des associations et fondations 2019',

	];

	static public function updateInstalled(string $chart_code): ?Chart
	{
		$file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);
		$country = strtoupper(substr($chart_code, 0, 2));
		$code = strtoupper(substr($chart_code, 3));

		$chart = EntityManager::findOne(Chart::class, 'SELECT * FROM @TABLE WHERE code = ? AND country = ?;', $code, $country);

		if (!$chart) {
			return null;
		}

		$chart->importCSV($file, true);
		return $chart;
	}










	static public function install(string $chart_code): Chart
	{
		if (!array_key_exists($chart_code, self::BUNDLED_CHARTS)) {
			throw new \InvalidArgumentException('Le plan comptable demandé n\'existe pas.');
		}








>

















>
>
>
>
>
>
>
>
>







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
	const BUNDLED_CHARTS = [
		'fr_pca_1999' => 'Plan comptable associatif 1999',
		'fr_pca_2018' => 'Plan comptable associatif 2020 (Règlement ANC n°2018-06)',
		'fr_pcg_2014' => 'Plan comptable général, pour entreprises (Règlement ANC n° 2014-03, consolidé 1er janvier 2019)',
		'fr_cse_2015' => 'Plan comptable des CSE (Comité Social et Économique) (Règlement ANC n°2015-01)',
		'fr_pcc_2020' => 'Plan comptable des copropriétés (2005 révisé en 2020)',
		'be_pcmn_2019' => 'Plan comptable minimum normalisé des associations et fondations 2019',
		'ch_asso' => 'Plan comptable associatif',
	];

	static public function updateInstalled(string $chart_code): ?Chart
	{
		$file = sprintf('%s/include/data/charts/%s.csv', ROOT, $chart_code);
		$country = strtoupper(substr($chart_code, 0, 2));
		$code = strtoupper(substr($chart_code, 3));

		$chart = EntityManager::findOne(Chart::class, 'SELECT * FROM @TABLE WHERE code = ? AND country = ?;', $code, $country);

		if (!$chart) {
			return null;
		}

		$chart->importCSV($file, true);
		return $chart;
	}

	static public function resetRules(array $country_list): void
	{
		foreach (self::list() as $c) {
			if (in_array($c->country, $country_list)) {
				$c->resetAccountsRules();
			}
		}
	}

	static public function install(string $chart_code): Chart
	{
		if (!array_key_exists($chart_code, self::BUNDLED_CHARTS)) {
			throw new \InvalidArgumentException('Le plan comptable demandé n\'existe pas.');
		}

104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
	{
		$where = $filter_archived ? ' AND archived = 0' : '';
		$sql = sprintf('SELECT id, country, label FROM %s WHERE 1 %s ORDER BY country, code DESC, label;', Chart::TABLE, $where);
		$list = DB::getInstance()->getGrouped($sql);
		$out = [];

		foreach ($list as $row) {
			$country = Utils::getCountryName($row->country);

			if (!array_key_exists($country, $out)) {
				$out[$country] = [];
			}

			$out[$country][$row->id] = $row->label;
		}







|







114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
	{
		$where = $filter_archived ? ' AND archived = 0' : '';
		$sql = sprintf('SELECT id, country, label FROM %s WHERE 1 %s ORDER BY country, code DESC, label;', Chart::TABLE, $where);
		$list = DB::getInstance()->getGrouped($sql);
		$out = [];

		foreach ($list as $row) {
			$country = $row->country ? Utils::getCountryName($row->country) : 'Aucun';

			if (!array_key_exists($country, $out)) {
				$out[$country] = [];
			}

			$out[$country][$row->id] = $row->label;
		}

Modified src/include/lib/Garradin/Accounting/Graph.php from [68d1ce90b8] to [fd86b5931f].

49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
		],
		'debts' => [
			'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
		],
	];

	const PIE_TYPES = [
		'revenue' => ['type' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING_REVENUE],
		'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING_EXPENSE],
		'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
	];

	const WEEKLY_INTERVAL = 604800; // 7 days
	const MONTHLY_INTERVAL = 2635200; // 1 month








|







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
		],
		'debts' => [
			'Comptes de tiers' => ['type' => Account::TYPE_THIRD_PARTY],
		],
	];

	const PIE_TYPES = [
		'revenue' => ['position' => Account::REVENUE, 'exclude_type' => Account::TYPE_VOLUNTEERING_REVENUE],
		'expense' => ['position' => Account::EXPENSE, 'exclude_type' => Account::TYPE_VOLUNTEERING_EXPENSE],
		'assets' => ['type' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING]],
	];

	const WEEKLY_INTERVAL = 604800; // 7 days
	const MONTHLY_INTERVAL = 2635200; // 1 month

235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
		$c1 = $config->get('color1') ?: ADMIN_COLOR1;
		$c2 = $config->get('color2') ?: ADMIN_COLOR2;
		list($h, $s, $v) = Utils::rgbToHsv($c1);
		list($h1, $s, $v) = Utils::rgbToHsv($c2);

		$colors = [];

		for ($i = 0; $i < 6; $i++) {
			if ($i % 2 == 0) {
				$s = $v = 50;
				$h =& $h1;
			}
			else {
				$s = $v = 70;
				$h =& $h2;







|







235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
		$c1 = $config->get('color1') ?: ADMIN_COLOR1;
		$c2 = $config->get('color2') ?: ADMIN_COLOR2;
		list($h, $s, $v) = Utils::rgbToHsv($c1);
		list($h1, $s, $v) = Utils::rgbToHsv($c2);

		$colors = [];

		for ($i = 0; $i < 5; $i++) {
			if ($i % 2 == 0) {
				$s = $v = 50;
				$h =& $h1;
			}
			else {
				$s = $v = 70;
				$h =& $h2;

Modified src/include/lib/Garradin/Accounting/Reports.php from [22353fbb2e] to [ee934a3c82].

33
34
35
36
37
38
39





40
41
42
43
44
45
46
			$where[] = sprintf($accounts_alias . 'position NOT IN (%s)', implode(',', $criterias['exclude_position']));
		}

		if (!empty($criterias['type'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type']);
			$where[] = sprintf($accounts_alias . 'type IN (%s)', implode(',', $criterias['type']));
		}






		if (!empty($criterias['exclude_type'])) {
			$criterias['exclude_type'] = array_map('intval', (array)$criterias['exclude_type']);
			$where[] = sprintf($accounts_alias . 'type NOT IN (%s)', implode(',', $criterias['exclude_type']));
		}

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







>
>
>
>
>







33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
			$where[] = sprintf($accounts_alias . 'position NOT IN (%s)', implode(',', $criterias['exclude_position']));
		}

		if (!empty($criterias['type'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type']);
			$where[] = sprintf($accounts_alias . 'type IN (%s)', implode(',', $criterias['type']));
		}

		if (!empty($criterias['type_or_bookmark'])) {
			$criterias['type'] = array_map('intval', (array)$criterias['type_or_bookmark']);
			$where[] = sprintf('(%stype IN (%s) OR %1$sbookmark = 1)', $accounts_alias, implode(',', $criterias['type']));
		}

		if (!empty($criterias['exclude_type'])) {
			$criterias['exclude_type'] = array_map('intval', (array)$criterias['exclude_type']);
			$where[] = sprintf($accounts_alias . 'type NOT IN (%s)', implode(',', $criterias['exclude_type']));
		}

		if (!empty($criterias['user'])) {
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
		$out->foot_right = [self::getTotalLine($out->body_right, 'Total passif')];

		return $out;
	}

	/**
	 * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
	 * @return \Generator list of accounts grouped by type
	 */
	static public function getClosingSumsFavoriteAccounts(array $criterias): \Generator
	{
		$types = Account::COMMON_TYPES;
		$accounts = self::getAccountsBalances($criterias + ['type' => $types], 'type, code COLLATE NOCASE', false);

		$group = null;

		foreach ($accounts as $row) {
			if (null !== $group && $row->type !== $group->type) {

				yield $group;
				$group = null;

			}

			if (null === $group) {
				$group = (object) [
					'label'    => Account::TYPES_NAMES[$row->type],
					'type'     => $row->type,
					'accounts' => []
				];
			}


			$group->accounts[] = $row;
		}

		if (null !== $group) {
			yield $group;
		}
	}

	/**
	 * Grand livre
	 */
	static public function getGeneralLedger(array $criterias): \Generator
	{







|

|


|

|

|
|
>
|
|
>
|

<
|
|
|
|
|
|
|
>
|


<
|
<







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
		$out->foot_right = [self::getTotalLine($out->body_right, 'Total passif')];

		return $out;
	}

	/**
	 * Return list of favorite accounts (accounts with a type), grouped by type, with their current sum
	 * @return array list of accounts grouped by type
	 */
	static public function getClosingSumsFavoriteAccounts(array $criterias): array
	{
		$types = Account::COMMON_TYPES;
		$accounts = self::getAccountsBalances($criterias + ['type_or_bookmark' => $types], 'type, code COLLATE NOCASE', false);

		$out = [];

		foreach ($types as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}


		$out[0] = (object) [
			'label'    => 'Autres',
			'type'     => 0,
			'accounts' => [],
		];

		foreach ($accounts as $row) {
			$t = in_array($row->type, $types, true) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}


		return $out;

	}

	/**
	 * Grand livre
	 */
	static public function getGeneralLedger(array $criterias): \Generator
	{

Modified src/include/lib/Garradin/Accounting/Transactions.php from [226bbcc6e1] to [d89e888046].

166
167
168
169
170
171
172

173
174
175
176
177
			if (isset($row->type_label)) {
				$row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
			}
		});
		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
			$row->projects = implode(', ', $row->projects);

		});

		return $list;
	}
}







>





166
167
168
169
170
171
172
173
174
175
176
177
178
			if (isset($row->type_label)) {
				$row->type_label = Transaction::TYPES_NAMES[(int)$row->type_label];
			}
		});
		$list->setExportCallback(function (&$row) {
			$row->change = Utils::money_format($row->change, '.', '', false);
			$row->projects = implode(', ', $row->projects);
			unset($row->project_code, $row->id_project);
		});

		return $list;
	}
}

Modified src/include/lib/Garradin/Accounting/Years.php from [ff8b86dbf1] to [cb467ec56a].

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
	/**
	 * Crée une écriture d'affectation automatique
	 * @param  Year   $year
	 * @return Transaction|null
	 */
	static public function makeAppropriation(Year $year): ?Transaction
	{

		$balances = DB::getInstance()->getGrouped('SELECT a.type, a.id, SUM(l.credit) - SUM(l.debit) AS balance
			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
			WHERE t.id_year = ? AND (a.type = ? OR a.type = ?) GROUP BY a.type;',
			$year->id, Account::TYPE_NEGATIVE_RESULT, Account::TYPE_POSITIVE_RESULT
		);

		if (!count($balances)) {
			return null;
		}

		$positive_appropriation = DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_APPROPRIATION_RESULT, $year->id_chart);
		$negative_appropriation = DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_DEBIT_REPORT, $year->id_chart);

		if (!$positive_appropriation || !$negative_appropriation) {
			return null;
		}

		$t = new Transaction;
		$t->type = $t::TYPE_ADVANCED;
		$t->id_year = $year->id();
		$t->label = 'Affectation automatique du résultat';
		$t->notes = 'Le résultat a été affecté automatiquement lors de l\'ouverture de l\'exercice';
		$t->date = new \KD2\DB\Date;

		if ($t->date > $year->end_date) {
			$t->date = $year->end_date;
		}

		if ($t->date < $year->start_date) {
			$t->date = $year->start_date;
		}

		$sum = 0;

		if (!empty($balances[Account::TYPE_NEGATIVE_RESULT])) {
			$account = $balances[Account::TYPE_NEGATIVE_RESULT];

			$line = Line::create($account->id, abs($account->balance), 0);
			$t->addLine($line);

			$sum += $account->balance;
		}

		if (!empty($balances[Account::TYPE_POSITIVE_RESULT])) {
			$account = $balances[Account::TYPE_POSITIVE_RESULT];

			$line = Line::create($account->id, 0, abs($account->balance));
			$t->addLine($line);

			$sum += $account->balance;
		}

		if ($sum > 0) {
			$line = Line::create($positive_appropriation, $sum, 0);
		}
		else {
			$line = Line::create($negative_appropriation, 0, abs($sum));
		}

		$t->addLine($line);

		return $t;
	}
}







>
|











|

<
<

|







|
<
<
<
<
<
<
<
|
<


















|



|


|



>



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
	/**
	 * Crée une écriture d'affectation automatique
	 * @param  Year   $year
	 * @return Transaction|null
	 */
	static public function makeAppropriation(Year $year): ?Transaction
	{
		$db = DB::getInstance();
		$balances = $db->getGrouped('SELECT a.type, a.id, SUM(l.credit) - SUM(l.debit) AS balance
			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
			WHERE t.id_year = ? AND (a.type = ? OR a.type = ?) GROUP BY a.type;',
			$year->id, Account::TYPE_NEGATIVE_RESULT, Account::TYPE_POSITIVE_RESULT
		);

		if (!count($balances)) {
			return null;
		}

		$appropriation_account = $db->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;',
			Account::TYPE_APPROPRIATION_RESULT, $year->id_chart);



		if (!$appropriation_account) {
			return null;
		}

		$t = new Transaction;
		$t->type = $t::TYPE_ADVANCED;
		$t->id_year = $year->id();
		$t->label = 'Affectation automatique du résultat';
		$t->notes = 'Le résultat a été affecté automatiquement lors de la balance d\'ouverture';







		$t->date = $year->start_date;


		$sum = 0;

		if (!empty($balances[Account::TYPE_NEGATIVE_RESULT])) {
			$account = $balances[Account::TYPE_NEGATIVE_RESULT];

			$line = Line::create($account->id, abs($account->balance), 0);
			$t->addLine($line);

			$sum += $account->balance;
		}

		if (!empty($balances[Account::TYPE_POSITIVE_RESULT])) {
			$account = $balances[Account::TYPE_POSITIVE_RESULT];

			$line = Line::create($account->id, 0, abs($account->balance));
			$t->addLine($line);

			$sum -= $account->balance;
		}

		if ($sum > 0) {
			$line = Line::create($appropriation_account, $sum, 0);
		}
		else {
			$line = Line::create($appropriation_account, 0, abs($sum));
		}

		$t->addLine($line);

		return $t;
	}
}

Modified src/include/lib/Garradin/Entities/Accounting/Account.php from [0f59f5f876] to [88c4f5f3ae].

131
132
133
134
135
136
137







138
139
140
141
142
143
144
			'^7' => self::REVENUE,
			'^5' => self::ASSET_OR_LIABILITY,
			'^4' => self::ASSET_OR_LIABILITY,
			'^3' => self::ASSET,
			'^2' => self::ASSET,
			'^1' => self::LIABILITY,
		],







	];

	/**
	 * Codes that should be enforced according to type (and vice-versa)
	 */
	const LOCAL_TYPES = [
		'FR' => [







>
>
>
>
>
>
>







131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
			'^7' => self::REVENUE,
			'^5' => self::ASSET_OR_LIABILITY,
			'^4' => self::ASSET_OR_LIABILITY,
			'^3' => self::ASSET,
			'^2' => self::ASSET,
			'^1' => self::LIABILITY,
		],
		'CH' => [
			'^1' => self::ASSET,
			'^2' => self::LIABILITY,
			'^3(?!910)|^4910' => self::EXPENSE,
			'^4(?!910)|^3910' => self::REVENUE,
			'^5' => self::ASSET_OR_LIABILITY,
		],
	];

	/**
	 * Codes that should be enforced according to type (and vice-versa)
	 */
	const LOCAL_TYPES = [
		'FR' => [
169
170
171
172
173
174
175















176
177
178
179
180
181
182
			self::TYPE_REVENUE => '7',
			self::TYPE_POSITIVE_RESULT => '692',
			self::TYPE_NEGATIVE_RESULT => '690',
			self::TYPE_THIRD_PARTY => '4',
			self::TYPE_OPENING => '890',
			self::TYPE_CLOSING => '891',
		],















	];

	const LIST_COLUMNS = [
		'id' => [
			'select' => 't.id',
			'label' => 'N°',
		],







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
			self::TYPE_REVENUE => '7',
			self::TYPE_POSITIVE_RESULT => '692',
			self::TYPE_NEGATIVE_RESULT => '690',
			self::TYPE_THIRD_PARTY => '4',
			self::TYPE_OPENING => '890',
			self::TYPE_CLOSING => '891',
		],
		'CH' => [
			self::TYPE_BANK => '102',
			self::TYPE_CASH => '100',
			self::TYPE_OUTSTANDING => '109',
			self::TYPE_THIRD_PARTY => '5',
			self::TYPE_EXPENSE => '3',
			self::TYPE_REVENUE => '4',
			self::TYPE_OPENING => '9100',
			self::TYPE_CLOSING => '9101',
			self::TYPE_POSITIVE_RESULT => '29991',
			self::TYPE_NEGATIVE_RESULT => '29999',
			self::TYPE_APPROPRIATION_RESULT => '2910',
			self::TYPE_CREDIT_REPORT => '2990',
			self::TYPE_DEBIT_REPORT => '2990',
		],
	];

	const LIST_COLUMNS = [
		'id' => [
			'select' => 't.id',
			'label' => 'N°',
		],
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
			'select' => 'l.reference',
		],
		'id_project' => [
			'select' => 'l.id_project',
		],
		'project_code' => [
			'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')',
		],
		'projects' => [
			'label' => 'Projet',
			'select' => null,
		],
		'status' => [
			'select' => 't.status',
		],
	];

	protected ?int $id;
	protected int $id_chart;
	protected string $code;
	protected string $label;
	protected ?string $description;
	protected int $position = 0;
	protected int $type;
	protected bool $user = false;
	protected bool $bookmark = false;

	protected $_position = [];
	protected ?Chart $_chart = null;



	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(trim($this->code) !== '', 'Le numéro de compte ne peut rester vide.');
		$this->assert(trim($this->label) !== '', 'L\'intitulé de compte ne peut rester vide.');



		$this->assert(strlen($this->code) <= 20, 'Le numéro de compte ne peut faire plus de 20 caractères.');
		$this->assert(preg_match('/^[a-z0-9_]+$/i', $this->code), 'Le numéro de compte ne peut comporter que des lettres et des chiffres.');


		$this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');
		$this->assert(!isset($this->description) || strlen($this->description) <= 2000, 'La description de compte ne peut faire plus de 2000 caractères.');

		$this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');

		$where = 'code = ? AND id_chart = ?';
		$where .= $this->exists() ? sprintf(' AND id != %d', $this->id()) : '';

		if ($db->test(self::TABLE, $where, $this->code, $this->id_chart)) {
			throw new ValidationException(sprintf('Le numéro "%s" est déjà utilisé par un autre compte.', $this->code));
		}

		$this->assert(isset($this->type));

		$this->checkLocalRules();

		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type invalide: ' . $this->type);
		$this->assert(array_key_exists($this->position, self::POSITIONS_NAMES), 'Position invalide');


		parent::selfCheck();
	}

	protected function getCountry(): ?string
	{



		static $charts_countries = null;


		if (null === $charts_countries) {





			$charts_countries = DB::getInstance()->getAssoc('SELECT id, country FROM acc_charts;');









		}

































		return $charts_countries[$this->id_chart] ?? null;
	}





	protected function matchType(int $type): bool
	{

		$country = $this->getCountry();


		$pattern = self::LOCAL_TYPES[$country][$type] ?? null;

		if (!$pattern) {
			return false;
		}

		if (in_array($type, self::COMMON_TYPES)) {
			$pattern = sprintf('/^%s.+/', $pattern);
		}
		else {
			$pattern = sprintf('/^%s$/', $pattern);
		}

		return (bool) preg_match($pattern, $this->code);
	}

	public function setLocalRules(): void
	{

		$country = $this->getCountry();

		if (array_key_exists($country, self::LOCAL_TYPES)) {
			foreach (self::LOCAL_TYPES[$country] as $type => $number) {
				if ($this->matchType($type)) {
					$this->set('type', $type);
					break;
				}
			}


			foreach (self::LOCAL_POSITIONS[$country] as $pattern => $position) {
				if (preg_match('/' . $pattern . '/', $this->code)) {
					// If the allowed position is asset OR liability, we allow either one of those 3 choices
					if ($position == self::ASSET_OR_LIABILITY
						&& in_array($this->position, [self::ASSET_OR_LIABILITY, self::ASSET, self::LIABILITY])) {
						break;
					}

					// Or else we force the position
					$this->set('position', $position);
					break;
				}
			}
		}

		if (!isset($this->type)) {
			$this->set('type', 0);
		}
	}

	public function checkLocalRules(): void
	{
		$country = $this->getCountry();

		if (!$this->type) {
			return;
		}

		if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
			return;
		}

		$this->assert($this->matchType($this->type), sprintf('Le numéro des comptes de type "%s" doit commencer par "%s" (%s).', self::TYPES_NAMES[$this->type], self::LOCAL_TYPES[$country][$this->type], $this->code));
	}

	public function getNewNumberAvailable(?string $base = null): ?string
	{
		$base ??= $this->getNumberBase();

		if (!$base) {







<
<

<



















>
>







>
>
|
|
>
>



















<





>
>
>
|
>
|
|
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>


>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
>
>
>
>
|

>
|
>
>
















|

>
|
|
|
|
<
|
|
|
|
>

<
|
|
|
|
<
<
<
<
|
<
<




















|







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
			'select' => 'l.reference',
		],
		'id_project' => [
			'select' => 'l.id_project',
		],
		'project_code' => [
			'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')',


			'label' => 'Projet',

		],
		'status' => [
			'select' => 't.status',
		],
	];

	protected ?int $id;
	protected int $id_chart;
	protected string $code;
	protected string $label;
	protected ?string $description;
	protected int $position = 0;
	protected int $type;
	protected bool $user = false;
	protected bool $bookmark = false;

	protected $_position = [];
	protected ?Chart $_chart = null;

	static protected ?array $_charts;

	public function selfCheck(): void
	{
		$db = DB::getInstance();

		$this->assert(trim($this->code) !== '', 'Le numéro de compte ne peut rester vide.');
		$this->assert(trim($this->label) !== '', 'L\'intitulé de compte ne peut rester vide.');

		// Only enforce code limits if the account is new, or if the code is changed
		if (!$this->exists() || $this->isModified('code')) {
			$this->assert(strlen($this->code) <= 20, 'Le numéro de compte ne peut faire plus de 20 caractères.');
			$this->assert(preg_match('/^[a-z0-9_]+$/i', $this->code), 'Le numéro de compte ne peut comporter que des lettres et des chiffres.');
		}

		$this->assert(strlen($this->label) <= 200, 'L\'intitulé de compte ne peut faire plus de 200 caractères.');
		$this->assert(!isset($this->description) || strlen($this->description) <= 2000, 'La description de compte ne peut faire plus de 2000 caractères.');

		$this->assert(!empty($this->id_chart), 'Aucun plan comptable lié');

		$where = 'code = ? AND id_chart = ?';
		$where .= $this->exists() ? sprintf(' AND id != %d', $this->id()) : '';

		if ($db->test(self::TABLE, $where, $this->code, $this->id_chart)) {
			throw new ValidationException(sprintf('Le numéro "%s" est déjà utilisé par un autre compte.', $this->code));
		}

		$this->assert(isset($this->type));

		$this->checkLocalRules();

		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type invalide: ' . $this->type);
		$this->assert(array_key_exists($this->position, self::POSITIONS_NAMES), 'Position invalide');


		parent::selfCheck();
	}

	protected function getCountry(): ?string
	{
		if (!isset(self::$_charts)) {
			self::$_charts = DB::getInstance()->getGrouped('SELECT id, country, code FROM acc_charts;');
		}

		return self::$_charts[$this->id_chart]->country ?? null;
	}

	protected function isChartOfficial(): bool
	{
		$country = $this->getCountry();
		return !empty(self::$_charts[$this->id_chart]->code);
	}

	/**
	 * This sets the account position according to local rules
	 * if the chart is linked to a country, but only
	 * if the account is user-created, or if the chart is non-official
	 */
	protected function getLocalPosition(string $country = null): ?int
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		$is_official = $this->isChartOfficial();

		if (!$country) {
			return null;
		}

		// Do not change position of official chart accounts
		if (!$this->user && $is_official) {
			return null;
		}

		foreach (self::LOCAL_POSITIONS[$country] as $pattern => $position) {
			if (preg_match('/' . $pattern . '/', $this->code)) {
				return $position;
			}
		}

		return null;
	}

	protected function getLocalType(string $country = null): int
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		if (!$country) {
			return self::TYPE_NONE;
		}

		foreach (self::LOCAL_TYPES[$country] as $type => $number) {
			if ($this->matchType($type, $country)) {
				return $type;
			}
		}

		return self::TYPE_NONE;
	}

	protected function matchType(int $type, string $country = null): bool
	{
		if (func_num_args() < 2) {
			$country = $this->getCountry();
		}

		$pattern = self::LOCAL_TYPES[$country][$type] ?? null;

		if (!$pattern) {
			return false;
		}

		if (in_array($type, self::COMMON_TYPES)) {
			$pattern = sprintf('/^%s.+/', $pattern);
		}
		else {
			$pattern = sprintf('/^%s$/', $pattern);
		}

		return (bool) preg_match($pattern, $this->code);
	}

	public function setLocalRules(string $country = null): void
	{
		if (!func_num_args()) {
			$country = $this->getCountry();
		}

		if (!$country) {

			$this->set('type', 0);
			return;
		}

		$this->set('type', $this->getLocalType($country));


		if (null !== ($p = $this->getLocalPosition($country))) {
			// If the allowed local position is asset OR liability, we allow either one of those 3 choices
			if ($p != self::ASSET_OR_LIABILITY
				|| !in_array($this->position, [self::ASSET_OR_LIABILITY, self::ASSET, self::LIABILITY])) {




				$this->set('position', $p);


			}
		}

		if (!isset($this->type)) {
			$this->set('type', 0);
		}
	}

	public function checkLocalRules(): void
	{
		$country = $this->getCountry();

		if (!$this->type) {
			return;
		}

		if (!isset(self::LOCAL_TYPES[$country][$this->type])) {
			return;
		}

		$this->assert($this->matchType($this->type), sprintf('Compte "%s - %s" : le numéro des comptes de type "%s" doit commencer par "%s" (%s).', $this->code, $this->label, self::TYPES_NAMES[$this->type], self::LOCAL_TYPES[$country][$this->type], $this->code));
	}

	public function getNewNumberAvailable(?string $base = null): ?string
	{
		$base ??= $this->getNumberBase();

		if (!$base) {
704
705
706
707
708
709
710













711
712
713
714
715
716
717
		 return DB::getInstance()->firstColumn('SELECT COUNT(*)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
	}














	public function getSum(int $year_id, bool $simple = false): ?\stdClass
	{
		$sum = DB::getInstance()->first('SELECT balance, credit, debit
			FROM acc_accounts_balances
			WHERE id = ? AND id_year = ?;', $this->id(), $year_id);








>
>
>
>
>
>
>
>
>
>
>
>
>







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
		 return DB::getInstance()->firstColumn('SELECT COUNT(*)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
	}

	public function getDepositMissingBalance(int $year_id): int
	{
		$deposit_balance = DB::getInstance()->firstColumn('SELECT SUM(l.debit)
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)
			ORDER BY t.date, t.id;',
			$year_id, $this->id(), Transaction::STATUS_DEPOSIT);
		$account_balance = $this->getSum($year_id)->balance;

		return $account_balance - $deposit_balance;
	}

	public function getSum(int $year_id, bool $simple = false): ?\stdClass
	{
		$sum = DB::getInstance()->first('SELECT balance, credit, debit
			FROM acc_accounts_balances
			WHERE id = ? AND id_year = ?;', $this->id(), $year_id);

777
778
779
780
781
782
783































784
785



786

787
788
789


790






791
792
793
794
795
796
797
		if ($has_transactions_in_closed_year) {
			return false;
		}

		return true;
	}
































	public function canSetAssetOrLiabilityPosition(): bool
	{



		if ($this->position == self::REVENUE || $this->position == self::EXPENSE) {

			return false;
		}



		if (!$this->type || $this->type == self::TYPE_THIRD_PARTY) {






			return true;
		}

		return false;
	}

	public function chart(): Chart







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


>
>
>
|
>



>
>
|
>
>
>
>
>
>







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
		if ($has_transactions_in_closed_year) {
			return false;
		}

		return true;
	}

	/**
	 * We can set account position if:
	 * - account is not in a supported chart country
	 * - account is not part of an official chart
	 * - account is not affected by local position rules
	 */
	public function canSetPosition(): bool
	{
		if (!$this->getCountry()) {
			return true;
		}

		if ($this->isChartOfficial() && !$this->user) {
			return false;
		}

		if ($this->type || $this->getLocalType()) {
			return false;
		}

		if (null !== $this->getLocalPosition()) {
			return false;
		}

		return true;
	}

	/**
	 * We can set account asset or liability if:
	 * - local position rules allow for asset or liability
	 */
	public function canSetAssetOrLiabilityPosition(): bool
	{
		if (!$this->getCountry()) {
			return true;
		}

		if ($this->isChartOfficial() && !$this->user) {
			return false;
		}

		$type = $this->type ?: $this->getLocalType();

		if ($type == self::TYPE_THIRD_PARTY) {
			return true;
		}

		$position = $this->getLocalPosition();

		if ($position == self::ASSET_OR_LIABILITY) {
			return true;
		}

		return false;
	}

	public function chart(): Chart

Modified src/include/lib/Garradin/Entities/Accounting/Chart.php from [f47d91877a] to [f46d8e9686].

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
	const NAME = 'Plan comptable';
	const PRIVATE_URL = '!acc/charts/accounts/all.php?id=%d';

	const TABLE = 'acc_charts';

	protected ?int $id;
	protected string $label;
	protected string $country;
	protected ?string $code;
	protected bool $archived = false;







	const REQUIRED_COLUMNS = ['code', 'label', 'description', 'position', 'bookmark'];

	const COLUMNS = [
		'code' => 'Numéro',
		'label' => 'Libellé',
		'description' => 'Description',
		'position' => 'Position',
		'added' => 'Ajouté',
		'bookmark' => 'Favori',
	];

	public function selfCheck(): void
	{
		$this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
		$this->assert(trim($this->country) !== '', 'Le pays ne peut rester vide.');
		$this->assert(Utils::getCountryName($this->country), 'Le code pays doit être un code ISO valide');
		parent::selfCheck();
	}

	public function accounts()
	{
		return new Accounts($this->id());
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		// Don't allow to change country
		if ($this->code) {
			unset($source['country']);
		}

		unset($source['code']);

		return Entity::importForm($source);
	}







|


>
>
>
>
>
>
















|
<















|







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
	const NAME = 'Plan comptable';
	const PRIVATE_URL = '!acc/charts/accounts/all.php?id=%d';

	const TABLE = 'acc_charts';

	protected ?int $id;
	protected string $label;
	protected ?string $country = null;
	protected ?string $code;
	protected bool $archived = false;

	const COUNTRY_LIST = [
		'FR' => 'France',
		'BE' => 'Belgique',
		'CH' => 'Suisse',
	];

	const REQUIRED_COLUMNS = ['code', 'label', 'description', 'position', 'bookmark'];

	const COLUMNS = [
		'code' => 'Numéro',
		'label' => 'Libellé',
		'description' => 'Description',
		'position' => 'Position',
		'added' => 'Ajouté',
		'bookmark' => 'Favori',
	];

	public function selfCheck(): void
	{
		$this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide.');
		$this->assert(strlen($this->label) <= 200, 'Le libellé ne peut faire plus de 200 caractères.');
		$this->assert(null === $this->country || array_key_exists($this->country, self::COUNTRY_LIST), 'Pays inconnu');

		parent::selfCheck();
	}

	public function accounts()
	{
		return new Accounts($this->id());
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		// Don't allow to change country
		if (isset($this->code)) {
			unset($source['country']);
		}

		unset($source['code']);

		return Entity::importForm($source);
	}
137
138
139
140
141
142
143
144



































		foreach ($res as $row) {
			$row->position = Account::POSITIONS_NAMES[$row->position];
			$row->user = $row->user ? 'Ajouté' : '';
			$row->bookmark = $row->bookmark ? 'Favori' : '';
			yield $row;
		}
	}
}










































|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
		foreach ($res as $row) {
			$row->position = Account::POSITIONS_NAMES[$row->position];
			$row->user = $row->user ? 'Ajouté' : '';
			$row->bookmark = $row->bookmark ? 'Favori' : '';
			yield $row;
		}
	}

	public function resetAccountsRules(): void
	{
		$db = DB::getInstance();
		$db->begin();

		try {
			foreach ($this->accounts()->listAll() as $account) {
				$account->setLocalRules($this->country);
				$account->save();
			}
		}
		catch (UserException $e) {
			$db->rollback();
			throw $e;
		}

		$db->commit();
	}

	public function save(bool $selfcheck = true): bool
	{
		$country_modified = $this->isModified('country');
		$exists = $this->exists();

		$ok = parent::save($selfcheck);

		// Change account types
		if ($ok && $exists && $country_modified) {
			$this->resetAccountsRules();
		}

		return $ok;
	}

}

Modified src/include/lib/Garradin/Entities/Accounting/Transaction.php from [d63d73755a] to [fb45525c86].

341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
				l.credit, l.debit, l.label, l.reference, b.id AS id_account, c.id AS id_project
			FROM acc_transactions_lines l
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts b ON b.code = a.code AND b.id_chart = ?
			LEFT JOIN acc_projects c ON c.id = l.id_project
			WHERE l.id_transaction = ?;',
			$year->chart()->id,
			$year->chart()->id,
			$this->id()
		);

		foreach ($lines as $l) {
			$line = new Line;
			foreach ($copy as $field) {
				// Do not copy id_account when it is null, as it will trigger an error (invalid entity)







<







341
342
343
344
345
346
347

348
349
350
351
352
353
354
				l.credit, l.debit, l.label, l.reference, b.id AS id_account, c.id AS id_project
			FROM acc_transactions_lines l
			INNER JOIN acc_accounts a ON a.id = l.id_account
			LEFT JOIN acc_accounts b ON b.code = a.code AND b.id_chart = ?
			LEFT JOIN acc_projects c ON c.id = l.id_project
			WHERE l.id_transaction = ?;',
			$year->chart()->id,

			$this->id()
		);

		foreach ($lines as $l) {
			$line = new Line;
			foreach ($copy as $field) {
				// Do not copy id_account when it is null, as it will trigger an error (invalid entity)
533
534
535
536
537
538
539


540
541
542
543
544
545
546
		}

		$db = DB::getInstance();

		if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
			throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui fait partie d\'un exercice clôturé');
		}



		Files::delete($this->getAttachementsDirectory());

		return parent::delete();
	}

	public function selfCheck(): void







>
>







532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
		}

		$db = DB::getInstance();

		if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) {
			throw new ValidationException('Il n\'est pas possible de supprimer une écriture qui fait partie d\'un exercice clôturé');
		}

		// FIXME when lettering is properly implemented: mark parent transaction non-deposited when deleting a deposit transaction

		Files::delete($this->getAttachementsDirectory());

		return parent::delete();
	}

	public function selfCheck(): void

Modified src/include/lib/Garradin/Entities/Accounting/Year.php from [bb7f32b7b9] to [eac679dbfb].

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
	/**
	 * List common accounts used in this year, grouped by type
	 * @return array
	 */
	public function listCommonAccountsGrouped(array $types = null): array
	{
		if (null === $types) {


			$types = Account::COMMON_TYPES;



		}

		$out = [];

		foreach ($types as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}









		$db = DB::getInstance();

		$sql = sprintf('SELECT a.* FROM acc_accounts a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			LEFT JOIN acc_transactions c ON c.id = b.id_transaction AND c.id_year = %d
			WHERE a.id_chart = %d AND a.%s AND (a.bookmark = 1 OR a.user = 1 OR c.id IS NOT NULL)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->id(),
			$this->id_chart,
			$db->where('type', $types)

		);

		$query = $db->iterate($sql);

		foreach ($query as $row) {

			$out[$row->type]->accounts[] = $row;






		}

		return $out;
	}

}







>
>
|
>
>
>




|






>
>
>
>
>
>
>
>






|




|
>





>
|
>
>
>
>
>
>






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
	/**
	 * List common accounts used in this year, grouped by type
	 * @return array
	 */
	public function listCommonAccountsGrouped(array $types = null): array
	{
		if (null === $types) {
			// If we want all types, then we will get used or bookmarked accounts in common types
			// and only bookmarked accounts for other types, grouped in "Others"
			$target = Account::COMMON_TYPES;
		}
		else {
			$target = $types;
		}

		$out = [];

		foreach ($target as $type) {
			$out[$type] = (object) [
				'label'    => Account::TYPES_NAMES[$type],
				'type'     => $type,
				'accounts' => [],
			];
		}

		if (null === $types) {
			$out[0] = (object) [
				'label'    => 'Autres',
				'type'     => 0,
				'accounts' => [],
			];
		}

		$db = DB::getInstance();

		$sql = sprintf('SELECT a.* FROM acc_accounts a
			LEFT JOIN acc_transactions_lines b ON b.id_account = a.id
			LEFT JOIN acc_transactions c ON c.id = b.id_transaction AND c.id_year = %d
			WHERE a.id_chart = %d AND ((a.%s AND (a.bookmark = 1 OR a.user = 1 OR c.id IS NOT NULL)) %s)
			GROUP BY a.id
			ORDER BY type, code COLLATE NOCASE;',
			$this->id(),
			$this->id_chart,
			$db->where('type', $target),
			(null === $types) ? 'OR (a.bookmark = 1)' : ''
		);

		$query = $db->iterate($sql);

		foreach ($query as $row) {
			$t = in_array($row->type, $target, true) ? $row->type : 0;
			$out[$t]->accounts[] = $row;
		}

		foreach ($out as $key => $v) {
			if (!count($v->accounts)) {
				unset($out[$key]);
			}
		}

		return $out;
	}

}

Modified src/include/lib/Garradin/Entities/Search.php from [118645601b] to [c19e2a6cec].

196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
	public function getProtectedTables(): ?array
	{
		if ($this->type != self::TYPE_SQL) {
			return null;
		}

		if ($this->target == self::TARGET_ACCOUNTING) {
			return ['acc_transactions' => null, 'acc_transactions_lines' => null, 'acc_accounts' => null, 'acc_charts' => null, 'acc_years' => null, 'acc_transactions_users' => null];
		}
		else {
			return ['users' => null, 'users_search' => null, 'users_categories' => null];
		}
	}

	public function getGroups(): array







|







196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
	public function getProtectedTables(): ?array
	{
		if ($this->type != self::TYPE_SQL) {
			return null;
		}

		if ($this->target == self::TARGET_ACCOUNTING) {
			return ['acc_transactions' => null, 'acc_transactions_lines' => null, 'acc_accounts' => null, 'acc_charts' => null, 'acc_years' => null, 'acc_transactions_users' => null, 'acc_projects' => null];
		}
		else {
			return ['users' => null, 'users_search' => null, 'users_categories' => null];
		}
	}

	public function getGroups(): array

Modified src/include/lib/Garradin/Entities/Web/Page.php from [4be2c85354] to [c8d7bd2cd7].

34
35
36
37
38
39
40
41
42

43
44
45
46
47
48
49
	protected string $format;
	protected \DateTime $published;
	protected \DateTime $modified;
	protected string $content;

	const FORMATS_LIST = [
		//Render::FORMAT_BLOCKS => 'Blocs (beta)',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_MARKDOWN => 'MarkDown',

		Render::FORMAT_ENCRYPTED => 'Chiffré',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [







<

>







34
35
36
37
38
39
40

41
42
43
44
45
46
47
48
49
	protected string $format;
	protected \DateTime $published;
	protected \DateTime $modified;
	protected string $content;

	const FORMATS_LIST = [
		//Render::FORMAT_BLOCKS => 'Blocs (beta)',

		Render::FORMAT_MARKDOWN => 'MarkDown',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_ENCRYPTED => 'Chiffré',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
			$source['parent'] = Form::getSelectorValue($source['parent']);
			$source['path'] = trim($source['parent'] . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', Render::FORMAT_ENCRYPTED);
		}
		else {
			$this->set('format', Render::FORMAT_SKRIV);
		}

		return parent::importForm($source);
	}

	public function getBreadcrumbs(): array
	{







|
|







281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
			$source['parent'] = Form::getSelectorValue($source['parent']);
			$source['path'] = trim($source['parent'] . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', Render::FORMAT_ENCRYPTED);
		}
		elseif (empty($source['format'])) {
			$this->set('format', Render::FORMAT_MARKDOWN);
		}

		return parent::importForm($source);
	}

	public function getBreadcrumbs(): array
	{

Modified src/include/lib/Garradin/Install.php from [c00befd3ec] to [d6b3752310].

322
323
324
325
326
327
328
329
330
331
332
333
334
335
336

		// Create an example saved search (accounting)
		$query = (object) [
			'groups' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'a2.code',
						'operator' => 'IS NULL',
						'values'   => [],
					],
				],
			]],
			'order' => 't.id',
			'desc' => false,







|







322
323
324
325
326
327
328
329
330
331
332
333
334
335
336

		// Create an example saved search (accounting)
		$query = (object) [
			'groups' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'p.code',
						'operator' => 'IS NULL',
						'values'   => [],
					],
				],
			]],
			'order' => 't.id',
			'desc' => false,

Modified src/include/lib/Garradin/Template.php from [3f6b5ecc4a] to [8624340bc6].

126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
		$this->register_function('display_dynamic_field', [$this, 'displayDynamicField']);
		$this->register_function('edit_dynamic_field', [$this, 'editDynamicField']);

		$this->register_function('csrf_field', function ($params) {
			return Form::tokenHTML($params['key']);
		});

		$this->register_modifier('strlen', 'strlen');
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', function($a) { return abs($a ?? 0); });

		$this->register_modifier('linkify_transactions', function ($str) {
			return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {







|







126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
		$this->register_function('display_dynamic_field', [$this, 'displayDynamicField']);
		$this->register_function('edit_dynamic_field', [$this, 'editDynamicField']);

		$this->register_function('csrf_field', function ($params) {
			return Form::tokenHTML($params['key']);
		});

		$this->register_modifier('strlen', fn($a) => strlen($a ?? ''));
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', function($a) { return abs($a ?? 0); });

		$this->register_modifier('linkify_transactions', function ($str) {
			return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {

Modified src/include/lib/Garradin/Upgrade.php from [6e0478b3a0] to [5c7264227e].

124
125
126
127
128
129
130
131
132
133
134
135
136
137
138







139
140
141
142
143
144
145

			if (version_compare($v, '1.1.31', '<')) {
				$db->import(ROOT . '/include/migrations/1.1/31.sql');
			}

			if (version_compare($v, '1.2.0', '<')) {
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/migrations/1.2/0.sql');
				Charts::updateInstalled('fr_pca_2018');
				Charts::updateInstalled('fr_pca_1999');
				Charts::updateInstalled('fr_pcc_2020');
				Charts::updateInstalled('fr_pcg_2014');
				Charts::updateInstalled('be_pcmn_2019');
				$db->commitSchemaUpdate();
			}








			if (version_compare($v, '1.3.0', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0.php';
			}

			Plugin::upgradeAllIfRequired();








|







>
>
>
>
>
>
>







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

			if (version_compare($v, '1.1.31', '<')) {
				$db->import(ROOT . '/include/migrations/1.1/31.sql');
			}

			if (version_compare($v, '1.2.0', '<')) {
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/migrations/1.2/1.2.0.sql');
				Charts::updateInstalled('fr_pca_2018');
				Charts::updateInstalled('fr_pca_1999');
				Charts::updateInstalled('fr_pcc_2020');
				Charts::updateInstalled('fr_pcg_2014');
				Charts::updateInstalled('be_pcmn_2019');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.2.1', '<')) {
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/migrations/1.2/1.2.1.sql');
				Charts::resetRules(['FR', 'CH', 'BE']);
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.3.0', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0.php';
			}

			Plugin::upgradeAllIfRequired();

Modified src/include/lib/Garradin/Utils.php from [bbf24b67ea] to [d0c16ac5a0].

822
823
824
825
826
827
828

829
830
831
832
833
834
835
        header_remove('Expires');

        if ($last_change) {
            header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)), true);
        }

        if ($hash) {

            header(sprintf('Etag: "%s"', $hash), true);
        }

        if (($etag && $etag === $hash) || ($last_modified && $last_modified >= $last_change)) {
            http_response_code(304);
            exit;
        }







>







822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
        header_remove('Expires');

        if ($last_change) {
            header(sprintf('Last-Modified: %s GMT', gmdate('D, d M Y H:i:s', $last_change)), true);
        }

        if ($hash) {
            $hash = md5(Utils::getVersionHash() . $hash);
            header(sprintf('Etag: "%s"', $hash), true);
        }

        if (($etag && $etag === $hash) || ($last_modified && $last_modified >= $last_change)) {
            http_response_code(304);
            exit;
        }

Added src/include/migrations/1.2/1.2.1.sql version [b8ed39033f].





















































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
ALTER TABLE acc_charts RENAME TO acc_charts_old;
DROP VIEW acc_accounts_balances;

.read schema.sql

INSERT INTO acc_charts SELECT * FROM acc_charts_old;

-- Reset country if code was official, changing the country should not have been possible
UPDATE acc_charts SET country = 'FR' WHERE code IS NOT NULL AND code != 'PCMN_2019';
UPDATE acc_charts SET country = 'BE' WHERE code IS NOT NULL AND code = 'PCMN_2019';

-- Reset country to FR for countries using something similar
UPDATE acc_charts SET country = 'FR' WHERE country IN ('GN', 'TN', 'RE', 'CN', 'PF', 'MW', 'CI', 'GP', 'GA', 'DE', 'NC');

-- Set country to NULL if outside of supported countries
UPDATE acc_charts SET country = NULL WHERE country NOT IN ('FR', 'BE', 'CH');

-- Reset type to zero if not supported
UPDATE acc_accounts SET type = 0 WHERE id_chart IN (SELECT id FROM acc_charts WHERE country IS NULL);

-- Reset other charts is done in PHP code

-- Fix some search
UPDATE recherches SET contenu = REPLACE(contenu, 'a2.code', 'p.code') WHERE contenu LIKE '%a2.code%';

DROP TABLE acc_charts_old;

Added src/include/migrations/1.2/schema.sql version [da30dfe665].











































































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
CREATE TABLE IF NOT EXISTS config (
    key TEXT PRIMARY KEY NOT NULL,
    value TEXT NULL
);

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);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
(
    selecteur TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selecteur, id_membre)
);

CREATE TABLE IF NOT EXISTS services
-- Types de services (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
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    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
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (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, 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
-- Rappels de devoir renouveller une cotisation
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

    delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel

    subject TEXT NOT NULL,
    body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (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);

--
-- COMPTA
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Plans comptables : il peut y en avoir plusieurs
(
    id INTEGER NOT NULL PRIMARY KEY,
    country TEXT NULL,
    code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
    label TEXT NOT NULL,
    archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Comptes des plans comptables
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,

    code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.

    label TEXT NOT NULL,
    description TEXT NULL,

    position INTEGER NOT NULL, -- position actif/passif/charge/produit
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1, -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
    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
-- 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,

    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
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
    status INTEGER NOT NULL DEFAULT 0, -- Statut (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),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable

    hash TEXT NULL,
    prev_hash TEXT NULL,

    id_year INTEGER NOT NULL REFERENCES acc_years(id),
    id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
);

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_related ON acc_transactions (id_related);
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 TABLE IF NOT EXISTS acc_transactions_lines
-- Lignes d'écritures d'une opération
(
    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), -- N° du compte dans le plan comptable

    credit INTEGER NOT NULL,
    debit INTEGER NOT NULL,

    reference TEXT NULL, -- Référence de paiement, eg. 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_users
-- Liaison des écritures et des membres
(
    id_user INTEGER NOT NULL REFERENCES membres (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)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    officiel INTEGER NOT NULL DEFAULT 0,
    nom TEXT NOT NULL,
    description TEXT NULL,
    auteur TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0,
    menu_condition TEXT NULL,
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signaux
-- Association entre plugins et signaux (hooks)
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

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);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,
    parent TEXT NOT NULL,
    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) = modified),
    image INT NOT NULL DEFAULT 0,

    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_name ON files (name);
CREATE INDEX IF NOT EXISTS files_modified ON files (modified);

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,
    compressed INT NOT NULL DEFAULT 0,
    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 NULL,
    content TEXT NOT NULL, -- Text content
    notindexed=path
);

CREATE TABLE IF NOT EXISTS web_pages
(
    id INTEGER NOT NULL PRIMARY KEY,
    parent TEXT NOT NULL, -- Parent path, empty = web root
    path TEXT NOT NULL, -- Full page directory name
    uri TEXT NOT NULL, -- Page identifier
    file_path TEXT NOT NULL, -- Full file path for contents
    type INTEGER NOT NULL, -- 1 = Category, 2 = Page
    status TEXT NOT NULL,
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (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);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
    intitule TEXT NOT NULL,
    creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
    cible TEXT NOT NULL, -- "membres" ou "compta"
    type TEXT NOT NULL, -- "json" ou "sql"
    contenu 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,
    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
);

Modified src/include/migrations/1.3/schema.sql from [3c3e1f77d9] to [e5107d635f].

208
209
210
211
212
213
214
215
216
217
218
219
220
221
222

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,

    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)
);







|







208
209
210
211
212
213
214
215
216
217
218
219
220
221
222

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)
);
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
    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

);

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);


-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
    SELECT id_year, id, label, code, type, debit, credit,
        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,
            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_years
-- Years (exercices)
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,







|
>





>




|















|







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
    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,
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
    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_analytical INTEGER NULL REFERENCES acc_accounts(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);







|







401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
    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);

Modified src/templates/acc/accounts/_nav.tpl from [d478e2bcf4] to [84ce1f0f13].

1
2
3
4

5
6
7
8
9
10
11
12
13
14
15
16

<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}

			{linkbutton shape="edit" href="!acc/charts/accounts/?id=%d"|args:$current_year.id_chart label="Modifier les comptes"}
		{/if}
		{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$current_year.id label="Recherche"}
	</aside>
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}acc/accounts/">Comptes favoris</a></li>
		<li{if $current == 'all'} class="current"{/if}><a href="{$admin_url}acc/accounts/all.php?year={$current_year.id}">Tous les comptes</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}acc/accounts/users.php">Comptes de membres</a></li>
		<li><a href="{$admin_url}acc/reports/statement.php?year={$current_year.id}"><em>Compte de résultat</em></a></li>
		<li><a href="{$admin_url}acc/reports/balance_sheet.php?year={$current_year.id}"><em>Bilan</em></a></li>
	</ul>
</nav>




>
|











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

<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<?php $page = $current == 'all' ? 'all.php' : ''; ?>
			{linkbutton shape="edit" href="!acc/charts/accounts/%s?id=%d"|args:$page,$current_year.id_chart label="Modifier les comptes"}
		{/if}
		{linkbutton shape="search" href="!acc/search.php?year=%d"|args:$current_year.id label="Recherche"}
	</aside>
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}acc/accounts/">Comptes favoris</a></li>
		<li{if $current == 'all'} class="current"{/if}><a href="{$admin_url}acc/accounts/all.php?year={$current_year.id}">Tous les comptes</a></li>
		<li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}acc/accounts/users.php">Comptes de membres</a></li>
		<li><a href="{$admin_url}acc/reports/statement.php?year={$current_year.id}"><em>Compte de résultat</em></a></li>
		<li><a href="{$admin_url}acc/reports/balance_sheet.php?year={$current_year.id}"><em>Bilan</em></a></li>
	</ul>
</nav>

Modified src/templates/acc/accounts/deposit.tpl from [0e6c098f4c] to [db6e5c4575].

1
2
3
4









5
6
7
8
9
10
11
12
13
14
{include file="_head.tpl" title="Dépôt en banque : %s — %s"|args:$account.code,$account.label current="acc/accounts"}

{form_errors}










{if !$journal_count}
	<p class="alert block">Il n'y a aucune écriture qui nécessite un dépôt.<br />
		{linkbutton href="!acc/transactions/new.php" shape="plus" label="Saisir un virement"}
	</p>
{else}
	<p class="help">
		Cocher les cases correspondant aux montants à déposer, une nouvelle écriture sera générée.
	</p>

	<form method="post" action="{$self_url}" data-focus="1">




>
>
>
>
>
>
>
>
>

|
<







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="Dépôt en banque : %s — %s"|args:$account.code,$account.label current="acc/accounts"}

{form_errors}

{if $missing_balance > 0}
<p class="alert block">
	Il existe une différence de {$missing_balance|raw|money_currency} entre la liste des écritures à déposer
	et le solde du compte.<br />
	Cette situation est généralement dûe à des écritures de dépôt qui ont été supprimées.<br />
	{linkbutton shape="plus" label="Faire un virement pour régulariser" href="!acc/transactions/new.php?a0=%d&l=Régularisation%%20dépôt&account=%d"|args:$missing_balance,$account.id}
</p>
{/if}

{if !$journal_count}
	<p class="alert block">Il n'y a aucune écriture qui nécessiterait un dépôt.

	</p>
{else}
	<p class="help">
		Cocher les cases correspondant aux montants à déposer, une nouvelle écriture sera générée.
	</p>

	<form method="post" action="{$self_url}" data-focus="1">

Modified src/templates/acc/accounts/index.tpl from [3ba932319b] to [92008ea2db].

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
{include file="acc/_simple_help.tpl" link="../reports/trial_balance.php?year=%d"|args:$current_year.id type=null}

{if !empty($grouped_accounts)}
	<?php $has_accounts = false; ?>
	<table class="list">
		<thead>
			<tr>

				<td class="num">Numéro</td>
				<th>Compte</th>
				<td class="money">Solde</td>
				<td></td>
				<td></td>
			</tr>
		</thead>
		{foreach from=$grouped_accounts item="group"}
		<tbody>
			<tr>
				<td colspan="5"><h2 class="ruler">{$group.label}</h2></td>
			</tr>
			{foreach from=$group.accounts item="account"}
				<tr>

					<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.code}</a></td>
					<th><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.label}</a></th>
					<td class="money">
						{if $account.balance < 0
							|| ($account.balance > 0 && $account.position == Account::LIABILITY && ($account.type == Account::TYPE_BANK || $account.type == Account::TYPE_THIRD_PARTY || $account.type == Account::TYPE_CASH))}
							<strong class="error">-{$account.balance|raw|abs|money_currency:false}</strong>
						{else}







>













|
>







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
{include file="acc/_simple_help.tpl" link="../reports/trial_balance.php?year=%d"|args:$current_year.id type=null}

{if !empty($grouped_accounts)}
	<?php $has_accounts = false; ?>
	<table class="list">
		<thead>
			<tr>
				<td></td>
				<td class="num">Numéro</td>
				<th>Compte</th>
				<td class="money">Solde</td>
				<td></td>
				<td></td>
			</tr>
		</thead>
		{foreach from=$grouped_accounts item="group"}
		<tbody>
			<tr>
				<td colspan="5"><h2 class="ruler">{$group.label}</h2></td>
			</tr>
			{foreach from=$group.accounts item="account"}
				<tr class="account">
					<td class="bookmark">{if $account.bookmark}{icon shape="star" title="Compte favori"}{/if}</td>
					<td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.code}</a></td>
					<th><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}&amp;year={$current_year.id}">{$account.label}</a></th>
					<td class="money">
						{if $account.balance < 0
							|| ($account.balance > 0 && $account.position == Account::LIABILITY && ($account.type == Account::TYPE_BANK || $account.type == Account::TYPE_THIRD_PARTY || $account.type == Account::TYPE_CASH))}
							<strong class="error">-{$account.balance|raw|abs|money_currency:false}</strong>
						{else}
80
81
82
83
84
85
86
87
88
89
90
91
92
			{linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
		</p>
	</div>
	{/if}
{/if}

<p class="help">
	Note : n'apparaissent ici que les comptes <strong>favoris</strong> qui ont été utilisés dans cet exercice (au moins une écriture).<br />
	Pour voir le solde de tous les comptes, se référer à la <a href="all.php">liste de tous comptes de l'exercice</a>.<br />
	Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au <a href="{$admin_url}acc/charts/accounts/?id={$current_year.id_chart}">plan comptable</a>.
</p>

{include file="_foot.tpl"}







|





82
83
84
85
86
87
88
89
90
91
92
93
94
			{linkbutton href="!acc/transactions/new.php" label="Saisir une écriture" shape="plus"}
		</p>
	</div>
	{/if}
{/if}

<p class="help">
	Note : n'apparaissent ici que les comptes qui ont été utilisés dans cet exercice (au moins une écriture) de types banque, caisse, tiers, dépenses ou recettes. Les autres comptes n'apparaissent que s'ils ont été utilisés et sont marqués comme favoris.<br />
	Pour voir le solde de tous les comptes, se référer à la <a href="all.php">liste de tous comptes de l'exercice</a>.<br />
	Pour voir la liste complète des comptes, même ceux qui n'ont pas été utilisés, se référer au <a href="{$admin_url}acc/charts/accounts/?id={$current_year.id_chart}">plan comptable</a>.
</p>

{include file="_foot.tpl"}

Modified src/templates/acc/accounts/journal.tpl from [5909c5b586] to [7c5da4fb99].

120
121
122
123
124
125
126









127
128
129
130
131
132
133
				<td class="money">{$line.sum|raw|money:false}</td>
			{/if}
			<td>{$line.reference}</td>
			<th>{$line.label}</th>
			{if !$simple}<td>{$line.line_label}</td>{/if}
			<td>{$line.line_reference}</td>
			<td class="num">{if $line.id_project}<a href="{$admin_url}acc/reports/statement.php?project={$line.id_project}">{$line.project_code}</a>{/if}</td>









			<td class="actions">
			{if ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)}
				{if $line.type == Entities\Accounting\Transaction::TYPE_DEBT}
					{linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/payoff.php?for=%d"|args:$line.id}
				{elseif $line.type == Entities\Accounting\Transaction::TYPE_CREDIT}
					{linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/payoff.php?for=%d"|args:$line.id}
				{/if}







>
>
>
>
>
>
>
>
>







120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
				<td class="money">{$line.sum|raw|money:false}</td>
			{/if}
			<td>{$line.reference}</td>
			<th>{$line.label}</th>
			{if !$simple}<td>{$line.line_label}</td>{/if}
			<td>{$line.line_reference}</td>
			<td class="num">{if $line.id_project}<a href="{$admin_url}acc/reports/statement.php?project={$line.id_project}">{$line.project_code}</a>{/if}</td>
			{* Deposit status, might be consufing
			<td>
				{if $account.type == $account::TYPE_OUTSTANDING && $line.debit}
					{if !($line.status & Entities\Accounting\Transaction::STATUS_DEPOSIT)}
						{icon shape="alert" title="Cette opération n'a pas été déposée"}
					{/if}
				{/if}
			</td>
			*}
			<td class="actions">
			{if ($line.status & Entities\Accounting\Transaction::STATUS_WAITING)}
				{if $line.type == Entities\Accounting\Transaction::TYPE_DEBT}
					{linkbutton shape="check" label="Régler cette dette" href="!acc/transactions/payoff.php?for=%d"|args:$line.id}
				{elseif $line.type == Entities\Accounting\Transaction::TYPE_CREDIT}
					{linkbutton shape="export" label="Régler cette créance" href="!acc/transactions/payoff.php?for=%d"|args:$line.id}
				{/if}

Added src/templates/acc/charts/_country_input.tpl version [c98c1a13ef].

































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
use Garradin\Entities\Accounting\Chart;
use Garradin\Config;

$country_list = Chart::COUNTRY_LIST + ['' => '— Autre'];

if (!isset($chart)) {
	$chart = new Chart;
	$chart->country = Config::getInstance()->pays;
}

$name ??= 'country';
?>

{input type="select" name=$name label="Appliquer les règles comptables de ce pays" required=1 options=$country_list default=$chart.country}

{if !$chart->exists()}
<dd class="help">Ce choix ne pourra plus être modifié une fois le plan comptable créé.</dd>
{else}
<dd class="help">Si un pays est sélectionné, ce choix ne pourra plus être modifié.</dd>
{/if}

<dd class="alert block {$name}_empty hidden"><strong>Attention&nbsp;:</strong> si <em>«&nbsp;Autre&nbsp;»</em> est sélectionné, alors&nbsp;:<br />
	- les comptes ne pourront pas être catégorisés automatiquement (banque, caisse, dépenses, recettes, etc.)&nbsp;;<br />
	- il faudra donc parcourir tout le plan comptable pour sélectionner un compte<br />
	- la position des comptes au bilan ou compte de résultat ne pourra pas être contrôlée : des erreurs sont possibles<br />
	<em>Si vous avez besoin d'ajouter les règles comptables d'un autre pays, merci de <a href="https://garradin.eu/contact" target="_blank">nous contacter</a>.</em>
</dd>

<dd class="help">

<script type="text/javascript">
(function () {ldelim}
	var n = {$name|escape:'json'};
	var c = $('#f_' + n);
	{literal}
	var changeCountry = () => {
		g.toggle('.' + n + '_empty', c.value == '' ? true : false);
		g.resizeParentDialog();
	};

	c.onchange = changeCountry;
	changeCountry();
})();
{/literal}
</script>

</dd>

Modified src/templates/acc/charts/accounts/_account_form.tpl from [6a136bee7d] to [0f5791955c].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{if $create}
<input type="hidden" name="type" value="{$account.type}" />
{/if}

<dl>
	{if $can_edit}
		{if !$account.type && !$account->exists()}
			<dt><label for="f_position_0">Position au bilan ou résultat</label> <b>(obligatoire)</b></dt>
			<dd class="help">La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.</dd>
			{input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account}
			{input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."}
			{input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."}
			{input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur"}
			{input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses"}






|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
{if $create}
<input type="hidden" name="type" value="{$account.type}" />
{/if}

<dl>
	{if $can_edit}
		{if $account->canSetPosition()}
			<dt><label for="f_position_0">Position au bilan ou résultat</label> <b>(obligatoire)</b></dt>
			<dd class="help">La position permet d'indiquer dans quelle partie du bilan ou du résultat doit figurer le compte.</dd>
			{input type="radio" label="Ne pas utiliser ce compte au bilan ni au résultat" name="position" value=0 source=$account}
			{input type="radio" label="Bilan : actif" name="position" value=Entities\Accounting\Account::ASSET source=$account help="ce que possède l'association : stocks, locaux, soldes bancaires, etc."}
			{input type="radio" label="Bilan : passif" name="position" value=Entities\Accounting\Account::LIABILITY source=$account help="ce que l'association doit : dettes, provisions, réserves, etc."}
			{input type="radio" label="Bilan : actif ou passif" name="position" value=Entities\Accounting\Account::ASSET_OR_LIABILITY source=$account help="le compte sera placé à l'actif si son solde est débiteur, ou au passif s'il est créditeur"}
			{input type="radio" label="Résultat : charge" name="position" value=Entities\Accounting\Account::EXPENSE source=$account help="dépenses"}

Modified src/templates/acc/charts/accounts/_nav.tpl from [874d4ab505] to [a992f235ac].

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
{if !$dialog || $dialog !== 'manage'}
<nav class="tabs">
{if $dialog}
	{* JS trick to get back to the original iframe URL! *}
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			{linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus"}
		{/if}
		{linkbutton shape="left" label="Retour à la sélection de compte" href="#" onclick="g.reloadParentDialog(); return false;"}
	</aside>

	<ul>

{else}
	<ul>
		<li><a href="{$admin_url}acc/years/">Exercices</a></li>
		<li><a href="{$admin_url}acc/projects/">Projets <em>(compta analytique)</em></a></li>
		<li class="current"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
	</ul>
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		<aside>{linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus" target=$dialog_target}</aside>
	{/if}
	<ul class="sub">
		<li class="title">{$chart.label}</li>
{/if}


		<li{if $current == 'favorites'} class="current"{/if}>{link href="!acc/charts/accounts/?id=%d&%s"|args:$chart.id,$types_arg label="Comptes usuels"}</li>

		<li{if $current == 'all'} class="current"{/if}>{link href="!acc/charts/accounts/all.php?id=%d&%s"|args:$chart.id,$types_arg label="Tous les comptes"}</li>
	</ul>
</nav>
{/if}










>

>













>

>




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
{if !$dialog || $dialog !== 'manage'}
<nav class="tabs">
{if $dialog}
	{* JS trick to get back to the original iframe URL! *}
	<aside>
		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			{linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus"}
		{/if}
		{linkbutton shape="left" label="Retour à la sélection de compte" href="#" onclick="g.reloadParentDialog(); return false;"}
	</aside>

	<ul>
		<li class="title">{$chart.label}</li>
{else}
	<ul>
		<li><a href="{$admin_url}acc/years/">Exercices</a></li>
		<li><a href="{$admin_url}acc/projects/">Projets <em>(compta analytique)</em></a></li>
		<li class="current"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
	</ul>
	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		<aside>{linkbutton href="!acc/charts/accounts/new.php?id=%d&%s"|args:$chart.id,$types_arg label="Ajouter un compte" shape="plus" target=$dialog_target}</aside>
	{/if}
	<ul class="sub">
		<li class="title">{$chart.label}</li>
{/if}

	{if $chart.country}
		<li{if $current == 'favorites'} class="current"{/if}>{link href="!acc/charts/accounts/?id=%d&%s"|args:$chart.id,$types_arg label="Comptes usuels"}</li>
	{/if}
		<li{if $current == 'all'} class="current"{/if}>{link href="!acc/charts/accounts/all.php?id=%d&%s"|args:$chart.id,$types_arg label="Tous les comptes"}</li>
	</ul>
</nav>
{/if}

Modified src/templates/acc/charts/accounts/all.tpl from [ac2fae1241] to [ea6b21e47e].

18
19
20
21
22
23
24








25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
	{foreach from=$list->iterate() item="account"}
		<tr class="account account-level-{$account.level}">
			<td class="num">{$account.code}</td>
			<th{if !$account.description} colspan=2{/if}>{$account.label}</th>
			{if $account.description}
			<td class="help">{$account.description|escape|nl2br}</td>
			{/if}








			<td>
				<?php
				$shape = $account->bookmark ? 'check' : 'uncheck';
				$title = $account->bookmark ? 'Ôter des favoris' : 'Marquer comme favori';
				?>
				{button shape=$shape name="bookmark[%d]"|args:$account.id value=$account.bookmark label="Favori" title=$title type="submit"}
			</td>
			<td>
				{if $account.user}<em>Ajouté</em>{/if}
			</td>
			<td class="actions">
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived}
					{if $account.user || !$chart.code}
						{linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d%s"|args:$account.id,$types_arg target=$dialog_target}
				{/if}







>
>
>
>
>
>
>
>







<
<
<







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
	{foreach from=$list->iterate() item="account"}
		<tr class="account account-level-{$account.level}">
			<td class="num">{$account.code}</td>
			<th{if !$account.description} colspan=2{/if}>{$account.label}</th>
			{if $account.description}
			<td class="help">{$account.description|escape|nl2br}</td>
			{/if}
			<td>
				{$account.position_report}
			<td>
				{$account.position_name}
			</td>
			<td>
				{if $account.user}<em>Ajouté</em>{/if}
			</td>
			<td>
				<?php
				$shape = $account->bookmark ? 'check' : 'uncheck';
				$title = $account->bookmark ? 'Ôter des favoris' : 'Marquer comme favori';
				?>
				{button shape=$shape name="bookmark[%d]"|args:$account.id value=$account.bookmark label="Favori" title=$title type="submit"}
			</td>



			<td class="actions">
				{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN) && !$chart.archived}
					{if $account.user || !$chart.code}
						{linkbutton shape="delete" label="Supprimer" href="!acc/charts/accounts/delete.php?id=%d&%s"|args:$account.id,$types_arg target=$dialog_target}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="!acc/charts/accounts/edit.php?id=%d%s"|args:$account.id,$types_arg target=$dialog_target}
				{/if}

Modified src/templates/acc/charts/accounts/index.tpl from [d633437db9] to [fae8c70754].

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

<table class="list">
{foreach from=$accounts_grouped item="group"}
	<tbody>
		<tr>
			<td colspan="4"><h2 class="ruler">{$group.label}</h2></td>
			<td class="actions">
				{if !$chart.archived}
					{linkbutton label="Ajouter un compte" shape="plus" href="!acc/charts/accounts/new.php?id=%d&type=%d&%s"|args:$chart.id,$group.type,$types_arg  target=$dialog_target}
				{/if}
			</td>
		</tr>

	{foreach from=$group.accounts item="account"}
		<tr class="account">







|







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

<table class="list">
{foreach from=$accounts_grouped item="group"}
	<tbody>
		<tr>
			<td colspan="4"><h2 class="ruler">{$group.label}</h2></td>
			<td class="actions">
				{if !$chart.archived && $group.type}
					{linkbutton label="Ajouter un compte" shape="plus" href="!acc/charts/accounts/new.php?id=%d&type=%d&%s"|args:$chart.id,$group.type,$types_arg  target=$dialog_target}
				{/if}
			</td>
		</tr>

	{foreach from=$group.accounts item="account"}
		<tr class="account">

Modified src/templates/acc/charts/accounts/selector.tpl from [ea4c622934] to [ffae656512].

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
{include file="_head.tpl" title="Sélectionner un compte"}

<div class="selector">

{if empty($grouped_accounts) && empty($accounts)}
	<p class="block alert">Le plan comptable ne comporte aucun compte de ce type.<br />
		{linkbutton href="!acc/charts/accounts/new.php?id=%s&type=%s"|args:$chart.id,$targets[0] label="Créer un compte" shape="plus"}
	</p>





{else}

	<header>
		<h2 class="quick-search">
			<input type="text" placeholder="Recherche rapide…" title="Filtrer la liste" />{button shape="delete" type="reset" title="Effacer la recherche"}
			{* We can't use input type="search" because Firefox sucks *}
		</h2>

		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
			<?php $page = isset($grouped_accounts) ? '' : 'all.php'; ?>
			<p class="edit">{linkbutton label="Modifier les comptes" href="!acc/charts/accounts/%s?id=%d&types=%s"|args:$page,$chart.id,$targets_str shape="edit"}</aside></p>
		{/if}

		<p>{input type="select" name="filter" options=$filter_options default=$filter}</p>
	</header>

	{if isset($grouped_accounts)}
		<?php $index = 1; ?>






<

>
>
>
>










<
|







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="Sélectionner un compte"}

<div class="selector">

{if empty($grouped_accounts) && empty($accounts)}
	<p class="block alert">Le plan comptable ne comporte aucun compte de ce type.<br />

	</p>

	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
		<p class="edit">{linkbutton label="Modifier les comptes" href=$edit_url shape="edit"}</p>
	{/if}

{else}

	<header>
		<h2 class="quick-search">
			<input type="text" placeholder="Recherche rapide…" title="Filtrer la liste" />{button shape="delete" type="reset" title="Effacer la recherche"}
			{* We can't use input type="search" because Firefox sucks *}
		</h2>

		{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}

			<p class="edit">{linkbutton label="Modifier les comptes" href=$edit_url shape="edit"}</p>
		{/if}

		<p>{input type="select" name="filter" options=$filter_options default=$filter}</p>
	</header>

	{if isset($grouped_accounts)}
		<?php $index = 1; ?>

Modified src/templates/acc/charts/edit.tpl from [599431b8b3] to [b40615c384].

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="Modifier un plan comptable" current="acc/years"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">
	<fieldset>
		<legend>Modifier un plan comptable</legend>
		<dl>
			{input type="text" name="label" label="Libellé" required=1 source=$chart}
			{if !$chart.code}
			{input type="select" name="country" label="Pays" required=1 options=$country_list source=$chart}
			{/if}
			<dt><label for="f_archived_1">Archivage</label></dt>
			{input type="checkbox" name="archived" value="1" source=$chart label="Plan comptable archivé" help="Ce plan comptable ne pourra plus être modifié"}
		</dl>
		<p class="submit">
			{csrf_field key="acc_charts_edit_%d"|args:$chart.id}
			{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}









|
|


|









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="Modifier un plan comptable" current="acc/years"}

{form_errors}

<form method="post" action="{$self_url}" data-focus="1">
	<fieldset>
		<legend>Modifier un plan comptable</legend>
		<dl>
			{input type="text" name="label" label="Libellé" required=1 source=$chart}
			{if !$chart.code && !$chart.country}
				{include file="./_country_input.tpl"}
			{/if}
			<dt><label for="f_archived_1">Archivage</label></dt>
			{input type="checkbox" name="archived" value="1" source=$chart label="Plan comptable archivé" help="Ce plan comptable ne pourra plus être modifié ni utilisé dans un nouvel exercice"}
		</dl>
		<p class="submit">
			{csrf_field key="acc_charts_edit_%d"|args:$chart.id}
			{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
		</p>
	</fieldset>
</form>

{include file="_foot.tpl"}

Modified src/templates/acc/charts/index.tpl from [30e14ccc12] to [f5f33a7d9c].

23
24
25
26
27
28
29
30
31
32
33
34

35

36
37
38
39
40
41
42
			<td>Type</td>
			<td>Archivé</td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="item"}
				<tr{if $item.archived} class="disabled"{/if}>
					<td>{$item.country|get_country_name}</td>
					<th><a href="{$admin_url}acc/charts/accounts/?id={$item.id}">{$item.label}</a></th>
					<td>{if $item.code}Officiel{else}Personnel{/if}</td>
					<td>{if $item.archived}<em>Archivé</em>{/if}</td>
					<td class="actions">

						{linkbutton shape="star" label="Comptes usuels" href="!acc/charts/accounts/?id=%d"|args:$item.id}

						{linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id target="_dialog"}
							{if $item->canDelete()}
								{linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id target="_dialog"}
							{/if}
						{/if}







|




>
|
>







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
			<td>Type</td>
			<td>Archivé</td>
			<td></td>
		</thead>
		<tbody>
			{foreach from=$list item="item"}
				<tr{if $item.archived} class="disabled"{/if}>
					<td>{if $item.country}{$item.country|get_country_name}{else}-Autre-{/if}</td>
					<th><a href="{$admin_url}acc/charts/accounts/?id={$item.id}">{$item.label}</a></th>
					<td>{if $item.code}Officiel{else}Personnel{/if}</td>
					<td>{if $item.archived}<em>Archivé</em>{/if}</td>
					<td class="actions">
						{if $item.country}
							{linkbutton shape="star" label="Comptes usuels" href="!acc/charts/accounts/?id=%d"|args:$item.id}
						{/if}
						{linkbutton shape="menu" label="Tous les comptes" href="!acc/charts/accounts/all.php?id=%d"|args:$item.id}
						{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN)}
							{linkbutton shape="edit" label="Modifier" href="!acc/charts/edit.php?id=%d"|args:$item.id target="_dialog"}
							{if $item->canDelete()}
								{linkbutton shape="delete" label="Supprimer" href="!acc/charts/delete.php?id=%d"|args:$item.id target="_dialog"}
							{/if}
						{/if}
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
				{input type="radio-btn" name="type" value="import" label="Importer un plan comptable personnel" help="À partir d'un tableau (CSV, Office, etc.)"}
			</dl>
		</fieldset>

		<fieldset class="type-copy hidden">
			<legend>Créer un nouveau plan comptable à partir d'un existant</legend>
			<dl>
				{input type="select_groups" name="copy" options=$charts_groupped label="Recopier depuis" required=1 default=$from}
				{input type="text" name="label" label="Libellé" required=1}
				{input type="select" name="country" label="Pays" required=1 options=$country_list default=$config.pays}
			</dl>
		</fieldset>

		<fieldset class="type-install hidden">
			<legend>Installer un nouveau plan comptable officiel</legend>
			<dl>
				{input type="select" name="install" label="Plan comptable" required=true options=$install_list}
			</dl>
		</fieldset>

		<fieldset class="type-import hidden">
			<legend>Importer un plan comptable personnel</legend>
			<dl>
				{input type="text" name="label" label="Libellé" required=1}
				{input type="select" name="country" label="Pays" required=1 options=$country_list default=$config.pays}
				{input type="file" name="file" label="Fichier à importer" accept="csv" required=1}
				<dd class="help"> {* FIXME utiliser _csv_help.tpl ici ! *}
					Règles à suivre pour créer le fichier&nbsp;:
					<ul>
						<li>Le fichier doit comporter les colonnes suivantes : <em>{$columns}</em></li>
						<li>Suggestion : pour obtenir un exemple du format attendu, faire un export d'un plan comptable existant</li>
					</ul>
				</dd>
			</dl>
		</fieldset>

		<p class="submit type-all">
			{csrf_field key=$csrf_key}
			{button type="submit" name="new" label="Créer" shape="right" class="main"}
		</p>
	</form>
	<script type="text/javascript">
	{literal}
	function toggleFormOption() {
		var v = $('input[name="type"]:checked')[0].value;

		if (!v) {
			return;
		}



		g.toggle('.type-import, .type-copy, .type-install', false);
		g.toggle('.type-' + v, true);
		g.toggle('.type-all', true);
	}

	$('input[name="type"]').forEach((e) => {







|

|
<













|



















|

|


>
>







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
				{input type="radio-btn" name="type" value="import" label="Importer un plan comptable personnel" help="À partir d'un tableau (CSV, Office, etc.)"}
			</dl>
		</fieldset>

		<fieldset class="type-copy hidden">
			<legend>Créer un nouveau plan comptable à partir d'un existant</legend>
			<dl>
				{input type="select_groups" name="copy" options=$charts_grouped label="Recopier depuis" required=1 default=$from}
				{input type="text" name="label" label="Libellé" required=1}
				{include file="./_country_input.tpl"}

		</fieldset>

		<fieldset class="type-install hidden">
			<legend>Installer un nouveau plan comptable officiel</legend>
			<dl>
				{input type="select" name="install" label="Plan comptable" required=true options=$install_list}
			</dl>
		</fieldset>

		<fieldset class="type-import hidden">
			<legend>Importer un plan comptable personnel</legend>
			<dl>
				{input type="text" name="label" label="Libellé" required=1}
				{include file="./_country_input.tpl" name="import_country"}
				{input type="file" name="file" label="Fichier à importer" accept="csv" required=1}
				<dd class="help"> {* FIXME utiliser _csv_help.tpl ici ! *}
					Règles à suivre pour créer le fichier&nbsp;:
					<ul>
						<li>Le fichier doit comporter les colonnes suivantes : <em>{$columns}</em></li>
						<li>Suggestion : pour obtenir un exemple du format attendu, faire un export d'un plan comptable existant</li>
					</ul>
				</dd>
			</dl>
		</fieldset>

		<p class="submit type-all">
			{csrf_field key=$csrf_key}
			{button type="submit" name="new" label="Créer" shape="right" class="main"}
		</p>
	</form>
	<script type="text/javascript">
	{literal}
	function toggleFormOption() {
		var v = $('input[name="type"]:checked');

		if (!v.length) {
			return;
		}

		v = v[0].value;

		g.toggle('.type-import, .type-copy, .type-install', false);
		g.toggle('.type-' + v, true);
		g.toggle('.type-all', true);
	}

	$('input[name="type"]').forEach((e) => {

Modified src/templates/acc/transactions/_form.tpl from [0ac1ca0222] to [71b8925321].

39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}"{if $is_new} class="hidden"{/if}>
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$current_year.id_chart}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart_id name=$account.selector_name label=$account.label required=1 default=$account.selector_value}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset{if $is_new} class="hidden"{/if}>







|



|







39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
	</fieldset>

	{foreach from=$types_details item="type"}
		<fieldset data-types="t{$type.id}"{if $is_new} class="hidden"{/if}>
			<legend>{$type.label}</legend>
			{if $type.id == $transaction::TYPE_ADVANCED}
				{* Saisie avancée *}
				{include file="acc/transactions/_lines_form.tpl" chart_id=$chart.id}
			{else}
				<dl>
				{foreach from=$type.accounts key="key" item="account"}
					{input type="list" target="!acc/charts/accounts/selector.php?targets=%s&chart=%d"|args:$account.targets_string,$chart.id name=$account.selector_name label=$account.label required=1 default=$account.selector_value}
				{/foreach}
				</dl>
			{/if}
		</fieldset>
	{/foreach}

	<fieldset{if $is_new} class="hidden"{/if}>

Modified src/templates/acc/transactions/_lines_form.tpl from [f29d6d7ce6] to [002ecab651].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			<td></td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$lines key="k" item="line"}
		<tr>
			<td class="account">
				{input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$chart_id name="lines[account_selector][]" default=$line.account_selector}
			</td>
			<td class="money">{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
			<td class="money">{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
			<td>{input type="text" name="lines[label][]" default=$line.label}</td>
			<td>{input type="text" name="lines[reference][]" default=$line.reference size=10}</td>
			{if count($projects) > 1}
				<td>{input default=$line.id_project type="select" name="lines[id_project][]" options=$projects}</td>







|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
			<td></td>
		</tr>
	</thead>
	<tbody>
	{foreach from=$lines key="k" item="line"}
		<tr>
			<td class="account">
				{input type="list" target="!acc/charts/accounts/selector.php?year=%d"|args:$transaction.id_year name="lines[account_selector][]" default=$line.account_selector}
			</td>
			<td class="money">{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
			<td class="money">{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
			<td>{input type="text" name="lines[label][]" default=$line.label}</td>
			<td>{input type="text" name="lines[reference][]" default=$line.reference size=10}</td>
			{if count($projects) > 1}
				<td>{input default=$line.id_project type="select" name="lines[id_project][]" options=$projects}</td>

Modified src/templates/acc/years/balance.tpl from [c17b5db189] to [3b8dda4a1a].

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
						<td>
							{$line.code} — {$line.label}
							<input type="hidden" name="lines[code][]" value="{$line.code}" />
							<input type="hidden" name="lines[label][]" value="{$line.label}" />
						</td>
					{/if}
					<th>
						{input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$year.id_chart name="lines[account_selector][]" default=$line.account}
					</th>
					<td>{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
					<td>{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
					<td>{button label="Enlever la ligne" shape="minus" min="1" name="remove_line"}</td>
				</tr>
			{/foreach}
			</tbody>
			<tfoot>
				<tr>
					<th>Total</th>
					{if $chart_change}
						<td></td>
					{/if}
					<td>{input type="money" name="debit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{input type="money" name="credit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{button label="Ajouter une ligne" shape="plus"}</td>
				</tr>
			</tfoot>
		</table>

		<dl>
			{input type="checkbox" name="appropriation" value="1" checked="checked" label="Affecter automatiquement le résultat (conseillé)" help="Si cette case est cochée, le résultat sera automatiquement affecté aux réserves s'il est excédentaire"}


		{/if}

	</fieldset>

	<p class="submit">
		{if null === $previous_year}
			{button type="submit" name="next" label="Continuer" shape="right" class="main"}
			- ou -
			{if $_GET.from}







|



















>

|
>
>

>







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
						<td>
							{$line.code} — {$line.label}
							<input type="hidden" name="lines[code][]" value="{$line.code}" />
							<input type="hidden" name="lines[label][]" value="{$line.label}" />
						</td>
					{/if}
					<th>
						{input type="list" target="!acc/charts/accounts/selector.php?chart=%d"|args:$year.id_chart name="lines[account_selector][]" default=$line.account_selector}
					</th>
					<td>{input type="money" name="lines[debit][]" default=$line.debit size=5}</td>
					<td>{input type="money" name="lines[credit][]" default=$line.credit size=5}</td>
					<td>{button label="Enlever la ligne" shape="minus" min="1" name="remove_line"}</td>
				</tr>
			{/foreach}
			</tbody>
			<tfoot>
				<tr>
					<th>Total</th>
					{if $chart_change}
						<td></td>
					{/if}
					<td>{input type="money" name="debit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{input type="money" name="credit_total" readonly="readonly" tabindex="-1" }</td>
					<td>{button label="Ajouter une ligne" shape="plus"}</td>
				</tr>
			</tfoot>
		</table>
		{if $can_appropriate}
		<dl>
			{input type="checkbox" name="appropriation" value="1" checked="checked" label="Affecter automatiquement le résultat (conseillé)"}
			<dd class="help">Si cette case est cochée, le résultat sera automatiquement affecté au compte « {$appropriation_account.code} — {$appropriation_account.label} ».</dd>
		</dl>
		{/if}
	{/if}
	</fieldset>

	<p class="submit">
		{if null === $previous_year}
			{button type="submit" name="next" label="Continuer" shape="right" class="main"}
			- ou -
			{if $_GET.from}

Modified src/templates/services/user/_service_user_form.tpl from [90998a1ecf] to [80d09033a2].

87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

		{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 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}







|







87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

		{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}
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158

			{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"|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) > 1}
			{input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false}
			{/if}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">
		{csrf_field key=$csrf_key}







|







144
145
146
147
148
149
150
151
152
153
154
155
156
157
158

			{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"|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) > 1}
			{input type="select" options=$projects name="id_project" label="Projet analytique" required=false}
			{/if}
		</dl>
	</fieldset>
	{/if}

	<p class="submit">
		{csrf_field key=$csrf_key}

Modified src/www/admin/acc/accounts/deposit.php from [0f05ff8022] to [b43f97e6b8].

48
49
50
51
52
53
54


55
56
57
58
59
60
61


62
63
64
	$date = $current_year->end_date;
}

$target = $account::TYPE_BANK;

$journal_count = $account->countDepositJournal(CURRENT_YEAR_ID);



$tpl->assign(compact(
	'account',
	'journal',
	'date',
	'target',
	'checked',
	'journal_count'


));

$tpl->display('acc/accounts/deposit.tpl');







>
>






|
>
>



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
	$date = $current_year->end_date;
}

$target = $account::TYPE_BANK;

$journal_count = $account->countDepositJournal(CURRENT_YEAR_ID);

$missing_balance = $account->getDepositMissingBalance(CURRENT_YEAR_ID);

$tpl->assign(compact(
	'account',
	'journal',
	'date',
	'target',
	'checked',
	'journal_count',
	'missing_balance',
	'transaction'
));

$tpl->display('acc/accounts/deposit.tpl');

Modified src/www/admin/acc/accounts/index.php from [c826784eaa] to [f19ff030ea].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/../_inc.php';

if (!CURRENT_YEAR_ID) {
	Utils::redirect(ADMIN_URL . 'acc/years/?msg=OPEN');
}

$tpl->assign('chart_id', $current_year->id_chart);
$tpl->assign('grouped_accounts', Reports::getClosingSumsFavoriteAccounts(['year' => CURRENT_YEAR_ID]));

$tpl->display('acc/accounts/index.tpl');








|






1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace Garradin;

use Garradin\Accounting\Reports;

require_once __DIR__ . '/../_inc.php';

if (!CURRENT_YEAR_ID) {
	Utils::redirect('!acc/years/?msg=OPEN');
}

$tpl->assign('chart_id', $current_year->id_chart);
$tpl->assign('grouped_accounts', Reports::getClosingSumsFavoriteAccounts(['year' => CURRENT_YEAR_ID]));

$tpl->display('acc/accounts/index.tpl');

Modified src/www/admin/acc/charts/accounts/index.php from [9349ac07de] to [61b1caaf37].

14
15
16
17
18
19
20




21
22
23
24
25
26
	$year = $current_year;
	$chart = $year->chart();
}

if (!$chart) {
	throw new UserException('Aucun plan comptable spécifié');
}





$accounts = $chart->accounts();

$tpl->assign(compact('chart'));
$tpl->assign('accounts_grouped', $accounts->listCommonGrouped($types, true));
$tpl->display('acc/charts/accounts/index.tpl');







>
>
>
>






14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
	$year = $current_year;
	$chart = $year->chart();
}

if (!$chart) {
	throw new UserException('Aucun plan comptable spécifié');
}

if (!$chart->country) {
	Utils::redirect('!acc/charts/accounts/all.php?id=' . $chart->id);
}

$accounts = $chart->accounts();

$tpl->assign(compact('chart'));
$tpl->assign('accounts_grouped', $accounts->listCommonGrouped($types, true));
$tpl->display('acc/charts/accounts/index.tpl');

Modified src/www/admin/acc/charts/accounts/new.php from [9e5d2bdbfd] to [18867cde04].

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
	throw new UserException("Il n'est pas possible de modifier un plan comptable archivé.");
}

$accounts = $chart->accounts();

$account = new Account;
$account->bookmark = true;


$account->id_chart = $chart->id();

$type = f('type') ?? qg('type');

// Simple creation with pre-determined account type
if ($type !== null) {
	$account->type = (int)$type;
}
elseif (isset($types) && is_array($types) && count($types) == 1) {
	$account->type = (int)current($types);
}




$csrf_key = 'account_new';

$form->runIf('toggle_bookmark', function () use ($accounts, $chart) {
	$a = $accounts->get(f('toggle_bookmark'));
	$a->bookmark = true;
	$a->save();







>
>











>
>
>







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
	throw new UserException("Il n'est pas possible de modifier un plan comptable archivé.");
}

$accounts = $chart->accounts();

$account = new Account;
$account->bookmark = true;
$account->user = true;
$account->code = '';
$account->id_chart = $chart->id();

$type = f('type') ?? qg('type');

// Simple creation with pre-determined account type
if ($type !== null) {
	$account->type = (int)$type;
}
elseif (isset($types) && is_array($types) && count($types) == 1) {
	$account->type = (int)current($types);
}
elseif (!$chart->country) {
	$account->type = $account::TYPE_NONE;
}

$csrf_key = 'account_new';

$form->runIf('toggle_bookmark', function () use ($accounts, $chart) {
	$a = $accounts->get(f('toggle_bookmark'));
	$a->bookmark = true;
	$a->save();

Modified src/www/admin/acc/charts/accounts/selector.php from [c9b4113a17] to [12ae13ee1e].

22
23
24
25
26
27
28
29

30
31
32
33
34
35
36
$filter_options = [
//	'bookmark' => 'Voir seulement les comptes favoris',
	'usual' => 'Voir seulement les comptes favoris et usuels',
	'all' => 'Voir tous les comptes',
];

if (!count($targets)) {
	$filter_options['any'] = 'Voir tout le plan comptable';

}

if (null !== $filter) {
	if (!array_key_exists($filter, $filter_options)) {
		$filter = 'usual';
	}








|
>







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$filter_options = [
//	'bookmark' => 'Voir seulement les comptes favoris',
	'usual' => 'Voir seulement les comptes favoris et usuels',
	'all' => 'Voir tous les comptes',
];

if (!count($targets)) {
	$filter_options['all'] = 'Voir tout le plan comptable';
	$targets = null;
}

if (null !== $filter) {
	if (!array_key_exists($filter, $filter_options)) {
		$filter = 'usual';
	}

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
	$chart = $current_year->chart();
	$year = $current_year;
}

if (!$chart) {
	throw new UserException('Aucun exercice ouvert disponible');
}






$accounts = $chart->accounts();

$tpl->assign(compact('chart', 'targets', 'targets_str', 'filter_options', 'filter'));

if (!count($targets)) {
	$tpl->assign('accounts', $filter != 'all' ? $accounts->listCommonTypes() : $accounts->listAll());
}
elseif ($filter == 'all') {
	$tpl->assign('accounts', $accounts->listAll($targets));
}
elseif ($filter == 'any') {
	$tpl->assign('accounts', $accounts->listAll());
}
elseif ($year) {
	$tpl->assign('grouped_accounts', $year->listCommonAccountsGrouped($targets));
}
else {
	$tpl->assign('grouped_accounts', $accounts->listCommonGrouped($targets));
}

$tpl->display('acc/charts/accounts/selector.tpl');







>
>
>
>
>



<
|
<
<
|
<
|
|
|
|









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
	$chart = $current_year->chart();
	$year = $current_year;
}

if (!$chart) {
	throw new UserException('Aucun exercice ouvert disponible');
}

// Charts with no country don't allow to use types
if (!$chart->country) {
	$targets = null;
}

$accounts = $chart->accounts();


$edit_url = sprintf('!acc/charts/accounts/%s?id=%d&types=%s', isset($grouped_accounts) ? '' : 'all.php', $chart->id(), $targets_str);




$tpl->assign(compact('chart', 'targets', 'targets_str', 'filter_options', 'filter', 'edit_url'));

if ($filter == 'all') {
	$tpl->assign('accounts', $accounts->listAll($targets));
}
elseif ($year) {
	$tpl->assign('grouped_accounts', $year->listCommonAccountsGrouped($targets));
}
else {
	$tpl->assign('grouped_accounts', $accounts->listCommonGrouped($targets));
}

$tpl->display('acc/charts/accounts/selector.tpl');

Modified src/www/admin/acc/charts/edit.php from [2bb82f5b29] to [d85bd0205b].

1
2
3

4
5
6
7
8
9
10
<?php
namespace Garradin;


use Garradin\Accounting\Charts;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$chart = Charts::get((int) qg('id'));



>







1
2
3
4
5
6
7
8
9
10
11
<?php
namespace Garradin;

use Garradin\Entities\Accounting\Chart;
use Garradin\Accounting\Charts;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);

$chart = Charts::get((int) qg('id'));
18
19
20
21
22
23
24
25
26
27
$form->runIf('save', function() use ($chart) {
	$chart->importForm();
	$chart->set('archived', (bool) f('archived'));
	$chart->save();
}, $csrf_key, '!acc/charts/');

$tpl->assign(compact('chart'));
$tpl->assign('country_list', Utils::getCountryList());

$tpl->display('acc/charts/edit.tpl');







<


19
20
21
22
23
24
25

26
27
$form->runIf('save', function() use ($chart) {
	$chart->importForm();
	$chart->set('archived', (bool) f('archived'));
	$chart->save();
}, $csrf_key, '!acc/charts/');

$tpl->assign(compact('chart'));


$tpl->display('acc/charts/edit.tpl');

Modified src/www/admin/acc/charts/index.php from [ef02d60edc] to [5060965ccd].

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
	}, $csrf_key, '!acc/charts/');

	$form->runIf(f('type') == 'install', function () {
		Charts::install(f('install'));
	}, $csrf_key, '!acc/charts/');

	$form->runIf(f('type') == 'import', function () {
		Charts::import('file', f('label'), f('country'));
	}, $csrf_key, '!acc/charts/');

	$tpl->assign(compact('csrf_key'));

	$tpl->assign('columns', implode(', ', Chart::COLUMNS));
	$tpl->assign('country_list', Utils::getCountryList());

	$tpl->assign('from', (int)qg('from'));
	$tpl->assign('charts_groupped', Charts::listByCountry());
	$tpl->assign('country_list', Utils::getCountryList());

	$tpl->assign('install_list', Charts::listInstallable());
}

$tpl->display('acc/charts/index.tpl');







|








|
|





19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
	}, $csrf_key, '!acc/charts/');

	$form->runIf(f('type') == 'install', function () {
		Charts::install(f('install'));
	}, $csrf_key, '!acc/charts/');

	$form->runIf(f('type') == 'import', function () {
		Charts::import('file', f('label'), f('country_import'));
	}, $csrf_key, '!acc/charts/');

	$tpl->assign(compact('csrf_key'));

	$tpl->assign('columns', implode(', ', Chart::COLUMNS));
	$tpl->assign('country_list', Utils::getCountryList());

	$tpl->assign('from', (int)qg('from'));
	$tpl->assign('charts_grouped', Charts::listByCountry());
	$tpl->assign('country_list', Chart::COUNTRY_LIST + ['' => '— Autre']);

	$tpl->assign('install_list', Charts::listInstallable());
}

$tpl->display('acc/charts/index.tpl');

Modified src/www/admin/acc/transactions/new.php from [919507f411] to [8ee9e95235].

19
20
21
22
23
24
25

26
27
28
29
30
31
32
33
34
35




36
37
38
39
40
41
42
}

$chart = $current_year->chart();
$accounts = $chart->accounts();

$csrf_key = 'acc_transaction_new';
$transaction = new Transaction;

$amount = 0;
$id_project = null;
$linked_users = null;
$lines = isset($_POST['lines']) ? Transaction::getFormLines() : [[], []];
$types_details = $transaction->getTypesDetails();

// Quick-fill transaction from query parameters
if (qg('a')) {
	$amount = Utils::moneyToInteger(qg('a'));
}





if (qg('l')) {
	$transaction->label = qg('l');
}

if (qg('d')) {
	$transaction->date = new Date(qg('d'));







>










>
>
>
>







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
}

$chart = $current_year->chart();
$accounts = $chart->accounts();

$csrf_key = 'acc_transaction_new';
$transaction = new Transaction;

$amount = 0;
$id_project = null;
$linked_users = null;
$lines = isset($_POST['lines']) ? Transaction::getFormLines() : [[], []];
$types_details = $transaction->getTypesDetails();

// Quick-fill transaction from query parameters
if (qg('a')) {
	$amount = Utils::moneyToInteger(qg('a'));
}

if (qg('a0')) {
	$amount = (int)qg('a0');
}

if (qg('l')) {
	$transaction->label = qg('l');
}

if (qg('d')) {
	$transaction->date = new Date(qg('d'));
63
64
65
66
67
68
69


70
71
72
73
74
75
76

	$id_project = $old->getProjectId();
	$amount = $transaction->getLinesCreditSum();
	$linked_users = $old->listLinkedUsersAssoc();

	$tpl->assign('duplicate_from', $old->id());
}



// Set last used date
if (empty($transaction->date) && $session->get('acc_last_date') && $date = Date::createFromFormat('!Y-m-d', $session->get('acc_last_date'))) {
	$transaction->date = $date;
}
// Set date of the day if no date was set
elseif (empty($transaction->date)) {







>
>







68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

	$id_project = $old->getProjectId();
	$amount = $transaction->getLinesCreditSum();
	$linked_users = $old->listLinkedUsersAssoc();

	$tpl->assign('duplicate_from', $old->id());
}

$transaction->id_year = $current_year->id();

// Set last used date
if (empty($transaction->date) && $session->get('acc_last_date') && $date = Date::createFromFormat('!Y-m-d', $session->get('acc_last_date'))) {
	$transaction->date = $date;
}
// Set date of the day if no date was set
elseif (empty($transaction->date)) {
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
	else {
		$lines = [['account_selector' => $s], []];
	}
}

$form->runIf('save', function () use ($transaction, $session, $current_year) {
	$transaction->importFromNewForm();
	$transaction->id_year = $current_year->id();
	$transaction->id_creator = $session->getUser()->id;
	$transaction->save();

	 // Link members
	if (null !== f('users') && is_array(f('users'))) {
		$transaction->updateLinkedUsers(array_keys(f('users')));
	}

	$session->set('acc_last_date', $transaction->date->format('Y-m-d'));

	Utils::redirect(sprintf('!acc/transactions/details.php?id=%d&created', $transaction->id()));
}, $csrf_key);

$tpl->assign(compact('csrf_key', 'transaction', 'amount', 'lines', 'id_project', 'types_details', 'linked_users'));

$tpl->assign('chart_id', $chart->id());
$tpl->assign('projects', Projects::listAssocWithEmpty());

$tpl->display('acc/transactions/new.tpl');







<















|



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
	else {
		$lines = [['account_selector' => $s], []];
	}
}

$form->runIf('save', function () use ($transaction, $session, $current_year) {
	$transaction->importFromNewForm();

	$transaction->id_creator = $session->getUser()->id;
	$transaction->save();

	 // Link members
	if (null !== f('users') && is_array(f('users'))) {
		$transaction->updateLinkedUsers(array_keys(f('users')));
	}

	$session->set('acc_last_date', $transaction->date->format('Y-m-d'));

	Utils::redirect(sprintf('!acc/transactions/details.php?id=%d&created', $transaction->id()));
}, $csrf_key);

$tpl->assign(compact('csrf_key', 'transaction', 'amount', 'lines', 'id_project', 'types_details', 'linked_users'));

$tpl->assign('chart', $chart);
$tpl->assign('projects', Projects::listAssocWithEmpty());

$tpl->display('acc/transactions/new.tpl');

Modified src/www/admin/acc/years/balance.php from [e6e0a94e71] to [af28d593f6].

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
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}

$csrf_key = 'acc_years_balance_' . $year->id();


$form->runIf('save', function () use ($year, $session) {




	$transaction = new Transaction;
	$transaction->id_creator = Session::getUserId();
	$transaction->importFromBalanceForm($year);
	$transaction->save();

	if (f('appropriation')) {
		// (affectation du résultat)
		$t2 = Years::makeAppropriation($year);

		if ($t2) {
			$t2->id_creator = $transaction->id_creator;
			$t2->save();
		}





		Utils::redirect('!acc/reports/journal.php?year=' . $year->id());
	}

	Utils::redirect('!acc/transactions/details.php?id=' . $transaction->id());
}, $csrf_key);









>


>
>
>
>













|
>
>
>
>







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
}

if ($year->closed) {
	throw new UserException('Impossible de modifier un exercice clôturé.');
}

$csrf_key = 'acc_years_balance_' . $year->id();
$accounts = $year->accounts();

$form->runIf('save', function () use ($year, $session) {
	$db = DB::getInstance();
	// Fail everything if appropriation failed
	$db->begin();

	$transaction = new Transaction;
	$transaction->id_creator = Session::getUserId();
	$transaction->importFromBalanceForm($year);
	$transaction->save();

	if (f('appropriation')) {
		// (affectation du résultat)
		$t2 = Years::makeAppropriation($year);

		if ($t2) {
			$t2->id_creator = $transaction->id_creator;
			$t2->save();
		}
	}

	$db->commit();

	if (f('appropriation')) {
		Utils::redirect('!acc/reports/journal.php?year=' . $year->id());
	}

	Utils::redirect('!acc/transactions/details.php?id=' . $transaction->id());
}, $csrf_key);


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
		$chart_change = true;
		$codes = [];

		foreach ($lines as $line) {
			$codes[] = $line->code;
		}

		$matching_accounts = $year->accounts()->listForCodes($codes);
	}

	// Append result
	$result = Reports::getResult(['year' => $previous_year->id()]);

	if ($result > 0) {
		$account = $year->accounts()->getSingleAccountForType(Account::TYPE_POSITIVE_RESULT);
	}
	else {
		$account = $year->accounts()->getSingleAccountForType(Account::TYPE_NEGATIVE_RESULT);
	}

	if (!$account) {
		$account = (object) [
			'id' => null,
			'code' => null,
			'label' => null,







|






|


|







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
		$chart_change = true;
		$codes = [];

		foreach ($lines as $line) {
			$codes[] = $line->code;
		}

		$matching_accounts = $accounts->listForCodes($codes);
	}

	// Append result
	$result = Reports::getResult(['year' => $previous_year->id()]);

	if ($result > 0) {
		$account = $accounts->getSingleAccountForType(Account::TYPE_POSITIVE_RESULT);
	}
	else {
		$account = $accounts->getSingleAccountForType(Account::TYPE_NEGATIVE_RESULT);
	}

	if (!$account) {
		$account = (object) [
			'id' => null,
			'code' => null,
			'label' => null,
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
	foreach ($lines as $k => &$line) {
		$line->credit = !$line->is_debt ? abs($line->balance) : 0;
		$line->debit = $line->is_debt ? abs($line->balance) : 0;

		if ($chart_change) {
			if (array_key_exists($line->code, $matching_accounts)) {
				$acc = $matching_accounts[$line->code];
				$line->account = [$acc->id => sprintf('%s — %s', $acc->code, $acc->label)];
			}
		}
		else {
			$line->account = $line->id ? [$line->id => sprintf('%s — %s', $line->code, $line->label)] : null;
		}

		$line = (array) $line;
	}

	unset($line);
}


if (!empty($_POST['lines']) && is_array($_POST['lines'])) {
	$lines = Utils::array_transpose($_POST['lines']);

	foreach ($lines as &$line) {
		$line['credit'] = Utils::moneyToInteger($line['credit']);
		$line['debit'] = Utils::moneyToInteger($line['debit']);
	}
}



$tpl->assign(compact('lines', 'years', 'chart_change', 'previous_year', 'year_selected', 'year', 'csrf_key'));

$tpl->display('acc/years/balance.tpl');







|



|








>

<
|
<
<
<
|
|
>
>

|


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
	foreach ($lines as $k => &$line) {
		$line->credit = !$line->is_debt ? abs($line->balance) : 0;
		$line->debit = $line->is_debt ? abs($line->balance) : 0;

		if ($chart_change) {
			if (array_key_exists($line->code, $matching_accounts)) {
				$acc = $matching_accounts[$line->code];
				$line->account_selector = [$acc->id => sprintf('%s — %s', $acc->code, $acc->label)];
			}
		}
		else {
			$line->account_selector = $line->id ? [$line->id => sprintf('%s — %s', $line->code, $line->label)] : null;
		}

		$line = (array) $line;
	}

	unset($line);
}


if (!empty($_POST['lines']) && is_array($_POST['lines'])) {

	$lines = Transaction::getFormLines();



}

$appropriation_account = $accounts->getSingleAccountForType(Account::TYPE_APPROPRIATION_RESULT);
$can_appropriate = $accounts->getIdForType(Account::TYPE_NEGATIVE_RESULT) && $accounts->getIdForType(Account::TYPE_POSITIVE_RESULT);

$tpl->assign(compact('lines', 'years', 'chart_change', 'previous_year', 'year_selected', 'year', 'csrf_key', 'can_appropriate', 'appropriation_account'));

$tpl->display('acc/years/balance.tpl');

Modified src/www/admin/docs/new_file.php from [345eeb0542] to [2cd6b3a91e].

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

$csrf_key = 'create_file';

$form->runIf('create', function () use ($parent) {
	$name = trim((string) f('name'));

	if (!strpos($name, '.')) {
		$name .= '.skriv';
	}

	$file = Files::createFromString($parent . '/' . $name, '');

	Utils::redirect('!common/files/edit.php?p=' . rawurlencode($file->path));
}, $csrf_key);

$tpl->assign(compact('csrf_key'));

$tpl->display('docs/new_file.tpl');







|










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

$csrf_key = 'create_file';

$form->runIf('create', function () use ($parent) {
	$name = trim((string) f('name'));

	if (!strpos($name, '.')) {
		$name .= '.md';
	}

	$file = Files::createFromString($parent . '/' . $name, '');

	Utils::redirect('!common/files/edit.php?p=' . rawurlencode($file->path));
}, $csrf_key);

$tpl->assign(compact('csrf_key'));

$tpl->display('docs/new_file.tpl');

Modified src/www/admin/static/scripts/accounts_list.js from [590fc24309] to [c39abd9210].

25
26
27
28
29
30
31
32



33
34
35
36
37
38
39
	var rows = document.querySelectorAll('table tr.account');

	rows.forEach((e, k) => {
		var l = e.querySelector('td.num').innerText + ' ' + e.querySelector('th').innerText;
		e.setAttribute('data-search-label', normalizeString(l));
	});

	q.addEventListener('keyup', filterTableList);



	document.querySelector('.quick-search button[type=reset]').onclick = () => {
		q.value = '';
		q.focus();
		return filterTableList();
	};
	q.focus();
}







|
>
>
>







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
	var rows = document.querySelectorAll('table tr.account');

	rows.forEach((e, k) => {
		var l = e.querySelector('td.num').innerText + ' ' + e.querySelector('th').innerText;
		e.setAttribute('data-search-label', normalizeString(l));
	});

	q.addEventListener('keyup', (e) => {
		filterTableList();
		return true;
	});
	document.querySelector('.quick-search button[type=reset]').onclick = () => {
		q.value = '';
		q.focus();
		return filterTableList();
	};
	q.focus();
}

Modified src/www/admin/static/scripts/service_form.js from [f636d7ed2f] to [50e70f7536].

42
43
44
45
46
47
48




49
50
51
52
53
54
55
		btn.value = btn.value.replace(/&year=\d+/, '') + '&year=' + elm.getAttribute('data-year');
	}

	// Fill the amount paid by the user
	if (amount && create) {
		$('#f_amount').value = g.formatMoney(amount);
	}




}

function initForm() {
	$('input[name=id_service]').forEach((e) => {
		e.onchange = () => { selectService(e); };
	});








>
>
>
>







42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
		btn.value = btn.value.replace(/&year=\d+/, '') + '&year=' + elm.getAttribute('data-year');
	}

	// Fill the amount paid by the user
	if (amount && create) {
		$('#f_amount').value = g.formatMoney(amount);
	}

	if (elm.dataset.project) {
		$('#f_id_project').value = elm.dataset.project;
	}
}

function initForm() {
	$('input[name=id_service]').forEach((e) => {
		e.onchange = () => { selectService(e); };
	});

Modified src/www/admin/static/styles/05-navigation.css from [70387da6cb] to [799e56eaa8].

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
nav.tabs .sub {
    margin: -1em 0 1em 2em;
    padding-top: 1em;
    border-left: 2px solid rgb(var(--gMainColor));
    border-bottom-left-radius: .5em;
}

nav.tabs .sub .title {
    margin: 0 1em 0 -1em;
    font-weight: bold;
    padding: .1em .5em;
}

nav.tabs li {
    margin: .3em .2em 0 .2em;







|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
nav.tabs .sub {
    margin: -1em 0 1em 2em;
    padding-top: 1em;
    border-left: 2px solid rgb(var(--gMainColor));
    border-bottom-left-radius: .5em;
}

nav.tabs .title {
    margin: 0 1em 0 -1em;
    font-weight: bold;
    padding: .1em .5em;
}

nav.tabs li {
    margin: .3em .2em 0 .2em;
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

main nav.menu-right span {
    right: -1em;
}

main nav.menu button {
    text-align: left;
}
nav.home ul {
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
}

nav.home ul li {
    display: block;







<
|







88
89
90
91
92
93
94

95
96
97
98
99
100
101
102

main nav.menu-right span {
    right: -1em;
}

main nav.menu button {
    text-align: left;

}nav.home ul {
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
}

nav.home ul li {
    display: block;

Modified src/www/admin/static/styles/10-accounting.css from [c27ff0ad62] to [b87d1b0cc6].

98
99
100
101
102
103
104

105
106
107
108
109
110
111
}

.year-infos .graphs.small figure img {
    max-width: 500px;
}

tr.account td.num { font-family: monospace; text-align: left; width: 8%;}

table tr.account th { font-weight: normal; }
tr.account-level-1 th { font-size: 1.6em; }
tr.account-level-2 th { padding-left: 1em; font-size: 1.3em; }
tr.account-level-3 th { padding-left: 2em; }
tr.account-level-4 th { padding-left: 3em; }
tr.account-level-5 th { padding-left: 4em; }
tr.account-level-6 th { padding-left: 5em; }







>







98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
}

.year-infos .graphs.small figure img {
    max-width: 500px;
}

tr.account td.num { font-family: monospace; text-align: left; width: 8%;}
tr.account td.num a { font-family: "Trebuchet MS", Arial, Helvetica, sans-serif; }
table tr.account th { font-weight: normal; }
tr.account-level-1 th { font-size: 1.6em; }
tr.account-level-2 th { padding-left: 1em; font-size: 1.3em; }
tr.account-level-3 th { padding-left: 2em; }
tr.account-level-4 th { padding-left: 3em; }
tr.account-level-5 th { padding-left: 4em; }
tr.account-level-6 th { padding-left: 5em; }