Files
apskel-pos-backend/internal/repository/payment_code_repository.go
T
efrilmandClaude Opus 5.5 0c4dd72583 feat(loyalty): one-time EnakPoint payment code
Adds POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8,
PC-304). The customer approves with their PIN on their own phone and gets a
6-digit code, as digits and as a QR payload (enakpoint:<code>) for the app
to render, valid for two minutes. The PIN is never typed at the cashier.

Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL,
bound to the customer; a new code retires the previous one. Redeeming is a
single Lua step that uses the code up only if it belongs to the order's
customer, so it stays one-time under a race, and a cashier scanning it
against the wrong order does not burn it for its owner, which a plain
GETDEL would. Expired, used, unknown and other customers' codes are all
refused alike.

Tests run against miniredis, added as a test dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:37:20 +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
}
}