340 lines
13 KiB
Go
340 lines
13 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"
|
|
)
|
|
|
|
// 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
|
|
}
|