Files
efrilmandClaude Opus 5.5 2c9753fae7 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>
2026-10-07 13:48:29 +07:00

214 lines
6.7 KiB
Go

package processor
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// Why an order earned nothing.
const (
EarningSkipNotPaid = "NOT_PAID"
EarningSkipVoid = "VOID"
EarningSkipNoCustomer = "NO_CUSTOMER"
EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER"
EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER"
EarningSkipNothingToEarn = "NOTHING_TO_EARN"
)
// EarningOutcome is what earning did for one order.
type EarningOutcome struct {
Points int64
Coins int64
// Set when the order earned nothing, to say why.
Skipped string
}
type outletSettingsReader interface {
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
}
// earningSettingsReader gives the outlet's earning settings and the organization's
// expiry. LoyaltySettingsProcessor is one.
type earningSettingsReader interface {
outletSettingsReader
organizationSettingsReader
}
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
// (docs/prd-point-coin.md F3).
type EarningProcessor struct {
orders repository.EarningRepository
settings earningSettingsReader
now func() time.Time
wallet *WalletProcessor
tx TxRunner
}
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now}
}
// OnOrderPaid is called once an order has become fully paid and the payment has
// committed. It never fails the caller: a failed earning is logged and picked up later
// by EarnMissing, and the idempotency keys make that retry safe.
func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
defer func() {
if r := recover(); r != nil {
logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r))
}
}()
if _, err := p.EarnForOrder(ctx, orderID); err != nil {
logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err)
}
}
// EarnForOrder credits what a paid order earns. Calling it again for the same order
// credits nothing more.
func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) {
order, err := p.orders.GetOrderForEarning(ctx, orderID)
if err != nil {
return nil, err
}
if skip := earningSkipReason(order); skip != "" {
return &EarningOutcome{Skipped: skip}, nil
}
settings, err := p.settings.Outlet(ctx, order.OutletID)
if err != nil {
return nil, err
}
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, *settings)
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
}
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
if err != nil {
return nil, err
}
receivedAt := p.now()
outcome := &EarningOutcome{}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
for _, c := range []struct {
currency string
line EarningLine
total *int64
}{
{constants.WalletCurrencyPoint, result.Point, &outcome.Points},
{constants.WalletCurrencyCoin, result.Coin, &outcome.Coins},
} {
if c.line.Amount == 0 {
continue
}
outletID := order.OutletID
res, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: *order.CustomerID,
Currency: c.currency,
Type: constants.WalletTxTypeEarn,
Amount: c.line.Amount,
ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: order.ID,
OutletID: &outletID,
Description: earningDescription(order),
Metadata: result.Metadata(c.line),
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
},
Lots: []WalletLotInput{{
Amount: c.line.Amount,
ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt),
}},
})
if err != nil {
return fmt.Errorf("crediting %s: %w", c.currency, err)
}
*c.total = res.Transaction.Amount
}
return nil
})
if err != nil {
return nil, err
}
return outcome, nil
}
// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the
// given time that should have earned and have no EARN row, and earns for them. It
// returns how many orders it looked at and how many now earned. One order failing does
// not stop the others.
func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) {
const page = 200
var after *repository.EarningCursor
for checked < maxOrders {
batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page)
if err != nil {
return checked, earned, err
}
if len(batch) == 0 {
break
}
for _, candidate := range batch {
checked++
outcome, err := p.EarnForOrder(ctx, candidate.ID)
if err != nil {
logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err)
continue
}
if outcome.Skipped == "" {
earned++
}
}
last := batch[len(batch)-1]
after = &last
}
return checked, earned, nil
}
func earningSkipReason(order *repository.EarningOrder) string {
switch {
case order.PaymentStatus != string(entities.PaymentStatusCompleted):
return EarningSkipNotPaid
case order.IsVoid:
return EarningSkipVoid
case order.CustomerID == nil || order.CustomerIsDefault == nil:
return EarningSkipNoCustomer
case *order.CustomerIsDefault:
return EarningSkipDefaultCustomer
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
return EarningSkipInactiveCustomer
}
return ""
}
func earningDescription(order *repository.EarningOrder) string {
description := "Belanja #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
return truncateRunes(description, walletDescriptionLimit)
}
// EarnedByOrders returns what each order earned, as written in its EARN rows, for the
// order screen and the receipt (F3). Orders that earned nothing are absent.
func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
rows, err := p.orders.EarnedByOrders(ctx, orderIDs)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]OrderEarned, len(rows))
for id, byCurrency := range rows {
out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]}
}
return out, nil
}