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 } }