feat(loyalty): remove paying with EnakPoint
EnakPoint can only be redeemed for vouchers now: it can no longer pay for orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001, EG-002). No order was ever paid with EnakPoint, so there is no data to move. Removed: - POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points and GET /orders/:id/point-payment/preview, with their processors, repositories, services, handlers and tests. - The point payment method type: paying, splitting and refunding with it, the outlet filter on the method list, and the system-method guard. - points and payment_code on CreatePayment; points_used and point_value on payments; accepts_point_payment on the customer outlets. - The outlet point_payment settings. A PUT that still sends them is rejected as an unknown field. - The EnakPoint split in the payment method analytics. - PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used them as a generic EnakPoint debit use REWARD_REDEEM. - The EnakPoint-paid part from the earning basis, which is subtotal − discount again. Migration 000102 drops the trigger, the point methods and their index, the payments columns, and the outlet settings, and restores the method type CHECK without point. payments.payment_method_id is ON DELETE RESTRICT, so it fails rather than lose a payment made with EnakPoint. The integration docs list the removed endpoints and fields, and the EnakPoint & EnakCoin PRD and tasks note what is superseded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3ebc09f818
commit
2c9753fae7
@@ -99,8 +99,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
pm.type as payment_method_type,
|
||||
COALESCE(SUM(p.amount), 0) as total_amount,
|
||||
COUNT(DISTINCT p.order_id) as order_count,
|
||||
COUNT(p.id) as payment_count,
|
||||
COALESCE(SUM(p.points_used), 0) as points_used
|
||||
COUNT(p.id) as payment_count
|
||||
`).
|
||||
Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id").
|
||||
Joins("JOIN orders o ON p.order_id = o.id").
|
||||
|
||||
@@ -62,8 +62,6 @@ type CustomerOrderPaymentRow struct {
|
||||
Amount float64
|
||||
Status string
|
||||
RefundAmount float64
|
||||
PointsUsed *int64
|
||||
PointValue *float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -145,7 +143,7 @@ func (r *customerOrderRepository) ListPayments(ctx context.Context, orderID uuid
|
||||
var rows []CustomerOrderPaymentRow
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT pay.id, COALESCE(pm.name, '') AS method_name, COALESCE(pm.type, '') AS method_type,
|
||||
pay.amount, pay.status, pay.refund_amount, pay.points_used, pay.point_value, pay.created_at
|
||||
pay.amount, pay.status, pay.refund_amount, pay.created_at
|
||||
FROM payments pay
|
||||
LEFT JOIN payment_methods pm ON pm.id = pay.payment_method_id
|
||||
WHERE pay.order_id = ?
|
||||
|
||||
@@ -44,9 +44,6 @@ type EarningCursor struct {
|
||||
// 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
|
||||
@@ -92,13 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
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,
|
||||
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
|
||||
@@ -134,21 +125,6 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
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 {
|
||||
|
||||
@@ -58,7 +58,6 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, int64(100), settings.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount)
|
||||
assert.Nil(t, settings.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent)
|
||||
|
||||
// Change three keys.
|
||||
settings.Point.Enabled = true
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
|
||||
ErrPaymentCodeTaken = errors.New("payment code already in use")
|
||||
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
|
||||
// already used.
|
||||
ErrPaymentCodeNotFound = errors.New("payment code not found")
|
||||
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
|
||||
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
|
||||
)
|
||||
|
||||
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
|
||||
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
|
||||
type PaymentCodeRepository interface {
|
||||
// Save stores a code for a customer for ttl, and retires the customer's previous
|
||||
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
|
||||
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
|
||||
// Consume uses a code up if it belongs to the customer. A code of another customer
|
||||
// is left in place, so a cashier scanning it against the wrong order does not burn
|
||||
// it for its owner.
|
||||
Consume(ctx context.Context, code string, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
type paymentCodeRepository struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
|
||||
return &paymentCodeRepository{client: client}
|
||||
}
|
||||
|
||||
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
|
||||
|
||||
func paymentCodeCustomerKey(customerID uuid.UUID) string {
|
||||
return "wallet:paycode:customer:" + customerID.String()
|
||||
}
|
||||
|
||||
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
|
||||
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store payment code: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return ErrPaymentCodeTaken
|
||||
}
|
||||
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("failed to track payment code: %w", err)
|
||||
}
|
||||
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
|
||||
if previous != "" && previous != code {
|
||||
// Only if it is still that customer's: the number may have been reissued.
|
||||
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// consumeScript deletes a code only if it belongs to the given customer, in one step.
|
||||
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
|
||||
var consumeScript = redis.NewScript(`
|
||||
local owner = redis.call('GET', KEYS[1])
|
||||
if not owner then return 0 end
|
||||
if owner ~= ARGV[1] then return -1 end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
`)
|
||||
|
||||
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return ErrPaymentCodeNotFound
|
||||
}
|
||||
return r.compareAndDelete(ctx, code, customerID)
|
||||
}
|
||||
|
||||
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to use payment code: %w", err)
|
||||
}
|
||||
switch result {
|
||||
case 1:
|
||||
return nil
|
||||
case -1:
|
||||
return ErrPaymentCodeWrongCustomer
|
||||
default:
|
||||
return ErrPaymentCodeNotFound
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,6 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
|
||||
case "search":
|
||||
searchValue := "%" + value.(string) + "%"
|
||||
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
|
||||
case "exclude_type":
|
||||
query = query.Where("type <> ?", value)
|
||||
default:
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
@@ -1,339 +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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -71,8 +71,8 @@ func TestWalletReconciliation_AgainstPostgres(t *testing.T) {
|
||||
}
|
||||
outlet := uuid.New()
|
||||
_, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem,
|
||||
Amount: 70, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Bayar"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user