Files
apskel-pos-backend/internal/repository/loyalty_settings_repository.go
T
efrilmandClaude Opus 5.5 39e47ff0e6 feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109).

Reading returns typed settings for an outlet (earning per currency, paying
with EnakPoint) and for an organization (point value, exchange rate,
transfers, and the expiry settings awaiting note N4). A key that was never
set takes the PRD default. A stored value that is unusable, such as an
earn_per_amount of 0 that would divide by zero, also falls back to the
default and is logged, so a bad row never reaches a calculation.

Writing takes the whole settings struct, validates every rule in the PRD
before touching the database, and stores and records in
loyalty_setting_changes only the keys whose effective value changes: old
value (NULL while it was on its default), new value, and who changed it.
Clearing a limit deletes the stored value. Each save runs in one
transaction under an advisory lock per outlet or organization, so two saves
at once cannot both compute their change from the same old value. The
outlet must belong to the caller's organization.

Every key is described once (key, default, valid range, bound field), and
reading, validating and diffing all use that description.

GET /customer/wallet now reads the point value through this processor; the
minimal organization settings repository from PC-106 is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:23:08 +07:00

177 lines
6.6 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"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.* 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)
}
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.%' 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
}