Adds POST /customer/orders/:id/pay-with-points (docs/prd-point-coin.md F9, PC-306) for the customer app and self-order. It uses the same payment path as the cashier, approved by the customer's PIN instead of a code: the session alone is not enough (K8), and a wrong PIN takes nothing and counts toward the lock. A customer can pay only their own order; any other order, and one that does not exist, answer 404 alike, so the endpoint does not reveal other customers' orders. The method is the organization's EnakPoint method, no cashier is recorded, and settling the order triggers earning through the same onOrderPaid hook as every other payment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
|
||
}
|