package processor import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the // order, the customer, the outlet or the amount. The message says which. var ErrPointPaymentRejected = errors.New("EnakPoint payment refused") type pointPaymentSettings interface { Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) } type spendableReader interface { SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) } // PointPaymentInput is one payment of an order with EnakPoint. type PointPaymentInput struct { OrderID uuid.UUID PaymentMethodID uuid.UUID Points int64 // The cashier taking the payment at the POS; nil when the customer pays in the app. CashierID *uuid.UUID // Authorize proves the customer agreed, before anything is taken: at the POS it // redeems the payment code, in the app it checks the PIN (K8). Authorize func(ctx context.Context, customerID uuid.UUID) error } // PointPaymentResult is the payment made and where it left the order. type PointPaymentResult struct { Payment *entities.Payment // True when this payment settled the order. Completed bool // Rupiah still to pay with another method. Remaining float64 } // pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents: // // cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint) // max_points = min(balance, floor(cap / point_value)) type pointPaymentLimits struct { RemainingCents int64 CapCents int64 MaxPoints int64 } func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits { remaining := toCents(total) - toCents(totalPaid) if remaining < 0 { remaining = 0 } byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints) capCents := min(remaining, byPercent) if capCents < 0 { capCents = 0 } maxPoints := int64(0) if pointValue > 0 { maxPoints = min(balance, capCents/(pointValue*100)) } if maxPoints < 0 { maxPoints = 0 } return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints} } // PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9). type PointPaymentProcessor struct { repo repository.PointPaymentRepository settings pointPaymentSettings spendable spendableReader wallet *WalletProcessor tx TxRunner now func() time.Time } func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor { return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now} } // Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with // EnakPoint, and at most how much. func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) { order, err := p.repo.GetOrder(ctx, orderID, false) if err != nil { return nil, err } if order.OrganizationID != organizationID { return nil, repository.ErrPointPaymentOrderNotFound } preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID} settings, err := p.settings.Outlet(ctx, order.OutletID) if err != nil { return nil, err } value, err := p.settings.PointValue(ctx, order.OrganizationID) if err != nil { return nil, err } preview.PointValue = value preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent if reason := pointPaymentOrderProblem(order, settings); reason != "" { preview.Reason = reason return preview, nil } limits, balance, err := p.limits(ctx, order, settings, value) if err != nil { return nil, err } preview.PointBalance = balance preview.RemainingAmount = float64(limits.RemainingCents) / 100 preview.MaxPoints = limits.MaxPoints preview.MaxAmount = limits.MaxPoints * value if limits.MaxPoints < settings.PointPayment.MinPaymentPoints { preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order" return preview, nil } preview.Eligible = true return preview, nil } // Pay takes EnakPoint from the order's customer and records the payment. The payment // row, the ledger PAYMENT row, the balance and the order change in one transaction // with the order row and the wallet locked (F9 steps 1–6). func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) { reject := func(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...)) } if in.Points <= 0 { return nil, reject("the number of EnakPoint must be positive") } order, err := p.repo.GetOrder(ctx, in.OrderID, false) if err != nil { return nil, err } methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID) if err != nil { return nil, err } if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID { return nil, reject("the payment method is not this organization's EnakPoint method") } settings, err := p.settings.Outlet(ctx, order.OutletID) if err != nil { return nil, err } if reason := pointPaymentOrderProblem(order, settings); reason != "" { return nil, reject("%s", reason) } if in.Points < settings.PointPayment.MinPaymentPoints { return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints) } customerID := *order.CustomerID // The customer agrees before anything is taken. A code is used up here even if the // payment then fails, and the customer shows a new one. if in.Authorize == nil { return nil, reject("the customer has not approved the payment") } if err := in.Authorize(ctx, customerID); err != nil { return nil, err } result := &PointPaymentResult{} err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { // Lock the order, then the wallet, and read everything again: another payment // may have landed since the checks above. order, err := p.repo.GetOrder(ctx, in.OrderID, true) if err != nil { return err } if reason := pointPaymentOrderProblem(order, settings); reason != "" { return reject("%s", reason) } if err := p.wallet.LockWallet(ctx, customerID); err != nil { return err } value, err := p.settings.PointValue(ctx, order.OrganizationID) if err != nil { return err } limits, _, err := p.limits(ctx, order, settings, value) if err != nil { return err } if in.Points > limits.MaxPoints { return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints) } amountCents := in.Points * value * 100 // No change is ever given for EnakPoint (K7); the limits already keep it // within what is left, this only guards that. if amountCents > limits.RemainingCents { return reject("EnakPoint cannot pay more than what is left on the order") } pointsUsed := in.Points frozenValue := float64(value) payment := &entities.Payment{ ID: uuid.New(), OrderID: order.ID, PaymentMethodID: in.PaymentMethodID, Amount: float64(amountCents) / 100, Status: entities.PaymentTransactionStatusCompleted, PointsUsed: &pointsUsed, PointValue: &frozenValue, Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value}, } if err := p.repo.InsertPayment(ctx, payment); err != nil { return err } outletID := order.OutletID if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ CustomerID: customerID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: in.Points, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment.ID, OutletID: &outletID, CreatedByUser: in.CashierID, Description: pointPaymentDescription(order, amountCents), Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount}, IdempotencyKey: "payment:" + payment.ID.String(), }}); err != nil { if errors.Is(err, repository.ErrWalletInsufficientBalance) { return reject("the customer does not have enough EnakPoint") } return err } remainingCents := limits.RemainingCents - amountCents completed := remainingCents == 0 if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil { return err } result.Payment = payment result.Completed = completed result.Remaining = float64(remainingCents) / 100 return nil }) if err != nil { return nil, err } return result, nil } func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) { totalPaid, err := p.repo.TotalPaid(ctx, order.ID) if err != nil { return pointPaymentLimits{}, 0, err } paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID) if err != nil { return pointPaymentLimits{}, 0, err } balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now()) if err != nil { return pointPaymentLimits{}, 0, err } balance := balances[constants.WalletCurrencyPoint] return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil } // pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or // "" when it can. func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string { switch { case order.IsVoid: return "the order is void" case order.PaymentStatus == string(entities.PaymentStatusCompleted): return "the order is already paid" case !settings.PointPayment.AcceptPayment: return "this outlet does not accept EnakPoint" case order.CustomerID == nil || order.CustomerIsDefault == nil: return "the order has no customer" case *order.CustomerIsDefault: return "a walk-in order cannot be paid with EnakPoint" case order.CustomerIsActive == nil || !*order.CustomerIsActive: return "the customer is not active" } return "" } func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string { description := "Bayar #" + order.OrderNumber if order.OutletName != "" { description += " di " + order.OutletName } description += " (Rp " + formatRupiah(amountCents/100) + ")" return truncateRunes(description, walletDescriptionLimit) } // formatRupiah writes 50000 as 50.000. func formatRupiah(n int64) string { s := fmt.Sprintf("%d", n) out := make([]byte, 0, len(s)+len(s)/3) for i, c := range []byte(s) { if i > 0 && (len(s)-i)%3 == 0 { out = append(out, '.') } out = append(out, c) } return string(out) }