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 }