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>
197 lines
7.2 KiB
Go
197 lines
7.2 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/entities"
|
|
)
|
|
|
|
// ErrPointPaymentOrderNotFound means the order does not exist.
|
|
var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found")
|
|
|
|
// PointPaymentOrder is what paying with EnakPoint needs to know about an order.
|
|
type PointPaymentOrder struct {
|
|
ID uuid.UUID
|
|
OrganizationID uuid.UUID
|
|
OutletID uuid.UUID
|
|
OrderNumber string
|
|
OutletName string
|
|
CustomerID *uuid.UUID
|
|
TotalAmount float64
|
|
PaymentStatus string
|
|
IsVoid bool
|
|
CustomerIsDefault *bool
|
|
CustomerIsActive *bool
|
|
}
|
|
|
|
// PointPaymentRepository reads and writes what paying an order with EnakPoint touches
|
|
// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method
|
|
// joins the caller's transaction, since the payment row, the balance and the order
|
|
// must change together.
|
|
type PointPaymentRepository interface {
|
|
// GetOrder reads the order; with lock it also locks the order row for the rest of
|
|
// the transaction, so two payments of the same order queue up.
|
|
GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error)
|
|
// GetPaymentMethod returns a method's organization and type.
|
|
GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error)
|
|
// TotalPaid sums the order's completed payments, as the rest of the order flow does.
|
|
TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error)
|
|
// PaidWithPoints sums the rupiah of the order's completed EnakPoint payments.
|
|
PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error)
|
|
// PointMethodID returns the organization's EnakPoint payment method.
|
|
PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)
|
|
InsertPayment(ctx context.Context, payment *entities.Payment) error
|
|
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
|
// nothing is.
|
|
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
|
}
|
|
|
|
type pointPaymentRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository {
|
|
return &pointPaymentRepository{db: db}
|
|
}
|
|
|
|
func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) {
|
|
lockClause := ""
|
|
if lock {
|
|
lockClause = "FOR UPDATE OF o"
|
|
}
|
|
var rows []struct {
|
|
ID string
|
|
OrganizationID string
|
|
OutletID string
|
|
OrderNumber string
|
|
OutletName string
|
|
CustomerID *string
|
|
TotalAmount float64
|
|
PaymentStatus string
|
|
IsVoid bool
|
|
CustomerIsDefault *bool
|
|
CustomerIsActive *bool
|
|
}
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
|
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
|
o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void,
|
|
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
|
FROM orders o
|
|
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
|
LEFT JOIN customers c ON c.id = o.customer_id
|
|
WHERE o.id = ?
|
|
`+lockClause, orderID).Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read order: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, ErrPointPaymentOrderNotFound
|
|
}
|
|
row := rows[0]
|
|
order := &PointPaymentOrder{
|
|
OrderNumber: row.OrderNumber,
|
|
OutletName: row.OutletName,
|
|
TotalAmount: row.TotalAmount,
|
|
PaymentStatus: row.PaymentStatus,
|
|
IsVoid: row.IsVoid,
|
|
CustomerIsDefault: row.CustomerIsDefault,
|
|
CustomerIsActive: row.CustomerIsActive,
|
|
}
|
|
order.ID, _ = uuid.Parse(row.ID)
|
|
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
|
order.OutletID, _ = uuid.Parse(row.OutletID)
|
|
if row.CustomerID != nil {
|
|
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
|
order.CustomerID = &id
|
|
}
|
|
}
|
|
return order, nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) {
|
|
var rows []struct {
|
|
OrganizationID string
|
|
Type string
|
|
}
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error
|
|
if err != nil {
|
|
return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return uuid.Nil, "", fmt.Errorf("payment method not found")
|
|
}
|
|
org, _ := uuid.Parse(rows[0].OrganizationID)
|
|
return org, rows[0].Type, nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
|
var total float64
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`,
|
|
orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to sum payments: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
|
var total float64
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
|
SELECT COALESCE(SUM(p.amount), 0) FROM payments p
|
|
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
|
WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`,
|
|
orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error {
|
|
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil {
|
|
return fmt.Errorf("failed to create payment: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error {
|
|
paymentStatus := entities.PaymentStatusPartial
|
|
if completed {
|
|
paymentStatus = entities.PaymentStatusCompleted
|
|
}
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
|
UPDATE orders SET remaining_amount = ?, payment_status = ?,
|
|
status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW()
|
|
WHERE id = ?`,
|
|
remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update order after payment: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
|
|
var ids []string
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Table("payment_methods").
|
|
Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint).
|
|
Limit(1).
|
|
Pluck("id::text", &ids).Error
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err)
|
|
}
|
|
if len(ids) == 0 {
|
|
return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method")
|
|
}
|
|
return uuid.Parse(ids[0])
|
|
}
|