Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user