Skip to main content

koprogo_api/infrastructure/database/repositories/
journal_entry_repository_impl.rs

1// Infrastructure: PostgreSQL Journal Entry Repository Implementation
2//
3// CREDITS & ATTRIBUTION:
4// This implementation is inspired by the Noalyss project (https://gitlab.com/noalyss/noalyss)
5// Noalyss is a free accounting software for Belgian and French accounting
6// License: GPL-2.0-or-later (GNU General Public License version 2 or later)
7// Copyright: (C) 1989, 1991 Free Software Foundation, Inc.
8// Copyright: Dany De Bontridder <dany@alchimerys.eu>
9
10use crate::application::ports::JournalEntryRepository;
11use crate::domain::entities::{JournalEntry, JournalEntryLine};
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use rust_decimal::Decimal;
15use rust_decimal_macros::dec;
16use sqlx::PgPool;
17use std::collections::HashMap;
18use uuid::Uuid;
19
20pub struct PostgresJournalEntryRepository {
21    pool: PgPool,
22}
23
24impl PostgresJournalEntryRepository {
25    pub fn new(pool: PgPool) -> Self {
26        Self { pool }
27    }
28
29    /// Load all lines for a journal entry
30    async fn load_lines(&self, journal_entry_id: Uuid) -> Result<Vec<JournalEntryLine>, String> {
31        let lines = sqlx::query_as!(
32            JournalEntryLineRow,
33            r#"
34            SELECT
35                id,
36                journal_entry_id,
37                organization_id,
38                account_code,
39                debit,
40                credit,
41                description,
42                created_at
43            FROM journal_entry_lines
44            WHERE journal_entry_id = $1
45            ORDER BY created_at
46            "#,
47            journal_entry_id
48        )
49        .fetch_all(&self.pool)
50        .await
51        .map_err(|e| format!("Failed to load journal entry lines: {}", e))?;
52
53        Ok(lines.into_iter().map(Into::into).collect())
54    }
55}
56
57#[async_trait]
58impl JournalEntryRepository for PostgresJournalEntryRepository {
59    async fn create(&self, entry: &JournalEntry) -> Result<JournalEntry, String> {
60        // Validate entry is balanced before inserting
61        if !entry.is_balanced() {
62            return Err(format!(
63                "Journal entry is unbalanced: debits={:.2}€ credits={:.2}€",
64                entry.total_debits(),
65                entry.total_credits()
66            ));
67        }
68
69        // Start transaction
70        let mut tx = self
71            .pool
72            .begin()
73            .await
74            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
75
76        // Insert journal entry
77        let entry_row = sqlx::query_as!(
78            JournalEntryRow,
79            r#"
80            INSERT INTO journal_entries (
81                acp_id, organization_id, building_id, entry_date, description, document_ref,
82                journal_type, expense_id, contribution_id, created_by
83            )
84            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
85            RETURNING id, acp_id, organization_id, building_id, entry_date, description, document_ref,
86                      journal_type, expense_id, contribution_id, created_at, updated_at, created_by
87            "#,
88            entry.acp_id,
89            entry.organization_id,
90            entry.building_id,
91            entry.entry_date,
92            entry.description,
93            entry.document_ref,
94            entry.journal_type,
95            entry.expense_id,
96            entry.contribution_id,
97            entry.created_by
98        )
99        .fetch_one(&mut *tx)
100        .await
101        .map_err(|e| format!("Failed to insert journal entry: {}", e))?;
102
103        // Insert journal entry lines
104        for line in &entry.lines {
105            sqlx::query!(
106                r#"
107                INSERT INTO journal_entry_lines (
108                    journal_entry_id, organization_id, account_code,
109                    debit, credit, description
110                )
111                VALUES ($1, $2, $3, $4, $5, $6)
112                "#,
113                entry_row.id,
114                line.organization_id,
115                line.account_code,
116                line.debit,
117                line.credit,
118                line.description
119            )
120            .execute(&mut *tx)
121            .await
122            .map_err(|e| format!("Failed to insert journal entry line: {}", e))?;
123        }
124
125        // Commit transaction (database trigger validates balance)
126        tx.commit()
127            .await
128            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
129
130        // Load lines to return complete entry
131        let lines = self.load_lines(entry_row.id).await?;
132
133        Ok(entry_row.into_journal_entry(lines))
134    }
135
136    async fn find_by_organization(
137        &self,
138        organization_id: Uuid,
139    ) -> Result<Vec<JournalEntry>, String> {
140        let entry_rows = sqlx::query_as!(
141            JournalEntryRow,
142            r#"
143            SELECT
144                id, acp_id, organization_id, building_id, entry_date, description, document_ref,
145                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
146            FROM journal_entries
147            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
148            ORDER BY entry_date DESC, created_at DESC
149            "#,
150            organization_id
151        )
152        .fetch_all(&self.pool)
153        .await
154        .map_err(|e| format!("Failed to find journal entries: {}", e))?;
155
156        let mut entries = Vec::new();
157        for row in entry_rows {
158            let lines = self.load_lines(row.id).await?;
159            entries.push(row.into_journal_entry(lines));
160        }
161
162        Ok(entries)
163    }
164
165    async fn find_by_contribution(
166        &self,
167        contribution_id: Uuid,
168    ) -> Result<Vec<JournalEntry>, String> {
169        let entry_rows = sqlx::query_as!(
170            JournalEntryRow,
171            r#"
172            SELECT
173                id, acp_id, organization_id, building_id, entry_date, description, document_ref,
174                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
175            FROM journal_entries
176            WHERE contribution_id = $1
177            ORDER BY entry_date DESC, created_at DESC
178            "#,
179            contribution_id
180        )
181        .fetch_all(&self.pool)
182        .await
183        .map_err(|e| format!("Failed to find journal entries for contribution: {}", e))?;
184
185        let mut entries = Vec::new();
186        for row in entry_rows {
187            let lines = self.load_lines(row.id).await?;
188            entries.push(row.into_journal_entry(lines));
189        }
190
191        Ok(entries)
192    }
193
194    async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<JournalEntry>, String> {
195        let entry_rows = sqlx::query_as!(
196            JournalEntryRow,
197            r#"
198            SELECT
199                id, acp_id, organization_id, building_id, entry_date, description, document_ref,
200                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
201            FROM journal_entries
202            WHERE expense_id = $1
203            ORDER BY entry_date DESC, created_at DESC
204            "#,
205            expense_id
206        )
207        .fetch_all(&self.pool)
208        .await
209        .map_err(|e| format!("Failed to find journal entries for expense: {}", e))?;
210
211        let mut entries = Vec::new();
212        for row in entry_rows {
213            let lines = self.load_lines(row.id).await?;
214            entries.push(row.into_journal_entry(lines));
215        }
216
217        Ok(entries)
218    }
219
220    async fn find_by_date_range(
221        &self,
222        organization_id: Uuid,
223        start_date: DateTime<Utc>,
224        end_date: DateTime<Utc>,
225    ) -> Result<Vec<JournalEntry>, String> {
226        let entry_rows = sqlx::query_as!(
227            JournalEntryRow,
228            r#"
229            SELECT
230                id, acp_id, organization_id, building_id, entry_date, description, document_ref,
231                journal_type, expense_id, contribution_id, created_at, updated_at, created_by
232            FROM journal_entries
233            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
234              AND entry_date >= $2
235              AND entry_date <= $3
236            ORDER BY entry_date, created_at
237            "#,
238            organization_id,
239            start_date,
240            end_date
241        )
242        .fetch_all(&self.pool)
243        .await
244        .map_err(|e| format!("Failed to find journal entries by date range: {}", e))?;
245
246        let mut entries = Vec::new();
247        for row in entry_rows {
248            let lines = self.load_lines(row.id).await?;
249            entries.push(row.into_journal_entry(lines));
250        }
251
252        Ok(entries)
253    }
254
255    async fn calculate_account_balances(
256        &self,
257        organization_id: Uuid,
258    ) -> Result<HashMap<String, Decimal>, String> {
259        // Use the account_balances view created in migration
260        let balances = sqlx::query!(
261            r#"
262            SELECT account_code, balance
263            FROM account_balances
264            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
265            "#,
266            organization_id
267        )
268        .fetch_all(&self.pool)
269        .await
270        .map_err(|e| format!("Failed to calculate account balances: {}", e))?;
271
272        let mut result = HashMap::new();
273        for row in balances {
274            if let Some(code) = row.account_code {
275                result.insert(code, row.balance.unwrap_or(Decimal::ZERO));
276            }
277        }
278
279        Ok(result)
280    }
281
282    async fn calculate_account_balances_for_period(
283        &self,
284        organization_id: Uuid,
285        start_date: DateTime<Utc>,
286        end_date: DateTime<Utc>,
287    ) -> Result<HashMap<String, Decimal>, String> {
288        // Similar to calculate_account_balances but filtered by date
289        let balances = sqlx::query!(
290            r#"
291            SELECT
292                jel.account_code,
293                a.account_type as "account_type: String",
294                SUM(jel.debit) as total_debit,
295                SUM(jel.credit) as total_credit
296            FROM journal_entry_lines jel
297            JOIN journal_entries je ON je.id = jel.journal_entry_id
298            JOIN accounts a ON a.organization_id = jel.organization_id
299                           AND a.code = jel.account_code
300            WHERE je.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
301              AND je.entry_date >= $2
302              AND je.entry_date <= $3
303            GROUP BY jel.account_code, a.account_type
304            "#,
305            organization_id,
306            start_date,
307            end_date
308        )
309        .fetch_all(&self.pool)
310        .await
311        .map_err(|e| format!("Failed to calculate account balances for period: {}", e))?;
312
313        let mut result = HashMap::new();
314        for row in balances {
315            let total_debit = row.total_debit.unwrap_or(Decimal::ZERO);
316            let total_credit = row.total_credit.unwrap_or(Decimal::ZERO);
317
318            // Calculate balance based on account type
319            let balance = match row.account_type.as_str() {
320                "ASSET" | "EXPENSE" => total_debit - total_credit,
321                "LIABILITY" | "REVENUE" => total_credit - total_debit,
322                _ => Decimal::ZERO,
323            };
324
325            result.insert(row.account_code, balance);
326        }
327
328        Ok(result)
329    }
330
331    async fn find_lines_by_account(
332        &self,
333        organization_id: Uuid,
334        account_code: &str,
335    ) -> Result<Vec<JournalEntryLine>, String> {
336        let lines = sqlx::query_as!(
337            JournalEntryLineRow,
338            r#"
339            SELECT
340                jel.id,
341                jel.journal_entry_id,
342                jel.organization_id,
343                jel.account_code,
344                jel.debit,
345                jel.credit,
346                jel.description,
347                jel.created_at
348            FROM journal_entry_lines jel
349            JOIN journal_entries je ON je.id = jel.journal_entry_id
350            WHERE je.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
351              AND jel.account_code = $2
352            ORDER BY jel.created_at
353            "#,
354            organization_id,
355            account_code
356        )
357        .fetch_all(&self.pool)
358        .await
359        .map_err(|e| format!("Failed to find lines by account: {}", e))?;
360
361        Ok(lines.into_iter().map(Into::into).collect())
362    }
363
364    async fn validate_balance(&self, entry_id: Uuid) -> Result<bool, String> {
365        let result = sqlx::query!(
366            r#"
367            SELECT
368                SUM(debit) as total_debits,
369                SUM(credit) as total_credits
370            FROM journal_entry_lines
371            WHERE journal_entry_id = $1
372            "#,
373            entry_id
374        )
375        .fetch_one(&self.pool)
376        .await
377        .map_err(|e| format!("Failed to validate balance: {}", e))?;
378
379        let total_debits = result.total_debits.unwrap_or(Decimal::ZERO);
380        let total_credits = result.total_credits.unwrap_or(Decimal::ZERO);
381
382        Ok((total_debits - total_credits).abs() <= dec!(0.01))
383    }
384
385    async fn calculate_account_balances_for_building(
386        &self,
387        organization_id: Uuid,
388        building_id: Uuid,
389    ) -> Result<HashMap<String, Decimal>, String> {
390        // Calculate balances from journal entries linked to expenses or contributions for this building
391        let rows = sqlx::query!(
392            r#"
393            SELECT
394                jel.account_code,
395                SUM(jel.debit) as total_debit,
396                SUM(jel.credit) as total_credit
397            FROM journal_entry_lines jel
398            JOIN journal_entries je ON jel.journal_entry_id = je.id
399            LEFT JOIN expenses e ON je.expense_id = e.id
400            WHERE je.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
401              AND (e.building_id = $2 OR e.building_id IS NULL)
402            GROUP BY jel.account_code
403            "#,
404            organization_id,
405            building_id
406        )
407        .fetch_all(&self.pool)
408        .await
409        .map_err(|e| format!("Failed to calculate building balances: {}", e))?;
410
411        let mut balances = HashMap::new();
412        for row in rows {
413            let debit = row.total_debit.unwrap_or(Decimal::ZERO);
414            let credit = row.total_credit.unwrap_or(Decimal::ZERO);
415
416            // For expenses/assets: positive balance = debit - credit
417            // For revenue/liabilities: positive balance = credit - debit
418            // We'll determine this based on account class
419            let account_code = &row.account_code;
420            let balance = if account_code.starts_with('6')
421                || account_code.starts_with('2')
422                || account_code.starts_with('3')
423                || account_code.starts_with('4')
424                || account_code.starts_with('5')
425            {
426                debit - credit // Assets/Expenses
427            } else {
428                credit - debit // Liabilities/Revenue (class 1, 7)
429            };
430
431            balances.insert(row.account_code.clone(), balance);
432        }
433
434        Ok(balances)
435    }
436
437    async fn calculate_account_balances_for_building_and_period(
438        &self,
439        organization_id: Uuid,
440        building_id: Uuid,
441        start_date: DateTime<Utc>,
442        end_date: DateTime<Utc>,
443    ) -> Result<HashMap<String, Decimal>, String> {
444        // Calculate balances from journal entries linked to expenses or contributions for this building and period
445        let rows = sqlx::query!(
446            r#"
447            SELECT
448                jel.account_code,
449                SUM(jel.debit) as total_debit,
450                SUM(jel.credit) as total_credit
451            FROM journal_entry_lines jel
452            JOIN journal_entries je ON jel.journal_entry_id = je.id
453            LEFT JOIN expenses e ON je.expense_id = e.id
454            WHERE je.acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
455              AND (e.building_id = $2 OR e.building_id IS NULL)
456              AND je.entry_date >= $3
457              AND je.entry_date <= $4
458            GROUP BY jel.account_code
459            "#,
460            organization_id,
461            building_id,
462            start_date,
463            end_date
464        )
465        .fetch_all(&self.pool)
466        .await
467        .map_err(|e| format!("Failed to calculate building period balances: {}", e))?;
468
469        let mut balances = HashMap::new();
470        for row in rows {
471            let debit = row.total_debit.unwrap_or(Decimal::ZERO);
472            let credit = row.total_credit.unwrap_or(Decimal::ZERO);
473
474            let account_code = &row.account_code;
475            let balance = if account_code.starts_with('6')
476                || account_code.starts_with('2')
477                || account_code.starts_with('3')
478                || account_code.starts_with('4')
479                || account_code.starts_with('5')
480            {
481                debit - credit // Assets/Expenses
482            } else {
483                credit - debit // Liabilities/Revenue
484            };
485
486            balances.insert(row.account_code.clone(), balance);
487        }
488
489        Ok(balances)
490    }
491
492    async fn create_manual_entry(
493        &self,
494        entry: &JournalEntry,
495        lines: &[JournalEntryLine],
496    ) -> Result<(), String> {
497        // Start transaction with deferred constraints
498        let mut tx = self
499            .pool
500            .begin()
501            .await
502            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
503
504        sqlx::query("SET CONSTRAINTS ALL DEFERRED")
505            .execute(&mut *tx)
506            .await
507            .map_err(|e| format!("Failed to defer constraints: {}", e))?;
508
509        // Insert journal entry header
510        sqlx::query!(
511            r#"
512            INSERT INTO journal_entries (
513                id, acp_id, organization_id, building_id, entry_date, description,
514                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
515            )
516            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
517            "#,
518            entry.id,
519            entry.acp_id,
520            entry.organization_id,
521            entry.building_id,
522            entry.entry_date,
523            entry.description,
524            entry.document_ref,
525            entry.journal_type,
526            entry.expense_id,
527            entry.contribution_id,
528            entry.created_at,
529            entry.updated_at,
530            entry.created_by
531        )
532        .execute(&mut *tx)
533        .await
534        .map_err(|e| format!("Failed to insert journal entry: {}", e))?;
535
536        // Insert journal entry lines
537        for line in lines {
538            sqlx::query!(
539                r#"
540                INSERT INTO journal_entry_lines (
541                    id, journal_entry_id, organization_id, account_code,
542                    debit, credit, description, created_at
543                )
544                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
545                "#,
546                line.id,
547                line.journal_entry_id,
548                line.organization_id,
549                line.account_code,
550                line.debit,
551                line.credit,
552                line.description,
553                line.created_at
554            )
555            .execute(&mut *tx)
556            .await
557            .map_err(|e| format!("Failed to insert journal entry line: {}", e))?;
558        }
559
560        // Commit transaction
561        tx.commit()
562            .await
563            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
564
565        Ok(())
566    }
567
568    async fn list_entries(
569        &self,
570        organization_id: Uuid,
571        building_id: Option<Uuid>,
572        journal_type: Option<String>,
573        start_date: Option<DateTime<Utc>>,
574        end_date: Option<DateTime<Utc>>,
575        limit: i64,
576        offset: i64,
577    ) -> Result<Vec<JournalEntry>, String> {
578        let rows = sqlx::query_as!(
579            JournalEntryRow,
580            r#"
581            SELECT
582                id, acp_id, organization_id, building_id, entry_date, description,
583                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
584            FROM journal_entries
585            WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
586              AND ($2::uuid IS NULL OR building_id = $2)
587              AND ($3::text IS NULL OR journal_type = $3)
588              AND ($4::timestamptz IS NULL OR entry_date >= $4)
589              AND ($5::timestamptz IS NULL OR entry_date <= $5)
590            ORDER BY entry_date DESC, created_at DESC
591            LIMIT $6 OFFSET $7
592            "#,
593            organization_id,
594            building_id,
595            journal_type,
596            start_date,
597            end_date,
598            limit,
599            offset
600        )
601        .fetch_all(&self.pool)
602        .await
603        .map_err(|e| format!("Failed to list journal entries: {}", e))?;
604
605        Ok(rows
606            .into_iter()
607            .map(|row| row.into_journal_entry(vec![]))
608            .collect())
609    }
610
611    async fn find_by_id(
612        &self,
613        entry_id: Uuid,
614        organization_id: Uuid,
615    ) -> Result<JournalEntry, String> {
616        let row = sqlx::query_as!(
617            JournalEntryRow,
618            r#"
619            SELECT
620                id, acp_id, organization_id, building_id, entry_date, description,
621                document_ref, journal_type, expense_id, contribution_id, created_at, updated_at, created_by
622            FROM journal_entries
623            WHERE id = $1 AND organization_id = $2
624            "#,
625            entry_id,
626            organization_id
627        )
628        .fetch_one(&self.pool)
629        .await
630        .map_err(|e| format!("Journal entry not found: {}", e))?;
631
632        Ok(row.into_journal_entry(vec![]))
633    }
634
635    async fn find_lines_by_entry(
636        &self,
637        entry_id: Uuid,
638        organization_id: Uuid,
639    ) -> Result<Vec<JournalEntryLine>, String> {
640        let rows = sqlx::query_as!(
641            JournalEntryLineRow,
642            r#"
643            SELECT
644                id, journal_entry_id, organization_id, account_code,
645                debit, credit, description, created_at
646            FROM journal_entry_lines
647            WHERE journal_entry_id = $1 AND organization_id = $2
648            ORDER BY created_at ASC
649            "#,
650            entry_id,
651            organization_id
652        )
653        .fetch_all(&self.pool)
654        .await
655        .map_err(|e| format!("Failed to fetch journal entry lines: {}", e))?;
656
657        Ok(rows.into_iter().map(JournalEntryLine::from).collect())
658    }
659
660    async fn delete_entry(&self, entry_id: Uuid, organization_id: Uuid) -> Result<(), String> {
661        // Start transaction
662        let mut tx = self
663            .pool
664            .begin()
665            .await
666            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
667
668        // Delete journal entry lines first (foreign key constraint)
669        sqlx::query!(
670            r#"
671            DELETE FROM journal_entry_lines
672            WHERE journal_entry_id = $1 AND organization_id = $2
673            "#,
674            entry_id,
675            organization_id
676        )
677        .execute(&mut *tx)
678        .await
679        .map_err(|e| format!("Failed to delete journal entry lines: {}", e))?;
680
681        // Delete journal entry
682        let result = sqlx::query!(
683            r#"
684            DELETE FROM journal_entries
685            WHERE id = $1 AND organization_id = $2
686            "#,
687            entry_id,
688            organization_id
689        )
690        .execute(&mut *tx)
691        .await
692        .map_err(|e| format!("Failed to delete journal entry: {}", e))?;
693
694        if result.rows_affected() == 0 {
695            return Err("Journal entry not found or already deleted".to_string());
696        }
697
698        // Commit transaction
699        tx.commit()
700            .await
701            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
702
703        Ok(())
704    }
705}
706
707// Database row structs
708#[derive(Debug)]
709struct JournalEntryRow {
710    id: Uuid,
711    acp_id: Uuid,
712    organization_id: Uuid,
713    building_id: Option<Uuid>,
714    entry_date: DateTime<Utc>,
715    description: Option<String>,
716    document_ref: Option<String>,
717    journal_type: Option<String>,
718    expense_id: Option<Uuid>,
719    contribution_id: Option<Uuid>,
720    created_at: DateTime<Utc>,
721    updated_at: DateTime<Utc>,
722    created_by: Option<Uuid>,
723}
724
725impl JournalEntryRow {
726    fn into_journal_entry(self, lines: Vec<JournalEntryLine>) -> JournalEntry {
727        JournalEntry {
728            id: self.id,
729            acp_id: self.acp_id,
730            organization_id: self.organization_id,
731            building_id: self.building_id,
732            entry_date: self.entry_date,
733            description: self.description,
734            document_ref: self.document_ref,
735            journal_type: self.journal_type,
736            expense_id: self.expense_id,
737            contribution_id: self.contribution_id,
738            lines,
739            created_at: self.created_at,
740            updated_at: self.updated_at,
741            created_by: self.created_by,
742        }
743    }
744}
745
746#[derive(Debug)]
747struct JournalEntryLineRow {
748    id: Uuid,
749    journal_entry_id: Uuid,
750    organization_id: Uuid,
751    account_code: String,
752    debit: Decimal,
753    credit: Decimal,
754    description: Option<String>,
755    created_at: DateTime<Utc>,
756}
757
758impl From<JournalEntryLineRow> for JournalEntryLine {
759    fn from(row: JournalEntryLineRow) -> Self {
760        Self {
761            id: row.id,
762            journal_entry_id: row.journal_entry_id,
763            organization_id: row.organization_id,
764            account_code: row.account_code,
765            debit: row.debit,
766            credit: row.credit,
767            description: row.description,
768            created_at: row.created_at,
769        }
770    }
771}