337 lines
12 KiB
Go
337 lines
12 KiB
Go
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
|
||
}
|