Files
apskel-pos-backend/internal/repository/payment_code_repository.go
T
2026-09-30 15:31:11 +07:00

104 lines
3.6 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
var (
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
ErrPaymentCodeTaken = errors.New("payment code already in use")
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
// already used.
ErrPaymentCodeNotFound = errors.New("payment code not found")
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
)
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
type PaymentCodeRepository interface {
// Save stores a code for a customer for ttl, and retires the customer's previous
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
// Consume uses a code up if it belongs to the customer. A code of another customer
// is left in place, so a cashier scanning it against the wrong order does not burn
// it for its owner.
Consume(ctx context.Context, code string, customerID uuid.UUID) error
}
type paymentCodeRepository struct {
client *redis.Client
}
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
return &paymentCodeRepository{client: client}
}
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
func paymentCodeCustomerKey(customerID uuid.UUID) string {
return "wallet:paycode:customer:" + customerID.String()
}
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
if err != nil {
return fmt.Errorf("failed to store payment code: %w", err)
}
if !ok {
return ErrPaymentCodeTaken
}
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("failed to track payment code: %w", err)
}
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
if previous != "" && previous != code {
// Only if it is still that customer's: the number may have been reissued.
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
return err
}
}
return nil
}
// consumeScript deletes a code only if it belongs to the given customer, in one step.
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
var consumeScript = redis.NewScript(`
local owner = redis.call('GET', KEYS[1])
if not owner then return 0 end
if owner ~= ARGV[1] then return -1 end
redis.call('DEL', KEYS[1])
return 1
`)
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimSpace(code)
if code == "" {
return ErrPaymentCodeNotFound
}
return r.compareAndDelete(ctx, code, customerID)
}
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
if err != nil {
return fmt.Errorf("failed to use payment code: %w", err)
}
switch result {
case 1:
return nil
case -1:
return ErrPaymentCodeWrongCustomer
default:
return ErrPaymentCodeNotFound
}
}