Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -1,217 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user