package db import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" mssqldb "github.com/denisenkom/go-mssqldb" // for error inspection log "github.com/sirupsen/logrus" "gitea.futuresens.co.uk/futuresens/hardlink/types" ) // InitMSSQL opens and pings the SQL Server instance (keeps your original behaviour) func InitMSSQL(port int, user, password, database string) (*sql.DB, error) { if port <= 0 || user == "" || database == "" { return nil, errors.New("incomplete database configuration") } dsn := fmt.Sprintf( "sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable", user, password, "localhost", port, database, ) db, err := sql.Open("sqlserver", dsn) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { db.Close() return nil, err } if err := db.QueryRowContext(ctx, "SELECT 1").Scan(new(int)); err != nil { db.Close() return nil, err } log.Info("Database connection established") return db, nil } func parseDateOnly(s string) (time.Time, error) { parsed, err := time.Parse(types.CustomLayout, s) if err == nil { // construct midnight in local timezone, then convert to UTC for storage consistency localMidnight := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local) return localMidnight.UTC(), nil } return time.Time{}, fmt.Errorf("parseDateOnly error: parsing %q: %w", s, err) } // InsertPreauth stores a preauthorization record. It is idempotent: duplicate TxnReference is ignored. func InsertPreauth(ctx context.Context, db *sql.DB, m map[string]string, checkoutDate string) error { const funcName = "InsertPreauth" totalMinorUnits := m[types.TotalAmount] txnReference := m[types.Reference] if txnReference == "" { return fmt.Errorf("%s: missing REFERENCE", funcName) } if totalMinorUnits == "" { return fmt.Errorf("%s: missing TotalAmount", funcName) } // parse minor units, fallback to 0 on parse error but report it amountInt, err := strconv.ParseInt(totalMinorUnits, 10, 64) if err != nil { log.WithFields(log.Fields{ "func": funcName, "value": totalMinorUnits, "error": err, }).Warnf("parsing TotalAmount, defaulting to 0") amountInt = 0 } totalAmount := float64(amountInt) / 100.0 txnTime := time.Now().UTC() // parse departure / checkout date and compute release date (48h after departure) checkOutDate, err := parseDateOnly(checkoutDate) if err != nil { return fmt.Errorf("InsertPreauth: parsing checkoutDate %q: %w", checkoutDate, err) } releaseDate := checkOutDate.Add(48 * time.Hour) const stmt = ` INSERT INTO dbo.Preauthorizations (TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate) VALUES (@TxnReference, @TotalMinorUnits, @TotalAmount, @TxnDateTime, @DepartureDate, @ReleaseDate); ` _, err = db.ExecContext(ctx, stmt, sql.Named("TxnReference", txnReference), sql.Named("TotalMinorUnits", totalMinorUnits), sql.Named("TotalAmount", totalAmount), sql.Named("TxnDateTime", txnTime), sql.Named("DepartureDate", checkOutDate), sql.Named("ReleaseDate", releaseDate), ) if err != nil { // handle duplicate-key (unique constraint) gracefully: SQL Server error numbers 2601/2627 var sqlErr mssqldb.Error if errors.As(err, &sqlErr) { if sqlErr.Number == 2627 || sqlErr.Number == 2601 { log.Infof("InsertPreauth: preauth %s already exists (duplicate key) - ignoring", txnReference) return nil } } return fmt.Errorf("InsertPreauth exec: %w", err) } log.Infof("Inserted preauth %s amount=%s minorUnits release=%s", txnReference, totalMinorUnits, releaseDate.Format(time.RFC3339)) return nil } // GetDuePreauths returns preauths with ReleaseDate <= now where Released = 0. // If limit > 0, the query uses TOP(limit) to bound results at DB level. func GetDuePreauths(ctx context.Context, db *sql.DB, now time.Time, limit int) ([]types.PreauthRec, error) { baseQuery := ` SELECT Id, TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate, Released, ReleasedAt FROM dbo.Preauthorizations WHERE Released = 0 AND ReleaseDate <= @Now ORDER BY ReleaseDate ASC ` query := baseQuery if limit > 0 { // embed TOP to keep DB from returning everything; limit is controlled by the caller. query = strings.Replace(baseQuery, "SELECT", fmt.Sprintf("SELECT TOP (%d)", limit), 1) } rows, err := db.QueryContext(ctx, query, sql.Named("Now", now)) if err != nil { return nil, fmt.Errorf("GetDuePreauths query: %w", err) } defer rows.Close() var out []types.PreauthRec for rows.Next() { var r types.PreauthRec var departure sql.NullTime var releasedAt sql.NullTime // Note: adjust scanning targets if types.PreauthRec fields differ if err := rows.Scan( &r.Id, &r.TxnReference, &r.TotalMinorUnits, &r.TotalAmount, &r.TxnDateTime, &departure, &r.ReleaseDate, &r.Released, &releasedAt, ); err != nil { return nil, fmt.Errorf("GetDuePreauths scan: %w", err) } if departure.Valid { r.DepartureDate = departure.Time } else { r.DepartureDate = time.Time{} } r.ReleasedAt = releasedAt out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("GetDuePreauths rows: %w", err) } return out, nil } // MarkPreauthReleased sets Released=1 and stores the timestamp. Returns error if no rows updated. func MarkPreauthReleased(ctx context.Context, db *sql.DB, txnReference string, releasedAt time.Time) error { const stmt = ` UPDATE dbo.Preauthorizations SET Released = 1, ReleasedAt = @ReleasedAt WHERE TxnReference = @TxnReference AND Released = 0; ` res, err := db.ExecContext(ctx, stmt, sql.Named("ReleasedAt", releasedAt), sql.Named("TxnReference", txnReference)) if err != nil { return fmt.Errorf("MarkPreauthReleased exec: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("MarkPreauthReleased RowsAffected: %w", err) } if n == 0 { return fmt.Errorf("no rows updated for %s (maybe already released)", txnReference) } log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339)) return nil }