Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204). VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded hook, called once their writes have committed and, like onOrderPaid, detached from the request so it can never block or fail the void or refund. For RefundPayment that is after its transaction. EarningProcessor.ReverseForOrder computes how much of each EARN row should have come back in total: everything for a void, otherwise floor(earned × refunded / basis) with the order's cumulative refund and the basis frozen on the EARN row, never more than was earned (a refund including tax can pass the basis). It takes only what has not been asked back yet, what was taken plus any shortfall, so repeats and successive partial refunds never add up to more than the earning. It writes an EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots the EARN created first, and records the shortfall when the balance was already spent. When the balance is empty there is no ledger row to carry the shortfall; that case is logged. VoidOrder still refuses fully paid orders, so a void has nothing to take back today; the hook keeps it correct if that changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
8.2 KiB
Go
235 lines
8.2 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)
|
|
}
|
|
|
|
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, COALESCE(o.refund_amount, 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
|
|
}
|