Files
apskel-pos-backend/internal/models/wallet.go
T
efrilmandClaude Opus 5.5 4b3beaed41 feat(loyalty): pay orders with EnakPoint at the cashier
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>
2026-09-30 11:46:31 +07:00

160 lines
6.3 KiB
Go

package models
import (
"time"
"github.com/google/uuid"
)
// CustomerWalletTransaction is one ledger row as the customer app shows it
// (docs/prd-point-coin.md F6).
type CustomerWalletTransaction struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
Type string `json:"type"`
// Signed: positive added to the balance, negative taken from it.
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balance_after"`
Description string `json:"description"`
// Where the value came from, set on additions.
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
// Where the value went, set on deductions.
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
// Shared by the two rows of an exchange or a transfer.
GroupID *uuid.UUID `json:"group_id,omitempty"`
// Additions only: the earliest expiry among the lots it created, nil when none of
// them expire, and the lots themselves.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Lots []CustomerWalletLot `json:"lots,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
type CustomerWalletTransactionRef struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
}
type CustomerWalletLot struct {
Amount int64 `json:"amount"`
Remaining int64 `json:"remaining"`
ExpiresAt *time.Time `json:"expires_at"`
}
// CustomerWalletExpiring is how much expires on one day.
type CustomerWalletExpiring struct {
Amount int64 `json:"amount"`
// YYYY-MM-DD, Asia/Jakarta.
Date string `json:"date"`
}
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
// nothing is due to expire.
type CustomerWalletNearestExpiring struct {
Point *CustomerWalletExpiring `json:"point"`
Coin *CustomerWalletExpiring `json:"coin"`
}
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
type ListCustomerWalletTransactionsQuery struct {
Page int `form:"page"`
Limit int `form:"limit"`
Currency string `form:"currency"`
// One type, or several separated by commas.
Type string `form:"type"`
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
From string `form:"from"`
To string `form:"to"`
}
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
// ones, every lot that still holds something, and the real names behind each row.
type AdminCustomerWallet struct {
Customer AdminWalletCustomer `json:"customer"`
// Balances as the ledger has them.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// What can be spent now. Lower than the ledger balance only while lots that have
// expired wait for the expiry job.
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
Lots []AdminWalletLot `json:"lots"`
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
}
type AdminWalletCustomer struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Phone *string `json:"phone,omitempty"`
}
type AdminWalletLot struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
OriginalAmount int64 `json:"original_amount"`
RemainingAmount int64 `json:"remaining_amount"`
ExpiresAt *time.Time `json:"expires_at"`
Expired bool `json:"expired"`
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminWalletTransaction is a ledger row with the names the customer does not see:
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
// took a payment, and the outlet.
type AdminWalletTransaction struct {
CustomerWalletTransaction
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
Reason *string `json:"reason,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminWalletNamedRef struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// WalletAdjustment is a manual correction by an admin.
type WalletAdjustment struct {
Currency string
// Signed: positive adds, negative takes away.
Amount int64
Reason string
IdempotencyKey string
}
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
type AdminWalletAdjustmentResult struct {
Transaction AdminWalletTransaction `json:"transaction"`
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
// True when the idempotency key had been used before and nothing changed.
Replayed bool `json:"replayed"`
}
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
// F9): whether the order can be paid with EnakPoint and at most how much, for the
// cashier's "use maximum" button.
type PointPaymentPreview struct {
OrderID uuid.UUID `json:"order_id"`
CustomerID *uuid.UUID `json:"customer_id"`
Eligible bool `json:"eligible"`
// Why not, when not eligible.
Reason string `json:"reason,omitempty"`
PointBalance int64 `json:"point_balance"`
PointValue int64 `json:"point_value"`
RemainingAmount float64 `json:"remaining_amount"`
MinPaymentPoints int64 `json:"min_payment_points"`
MaxPaymentPercent int64 `json:"max_payment_percent"`
MaxPoints int64 `json:"max_points"`
// Rupiah covered by MaxPoints.
MaxAmount int64 `json:"max_amount"`
}