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 }