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 }