Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user