package repository import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "apskel-pos-be/internal/entities" ) // ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another // organization. var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found") // LoyaltySettingChangeFilter narrows the loyalty settings history. type LoyaltySettingChangeFilter struct { OrganizationID uuid.UUID // Nil for the whole organization, outlets included. OutletID *uuid.UUID Offset int Limit int } // LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* and // enakgame.* keys of outlet_settings and organization_settings) and their history // (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into // typed values and is the only writer. type LoyaltySettingsRepository interface { // GetOutletValues returns the stored loyalty keys of an outlet. A key with no row, // or a NULL value, is absent. GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) // OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet. OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) // LockScope serializes writers of one outlet's or one organization's settings // until the transaction ends, so two saves at once cannot both compute their // changes from the same old values. LockScope(ctx context.Context, scopeID uuid.UUID) error // SetOutletValue stores a value, or removes the key when value is nil so it falls // back to its default. SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error // ListChanges returns a page of history, newest first, and the total count. ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) // UndatedBalance counts an organization's lots of one currency that still hold // something and have no expiry, and what they hold. UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error) // DateUndatedLots gives those lots an expiry, when expiry is turned on (F12). DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error) } type loyaltySettingsRepository struct { db *gorm.DB } func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository { return &loyaltySettingsRepository{db: db} } // requireTx returns the caller's transaction. Writes must run in one, together with // the history rows they produce. func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) { if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { return tx.WithContext(ctx), nil } return nil, errors.New("loyalty settings: write must run inside a transaction") } func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) { var rows []struct { Key string Value string } err := DBFromContext(ctx, r.db).WithContext(ctx). Table(table). Select("key, value"). Where(scopeColumn+" = ? AND (key LIKE 'loyalty.%' OR key LIKE 'enakgame.%') AND value IS NOT NULL", scopeID). Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to read %s: %w", table, err) } values := make(map[string]string, len(rows)) for _, row := range rows { values[row.Key] = row.Value } return values, nil } func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) { return r.values(ctx, "outlet_settings", "outlet_id", outletID) } func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) { return r.values(ctx, "organization_settings", "organization_id", organizationID) } func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) { var ids []string err := DBFromContext(ctx, r.db).WithContext(ctx). Table("outlets"). Where("id = ?", outletID). Limit(1). Pluck("organization_id::text", &ids).Error if err != nil { return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err) } if len(ids) == 0 { return uuid.Nil, ErrLoyaltyOutletNotFound } return uuid.Parse(ids[0]) } func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error { db, err := r.requireTx(ctx) if err != nil { return err } return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error } func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error { return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value) } func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error { return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value) } // setValue upserts or deletes one key. table and scopeColumn are never user input. func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error { db, err := r.requireTx(ctx) if err != nil { return err } if value == nil { err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error } else { err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?) ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, scopeID, key, *value).Error } if err != nil { return fmt.Errorf("failed to store %s in %s: %w", key, table, err) } return nil } func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error { if len(changes) == 0 { return nil } db, err := r.requireTx(ctx) if err != nil { return err } return db.Create(&changes).Error } func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { query := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.LoyaltySettingChange{}). Where("organization_id = ?", filter.OrganizationID) if filter.OutletID != nil { query = query.Where("outlet_id = ?", *filter.OutletID) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err) } var changes []entities.LoyaltySettingChange err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error if err != nil { return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err) } return changes, total, nil } func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) { var row struct{ Lots, Amount int64 } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount FROM wallet_lots WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`, organizationID, currency).Scan(&row).Error if err != nil { return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err) } return row.Lots, row.Amount, nil } func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { db, err := r.requireTx(ctx) if err != nil { return 0, 0, err } var rows []struct{ RemainingAmount int64 } // Only lots that still hold something: an empty lot never expires anything, and // leaving it undated keeps the history as it was. err = db.Raw(` UPDATE wallet_lots SET expires_at = ? WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0 RETURNING remaining_amount`, expiresAt, organizationID, currency).Scan(&rows).Error if err != nil { return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err) } var amount int64 for _, row := range rows { amount += row.RemainingAmount } return int64(len(rows)), amount, nil }