feat(loyalty): earn EnakPoint and EnakCoin when an order is paid

Adds earning at payment time (docs/prd-point-coin.md F3, PC-203).

An order becomes fully paid through UpdateOrder, CreatePayment and both
kinds of split bill. All of them now go through one OrderProcessorImpl
hook, onOrderPaid, called after the payment has committed; for
CreatePayment that is after its transaction, not from updateOrderStatus
inside it. The hook runs detached from the caller's transaction and from
the request being cancelled, and it runs synchronously so the order
response can show what was earned.

EarningProcessor.EarnForOrder skips orders that are not paid, are void,
have no customer, or whose customer is the walk-in customer or inactive.
It computes the earning with CalculateEarning, subtracting any part paid
with EnakPoint (none until phase 3), and credits each currency through the
wallet engine as EARN with key earn:{order_id}:{currency} and the settings
snapshot in metadata. A repeat, even concurrent, credits nothing more.
OnOrderPaid never fails the payment: errors and panics are logged.

EarningBackfillJob is the safety net: every 30 minutes it earns for orders
paid in the last three days that have no EARN row. It only looks at
outlets with earning switched on and pages by (updated_at, id), so orders
that correctly earned nothing cannot starve the ones that were missed.

Lots from earning never expire until the expiry model is decided (F12,
note N4). Adds the point payment method type constant, not yet accepted as
a payment method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 10:40:05 +07:00
co-authored by Claude Opus 5.5
parent 5baed18b22
commit 78c0c11774
10 changed files with 959 additions and 2 deletions
+193
View File
@@ -0,0 +1,193 @@
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
// 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)
}
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
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,
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,
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
}