package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" ) type Transaction struct { ID int64 `json:"id"` Date string `json:"date"` AccountSourceID *int32 `json:"account_source_id,omitempty"` InstrumentSourceID *int32 `json:"instrument_source_id,omitempty"` QuantiteSource *float64 `json:"quantite_source,omitempty"` AccountDestID *int32 `json:"account_dest_id,omitempty"` InstrumentDestID *int32 `json:"instrument_dest_id,omitempty"` QuantiteDest *float64 `json:"quantite_dest,omitempty"` Tiers *string `json:"tiers,omitempty"` Label string `json:"label"` Categorie *string `json:"categorie,omitempty"` Validated bool `json:"validated"` RecurringRuleID *int32 `json:"recurring_rule_id,omitempty"` } // AccountTransaction est la vue centrée sur un compte : // montant signé (+ crédit, - débit), instrument du côté concerné. type AccountTransaction struct { ID int64 `json:"id"` Date string `json:"date"` Montant float64 `json:"montant"` InstrumentID int32 `json:"instrument_id"` Tiers *string `json:"tiers,omitempty"` Label string `json:"label"` Categorie *string `json:"categorie,omitempty"` Validated bool `json:"validated"` ContrepartieAccountID *int32 `json:"contrepartie_account_id,omitempty"` } type CreateTransactionParams struct { Date string `json:"date"` AccountSourceID *int32 `json:"account_source_id,omitempty"` InstrumentSourceID *int32 `json:"instrument_source_id,omitempty"` QuantiteSource *float64 `json:"quantite_source,omitempty"` AccountDestID *int32 `json:"account_dest_id,omitempty"` InstrumentDestID *int32 `json:"instrument_dest_id,omitempty"` QuantiteDest *float64 `json:"quantite_dest,omitempty"` Tiers *string `json:"tiers,omitempty"` Label string `json:"label"` Categorie *string `json:"categorie,omitempty"` Validated bool `json:"validated"` } type AccountTransactionFilters struct { Validated *bool Pending *bool From *string To *string } const txCols = `id, date::text, account_source_id, instrument_source_id, quantite_source, account_dest_id, instrument_dest_id, quantite_dest, tiers, label, categorie, validated, recurring_rule_id` // ListAccountTransactions retourne les transactions d'un compte avec montant signé. func (s *Store) ListAccountTransactions(ctx context.Context, accountID int32, f AccountTransactionFilters) ([]AccountTransaction, error) { q := ` SELECT id, date::text, CASE WHEN account_dest_id = $1 THEN quantite_dest ELSE -quantite_source END AS montant, CASE WHEN account_dest_id = $1 THEN instrument_dest_id ELSE instrument_source_id END AS instrument_id, tiers, label, categorie, validated, CASE WHEN account_dest_id = $1 THEN account_source_id ELSE account_dest_id END AS contrepartie_account_id FROM transaction WHERE (account_source_id = $1 OR account_dest_id = $1)` args := []any{accountID} i := 2 if f.Pending != nil && *f.Pending { q += ` AND validated = false AND date <= CURRENT_DATE` } else if f.Validated != nil { q += fmt.Sprintf(` AND validated = $%d`, i) args = append(args, *f.Validated) i++ } if f.From != nil { q += fmt.Sprintf(` AND date >= $%d::date`, i) args = append(args, *f.From) i++ } if f.To != nil { q += fmt.Sprintf(` AND date <= $%d::date`, i) args = append(args, *f.To) i++ } q += ` ORDER BY date DESC, id DESC` rows, err := s.pool.Query(ctx, q, args...) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[AccountTransaction]) } // GetTransaction retourne une transaction. Si ownerID > 0, vérifie que l'un des // comptes impliqués appartient à cet owner (retourne ErrNoRows sinon). func (s *Store) GetTransaction(ctx context.Context, id int64, ownerID int32) (Transaction, error) { q := `SELECT t.id, t.date::text, t.account_source_id, t.instrument_source_id, t.quantite_source, t.account_dest_id, t.instrument_dest_id, t.quantite_dest, t.tiers, t.label, t.categorie, t.validated, t.recurring_rule_id FROM transaction t LEFT JOIN account src ON src.id = t.account_source_id LEFT JOIN account dst ON dst.id = t.account_dest_id WHERE t.id = $1` args := []any{id} if ownerID > 0 { q += ` AND (src.owner_id = $2 OR dst.owner_id = $2)` args = append(args, ownerID) } rows, err := s.pool.Query(ctx, q, args...) if err != nil { return Transaction{}, err } return pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) } func (s *Store) CreateTransaction(ctx context.Context, p CreateTransactionParams) (Transaction, error) { rows, err := s.pool.Query(ctx, `INSERT INTO transaction ( date, account_source_id, instrument_source_id, quantite_source, account_dest_id, instrument_dest_id, quantite_dest, tiers, label, categorie, validated ) VALUES ( $1::date, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) RETURNING `+txCols, p.Date, p.AccountSourceID, p.InstrumentSourceID, p.QuantiteSource, p.AccountDestID, p.InstrumentDestID, p.QuantiteDest, p.Tiers, p.Label, p.Categorie, p.Validated) if err != nil { return Transaction{}, err } tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) if err != nil { return tx, err } s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID) return tx, nil } func (s *Store) UpdateTransaction(ctx context.Context, id int64, p CreateTransactionParams, ownerID int32) (Transaction, error) { // Récupérer l'ancienne date pour invalider à partir du MIN(ancienne, nouvelle). old, err := s.GetTransaction(ctx, id, ownerID) if err != nil { return Transaction{}, err } rows, err := s.pool.Query(ctx, `UPDATE transaction SET date = $2::date, account_source_id = $3, instrument_source_id = $4, quantite_source = $5, account_dest_id = $6, instrument_dest_id = $7, quantite_dest = $8, tiers = $9, label = $10, categorie = $11, validated = $12 WHERE id = $1 RETURNING `+txCols, id, p.Date, p.AccountSourceID, p.InstrumentSourceID, p.QuantiteSource, p.AccountDestID, p.InstrumentDestID, p.QuantiteDest, p.Tiers, p.Label, p.Categorie, p.Validated) if err != nil { return Transaction{}, err } tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) if err != nil { return tx, err } // Invalider depuis la date la plus ancienne (ancienne ou nouvelle). earliest := minDateStr(old.Date, tx.Date) s.invalidateAccounts(ctx, earliest, tx.AccountSourceID, tx.AccountDestID) // Si les comptes ont changé, invalider aussi les anciens. s.invalidateAccounts(ctx, earliest, old.AccountSourceID, old.AccountDestID) return tx, nil } func (s *Store) SetValidated(ctx context.Context, id int64, validated bool, ownerID int32) (Transaction, error) { // Vérifie l'ownership avant modification. if _, err := s.GetTransaction(ctx, id, ownerID); err != nil { return Transaction{}, err } rows, err := s.pool.Query(ctx, `UPDATE transaction SET validated = $2 WHERE id = $1 RETURNING `+txCols, id, validated) if err != nil { return Transaction{}, err } tx, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Transaction]) if err != nil { return tx, err } s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID) return tx, nil } func (s *Store) DeleteTransaction(ctx context.Context, id int64, ownerID int32) error { tx, err := s.GetTransaction(ctx, id, ownerID) if err != nil { return err } if _, err := s.pool.Exec(ctx, `DELETE FROM transaction WHERE id = $1`, id); err != nil { return err } s.invalidateAccounts(ctx, tx.Date, tx.AccountSourceID, tx.AccountDestID) return nil } // invalidateAccounts marque les comptes non-nil comme devant être recalculés depuis dateStr. func (s *Store) invalidateAccounts(ctx context.Context, dateStr string, accountIDs ...*int32) { date, err := time.Parse("2006-01-02", dateStr) if err != nil { return } seen := map[int32]bool{} for _, id := range accountIDs { if id != nil && !seen[*id] { seen[*id] = true s.InvalidateSnapshot(ctx, *id, date) //nolint:errcheck } } } // minDateStr retourne la plus petite des deux dates au format YYYY-MM-DD. func minDateStr(a, b string) string { if a <= b { return a } return b }