Files
apskel-pos-backend/internal/processor/point_payment_processor.go
T
2026-09-30 15:31:44 +07:00

337 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
// PointMethodID returns the organization's EnakPoint payment method.
func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
return p.repo.PointMethodID(ctx, organizationID)
}
// OrderOwner returns the organization and customer of an order, for checking that a
// customer pays only their own order.
func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return uuid.Nil, nil, err
}
return order.OrganizationID, order.CustomerID, nil
}