Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13, PC-307). After a void or refund, onOrderRefunded now returns EnakPoint before taking earning back. For each EnakPoint payment of the order it returns everything on a void, and floor(refunded rupiah / the frozen point_value) when the payment itself was refunded, so a later change of the point value does not change how many come back and a remainder below one EnakPoint is lost. It never returns more than the payment used, and only what has not come back yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they reverse, and the EnakPoint go back into lots with the expiry of the lots they were taken from, longest-lasting first (the 7-day extension waits on note N4). RefundOrder, which hands money back in cash or another method, is now limited to what was paid with other methods; the EnakPoint part has to be refunded through its own payment. That answers 400. Fixes earning reversal from PC-204: a refund of the EnakPoint part raised orders.refund_amount and so took earning back, although that part never earned. It is now left out of the refund the reversal uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.7 KiB
Go
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
|
|
}
|