Every lot now gets its expiry when it is created (docs/prd-point-coin.md F12, PC-502), where it used to never expire until note N4 was settled: - EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's settings for that currency, from the moment received. - EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint received now expire (F4). - PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at least seven days from the refund (N4, decided). A lot that never expired stays so. - TRANSFER_IN: unchanged, exactly the sender's expiry. Turning expiry on for a currency for the first time dates every lot of the organization that still holds something and has no expiry, MIGRATION lots included, in the same transaction as the setting: a full period from now when ROLLING, the second fixed date on or after today when FIXED_DATE, so no customer loses a balance soon after the rule is announced (N4, decided). Turning it off leaves dated lots as they are. PUT /marketing/loyalty-settings reports these as expiry_activations (currency, lots, amount, expires_at); a dry run counts them without dating anything. The earning processor now also reads the organization settings, and the wallet admin processor takes the settings reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
6.8 KiB
Go
218 lines
6.8 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
|
|
}
|
|
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *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
|
|
}
|