104 lines
3.6 KiB
Go
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
|
||
|
|
}
|
||
|
|
}
|