194 lines
6.6 KiB
Go
194 lines
6.6 KiB
Go
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
|
||
|
|
// 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)
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
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,
|
||
|
|
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,
|
||
|
|
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
|
||
|
|
}
|