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 }