Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).
POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.
Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.
GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.
The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
11 KiB
Go
322 lines
11 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)
|
||
}
|