Files
apskel-pos-backend/internal/models/wallet.go
T
efrilmandClaude Opus 5.5 550122f29c feat(loyalty): remind customers before balances expire
Adds what the customer sees of expiry (docs/prd-point-coin.md F6, F12,
PC-504).

GET /customer/wallet/expiring lists everything that will expire, per
currency and day, soonest first. GET /customer/wallet already had the
nearest expiry per currency.

The expiry job now also sends reminders, with the settings of note N4 as
decided: once, reminder_days before (7 by default, 0 for none), per
currency. A customer gets one FCM push per currency and expiry day,
however many lots make it up: "150 EnakPoint akan kedaluwarsa pada 31 Okt
2026. Pakai sebelum hangus.", with type WALLET_EXPIRING, the currency,
amount and expiry_date in its data. Reminders cover whatever falls within
the window, so a run that was missed catches up rather than skipping a day.

Migration 000097 adds wallet_expiry_reminders, one row per customer,
currency and expiry day. The row is written before the push is sent, so
several instances of the job or a restart never remind twice; a push that
then fails is logged and not retried. Lots that expire later on the same
day as an earlier reminder are not reminded of again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:35:12 +07:00

167 lines
6.6 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"`
}
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
// F6): everything that will expire, per currency and day, soonest first.
type CustomerWalletExpiringList struct {
Point []CustomerWalletExpiring `json:"point"`
Coin []CustomerWalletExpiring `json:"coin"`
}