hardlink/db/db.go
2025-12-22 18:11:05 +00:00

201 lines
6.1 KiB
Go

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
}