Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
pm.type as payment_method_type,
|
||||
COALESCE(SUM(p.amount), 0) as total_amount,
|
||||
COUNT(DISTINCT p.order_id) as order_count,
|
||||
COUNT(p.id) as payment_count
|
||||
COUNT(p.id) as payment_count,
|
||||
COALESCE(SUM(p.points_used), 0) as points_used
|
||||
`).
|
||||
Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id").
|
||||
Joins("JOIN orders o ON p.order_id = o.id").
|
||||
@@ -478,7 +479,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
||||
COALESCE(SUM(oi.total_price), 0) as revenue,
|
||||
CASE
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
|
||||
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`)
|
||||
ELSE 0
|
||||
END as average_price,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
@@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
FROM product_recipes pr
|
||||
JOIN ingredients i ON pr.ingredient_id = i.id
|
||||
WHERE pr.product_id = p.id
|
||||
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
|
||||
), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total,
|
||||
CASE
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
|
||||
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`)
|
||||
ELSE 0
|
||||
END as fifo_hpp_per_unit,
|
||||
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -551,9 +552,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -604,9 +605,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomerDevice is one install of the customer app that can receive push
|
||||
// notifications.
|
||||
type CustomerDevice struct {
|
||||
CustomerID uuid.UUID
|
||||
DeviceID string
|
||||
Platform *string
|
||||
FCMToken string
|
||||
AppVersion *string
|
||||
}
|
||||
|
||||
// CustomerDeviceRepository keeps the FCM tokens of the customer app.
|
||||
type CustomerDeviceRepository interface {
|
||||
// Register saves the device's current token. A token is only ever one customer's:
|
||||
// registering it takes it away from whoever had it before on that phone, so they
|
||||
// stop getting this customer's notifications.
|
||||
Register(ctx context.Context, device CustomerDevice, now time.Time) error
|
||||
// Unregister forgets a device, for instance on logout. An unknown device is not an error.
|
||||
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error
|
||||
ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error)
|
||||
}
|
||||
|
||||
type customerDeviceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository {
|
||||
return &customerDeviceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error {
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`,
|
||||
d.FCMToken, d.CustomerID, d.DeviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release FCM token: %w", err)
|
||||
}
|
||||
err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (customer_id, device_id) DO UPDATE SET
|
||||
platform = EXCLUDED.platform,
|
||||
fcm_token = EXCLUDED.fcm_token,
|
||||
app_version = EXCLUDED.app_version,
|
||||
last_active_at = EXCLUDED.last_active_at,
|
||||
updated_at = NOW()`,
|
||||
d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unregister customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customer_devices").
|
||||
Where("customer_id = ? AND fcm_token <> ''", customerID).
|
||||
Order("last_active_at DESC NULLS LAST").
|
||||
Pluck("fcm_token", &tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrPinCustomerNotFound means the customer does not exist.
|
||||
var ErrPinCustomerNotFound = errors.New("pin: customer not found")
|
||||
|
||||
// CustomerPinState is a customer's PIN and what guards it. It lives in the customers
|
||||
// table but is read and written only here, never through the Customer entity, so the
|
||||
// hash cannot end up in a customer response.
|
||||
type CustomerPinState struct {
|
||||
CustomerID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
PhoneNumber *string
|
||||
BirthDate *time.Time
|
||||
PinHash *string
|
||||
PinSetAt *time.Time
|
||||
FailedAttempts int
|
||||
LockedUntil *time.Time
|
||||
TransferBlockedUntil *time.Time
|
||||
}
|
||||
|
||||
// CustomerSecurityEvent is one row of the PIN security log.
|
||||
type CustomerSecurityEvent struct {
|
||||
ID uuid.UUID
|
||||
CustomerID uuid.UUID
|
||||
Event string
|
||||
ActorUser *uuid.UUID
|
||||
Reason *string
|
||||
IPAddress *string
|
||||
UserAgent *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CustomerPinRepository stores customer PINs and their security log
|
||||
// (docs/prd-point-coin.md F11).
|
||||
type CustomerPinRepository interface {
|
||||
GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error)
|
||||
// SetPin stores a new PIN hash, clears the failure counter and any lock, and sets
|
||||
// or clears the transfer hold.
|
||||
SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error
|
||||
// RemovePin deletes the PIN, so the customer has to create a new one through OTP.
|
||||
RemovePin(ctx context.Context, customerID uuid.UUID) error
|
||||
// RecordFailure adds one wrong attempt in a single statement, so wrong attempts
|
||||
// made at the same time all count. A lock that has already run out starts the
|
||||
// count again. When the count reaches maxAttempts the PIN is locked until
|
||||
// lockUntil. It returns the count and lock after the update.
|
||||
RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error)
|
||||
ClearFailures(ctx context.Context, customerID uuid.UUID) error
|
||||
|
||||
InsertEvent(ctx context.Context, event CustomerSecurityEvent) error
|
||||
// ListEvents returns a page of the customer's log, newest first, and the total.
|
||||
ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error)
|
||||
}
|
||||
|
||||
type customerPinRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository {
|
||||
return &customerPinRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) {
|
||||
var rows []struct {
|
||||
CustomerID string
|
||||
OrganizationID string
|
||||
PhoneNumber *string
|
||||
BirthDate *time.Time
|
||||
PinHash *string
|
||||
PinSetAt *time.Time
|
||||
PinFailedAttempts int
|
||||
PinLockedUntil *time.Time
|
||||
TransferBlockedUntil *time.Time
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT id::text AS customer_id, organization_id::text AS organization_id,
|
||||
COALESCE(phone_number, phone) AS phone_number, birth_date,
|
||||
pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until
|
||||
FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read customer PIN: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrPinCustomerNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
state := &CustomerPinState{
|
||||
PhoneNumber: row.PhoneNumber,
|
||||
BirthDate: row.BirthDate,
|
||||
PinHash: row.PinHash,
|
||||
PinSetAt: row.PinSetAt,
|
||||
FailedAttempts: row.PinFailedAttempts,
|
||||
LockedUntil: row.PinLockedUntil,
|
||||
TransferBlockedUntil: row.TransferBlockedUntil,
|
||||
}
|
||||
state.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||
state.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error {
|
||||
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW()
|
||||
WHERE id = ?`, hash, transferBlockedUntil, customerID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to store customer PIN: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPinCustomerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error {
|
||||
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL, updated_at = NOW()
|
||||
WHERE id = ?`, customerID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to remove customer PIN: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPinCustomerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
|
||||
var rows []struct {
|
||||
PinFailedAttempts int
|
||||
PinLockedUntil *time.Time
|
||||
}
|
||||
// When an earlier lock has run out, this attempt is the first of a new series.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
UPDATE customers SET
|
||||
pin_failed_attempts = CASE
|
||||
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1
|
||||
ELSE pin_failed_attempts + 1 END,
|
||||
pin_locked_until = CASE
|
||||
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL
|
||||
WHEN pin_failed_attempts + 1 >= @max THEN @lock
|
||||
ELSE pin_locked_until END
|
||||
WHERE id = @id
|
||||
RETURNING pin_failed_attempts, pin_locked_until`,
|
||||
map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil, ErrPinCustomerNotFound
|
||||
}
|
||||
return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error {
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL
|
||||
WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error {
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record security event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) {
|
||||
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||
var total int64
|
||||
if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count security events: %w", err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
CustomerID string
|
||||
Event string
|
||||
ActorUser *string
|
||||
Reason *string
|
||||
IPAddress *string
|
||||
UserAgent *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user,
|
||||
reason, ip_address, user_agent, created_at
|
||||
FROM customer_security_events WHERE customer_id = ?
|
||||
ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list security events: %w", err)
|
||||
}
|
||||
events := make([]CustomerSecurityEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt}
|
||||
e.ID, _ = uuid.Parse(row.ID)
|
||||
e.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||
if row.ActorUser != nil {
|
||||
if id, err := uuid.Parse(*row.ActorUser); err == nil {
|
||||
e.ActorUser = &id
|
||||
}
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, total, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerPointsRepository interface {
|
||||
GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error)
|
||||
GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error)
|
||||
GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error)
|
||||
GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error)
|
||||
}
|
||||
|
||||
type customerPointsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository {
|
||||
return &customerPointsRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) {
|
||||
var totalPoints int64
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&entities.CustomerPoints{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Select("COALESCE(SUM(balance), 0)").
|
||||
Scan(&totalPoints).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
}
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) {
|
||||
var totalTokens int64
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Select("COALESCE(SUM(balance), 0)").
|
||||
Scan(&totalTokens).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
}
|
||||
|
||||
return totalTokens, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) {
|
||||
var pointsHistory []entities.CustomerPoints
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&pointsHistory).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
}
|
||||
|
||||
return pointsHistory, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) {
|
||||
var tokensHistory []entities.CustomerTokens
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&tokensHistory).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
return tokensHistory, nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerTokensRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerTokensRepository(db *gorm.DB) *CustomerTokensRepository {
|
||||
return &CustomerTokensRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Create(ctx context.Context, customerTokens *entities.CustomerTokens) error {
|
||||
return r.db.WithContext(ctx).Create(customerTokens).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerTokens, error) {
|
||||
var customerTokens entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
|
||||
var customerTokens entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ? AND token_type = ?", customerID, tokenType).First(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) ([]entities.CustomerTokens, error) {
|
||||
var customerTokens []entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).Find(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) List(ctx context.Context, offset, limit int, search, tokenType string, sortBy, sortOrder string) ([]entities.CustomerTokens, int64, error) {
|
||||
var customerTokens []entities.CustomerTokens
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Customer")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
query = query.Joins("JOIN customers ON customer_tokens.customer_id = customers.id").
|
||||
Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if tokenType != "" {
|
||||
query = query.Where("token_type = ?", tokenType)
|
||||
}
|
||||
|
||||
if err := query.Model(&entities.CustomerTokens{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if sortBy != "" {
|
||||
if sortOrder == "" {
|
||||
sortOrder = "asc"
|
||||
}
|
||||
query = query.Order(fmt.Sprintf("customer_tokens.%s %s", sortBy, sortOrder))
|
||||
} else {
|
||||
query = query.Order("customer_tokens.created_at DESC")
|
||||
}
|
||||
|
||||
err := query.Offset(offset).Limit(limit).Find(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return customerTokens, total, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Update(ctx context.Context, customerTokens *entities.CustomerTokens) error {
|
||||
return r.db.WithContext(ctx).Save(customerTokens).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.CustomerTokens{}, id).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ? AND token_type = ?", customerID, tokenType).
|
||||
Update("balance", gorm.Expr("balance + ?", tokens)).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ? AND token_type = ? AND balance >= ?", customerID, tokenType, tokens).
|
||||
Update("balance", gorm.Expr("balance - ?", tokens)).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) EnsureCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
|
||||
customerTokens, err := r.GetByCustomerIDAndType(ctx, customerID, tokenType)
|
||||
if err == nil {
|
||||
return customerTokens, nil
|
||||
}
|
||||
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new customer tokens record
|
||||
newCustomerTokens := &entities.CustomerTokens{
|
||||
CustomerID: customerID,
|
||||
TokenType: tokenType,
|
||||
Balance: 0,
|
||||
}
|
||||
|
||||
err = r.Create(ctx, newCustomerTokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCustomerTokens, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrEarningOrderNotFound means the order does not exist.
|
||||
var ErrEarningOrderNotFound = errors.New("earning: order not found")
|
||||
|
||||
// EarningOrder is what earning needs to know about an order.
|
||||
type EarningOrder struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *uuid.UUID
|
||||
Subtotal float64
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
// Cumulative rupiah refunded so far.
|
||||
RefundAmount float64
|
||||
// Nil when the order has no customer, or the customer row is gone.
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
|
||||
// EarningCursor pages through orders by (updated_at, id).
|
||||
type EarningCursor struct {
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3).
|
||||
type EarningRepository interface {
|
||||
GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error)
|
||||
// PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns
|
||||
// nothing (Q10). Zero until EnakPoint payment exists (phase 3).
|
||||
PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since
|
||||
// the given time that are paid, not void, have an eligible customer, belong to an
|
||||
// outlet that earns something, and have no EARN row yet. Pass the previous page's
|
||||
// last cursor to continue; nil starts at the beginning.
|
||||
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
|
||||
// ListEarnTransactions returns the EARN rows written for an order.
|
||||
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
|
||||
// ReversalRequested is how much has been asked back from an EARN row so far: what
|
||||
// was taken plus any shortfall.
|
||||
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
|
||||
// LotIDsCreatedBy returns the lots a credit created.
|
||||
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
|
||||
// EarnedByOrders sums the EARN rows of each order per currency, as
|
||||
// map[order]map[currency]amount. Orders that earned nothing are absent.
|
||||
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error)
|
||||
}
|
||||
|
||||
type earningRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEarningRepository(db *gorm.DB) EarningRepository {
|
||||
return &earningRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) {
|
||||
var rows []struct {
|
||||
ID string
|
||||
OrganizationID string
|
||||
OutletID string
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *string
|
||||
Subtotal float64
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
RefundAmount float64
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
|
||||
COALESCE(o.is_void, false) AS is_void,
|
||||
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
|
||||
-- so giving it back must not take earning back.
|
||||
COALESCE(o.refund_amount, 0) - COALESCE((
|
||||
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = ?
|
||||
LIMIT 1`, orderID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order for earning: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrEarningOrderNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
order := &EarningOrder{
|
||||
OrderNumber: row.OrderNumber,
|
||||
OutletName: row.OutletName,
|
||||
Subtotal: row.Subtotal,
|
||||
DiscountAmount: row.DiscountAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
RefundAmount: row.RefundAmount,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
order.ID, _ = uuid.Parse(row.ID)
|
||||
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
order.OutletID, _ = uuid.Parse(row.OutletID)
|
||||
if row.CustomerID != nil {
|
||||
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
||||
order.CustomerID = &id
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount), 0)
|
||||
FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`,
|
||||
orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) {
|
||||
cursorAt, cursorID := since, uuid.Nil
|
||||
if after != nil {
|
||||
cursorAt, cursorID = after.UpdatedAt, after.ID
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
// An outlet that has neither currency switched on can never earn, so its orders are
|
||||
// not candidates; otherwise every order of such an outlet would be rescanned on
|
||||
// every run.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.updated_at
|
||||
FROM orders o
|
||||
JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.payment_status = ?
|
||||
AND COALESCE(o.is_void, false) = false
|
||||
AND c.is_default = false AND c.is_active = true
|
||||
AND o.updated_at >= ?
|
||||
AND (o.updated_at, o.id) > (?, ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM outlet_settings s
|
||||
WHERE s.outlet_id = o.outlet_id
|
||||
AND s.key IN (?, ?)
|
||||
AND lower(trim(s.value)) IN ('true', 't', '1')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wallet_transactions t
|
||||
WHERE t.reference_type = ? AND t.reference_id = o.id AND t.type = ?
|
||||
)
|
||||
ORDER BY o.updated_at, o.id
|
||||
LIMIT ?`,
|
||||
entities.PaymentStatusCompleted, since, cursorAt, cursorID,
|
||||
constants.LoyaltyPointEnabledKey, constants.LoyaltyCoinEnabledKey,
|
||||
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, limit).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list paid orders without earning: %w", err)
|
||||
}
|
||||
out := make([]EarningCursor, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.ID); err == nil {
|
||||
out = append(out, EarningCursor{UpdatedAt: row.UpdatedAt, ID: id})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("reference_type = ? AND reference_id = ? AND type = ?", constants.WalletRefTypeOrder, orderID, constants.WalletTxTypeEarn).
|
||||
Order("currency").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list EARN rows: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0)
|
||||
FROM wallet_transactions
|
||||
WHERE reverses_transaction_id = ? AND type = ?`,
|
||||
earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum earning reversals: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_lots").
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("expires_at NULLS LAST, created_at, id").
|
||||
Pluck("id::text", &ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list lots of a credit: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) {
|
||||
earned := map[uuid.UUID]map[string]int64{}
|
||||
if len(orderIDs) == 0 {
|
||||
return earned, nil
|
||||
}
|
||||
var rows []struct {
|
||||
OrderID string
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount
|
||||
FROM wallet_transactions
|
||||
WHERE reference_type = ? AND type = ? AND reference_id IN ?
|
||||
GROUP BY reference_id, currency`,
|
||||
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sum earnings of orders: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
id, err := uuid.Parse(row.OrderID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if earned[id] == nil {
|
||||
earned[id] = map[string]int64{}
|
||||
}
|
||||
earned[id][row.Currency] = row.Amount
|
||||
}
|
||||
return earned, nil
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository {
|
||||
}
|
||||
|
||||
func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error {
|
||||
if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil {
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil {
|
||||
return fmt.Errorf("failed to create game play: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game
|
||||
|
||||
func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) {
|
||||
var gamePlay entities.GamePlay
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Game").
|
||||
Preload("Customer").
|
||||
Preload("Prize").
|
||||
@@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
|
||||
var gamePlays []*entities.GamePlay
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entities.GamePlay{})
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{})
|
||||
|
||||
// Apply filters
|
||||
if search != "" {
|
||||
@@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
|
||||
|
||||
func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||
var gamePlays []*entities.GamePlay
|
||||
query := r.db.WithContext(ctx).
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Preload("Game").
|
||||
Preload("Prize").
|
||||
@@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui
|
||||
|
||||
func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||
var gamePlays []*entities.GamePlay
|
||||
query := r.db.WithContext(ctx).
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("game_id = ?", gameID).
|
||||
Preload("Customer").
|
||||
Preload("Prize").
|
||||
@@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID,
|
||||
|
||||
func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.GamePlay{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Count(&count).Error
|
||||
@@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u
|
||||
|
||||
func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.GamePlay{}).
|
||||
Where("game_id = ?", gameID).
|
||||
Count(&count).Error
|
||||
|
||||
@@ -3,12 +3,16 @@ package repository
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it.
|
||||
var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock")
|
||||
|
||||
type GamePrizeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository {
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error {
|
||||
return r.db.WithContext(ctx).Create(gamePrize).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) {
|
||||
var gamePrize entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -32,7 +36,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
|
||||
|
||||
func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var gamePrizes []entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
|
||||
var gamePrizes []entities.GamePrize
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize")
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
@@ -76,22 +80,31 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error {
|
||||
return r.db.WithContext(ctx).Save(gamePrize).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
|
||||
}
|
||||
|
||||
// DecreaseStock takes amount from a prize's stock. When less than amount is left it
|
||||
// changes nothing and returns ErrGamePrizeOutOfStock.
|
||||
func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.GamePrize{}).
|
||||
res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}).
|
||||
Where("id = ? AND stock >= ?", id, amount).
|
||||
Update("stock", gorm.Expr("stock - ?", amount)).Error
|
||||
Update("stock", gorm.Expr("stock - ?", amount))
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("failed to decrease prize stock: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrGamePrizeOutOfStock
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var gamePrizes []entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").
|
||||
Where("game_id = ? AND stock > 0", gameID).
|
||||
Order("weight DESC").
|
||||
Find(&gamePrizes).Error
|
||||
|
||||
@@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository {
|
||||
}
|
||||
|
||||
func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error {
|
||||
return r.db.WithContext(ctx).Create(game).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||
var game entities.Game
|
||||
err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
|
||||
var games []entities.Game
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Prizes")
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
@@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
|
||||
}
|
||||
|
||||
func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error {
|
||||
return r.db.WithContext(ctx).Save(game).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) {
|
||||
var games []entities.Game
|
||||
err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e
|
||||
|
||||
func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) {
|
||||
var games []entities.Game
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Prizes").
|
||||
Where("is_active = ? AND type = ?", true, entities.GameTypeSpin).
|
||||
Find(&games).Error
|
||||
@@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam
|
||||
|
||||
func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) {
|
||||
var game entities.Game
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Prizes").
|
||||
Where("is_active = ? AND type = ?", true, "SPIN").
|
||||
First(&game).Error
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
|
||||
// UndatedBalance counts an organization's lots of one currency that still hold
|
||||
// something and have no expiry, and what they hold.
|
||||
UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error)
|
||||
// DateUndatedLots gives those lots an expiry, when expiry is turned on (F12).
|
||||
DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err 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
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) {
|
||||
var row struct{ Lots, Amount int64 }
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`,
|
||||
organizationID, currency).Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err)
|
||||
}
|
||||
return row.Lots, row.Amount, nil
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
|
||||
db, err := r.requireTx(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var rows []struct{ RemainingAmount int64 }
|
||||
// Only lots that still hold something: an empty lot never expires anything, and
|
||||
// leaving it undated keeps the history as it was.
|
||||
err = db.Raw(`
|
||||
UPDATE wallet_lots SET expires_at = ?
|
||||
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0
|
||||
RETURNING remaining_amount`,
|
||||
expiresAt, organizationID, currency).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err)
|
||||
}
|
||||
var amount int64
|
||||
for _, row := range rows {
|
||||
amount += row.RemainingAmount
|
||||
}
|
||||
return int64(len(rows)), amount, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Reads and writes loyalty settings through the processor against Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go.
|
||||
func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
// A setting that is not a loyalty setting must be left alone.
|
||||
exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
repo := repository.NewLoyaltySettingsRepository(db)
|
||||
p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db))
|
||||
|
||||
// No settings: every PRD default.
|
||||
settings, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, settings.Point.Enabled)
|
||||
assert.Equal(t, int64(100), settings.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount)
|
||||
assert.Nil(t, settings.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent)
|
||||
|
||||
// Change three keys.
|
||||
settings.Point.Enabled = true
|
||||
settings.Point.EarnPerAmount = 1000
|
||||
settings.Coin.MaxPerOrder = ptrInt64(5)
|
||||
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, changes, 3)
|
||||
|
||||
var stored []struct{ Key, Value string }
|
||||
require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error)
|
||||
assert.Equal(t, []struct{ Key, Value string }{
|
||||
{constants.LoyaltyCoinMaxPerOrderKey, "5"},
|
||||
{constants.LoyaltyPointEarnPerAmountKey, "1000"},
|
||||
{constants.LoyaltyPointEnabledKey, "true"},
|
||||
{"receipt.footer", "Terima kasih"},
|
||||
}, stored, "only the changed keys are stored, next to the untouched non-loyalty key")
|
||||
|
||||
history, err := p.ListChanges(ctx, org, &outlet, 1, 20)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, history.Data, 3)
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
assert.Nil(t, c.OldValue, "%s was on its default", c.Key)
|
||||
assert.NotNil(t, c.NewValue)
|
||||
}
|
||||
|
||||
// A second change records the old value.
|
||||
settings.Point.EarnPerAmount = 500
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 1)
|
||||
assert.Equal(t, "1000", *changes[0].OldValue)
|
||||
assert.Equal(t, "500", *changes[0].NewValue)
|
||||
|
||||
// Another organization cannot change this outlet.
|
||||
_, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
|
||||
// Writing outside a transaction is refused.
|
||||
assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil))
|
||||
|
||||
// A save must wait while another save of the same organization holds the lock, so
|
||||
// two admins saving at once cannot both compute their change from the same old
|
||||
// value. Hold the lock in another transaction and check the save blocks until it
|
||||
// is released.
|
||||
txm := repository.NewTxManager(db)
|
||||
holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1)
|
||||
go func() {
|
||||
holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := repo.LockScope(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
close(holding)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-holding
|
||||
|
||||
orgSettings, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 200
|
||||
saved := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
||||
saved <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-saved:
|
||||
t.Fatalf("the save finished while another transaction held the lock (err: %v)", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
require.NoError(t, <-holderDone)
|
||||
require.NoError(t, <-saved)
|
||||
|
||||
value, err := p.PointValue(ctx, org)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(200), value)
|
||||
|
||||
// The whole organization's history includes the outlet's changes.
|
||||
all, err := p.ListChanges(ctx, org, nil, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change")
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 { return &v }
|
||||
@@ -0,0 +1,103 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,8 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
|
||||
case "search":
|
||||
searchValue := "%" + value.(string) + "%"
|
||||
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
|
||||
case "exclude_type":
|
||||
query = query.Where("type <> ?", value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrPointPaymentOrderNotFound means the order does not exist.
|
||||
var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found")
|
||||
|
||||
// PointPaymentOrder is what paying with EnakPoint needs to know about an order.
|
||||
type PointPaymentOrder struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *uuid.UUID
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
|
||||
// PointPaymentRepository reads and writes what paying an order with EnakPoint touches
|
||||
// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method
|
||||
// joins the caller's transaction, since the payment row, the balance and the order
|
||||
// must change together.
|
||||
type PointPaymentRepository interface {
|
||||
// GetOrder reads the order; with lock it also locks the order row for the rest of
|
||||
// the transaction, so two payments of the same order queue up.
|
||||
GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error)
|
||||
// GetPaymentMethod returns a method's organization and type.
|
||||
GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error)
|
||||
// TotalPaid sums the order's completed payments, as the rest of the order flow does.
|
||||
TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// PaidWithPoints sums the rupiah of the order's completed EnakPoint payments.
|
||||
PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// PointMethodID returns the organization's EnakPoint payment method.
|
||||
PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)
|
||||
InsertPayment(ctx context.Context, payment *entities.Payment) error
|
||||
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
||||
// nothing is.
|
||||
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
||||
|
||||
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
|
||||
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
|
||||
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
|
||||
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
|
||||
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
|
||||
// returned for a PAYMENT row.
|
||||
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
|
||||
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
|
||||
// back: paid minus already refunded.
|
||||
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
}
|
||||
|
||||
type pointPaymentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository {
|
||||
return &pointPaymentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) {
|
||||
lockClause := ""
|
||||
if lock {
|
||||
lockClause = "FOR UPDATE OF o"
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
OrganizationID string
|
||||
OutletID string
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *string
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = ?
|
||||
`+lockClause, orderID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read order: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrPointPaymentOrderNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
order := &PointPaymentOrder{
|
||||
OrderNumber: row.OrderNumber,
|
||||
OutletName: row.OutletName,
|
||||
TotalAmount: row.TotalAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
order.ID, _ = uuid.Parse(row.ID)
|
||||
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
order.OutletID, _ = uuid.Parse(row.OutletID)
|
||||
if row.CustomerID != nil {
|
||||
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
||||
order.CustomerID = &id
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) {
|
||||
var rows []struct {
|
||||
OrganizationID string
|
||||
Type string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return uuid.Nil, "", fmt.Errorf("payment method not found")
|
||||
}
|
||||
org, _ := uuid.Parse(rows[0].OrganizationID)
|
||||
return org, rows[0].Type, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error {
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil {
|
||||
return fmt.Errorf("failed to create payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error {
|
||||
paymentStatus := entities.PaymentStatusPartial
|
||||
if completed {
|
||||
paymentStatus = entities.PaymentStatusCompleted
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE orders SET remaining_amount = ?, payment_status = ?,
|
||||
status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update order after payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("payment_methods").
|
||||
Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint).
|
||||
Limit(1).
|
||||
Pluck("id::text", &ids).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method")
|
||||
}
|
||||
return uuid.Parse(ids[0])
|
||||
}
|
||||
|
||||
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
|
||||
type PointPaymentRow struct {
|
||||
PaymentID uuid.UUID
|
||||
Status string
|
||||
PointsUsed int64
|
||||
PointValue float64
|
||||
RefundAmount float64
|
||||
// The PAYMENT ledger row that took the balance; nil if none was written.
|
||||
LedgerID *uuid.UUID
|
||||
CustomerID *uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
}
|
||||
|
||||
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
|
||||
// expiry, so a refund can return it to the same expiry.
|
||||
type PointPaymentAllocation struct {
|
||||
LotID uuid.UUID
|
||||
Amount int64
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
|
||||
var rows []struct {
|
||||
PaymentID string
|
||||
Status string
|
||||
PointsUsed int64
|
||||
PointValue float64
|
||||
RefundAmount float64
|
||||
LedgerID *string
|
||||
CustomerID *string
|
||||
OutletID *string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
|
||||
COALESCE(p.refund_amount, 0) AS refund_amount,
|
||||
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
|
||||
FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
|
||||
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
|
||||
ORDER BY p.created_at, p.id`,
|
||||
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
|
||||
}
|
||||
out := make([]PointPaymentRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
|
||||
p.PaymentID, _ = uuid.Parse(row.PaymentID)
|
||||
p.LedgerID = parseOptionalUUID(row.LedgerID)
|
||||
p.CustomerID = parseOptionalUUID(row.CustomerID)
|
||||
p.OutletID = parseOptionalUUID(row.OutletID)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
|
||||
var rows []struct {
|
||||
LotID string
|
||||
Amount int64
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
|
||||
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
|
||||
WHERE a.transaction_id = ?
|
||||
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
|
||||
}
|
||||
out := make([]PointPaymentAllocation, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
id, err := uuid.Parse(row.LotID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
|
||||
var rows []struct {
|
||||
OriginLotID string
|
||||
Amount int64
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
|
||||
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
|
||||
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
|
||||
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
|
||||
}
|
||||
out := make(map[uuid.UUID]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.OriginLotID); err == nil {
|
||||
out[id] = row.Amount
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
|
||||
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
|
||||
orderID, constants.PaymentMethodTypePoint,
|
||||
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func parseOptionalUUID(s *string) *uuid.UUID {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.Parse(*s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
@@ -50,3 +50,10 @@ func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOpti
|
||||
return fn(ctxTx)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// DetachTransaction returns ctx without the caller's transaction, so work started from
|
||||
// it (such as loyalty earning after a payment) reads committed data and commits on its
|
||||
// own, whatever happens to the caller's transaction.
|
||||
func DetachTransaction(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, txKey, (*gorm.DB)(nil))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// WalletCustomer is the customer a dashboard wallet screen is about.
|
||||
type WalletCustomer struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Phone *string
|
||||
}
|
||||
|
||||
// WalletAdminRepository serves what the dashboard adds on top of the customer's own
|
||||
// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance,
|
||||
// and the real names behind a ledger row. Nothing here changes a balance.
|
||||
type WalletAdminRepository interface {
|
||||
// GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to
|
||||
// the organization, so one organization can never read another's customers.
|
||||
GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error)
|
||||
// GetWallet returns nil, nil for a customer who has never had a wallet.
|
||||
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
|
||||
// ListLotsWithBalance returns every lot that still holds something, expired or
|
||||
// not, in K9 spending order.
|
||||
ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error)
|
||||
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
}
|
||||
|
||||
type walletAdminRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository {
|
||||
return &walletAdminRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) {
|
||||
var customers []WalletCustomer
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Select("id, name, COALESCE(phone_number, phone) AS phone").
|
||||
Where("id = ? AND organization_id = ?", customerID, organizationID).
|
||||
Limit(1).
|
||||
Scan(&customers).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer: %w", err)
|
||||
}
|
||||
if len(customers) == 0 {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return &customers[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
var wallets []entities.CustomerWallet
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Limit(1).
|
||||
Find(&wallets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
if len(wallets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &wallets[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ? AND remaining_amount > 0", customerID).
|
||||
Order("currency, expires_at NULLS LAST, created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "customers", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "users", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "outlets", ids)
|
||||
}
|
||||
|
||||
// names looks up the name column of a table by id. The table name is never taken
|
||||
// from user input.
|
||||
func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
names := make(map[uuid.UUID]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table(table).
|
||||
Select("id::text AS id, name").
|
||||
Where("id IN ?", ids).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up %s names: %w", table, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.ID); err == nil {
|
||||
names[id] = row.Name
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DueLot is a lot whose expiry has passed and that still holds something.
|
||||
type DueLot struct {
|
||||
ID uuid.UUID
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
Remaining int64
|
||||
ExpiresAt time.Time
|
||||
// The description of the row that created the lot, for the EXPIRE row's.
|
||||
SourceDescription string
|
||||
}
|
||||
|
||||
// UpcomingExpiry is how much of a customer's balance expires on one day.
|
||||
type UpcomingExpiry struct {
|
||||
CustomerID uuid.UUID
|
||||
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
|
||||
Date string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md
|
||||
// F12). Balances only change through WalletProcessor.
|
||||
type WalletExpiryRepository interface {
|
||||
// ListDueLots returns up to limit lots due at asOf, the longest overdue first. It
|
||||
// takes no lock: locking a lot before its wallet would deadlock against payments,
|
||||
// which lock the wallet first. WalletProcessor.ExpireLot locks and reads again.
|
||||
ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error)
|
||||
|
||||
// OrganizationsWithUpcomingExpiry lists the organizations that have balance
|
||||
// expiring after asOf.
|
||||
OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error)
|
||||
// UpcomingUnreminded sums, per customer and expiry day, the balance of one currency
|
||||
// of an organization expiring after asOf and up to until, leaving out the days the
|
||||
// customer has already been reminded of.
|
||||
UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error)
|
||||
// MarkReminded records a reminder, and reports false when it was already recorded,
|
||||
// by this run or another.
|
||||
MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error)
|
||||
}
|
||||
|
||||
type walletExpiryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository {
|
||||
return &walletExpiryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) {
|
||||
var lots []DueLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at,
|
||||
t.description AS source_description
|
||||
FROM wallet_lots l
|
||||
JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
||||
WHERE l.remaining_amount > 0 AND l.expires_at <= ?
|
||||
ORDER BY l.expires_at, l.id
|
||||
LIMIT ?`, asOf, limit).Scan(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list due wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
||||
var ids []uuid.UUID
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT DISTINCT organization_id FROM wallet_lots
|
||||
WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) {
|
||||
var rows []UpcomingExpiry
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
WITH by_day AS (
|
||||
SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE organization_id = ? AND currency = ? AND remaining_amount > 0
|
||||
AND expires_at > ? AND expires_at <= ?
|
||||
GROUP BY customer_id, day
|
||||
)
|
||||
SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount
|
||||
FROM by_day d
|
||||
LEFT JOIN wallet_expiry_reminders w
|
||||
ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day
|
||||
WHERE w.customer_id IS NULL
|
||||
ORDER BY d.day, d.customer_id`,
|
||||
walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list upcoming expiry: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) {
|
||||
res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount)
|
||||
VALUES (?, ?, ?::date, ?)
|
||||
ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// LegacyBalance is what one customer holds in customer_points and customer_tokens,
|
||||
// the tables the wallet replaces (docs/prd-point-coin.md §10).
|
||||
type LegacyBalance struct {
|
||||
CustomerID uuid.UUID
|
||||
// Nil when the customer has no customer_points row.
|
||||
PointsRowID *uuid.UUID
|
||||
Points int64
|
||||
Tokens []entities.CustomerTokens
|
||||
}
|
||||
|
||||
// Coins is the sum of every token type: all of them become EnakCoin (Q6).
|
||||
func (b LegacyBalance) Coins() int64 {
|
||||
var total int64
|
||||
for _, t := range b.Tokens {
|
||||
total += t.Balance
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// WalletMigrationTotals compares the legacy tables with what has been migrated.
|
||||
type WalletMigrationTotals struct {
|
||||
LegacyPoints int64
|
||||
LegacyCoins int64
|
||||
MigratedPoints int64
|
||||
MigratedCoins int64
|
||||
WalletPoints int64
|
||||
WalletCoins int64
|
||||
}
|
||||
|
||||
// WalletMigrationRepository reads the legacy balances for the one-time move into the
|
||||
// wallet. The writes go through the wallet processor like any other credit.
|
||||
type WalletMigrationRepository interface {
|
||||
// ListLegacyCustomers returns, in id order, up to limit customers after the given
|
||||
// id that have a row in customer_points or customer_tokens.
|
||||
ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error)
|
||||
GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error)
|
||||
// SumMigrated returns how much has already been credited to the customer by
|
||||
// MIGRATION ledger rows in the currency.
|
||||
SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error)
|
||||
Totals(ctx context.Context) (*WalletMigrationTotals, error)
|
||||
}
|
||||
|
||||
type walletMigrationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletMigrationRepository(db *gorm.DB) WalletMigrationRepository {
|
||||
return &walletMigrationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error) {
|
||||
var ids []uuid.UUID
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT customer_id FROM (
|
||||
SELECT customer_id FROM customer_points
|
||||
UNION
|
||||
SELECT customer_id FROM customer_tokens
|
||||
) legacy
|
||||
WHERE customer_id > ?
|
||||
ORDER BY customer_id
|
||||
LIMIT ?`, after, limit).
|
||||
Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list legacy customers: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error) {
|
||||
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||
balance := &LegacyBalance{CustomerID: customerID}
|
||||
|
||||
// Find rather than First: many customers have tokens but no points row, and First
|
||||
// would log each of them as a "record not found" error.
|
||||
var points []entities.CustomerPoints
|
||||
if err := db.Where("customer_id = ?", customerID).Limit(1).Find(&points).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get legacy points: %w", err)
|
||||
}
|
||||
if len(points) > 0 {
|
||||
balance.PointsRowID = &points[0].ID
|
||||
balance.Points = points[0].Balance
|
||||
}
|
||||
|
||||
err := db.Where("customer_id = ?", customerID).Order("token_type").Find(&balance.Tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get legacy tokens: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.WalletTransaction{}).
|
||||
Where("customer_id = ? AND currency = ? AND type = ?", customerID, currency, constants.WalletTxTypeMigration).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum migrated balance: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *walletMigrationRepository) Totals(ctx context.Context) (*WalletMigrationTotals, error) {
|
||||
var totals WalletMigrationTotals
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT
|
||||
(SELECT COALESCE(SUM(balance), 0) FROM customer_points) AS legacy_points,
|
||||
(SELECT COALESCE(SUM(balance), 0) FROM customer_tokens) AS legacy_coins,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_points,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_coins,
|
||||
(SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets) AS wallet_points,
|
||||
(SELECT COALESCE(SUM(coin_balance), 0) FROM customer_wallets) AS wallet_coins`,
|
||||
constants.WalletTxTypeMigration, constants.WalletCurrencyPoint,
|
||||
constants.WalletTxTypeMigration, constants.WalletCurrencyCoin).
|
||||
Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute wallet migration totals: %w", err)
|
||||
}
|
||||
return &totals, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
)
|
||||
|
||||
// WalletMoveCustomer is a customer on either side of an exchange or a transfer.
|
||||
type WalletMoveCustomer struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
Name string
|
||||
PhoneNumber *string
|
||||
IsDefault bool
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// WalletMoveRepository reads what exchanges and transfers check before moving a
|
||||
// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through
|
||||
// WalletProcessor.
|
||||
type WalletMoveRepository interface {
|
||||
// GetCustomer returns ErrWalletNotFound when the customer does not exist.
|
||||
GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error)
|
||||
// FindCustomerByPhone returns ErrWalletNotFound when no customer has the number.
|
||||
// Phone numbers are unique across organizations, so there is at most one.
|
||||
FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error)
|
||||
// TransferredOutSince sums what a customer has sent in one currency since a time.
|
||||
// Call it under the sender's wallet lock, so transfers at the same time count.
|
||||
TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type walletMoveRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository {
|
||||
return &walletMoveRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) {
|
||||
return r.customer(ctx, "id = ?", customerID)
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) {
|
||||
return r.customer(ctx, "phone_number = ?", phoneNumber)
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_transactions").
|
||||
Select("COALESCE(SUM(-amount), 0)").
|
||||
Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum transfers: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) {
|
||||
var c WalletMoveCustomer
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Select("id, organization_id, name, phone_number, is_default, is_active").
|
||||
Where(where, arg).
|
||||
Take(&c).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get customer: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// walletDisplayTimeZone is the zone expiry dates are grouped in for display, the same
|
||||
// one the database connection uses (config.Database.DSN).
|
||||
const walletDisplayTimeZone = "Asia/Jakarta"
|
||||
|
||||
// WalletTransactionFilter narrows a customer's ledger. Zero values mean no filter.
|
||||
type WalletTransactionFilter struct {
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
Types []string
|
||||
// From is inclusive and To is exclusive.
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// WalletExpiringAmount is how much of one currency expires on one day.
|
||||
type WalletExpiringAmount struct {
|
||||
Currency string
|
||||
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
|
||||
Date string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// WalletQueryRepository serves the read side of the wallet: balances, history and
|
||||
// upcoming expiry for display. Nothing here changes a balance.
|
||||
type WalletQueryRepository interface {
|
||||
// GetCustomerOrganizationID returns ErrWalletNotFound when the customer does not exist.
|
||||
GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error)
|
||||
// SpendableBalances sums, per currency, the lots that still have balance and have
|
||||
// not expired at asOf. It differs from the wallet balance only for lots that have
|
||||
// expired but that the expiry job has not processed yet.
|
||||
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
||||
// NearestExpiring returns, per currency, the earliest day after asOf on which some
|
||||
// balance expires, and how much expires that day.
|
||||
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
||||
// ExpiringByDay returns, per currency and day, everything that expires after asOf,
|
||||
// soonest first.
|
||||
ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
||||
// ListTransactions returns a page of the ledger, newest first, and the total count.
|
||||
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
|
||||
// OrganizationOutstanding sums every wallet balance of an organization.
|
||||
OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error)
|
||||
// ListLotsBySourceTransactions returns the lots created by the given credits.
|
||||
ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error)
|
||||
}
|
||||
|
||||
type walletQueryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletQueryRepository(db *gorm.DB) WalletQueryRepository {
|
||||
return &walletQueryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Where("id = ?", customerID).
|
||||
Limit(1).
|
||||
Pluck("organization_id::text", &ids).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("failed to get customer organization: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return uuid.Nil, ErrWalletNotFound
|
||||
}
|
||||
return uuid.Parse(ids[0])
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
|
||||
var rows []struct {
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.WalletLot{}).
|
||||
Select("currency, SUM(remaining_amount) AS amount").
|
||||
Where("customer_id = ? AND remaining_amount > 0", customerID).
|
||||
Where("(expires_at IS NULL OR expires_at > ?)", asOf).
|
||||
Group("currency").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sum spendable wallet balance: %w", err)
|
||||
}
|
||||
balances := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
balances[row.Currency] = row.Amount
|
||||
}
|
||||
return balances, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
|
||||
var rows []WalletExpiringAmount
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
WITH by_day AS (
|
||||
SELECT currency,
|
||||
(expires_at AT TIME ZONE ?)::date AS day,
|
||||
SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
|
||||
GROUP BY currency, day
|
||||
)
|
||||
SELECT DISTINCT ON (currency) currency, to_char(day, 'YYYY-MM-DD') AS date, amount
|
||||
FROM by_day
|
||||
ORDER BY currency, day`, walletDisplayTimeZone, customerID, asOf).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get nearest expiring wallet balance: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.WalletTransaction{}).
|
||||
Where("customer_id = ?", filter.CustomerID)
|
||||
if filter.Currency != "" {
|
||||
query = query.Where("currency = ?", filter.Currency)
|
||||
}
|
||||
if len(filter.Types) > 0 {
|
||||
query = query.Where("type IN ?", filter.Types)
|
||||
}
|
||||
if filter.From != nil {
|
||||
query = query.Where("created_at >= ?", *filter.From)
|
||||
}
|
||||
if filter.To != nil {
|
||||
query = query.Where("created_at < ?", *filter.To)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count wallet transactions: %w", err)
|
||||
}
|
||||
|
||||
var transactions []entities.WalletTransaction
|
||||
err := query.
|
||||
Order("created_at DESC, id DESC").
|
||||
Offset(filter.Offset).
|
||||
Limit(filter.Limit).
|
||||
Find(&transactions).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list wallet transactions: %w", err)
|
||||
}
|
||||
return transactions, total, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
if len(transactionIDs) == 0 {
|
||||
return lots, nil
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("source_transaction_id IN ?", transactionIDs).
|
||||
Order("expires_at NULLS LAST, created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) {
|
||||
var totals struct{ Points, Coins int64 }
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins
|
||||
FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err)
|
||||
}
|
||||
return totals.Points, totals.Coins, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
|
||||
var rows []WalletExpiringAmount
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date,
|
||||
SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
|
||||
GROUP BY currency, date
|
||||
ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The reconciliation checks of docs/prd-point-coin.md §7.5.
|
||||
const (
|
||||
// Wallet balance = SUM(amount) of the customer's ledger rows, per currency.
|
||||
WalletCheckBalanceVsLedger = "BALANCE_VS_LEDGER"
|
||||
// Wallet balance = SUM(remaining_amount) of the customer's lots, per currency.
|
||||
WalletCheckBalanceVsLots = "BALANCE_VS_LOTS"
|
||||
// For every lot: original_amount - SUM(allocations) = remaining_amount.
|
||||
WalletCheckLotVsAllocations = "LOT_VS_ALLOCATIONS"
|
||||
// For every deduction: SUM(allocations) = |amount|.
|
||||
WalletCheckDebitVsAllocations = "DEBIT_VS_ALLOCATIONS"
|
||||
// For every addition: SUM(original_amount) of the lots it created = amount. Not
|
||||
// listed in §7.5, but the engine keeps it and the other checks rely on it.
|
||||
WalletCheckCreditVsLots = "CREDIT_VS_LOTS"
|
||||
)
|
||||
|
||||
// WalletDiscrepancy is one place where the wallet tables disagree with each other.
|
||||
type WalletDiscrepancy struct {
|
||||
Check string
|
||||
OrganizationID uuid.UUID
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
// The lot or ledger row the check is about. Nil for the per-wallet checks.
|
||||
ObjectID *uuid.UUID
|
||||
Expected int64
|
||||
Actual int64
|
||||
}
|
||||
|
||||
// WalletReconciliationRepository runs the §7.5 checks across every wallet.
|
||||
type WalletReconciliationRepository interface {
|
||||
// FindDiscrepancies returns every discrepancy, at most limit per check, so one
|
||||
// systematic bug cannot produce an unbounded report.
|
||||
FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error)
|
||||
}
|
||||
|
||||
type walletReconciliationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletReconciliationRepository(db *gorm.DB) WalletReconciliationRepository {
|
||||
return &walletReconciliationRepository{db: db}
|
||||
}
|
||||
|
||||
// Each query returns check, organization_id, customer_id, currency, object_id,
|
||||
// expected and actual. Aggregates are joined rather than correlated, so each check is
|
||||
// a handful of scans however many customers there are.
|
||||
var walletReconciliationQueries = []struct {
|
||||
check string
|
||||
sql string
|
||||
}{
|
||||
{WalletCheckBalanceVsLedger, `
|
||||
WITH ledger AS (
|
||||
SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(amount) AS total
|
||||
FROM wallet_transactions GROUP BY customer_id, currency
|
||||
), balances AS (
|
||||
SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets
|
||||
UNION ALL
|
||||
SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets
|
||||
)
|
||||
SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id,
|
||||
COALESCE(b.customer_id, l.customer_id)::text AS customer_id,
|
||||
COALESCE(b.currency, l.currency) AS currency,
|
||||
NULL AS object_id,
|
||||
COALESCE(l.total, 0) AS expected,
|
||||
COALESCE(b.balance, 0) AS actual
|
||||
FROM balances b
|
||||
FULL JOIN ledger l ON l.customer_id = b.customer_id AND l.currency = b.currency
|
||||
WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0)
|
||||
LIMIT ?`},
|
||||
{WalletCheckBalanceVsLots, `
|
||||
WITH lots AS (
|
||||
SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(remaining_amount) AS total
|
||||
FROM wallet_lots GROUP BY customer_id, currency
|
||||
), balances AS (
|
||||
SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets
|
||||
UNION ALL
|
||||
SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets
|
||||
)
|
||||
SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id,
|
||||
COALESCE(b.customer_id, l.customer_id)::text AS customer_id,
|
||||
COALESCE(b.currency, l.currency) AS currency,
|
||||
NULL AS object_id,
|
||||
COALESCE(l.total, 0) AS expected,
|
||||
COALESCE(b.balance, 0) AS actual
|
||||
FROM balances b
|
||||
FULL JOIN lots l ON l.customer_id = b.customer_id AND l.currency = b.currency
|
||||
WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0)
|
||||
LIMIT ?`},
|
||||
{WalletCheckLotVsAllocations, `
|
||||
SELECT l.organization_id::text AS organization_id, l.customer_id::text AS customer_id, l.currency,
|
||||
l.id::text AS object_id,
|
||||
l.original_amount - COALESCE(a.total, 0) AS expected,
|
||||
l.remaining_amount AS actual
|
||||
FROM wallet_lots l
|
||||
LEFT JOIN (SELECT lot_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY lot_id) a ON a.lot_id = l.id
|
||||
WHERE l.original_amount - COALESCE(a.total, 0) <> l.remaining_amount
|
||||
LIMIT ?`},
|
||||
{WalletCheckDebitVsAllocations, `
|
||||
SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency,
|
||||
t.id::text AS object_id,
|
||||
-t.amount AS expected,
|
||||
COALESCE(a.total, 0) AS actual
|
||||
FROM wallet_transactions t
|
||||
LEFT JOIN (SELECT transaction_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY transaction_id) a ON a.transaction_id = t.id
|
||||
WHERE t.amount < 0 AND -t.amount <> COALESCE(a.total, 0)
|
||||
LIMIT ?`},
|
||||
{WalletCheckCreditVsLots, `
|
||||
SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency,
|
||||
t.id::text AS object_id,
|
||||
t.amount AS expected,
|
||||
COALESCE(l.total, 0) AS actual
|
||||
FROM wallet_transactions t
|
||||
LEFT JOIN (SELECT source_transaction_id, SUM(original_amount) AS total FROM wallet_lots GROUP BY source_transaction_id) l ON l.source_transaction_id = t.id
|
||||
WHERE t.amount > 0 AND t.amount <> COALESCE(l.total, 0)
|
||||
LIMIT ?`},
|
||||
}
|
||||
|
||||
func (r *walletReconciliationRepository) FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) {
|
||||
db := DBFromContext(ctx, r.db).WithContext(ctx)
|
||||
var found []WalletDiscrepancy
|
||||
for _, q := range walletReconciliationQueries {
|
||||
var rows []struct {
|
||||
OrganizationID string
|
||||
CustomerID string
|
||||
Currency string
|
||||
ObjectID *string
|
||||
Expected int64
|
||||
Actual int64
|
||||
}
|
||||
if err := db.Raw(q.sql, limit).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("wallet reconciliation check %s failed: %w", q.check, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
d := WalletDiscrepancy{
|
||||
Check: q.check,
|
||||
Currency: row.Currency,
|
||||
Expected: row.Expected,
|
||||
Actual: row.Actual,
|
||||
}
|
||||
d.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
d.CustomerID, _ = uuid.Parse(row.CustomerID)
|
||||
if row.ObjectID != nil {
|
||||
if id, err := uuid.Parse(*row.ObjectID); err == nil {
|
||||
d.ObjectID = &id
|
||||
}
|
||||
}
|
||||
found = append(found, d)
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Builds consistent wallets through the engine, checks the reconciliation is silent,
|
||||
// then breaks each §7.5 invariant for a different customer and checks each break is
|
||||
// found by the right checks and nothing else is. Needs TEST_DATABASE_URL pointing at
|
||||
// a migrated database; see wallet_repository_test.go.
|
||||
func TestWalletReconciliation_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org := uuid.New()
|
||||
names := []string{"clean", "balance", "lot", "allocation", "credit"}
|
||||
customers := map[string]uuid.UUID{}
|
||||
var ids []uuid.UUID
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'recon test', 'basic')`, org).Error)
|
||||
for _, n := range names {
|
||||
customers[n] = uuid.New()
|
||||
ids = append(ids, customers[n])
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, ?)`, customers[n], org, n).Error)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, ids)
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id IN ?)`, ids)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, ids)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, ids)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, ids)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, ids)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Every customer: +100 in two lots, +30 coins, -70 points across both lots.
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
for _, id := range ids {
|
||||
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: id, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeMigration, Amount: 100, ReferenceType: constants.WalletRefTypeLegacyPoints,
|
||||
ReferenceID: uuid.New(), Description: "Saldo awal"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 60}, {Amount: 40}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 30, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: id, Description: "Saldo awal"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
outlet := uuid.New()
|
||||
_, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Bayar"}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
recon := repository.NewWalletReconciliationRepository(db)
|
||||
// Other packages' tests may share the database, so only these customers count.
|
||||
checksByCustomer := func() map[string][]string {
|
||||
t.Helper()
|
||||
found, err := recon.FindDiscrepancies(ctx, 1000)
|
||||
require.NoError(t, err)
|
||||
byName := map[uuid.UUID]string{}
|
||||
for n, id := range customers {
|
||||
byName[id] = n
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, d := range found {
|
||||
if n, ok := byName[d.CustomerID]; ok {
|
||||
assert.Equal(t, org, d.OrganizationID)
|
||||
out[n] = append(out[n], d.Check)
|
||||
}
|
||||
}
|
||||
for n := range out {
|
||||
sort.Strings(out[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
assert.Empty(t, checksByCustomer(), "consistent data reports nothing")
|
||||
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
// A balance moved without a ledger row or a lot.
|
||||
exec(`UPDATE customer_wallets SET point_balance = point_balance + 5 WHERE customer_id = ?`, customers["balance"])
|
||||
// A lot's remainder changed without an allocation.
|
||||
exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - 1
|
||||
WHERE id = (SELECT id FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 LIMIT 1)`, customers["lot"])
|
||||
// An allocation lost.
|
||||
exec(`DELETE FROM wallet_lot_allocations WHERE (transaction_id, lot_id) IN (
|
||||
SELECT a.transaction_id, a.lot_id FROM wallet_lot_allocations a
|
||||
JOIN wallet_transactions t ON t.id = a.transaction_id WHERE t.customer_id = ? LIMIT 1)`, customers["allocation"])
|
||||
// A credit whose lot was never written, with the balance moved to match the ledger.
|
||||
exec(`INSERT INTO wallet_transactions (organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
|
||||
VALUES (?, ?, 'COIN', 'MIGRATION', 10, 40, 'LEGACY_TOKENS', ?, 'x')`, org, customers["credit"], customers["credit"])
|
||||
exec(`UPDATE customer_wallets SET coin_balance = coin_balance + 10 WHERE customer_id = ?`, customers["credit"])
|
||||
|
||||
assert.Equal(t, map[string][]string{
|
||||
"balance": {repository.WalletCheckBalanceVsLedger, repository.WalletCheckBalanceVsLots},
|
||||
"lot": {repository.WalletCheckBalanceVsLots, repository.WalletCheckLotVsAllocations},
|
||||
"allocation": {repository.WalletCheckDebitVsAllocations, repository.WalletCheckLotVsAllocations},
|
||||
"credit": {repository.WalletCheckBalanceVsLots, repository.WalletCheckCreditVsLots},
|
||||
}, checksByCustomer(), "each break is found by exactly the checks it violates, and the clean customer by none")
|
||||
|
||||
// The per-check limit caps the report.
|
||||
found, err := recon.FindDiscrepancies(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
perCheck := map[string]int{}
|
||||
for _, d := range found {
|
||||
perCheck[d.Check]++
|
||||
}
|
||||
for check, n := range perCheck {
|
||||
assert.LessOrEqual(t, n, 1, check)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrWalletTxRequired is returned by every write and lock when the context carries
|
||||
// no transaction from TxManager. Outside a transaction a lock is released as soon as
|
||||
// it is taken, and a balance could move without its ledger row.
|
||||
ErrWalletTxRequired = errors.New("wallet: operation must run inside a transaction")
|
||||
// ErrWalletNotFound means the customer does not exist, so no wallet could be made.
|
||||
ErrWalletNotFound = errors.New("wallet: customer not found")
|
||||
// ErrWalletInsufficientBalance means a conditional update matched no row because
|
||||
// the balance would have gone negative.
|
||||
ErrWalletInsufficientBalance = errors.New("wallet: insufficient balance")
|
||||
// ErrWalletLotInsufficient means a lot had less remaining than was taken from it.
|
||||
ErrWalletLotInsufficient = errors.New("wallet: lot has insufficient remaining amount")
|
||||
)
|
||||
|
||||
// WalletRepository reads and writes the wallet tables (docs/prd-point-coin.md §7, §8).
|
||||
// Only the wallet processor should call its write methods: it is the one place that
|
||||
// keeps balances, ledger rows and lots in step.
|
||||
//
|
||||
// Unlike the gamification repositories, every method goes through DBFromContext so it
|
||||
// joins the caller's transaction. Writes and locks refuse to run without one.
|
||||
type WalletRepository interface {
|
||||
// LockWallet locks the customer's wallet row for the rest of the transaction,
|
||||
// creating the row first if the customer has none yet.
|
||||
LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
|
||||
// LockWallets locks two wallets, always in customer_id order so that two transfers
|
||||
// in opposite directions cannot deadlock. The results come back in argument order.
|
||||
LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error)
|
||||
// AddBalance moves one balance by delta and returns the new balance. A debit that
|
||||
// would make it negative changes nothing and returns ErrWalletInsufficientBalance.
|
||||
AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error)
|
||||
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
|
||||
|
||||
CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error
|
||||
// GetTransactionByIdempotencyKey returns nil, nil when no row has the key.
|
||||
GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error)
|
||||
|
||||
CreateLot(ctx context.Context, lot *entities.WalletLot) error
|
||||
// GetLotsByIDs returns the lots with the given ids, expired or not, in no
|
||||
// particular order. Ids that match no lot are left out.
|
||||
GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
|
||||
// ListLotsBySourceTransaction returns the lots a credit created, oldest first.
|
||||
ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
|
||||
// ListActiveLots returns the lots that still have balance and have not expired at
|
||||
// asOf, in the order they are spent (K9): soonest expiry first, lots without an
|
||||
// expiry last, oldest first within the same expiry.
|
||||
ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error)
|
||||
// ConsumeLot takes amount from a lot's remaining amount. Taking more than remains
|
||||
// changes nothing and returns ErrWalletLotInsufficient.
|
||||
ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error
|
||||
|
||||
CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error
|
||||
ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error)
|
||||
}
|
||||
|
||||
type walletRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletRepository(db *gorm.DB) WalletRepository {
|
||||
return &walletRepository{db: db}
|
||||
}
|
||||
|
||||
// txDB returns the caller's transaction, or ErrWalletTxRequired if there is none.
|
||||
func (r *walletRepository) txDB(ctx context.Context) (*gorm.DB, error) {
|
||||
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil {
|
||||
return tx.WithContext(ctx), nil
|
||||
}
|
||||
return nil, ErrWalletTxRequired
|
||||
}
|
||||
|
||||
func (r *walletRepository) LockWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The wallet takes its organization from the customer, so the two cannot disagree.
|
||||
// ON CONFLICT covers two first operations racing to create the same wallet.
|
||||
err = db.Exec(`INSERT INTO customer_wallets (customer_id, organization_id)
|
||||
SELECT id, organization_id FROM customers WHERE id = ?
|
||||
ON CONFLICT (customer_id) DO NOTHING`, customerID).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create customer wallet: %w", err)
|
||||
}
|
||||
|
||||
var wallet entities.CustomerWallet
|
||||
err = db.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("customer_id = ?", customerID).
|
||||
First(&wallet).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to lock customer wallet: %w", err)
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) {
|
||||
if a == b {
|
||||
return nil, nil, errors.New("wallet: cannot lock the same wallet twice")
|
||||
}
|
||||
|
||||
ids := []uuid.UUID{a, b}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i].String() < ids[j].String() })
|
||||
|
||||
locked := make(map[uuid.UUID]*entities.CustomerWallet, 2)
|
||||
for _, id := range ids {
|
||||
wallet, err := r.LockWallet(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
locked[id] = wallet
|
||||
}
|
||||
return locked[a], locked[b], nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) AddBalance(ctx context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) {
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var column string
|
||||
switch currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
column = "point_balance"
|
||||
case constants.WalletCurrencyCoin:
|
||||
column = "coin_balance"
|
||||
default:
|
||||
return 0, fmt.Errorf("wallet: unknown currency %q", currency)
|
||||
}
|
||||
|
||||
// The WHERE clause makes an overdraft match no row instead of tripping the CHECK,
|
||||
// so the caller gets a clean error and the transaction stays usable.
|
||||
var balances []int64
|
||||
err = db.Raw(`UPDATE customer_wallets
|
||||
SET `+column+` = `+column+` + ?, updated_at = NOW()
|
||||
WHERE customer_id = ? AND `+column+` + ? >= 0
|
||||
RETURNING `+column, delta, customerID, delta).
|
||||
Scan(&balances).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to update wallet balance: %w", err)
|
||||
}
|
||||
if len(balances) == 0 {
|
||||
if delta >= 0 {
|
||||
return 0, ErrWalletNotFound
|
||||
}
|
||||
return 0, ErrWalletInsufficientBalance
|
||||
}
|
||||
return balances[0], nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
var wallet entities.CustomerWallet
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
First(&wallet).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) CreateTransaction(ctx context.Context, walletTx *entities.WalletTransaction) error {
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(walletTx).Error
|
||||
}
|
||||
|
||||
func (r *walletRepository) GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error) {
|
||||
// Find rather than First: a new key is the normal case, and First would log every
|
||||
// one of them as a "record not found" error.
|
||||
var walletTxs []entities.WalletTransaction
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("idempotency_key = ?", key).
|
||||
Limit(1).
|
||||
Find(&walletTxs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet transaction by idempotency key: %w", err)
|
||||
}
|
||||
if len(walletTxs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &walletTxs[0], nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLot) error {
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(lot).Error
|
||||
}
|
||||
|
||||
func (r *walletRepository) GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
if len(ids) == 0 {
|
||||
return lots, nil
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("id IN ?", ids).
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots by source transaction: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
// Filter and order match idx_wallet_lots_consume.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ? AND currency = ? AND remaining_amount > 0", customerID, currency).
|
||||
Where("(expires_at IS NULL OR expires_at > ?)", asOf).
|
||||
Order("expires_at NULLS LAST, created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list active wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) ConsumeLot(ctx context.Context, lotID uuid.UUID, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("wallet: lot consumption must be positive, got %d", amount)
|
||||
}
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := db.Exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - ?
|
||||
WHERE id = ? AND remaining_amount >= ?`, amount, lotID, amount)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to consume wallet lot: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrWalletLotInsufficient
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) CreateAllocations(ctx context.Context, allocations []entities.WalletLotAllocation) error {
|
||||
if len(allocations) == 0 {
|
||||
return nil
|
||||
}
|
||||
db, err := r.txDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&allocations).Error
|
||||
}
|
||||
|
||||
func (r *walletRepository) ListAllocationsByTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||
var allocations []entities.WalletLotAllocation
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("transaction_id = ?", transactionID).
|
||||
Find(&allocations).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err)
|
||||
}
|
||||
return allocations, nil
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// These tests need a real Postgres, because what they check (row locks and
|
||||
// conditional updates) only exists there. Point TEST_DATABASE_URL at a database with
|
||||
// all migrations applied, e.g.
|
||||
//
|
||||
// TEST_DATABASE_URL=postgres://user:pass@localhost:5432/pos_test?sslmode=disable go test ./internal/repository/ -run Wallet
|
||||
//
|
||||
// Each test creates its own organization and customers and removes them afterwards.
|
||||
func walletTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
type walletFixture struct {
|
||||
db *gorm.DB
|
||||
repo WalletRepository
|
||||
txm *TxManager
|
||||
orgID uuid.UUID
|
||||
customers []uuid.UUID
|
||||
}
|
||||
|
||||
func newWalletFixture(t *testing.T, customerCount int) *walletFixture {
|
||||
t.Helper()
|
||||
db := walletTestDB(t)
|
||||
f := &walletFixture{db: db, repo: NewWalletRepository(db), txm: NewTxManager(db), orgID: uuid.New()}
|
||||
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, f.orgID).Error)
|
||||
for i := 0; i < customerCount; i++ {
|
||||
id := uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'wallet test')`, id, f.orgID).Error)
|
||||
f.customers = append(f.customers, id)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`,
|
||||
`DELETE FROM wallet_lots WHERE customer_id IN ?`,
|
||||
`DELETE FROM wallet_transactions WHERE customer_id IN ?`,
|
||||
`DELETE FROM customer_wallets WHERE customer_id IN ?`,
|
||||
`DELETE FROM customers WHERE id IN ?`,
|
||||
} {
|
||||
db.Exec(q, f.customers)
|
||||
}
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, f.orgID)
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// inTx runs fn in a transaction and fails the test on error.
|
||||
func (f *walletFixture) inTx(t *testing.T, fn func(ctx context.Context) error) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.txm.WithTransaction(context.Background(), fn))
|
||||
}
|
||||
|
||||
// credit writes a ledger row and a lot and moves the balance, the minimum the
|
||||
// database accepts for a credit.
|
||||
func (f *walletFixture) credit(t *testing.T, ctx context.Context, customerID uuid.UUID, amount int64, expiresAt *time.Time) *entities.WalletLot {
|
||||
t.Helper()
|
||||
balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, amount)
|
||||
require.NoError(t, err)
|
||||
walletTx := &entities.WalletTransaction{
|
||||
OrganizationID: f.orgID,
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeMigration,
|
||||
Amount: amount,
|
||||
BalanceAfter: balance,
|
||||
ReferenceType: constants.WalletRefTypeLegacyPoints,
|
||||
ReferenceID: uuid.New(),
|
||||
Description: "test",
|
||||
}
|
||||
require.NoError(t, f.repo.CreateTransaction(ctx, walletTx))
|
||||
lot := &entities.WalletLot{
|
||||
OrganizationID: f.orgID,
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
SourceTransactionID: walletTx.ID,
|
||||
OriginalAmount: amount,
|
||||
RemainingAmount: amount,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
require.NoError(t, f.repo.CreateLot(ctx, lot))
|
||||
return lot
|
||||
}
|
||||
|
||||
func TestWalletRepository_WritesRequireTransaction(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := f.repo.LockWallet(ctx, f.customers[0])
|
||||
assert.ErrorIs(t, err, ErrWalletTxRequired)
|
||||
_, err = f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 10)
|
||||
assert.ErrorIs(t, err, ErrWalletTxRequired)
|
||||
assert.ErrorIs(t, f.repo.ConsumeLot(ctx, uuid.New(), 1), ErrWalletTxRequired)
|
||||
assert.ErrorIs(t, f.repo.CreateTransaction(ctx, &entities.WalletTransaction{}), ErrWalletTxRequired)
|
||||
assert.ErrorIs(t, f.repo.CreateLot(ctx, &entities.WalletLot{}), ErrWalletTxRequired)
|
||||
}
|
||||
|
||||
func TestWalletRepository_LockWalletCreatesWallet(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
wallet, err := f.repo.LockWallet(ctx, f.customers[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, f.orgID, wallet.OrganizationID, "organization comes from the customer")
|
||||
assert.Zero(t, wallet.PointBalance)
|
||||
assert.Zero(t, wallet.CoinBalance)
|
||||
|
||||
// Locking again in the same transaction finds the same row.
|
||||
again, err := f.repo.LockWallet(ctx, f.customers[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wallet.CustomerID, again.CustomerID)
|
||||
return nil
|
||||
})
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.LockWallet(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, ErrWalletNotFound)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalletRepository_AddBalanceRejectsOverdraft(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
customerID := f.customers[0]
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.LockWallet(ctx, customerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
balance, err := f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), balance)
|
||||
|
||||
_, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -6)
|
||||
assert.ErrorIs(t, err, ErrWalletInsufficientBalance)
|
||||
|
||||
// Coin is a separate balance: point balance does not cover it.
|
||||
_, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyCoin, -1)
|
||||
assert.ErrorIs(t, err, ErrWalletInsufficientBalance)
|
||||
|
||||
// The failed update left the transaction usable and the balance untouched.
|
||||
balance, err = f.repo.AddBalance(ctx, customerID, constants.WalletCurrencyPoint, -5)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), balance)
|
||||
return nil
|
||||
})
|
||||
|
||||
wallet, err := f.repo.GetWallet(context.Background(), customerID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), wallet.PointBalance)
|
||||
assert.Equal(t, int64(0), wallet.CoinBalance)
|
||||
}
|
||||
|
||||
func TestWalletRepository_AddBalanceWithoutWallet(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.AddBalance(ctx, f.customers[0], constants.WalletCurrencyPoint, 5)
|
||||
assert.ErrorIs(t, err, ErrWalletNotFound)
|
||||
_, err = f.repo.AddBalance(ctx, f.customers[0], "GOLD", 5)
|
||||
assert.Error(t, err)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalletRepository_ConsumeLotRejectsOverdraw(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
customerID := f.customers[0]
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.LockWallet(ctx, customerID)
|
||||
require.NoError(t, err)
|
||||
lot := f.credit(t, ctx, customerID, 10, nil)
|
||||
|
||||
require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 4))
|
||||
assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 7), ErrWalletLotInsufficient)
|
||||
require.NoError(t, f.repo.ConsumeLot(ctx, lot.ID, 6))
|
||||
assert.ErrorIs(t, f.repo.ConsumeLot(ctx, lot.ID, 1), ErrWalletLotInsufficient)
|
||||
assert.Error(t, f.repo.ConsumeLot(ctx, lot.ID, 0))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Two goroutines lock the same wallet and do a read-modify-write with a pause in
|
||||
// between. Without the lock both would read 0 and the result would be 1.
|
||||
func TestWalletRepository_LockWalletSerializes(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
customerID := f.customers[0]
|
||||
|
||||
// Create the wallet up front. Otherwise the second goroutine's INSERT ... ON
|
||||
// CONFLICT waits on the first one's uncommitted insert, which serializes them
|
||||
// even without FOR UPDATE and the test would prove nothing about the lock.
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.LockWallet(ctx, customerID)
|
||||
return err
|
||||
})
|
||||
|
||||
type window struct{ locked, released time.Time }
|
||||
windows := make([]window, 2)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
wallet, err := f.repo.LockWallet(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
windows[i].locked = time.Now()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
db := DBFromContext(ctx, f.db)
|
||||
if err := db.Exec(`UPDATE customer_wallets SET point_balance = ? WHERE customer_id = ?`,
|
||||
wallet.PointBalance+1, customerID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
windows[i].released = time.Now()
|
||||
return nil
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
wallet, err := f.repo.GetWallet(context.Background(), customerID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), wallet.PointBalance, "second transaction must see the first one's write")
|
||||
|
||||
first, second := windows[0], windows[1]
|
||||
if second.locked.Before(first.locked) {
|
||||
first, second = second, first
|
||||
}
|
||||
assert.False(t, second.locked.Before(first.released), "second lock was taken while the first was held")
|
||||
}
|
||||
|
||||
// Transfers in opposite directions lock the same pair of wallets. Because LockWallets
|
||||
// always locks in customer_id order, they queue instead of deadlocking.
|
||||
func TestWalletRepository_LockWalletsOppositeOrderDoesNotDeadlock(t *testing.T) {
|
||||
f := newWalletFixture(t, 2)
|
||||
a, b := f.customers[0], f.customers[1]
|
||||
// Existing wallets, for the same reason as in LockWalletSerializes.
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, _, err := f.repo.LockWallets(ctx, a, b)
|
||||
return err
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 20)
|
||||
for i := 0; i < 10; i++ {
|
||||
for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} {
|
||||
wg.Add(1)
|
||||
go func(first, second uuid.UUID) {
|
||||
defer wg.Done()
|
||||
errs <- f.txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
w1, w2, err := f.repo.LockWallets(ctx, first, second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w1.CustomerID != first || w2.CustomerID != second {
|
||||
return errors.New("wallets returned out of argument order")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
}(pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, _, err := f.repo.LockWallets(ctx, a, a)
|
||||
assert.Error(t, err)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalletRepository_ListActiveLotsOrder(t *testing.T) {
|
||||
f := newWalletFixture(t, 1)
|
||||
customerID := f.customers[0]
|
||||
now := time.Now()
|
||||
at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
|
||||
|
||||
create := func(expiresAt *time.Time) *entities.WalletLot {
|
||||
var lot *entities.WalletLot
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
_, err := f.repo.LockWallet(ctx, customerID)
|
||||
require.NoError(t, err)
|
||||
lot = f.credit(t, ctx, customerID, 10, expiresAt)
|
||||
return nil
|
||||
})
|
||||
return lot
|
||||
}
|
||||
neverOld := create(nil)
|
||||
late := create(at(48 * time.Hour))
|
||||
soon := create(at(time.Hour))
|
||||
neverNew := create(nil)
|
||||
expired := create(at(-time.Hour))
|
||||
empty := create(at(30 * time.Minute))
|
||||
f.inTx(t, func(ctx context.Context) error {
|
||||
return f.repo.ConsumeLot(ctx, empty.ID, 10)
|
||||
})
|
||||
|
||||
lots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyPoint, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []uuid.UUID
|
||||
for _, lot := range lots {
|
||||
got = append(got, lot.ID)
|
||||
}
|
||||
assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, got,
|
||||
"soonest expiry first, no expiry last and oldest first, expired and empty lots left out")
|
||||
assert.NotContains(t, got, expired.ID)
|
||||
|
||||
coinLots, err := f.repo.ListActiveLots(context.Background(), customerID, constants.WalletCurrencyCoin, now)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, coinLots)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to
|
||||
// another organization.
|
||||
var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found")
|
||||
|
||||
// WalletTraceRepository reads what tracing a ledger row lot by lot needs
|
||||
// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance.
|
||||
type WalletTraceRepository interface {
|
||||
// GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to
|
||||
// the organization.
|
||||
GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error)
|
||||
GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error)
|
||||
// ListAllocations returns what a debit took from each lot, in the order it took.
|
||||
ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error)
|
||||
// ListLotsBySource returns the lots a credit created, oldest first.
|
||||
ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
|
||||
GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
|
||||
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
}
|
||||
|
||||
type walletTraceRepository struct {
|
||||
db *gorm.DB
|
||||
names *walletAdminRepository
|
||||
}
|
||||
|
||||
func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository {
|
||||
return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}}
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("id = ? AND organization_id = ?", id, organizationID).
|
||||
Limit(1).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet transaction: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrWalletTransactionNotFound
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
if len(ids) == 0 {
|
||||
return rows, nil
|
||||
}
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet transactions: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||
var rows []entities.WalletLotAllocation
|
||||
// A debit draws in K9 order, so the lots' order is the order it took from them.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_lot_allocations a").
|
||||
Select("a.*").
|
||||
Joins("JOIN wallet_lots l ON l.id = a.lot_id").
|
||||
Where("a.transaction_id = ?", transactionID).
|
||||
Order("l.expires_at NULLS LAST, l.created_at, l.id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
if len(ids) == 0 {
|
||||
return lots, nil
|
||||
}
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names.CustomerNames(ctx, ids)
|
||||
}
|
||||
Reference in New Issue
Block a user