Adds GET /customer/wallet/transfer/recipient?phone= and
POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16,
PC-402).
The recipient is found by phone number and must be an active customer of
the same organization, not the walk-in customer and not the sender. A
number of another organization answers 404 like an unknown one, so the
check does not reveal who uses the app elsewhere. The recipient check
returns the name and number masked ("Bu*** Sa***", "08**-****-1234").
The organization's transfer settings apply: transfers turned off, the
minimum, the maximum per transaction and the daily limit per currency,
which starts over at midnight WIB. Everything the request alone can get
wrong is refused before the PIN, so it costs no attempt; the PIN then
refuses a transfer held for 24 hours after a PIN reset.
Both wallets are locked in customer_id order, so transfers in opposite
directions cannot deadlock, and the daily limit is summed under the lock.
TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN
gives the recipient lots with exactly the same expiries, pointing back at
the sender's lots. The rows share a group, reference each other and name
the other customer; descriptions carry only the masked name.
The Idempotency-Key header is required. A retry is recognised under the
lock before the daily limit, so it replays instead of counting twice; the
same key towards another recipient is refused.
The recipient is told by WhatsApp after the commit, as PIN locks are:
NotificationService only reaches staff devices, there is no push channel
to customers yet. A failure to send is logged, never undoes the transfer.
Transfers must not be released before note N3 (legal) is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
2.8 KiB
Go
83 lines
2.8 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
)
|
|
|
|
// WalletMoveCustomer is a customer on either side of an exchange or a transfer.
|
|
type WalletMoveCustomer struct {
|
|
ID uuid.UUID
|
|
OrganizationID uuid.UUID
|
|
Name string
|
|
PhoneNumber *string
|
|
IsDefault bool
|
|
IsActive bool
|
|
}
|
|
|
|
// WalletMoveRepository reads what exchanges and transfers check before moving a
|
|
// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through
|
|
// WalletProcessor.
|
|
type WalletMoveRepository interface {
|
|
// GetCustomer returns ErrWalletNotFound when the customer does not exist.
|
|
GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error)
|
|
// FindCustomerByPhone returns ErrWalletNotFound when no customer has the number.
|
|
// Phone numbers are unique across organizations, so there is at most one.
|
|
FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error)
|
|
// TransferredOutSince sums what a customer has sent in one currency since a time.
|
|
// Call it under the sender's wallet lock, so transfers at the same time count.
|
|
TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error)
|
|
}
|
|
|
|
type walletMoveRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository {
|
|
return &walletMoveRepository{db: db}
|
|
}
|
|
|
|
func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) {
|
|
return r.customer(ctx, "id = ?", customerID)
|
|
}
|
|
|
|
func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) {
|
|
return r.customer(ctx, "phone_number = ?", phoneNumber)
|
|
}
|
|
|
|
func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
|
|
var total int64
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Table("wallet_transactions").
|
|
Select("COALESCE(SUM(-amount), 0)").
|
|
Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since).
|
|
Scan(&total).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to sum transfers: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) {
|
|
var c WalletMoveCustomer
|
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
|
Table("customers").
|
|
Select("id, organization_id, name, phone_number, is_default, is_active").
|
|
Where(where, arg).
|
|
Take(&c).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrWalletNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to get customer: %w", err)
|
|
}
|
|
return &c, nil
|
|
}
|