Files
2026-09-30 15:31:44 +07:00

276 lines
9.7 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
// 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
}