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) } // EarningProcessor credits EnakPoint and EnakCoin for paid orders // (docs/prd-point-coin.md F3). type EarningProcessor struct { orders repository.EarningRepository settings outletSettingsReader wallet *WalletProcessor tx TxRunner } func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor { return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx} } // 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 } 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 never expire until the expiry model is decided (F12, note N4). }}) 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) }