Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:44 +07:00
parent 4e24f9bbb0
commit 582dc75543
195 changed files with 20058 additions and 2177 deletions
+10 -9
View File
@@ -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
}
+275
View File
@@ -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
}
+7 -7
View File
@@ -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
+22 -9
View File
@@ -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
+8 -8
View File
@@ -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
}
+7
View File
@@ -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)
}
}
+295
View File
@@ -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)
}