Compare commits

...
10 Commits
Author SHA1 Message Date
efrilmandClaude Opus 5.5 4432f0a10d feat(loyalty): push a locked PIN through FCM
A customer whose PIN locks after five wrong attempts is now told by a push
through FCM instead of WhatsApp (docs/prd-point-coin.md F11), to every
device registered at /customer/devices. The push is titled "PIN terkunci",
says until when it is locked, and carries type PIN_LOCKED and locked_until
in its data so the app can offer the PIN reset. As before, only the attempt
that reached the limit sends it, and a failure to send is logged without
affecting the lock.

OtpProcessor.SendWhatsAppMessage was only there for this alert and is
removed; OTPs still go out by WhatsApp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:20 +07:00
efrilmandClaude Opus 5.5 bf9651e221 feat(loyalty): notify transfer recipients through FCM
The recipient of a transfer now gets a push through FCM instead of a
WhatsApp message (docs/prd-point-coin.md F5).

Customers had nowhere to keep FCM tokens: user_devices only holds staff
devices. Migration 000096 adds customer_devices, and the customer app
registers with PUT /customer/devices { device_id, fcm_token, platform,
app_version } after login and whenever FCM refreshes the token, and
unregisters with DELETE /customer/devices/:device_id on logout. A token
belongs to one customer only: registering it takes it away from whoever
had it on that phone before, so they stop getting this customer's
notifications.

The push goes to every device of the recipient after the commit, titled
"EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the
TRANSFER_IN transaction id, the group id, the currency and the amount in
its data so the app can open it. A retried transfer sends nothing again. It
stays best effort: no device, FCM not configured or FCM failing is logged
and never undoes the transfer.

The app builds one FCM client and shares it between staff notifications
and customer pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:30:14 +07:00
efrilmandClaude Opus 5.5 8bf1d5c1a8 feat(loyalty): trace a wallet row lot by lot in the dashboard
Adds GET /marketing/wallet-transactions/:id/trace (docs/prd-point-coin.md
F7, §8.1, PC-404).

From any ledger row of the organization, the trace lists the lots a debit
took from, with how much it took from each, or the lots a credit created.
Each lot is followed back through origin_lot_id, across transfers,
exchanges and refunds, to the lot an EARN, ADJUSTMENT or MIGRATION first
created. Every step shows the lot and the row that created it, with the
real name of the customer it belongs to, so the example of §8 (A sends 120
to B, B pays 30) leads from B's payment to A's order #ORD-1.

Lots are loaded a generation at a time, and a chain stops at 100 steps or
at a lot it has already seen, which only bad data could cause. A row of
another organization answers 404.

The dashboard's wallet view now builds its lots with the same helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:25:17 +07:00
efrilmandClaude Opus 5.5 a18bb072f5 feat(loyalty): pay every game with EnakCoin
Games now spend the wallet's EnakCoin instead of the per-type tokens
(docs/prd-point-coin.md F8, K1, PC-403).

GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when
it is not set; a cost that is not a whole number of at least 1 refuses the
game. In one transaction it picks the prize, takes the EnakCoin with a
GAME_SPEND row pointing at the new game_plays.id (which locks the wallet,
so a customer's plays at the same time queue up), records the play and
takes the prize from stock. The play owns its transaction, so the spin
service no longer wraps it, and the admin play endpoint is now atomic too.

The game, game prize and game play repositories go through DBFromContext
so they join that transaction. DecreaseStock now reports a prize that ran
out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that,
or any other stock failure, cancels the whole play, where it used to be
only printed. The manual AddTokens rollback is gone. Not enough EnakCoin,
an inactive game or a prize that ran out answer 400 on /customer/spin
instead of 500.

game_plays.token_used is renamed coins_used (migration 000095). What a
play costs is no longer the caller's choice, so PlayGameRequest loses
token_used. Responses carry coins_used and coins_remaining; token_used and
tokens_remaining stay as deprecated copies until the apps move over, and
sort_by=token_used still sorts by coins_used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:20:42 +07:00
efrilmandClaude Opus 5.5 694d65b6d8 feat(loyalty): send EnakPoint and EnakCoin to another customer
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>
2026-09-30 12:14:32 +07:00
efrilmandClaude Opus 5.5 ab3425070b feat(loyalty): exchange EnakCoin into EnakPoint
Adds GET /customer/wallet/exchange/preview?coins= and
POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401).

The customer exchanges a multiple of the organization's coin_amount and
gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN
(K8). A malformed amount is refused before the PIN is checked, so it costs
no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the
EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share
a group, point at each other and both freeze the rate in their metadata.

The EnakPoint are split over the EnakCoin lots they came from, each part
keeping its lot's expiry and pointing back at it, so exchanging cannot
extend a balance's life. The split takes floor(coins so far x rate) per
lot, which adds up exactly because the total is a multiple of coin_amount.
EnakPoint have no validity of their own until the expiry model is decided
(N4), so the EnakCoin lot is for now the only bound.

The Idempotency-Key header (or X-Idempotency-Key) is required. A retry
with the same key is recognised under the wallet lock and replayed with
the ids and rate the first attempt froze, even if the rate has changed
since; the same key for another amount is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:53 +07:00
efrilmandClaude Opus 5.5 0b52edf84e feat(loyalty): report EnakPoint apart from money received
Keeps EnakPoint out of the money in on the payment method analytics
(docs/prd-point-coin.md F9, K7, PC-308), the one report that sums payments;
the daily transaction and profit-loss PDFs do not break payments down by
method.

summary.total_amount is now only money actually received. EnakPoint stays
listed as its own method, with points_used, and the summary adds
point_amount, points_used and total_with_points. Each method row says
whether it counts_as_cash_in, and the shares are of the money received, 0
for EnakPoint. The average order value still includes what EnakPoint paid,
since that is part of what the orders were worth. How EnakPoint is booked
waits on note N2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:59:33 +07:00
efrilmandClaude Opus 5.5 d7138b8f87 feat(loyalty): refund EnakPoint payments as EnakPoint only
Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13,
PC-307).

After a void or refund, onOrderRefunded now returns EnakPoint before taking
earning back. For each EnakPoint payment of the order it returns everything
on a void, and floor(refunded rupiah / the frozen point_value) when the
payment itself was refunded, so a later change of the point value does not
change how many come back and a remainder below one EnakPoint is lost. It
never returns more than the payment used, and only what has not come back
yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they
reverse, and the EnakPoint go back into lots with the expiry of the lots
they were taken from, longest-lasting first (the 7-day extension waits on
note N4).

RefundOrder, which hands money back in cash or another method, is now
limited to what was paid with other methods; the EnakPoint part has to be
refunded through its own payment. That answers 400.

Fixes earning reversal from PC-204: a refund of the EnakPoint part raised
orders.refund_amount and so took earning back, although that part never
earned. It is now left out of the refund the reversal uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:55:30 +07:00
efrilmandClaude Opus 5.5 43eac0ced4 feat(loyalty): pay own orders with EnakPoint from the app
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>
2026-09-30 11:49:49 +07:00
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
81 changed files with 5387 additions and 310 deletions
+52 -16
View File
@@ -157,6 +157,10 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.walletValidator,
services.loyaltySettingsService,
services.customerPinService,
services.pointPaymentService,
services.customerOrderPaymentService,
services.customerWalletService,
services.customerDeviceService,
a.redisClient,
)
@@ -389,14 +393,22 @@ type processors struct {
earningProcessor *processor.EarningProcessor
customerPinProcessor *processor.CustomerPinProcessor
paymentCodeProcessor *processor.PaymentCodeProcessor
pointPaymentProcessor *processor.PointPaymentProcessor
walletExchangeProcessor *processor.WalletExchangeProcessor
walletTransferProcessor *processor.WalletTransferProcessor
walletTraceProcessor *processor.WalletTraceProcessor
customerDeviceProcessor *processor.CustomerDeviceProcessor
}
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config)
fcmClient := buildFCMClient(cfg)
// Devices of the customer app, for pushes through FCM
customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient)
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
// Customer PIN (docs/prd-point-coin.md F11)
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor)
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
@@ -405,6 +417,13 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
orderProcessor.SetLoyalty(earningProcessor)
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
@@ -436,7 +455,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo),
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager),
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
@@ -447,7 +466,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
fileClient: fileClient,
inventoryMovementService: inventoryMovementService,
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
notificationProcessor: buildNotificationProcessor(repos, fcmClient),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
@@ -456,6 +475,11 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
earningProcessor: earningProcessor,
customerPinProcessor: customerPinProcessor,
paymentCodeProcessor: paymentCodeProcessor,
pointPaymentProcessor: pointPaymentProcessor,
walletExchangeProcessor: walletExchangeProcessor,
walletTransferProcessor: walletTransferProcessor,
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
customerDeviceProcessor: customerDeviceProcessor,
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
}
}
@@ -502,6 +526,10 @@ type services struct {
walletAdminService *service.WalletAdminServiceImpl
loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
pointPaymentService *service.PointPaymentServiceImpl
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
customerWalletService *service.CustomerWalletServiceImpl
customerDeviceService *service.CustomerDeviceServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@@ -537,7 +565,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
notificationService := service.NewNotificationService(processors.notificationProcessor)
@@ -583,9 +611,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
expenseService: service.NewExpenseService(processors.expenseProcessor),
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
}
}
@@ -669,19 +701,23 @@ func (a *App) initValidators() *validators {
}
}
// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to
// start. FCM failing is non-fatal: pushes are skipped and notifications are still
// persisted.
func buildFCMClient(cfg *config.Config) client.FCMClient {
if cfg.FCM.CredentialsFile == "" {
return nil
}
fcmClient, err := client.NewFCMClient(&cfg.FCM)
if err != nil {
return nil
}
return fcmClient
}
// buildNotificationProcessor creates the notification processor with FCM integration.
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
var fcmClient client.FCMClient
if cfg.FCM.CredentialsFile != "" {
var err error
fcmClient, err = client.NewFCMClient(&cfg.FCM)
if err != nil {
// FCM init failure is non-fatal; notifications will still be persisted.
fcmClient = nil
}
}
func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl {
return processor.NewNotificationProcessor(
repos.notificationRepo,
repos.notificationReceiverRepo,
+6
View File
@@ -28,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct {
// Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9).
TotalAmount float64 `json:"total_amount"`
PointAmount float64 `json:"point_amount"`
PointsUsed int64 `json:"points_used"`
TotalWithPoints float64 `json:"total_with_points"`
TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"`
@@ -42,6 +46,8 @@ type PaymentMethodAnalyticsData struct {
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
CountsAsCashIn bool `json:"counts_as_cash_in"`
}
type SalesAnalyticsRequest struct {
@@ -0,0 +1,11 @@
package contract
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
// app got for this device. Send it after login and whenever FCM refreshes the token.
type RegisterCustomerDeviceRequest struct {
DeviceID string `json:"device_id" binding:"required"`
FCMToken string `json:"fcm_token" binding:"required"`
// android, ios or web.
Platform *string `json:"platform"`
AppVersion *string `json:"app_version"`
}
@@ -31,3 +31,9 @@ type RemoveCustomerPinRequest struct {
type IssuePaymentCodeRequest struct {
Pin string `json:"pin" binding:"required"`
}
// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points.
type PayWithPointsRequest struct {
Points int64 `json:"points" binding:"required,min=1"`
Pin string `json:"pin" binding:"required"`
}
+15 -10
View File
@@ -9,15 +9,17 @@ import (
type CreateGamePlayRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
CoinsUsed int `json:"coins_used" validate:"min=0"`
RandomSeed *string `json:"random_seed,omitempty"`
}
type GamePlayResponse struct {
ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
CoinsUsed int `json:"coins_used"`
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
TokenUsed int `json:"token_used"`
RandomSeed *string `json:"random_seed,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -33,7 +35,7 @@ type ListGamePlaysRequest struct {
GameID *uuid.UUID `json:"game_id"`
CustomerID *uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at token_used"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
}
@@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct {
TotalPages int `json:"total_pages"`
}
// PlayGameRequest plays a game for a customer. What it costs is the game's
// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice.
type PlayGameRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
}
type PlayGameResponse struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
}
+24 -16
View File
@@ -186,9 +186,13 @@ type SetOrderCustomerResponse struct {
}
type CreatePaymentRequest struct {
OrderID uuid.UUID `json:"order_id" validate:"required"`
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,min=0"`
OrderID uuid.UUID `json:"order_id" validate:"required"`
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
// For the EnakPoint method: how many to use and the code the customer shows. The
// amount is then computed from them.
Points *int64 `json:"points,omitempty"`
PaymentCode *string `json:"payment_code,omitempty"`
Amount float64 `json:"amount" validate:"min=0"`
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
@@ -204,19 +208,23 @@ type CreatePaymentOrderItemRequest struct {
}
type PaymentResponse struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
PaymentMethodID uuid.UUID `json:"payment_method_id"`
PaymentMethodName string `json:"payment_method_name"`
PaymentMethodType string `json:"payment_method_type"`
Amount float64 `json:"amount"`
Status string `json:"status"`
TransactionID *string `json:"transaction_id,omitempty"`
SplitNumber int `json:"split_number"`
SplitTotal int `json:"split_total"`
SplitType *string `json:"split_type,omitempty"`
SplitDescription *string `json:"split_description,omitempty"`
RefundAmount float64 `json:"refund_amount"`
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
PaymentMethodID uuid.UUID `json:"payment_method_id"`
PaymentMethodName string `json:"payment_method_name"`
PaymentMethodType string `json:"payment_method_type"`
Amount float64 `json:"amount"`
Status string `json:"status"`
TransactionID *string `json:"transaction_id,omitempty"`
SplitNumber int `json:"split_number"`
SplitTotal int `json:"split_total"`
SplitType *string `json:"split_type,omitempty"`
SplitDescription *string `json:"split_description,omitempty"`
RefundAmount float64 `json:"refund_amount"`
// Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the
// receipt (docs/prd-point-coin.md F9).
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `json:"point_value,omitempty"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundedAt *time.Time `json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
+5 -3
View File
@@ -14,7 +14,9 @@ type SpinGameResponse struct {
// SpinGameResponseData contains the game play result
type SpinGameResponseData struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
}
+17
View File
@@ -12,3 +12,20 @@ type AdjustCustomerWalletRequest struct {
// adjusting twice, so a retried request is safe.
IdempotencyKey string `json:"idempotency_key"`
}
// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4).
// The Idempotency-Key header is required.
type ExchangeCoinsRequest struct {
Coins int64 `json:"coins" binding:"required,min=1"`
Pin string `json:"pin" binding:"required"`
}
// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5).
// The Idempotency-Key header is required.
type TransferWalletRequest struct {
// POINT or COIN.
Currency string `json:"currency" binding:"required"`
Amount int64 `json:"amount" binding:"required,min=1"`
RecipientPhone string `json:"recipient_phone" binding:"required"`
Pin string `json:"pin" binding:"required"`
}
+11 -9
View File
@@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
// EnakPoint used, for the EnakPoint method.
PointsUsed int64 `json:"points_used"`
}
// SalesAnalytics represents sales analytics data
@@ -173,15 +175,15 @@ type BudgetCutOffWeek struct {
// DashboardOverview represents dashboard overview data
type DashboardOverview struct {
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
TotalSales float64 `json:"total_sales"`
TotalOrders int64 `json:"total_orders"`
AverageOrderValue float64 `json:"average_order_value"`
TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
}
type ProfitLossAnalytics struct {
+1 -1
View File
@@ -12,7 +12,7 @@ type GamePlay struct {
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"`
CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"`
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
@@ -0,0 +1,45 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// CustomerDeviceHandler serves /customer/devices, where the customer app registers
// the FCM token of each device it runs on.
type CustomerDeviceHandler struct {
devices service.CustomerDeviceService
}
func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler {
return &CustomerDeviceHandler{devices: devices}
}
// Register is PUT /customer/devices.
func (h *CustomerDeviceHandler) Register(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register")
if !ok {
return
}
var req contract.RegisterCustomerDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"),
}), "CustomerDeviceHandler::Register")
return
}
util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register")
}
// Unregister is DELETE /customer/devices/:device_id, for logout.
func (h *CustomerDeviceHandler) Unregister(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister")
if !ok {
return
}
util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister")
}
@@ -0,0 +1,35 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points
// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged.
type CustomerOrderPaymentHandler struct {
payments service.CustomerOrderPaymentService
}
func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler {
return &CustomerOrderPaymentHandler{payments: payments}
}
func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
var req contract.PayWithPointsRequest
if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") {
return
}
util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints")
}
@@ -0,0 +1,92 @@
package handler
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the
// X- form the rest of the API uses, which is accepted too.
const (
idempotencyKeyHeader = "Idempotency-Key"
legacyIdempotencyKeyHeader = "X-Idempotency-Key"
)
// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies
// hold the PIN, so they are never logged.
type CustomerWalletHandler struct {
wallets service.CustomerWalletService
}
func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler {
return &CustomerWalletHandler{wallets: wallets}
}
// PreviewExchange is GET /customer/wallet/exchange/preview?coins=.
func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange")
if !ok {
return
}
var coins int64
if raw := c.Query("coins"); raw != "" {
var err error
coins, err = strconv.ParseInt(raw, 10, 64)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"),
}), "CustomerWalletHandler::PreviewExchange")
return
}
}
util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange")
}
// Exchange is POST /customer/wallet/exchange.
func (h *CustomerWalletHandler) Exchange(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange")
if !ok {
return
}
var req contract.ExchangeCoinsRequest
if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") {
return
}
util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange")
}
func idempotencyKey(c *gin.Context) string {
if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" {
return key
}
return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader))
}
// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=.
func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient")
if !ok {
return
}
util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient")
}
// Transfer is POST /customer/wallet/transfer.
func (h *CustomerWalletHandler) Transfer(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer")
if !ok {
return
}
var req contract.TransferWalletRequest
if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") {
return
}
util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer")
}
+13
View File
@@ -1,8 +1,11 @@
package handler
import (
"errors"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/transformer"
"apskel-pos-be/internal/util"
@@ -202,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
}
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
if errors.Is(err, processor.ErrPointPaymentRejected) {
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
return
}
@@ -225,6 +233,11 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
if err != nil {
// A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault.
if errors.Is(err, processor.ErrPointPaymentRejected) {
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
return
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// PointPaymentHandler serves GET /orders/:id/point-payment/preview
// (docs/prd-point-coin.md F9).
type PointPaymentHandler struct {
pointPaymentService service.PointPaymentService
}
func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler {
return &PointPaymentHandler{pointPaymentService: pointPaymentService}
}
func (h *PointPaymentHandler) Preview(c *gin.Context) {
orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview")
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview")
}
+10 -1
View File
@@ -1,10 +1,13 @@
package handler
import (
"errors"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
@@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
// Play the spin game
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
if err != nil {
// Not enough EnakCoin, an inactive game or a prize that just ran out is the
// customer's to fix, not a server failure.
code := constants.InternalServerErrorCode
if errors.Is(err, processor.ErrGamePlayRefused) {
code = constants.ValidationErrorCode
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()),
contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()),
}), "SpinGameHandler::PlaySpinGame")
return
}
+1 -1
View File
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
}))
h := NewWalletAdminHandler(
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
validator.NewWalletValidator(),
)
gin.SetMode(gin.TestMode)
+14
View File
@@ -83,3 +83,17 @@ func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
}
// TraceTransaction is GET /marketing/wallet-transactions/:id/trace.
func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) {
ctx := c.Request.Context()
transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction")
if !ok {
return
}
response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction")
}
+2 -2
View File
@@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
GameID: gamePlay.GameID,
CustomerID: gamePlay.CustomerID,
PrizeID: gamePlay.PrizeID,
TokenUsed: gamePlay.TokenUsed,
CoinsUsed: gamePlay.CoinsUsed,
RandomSeed: gamePlay.RandomSeed,
CreatedAt: gamePlay.CreatedAt,
Game: ToGameResponse(&gamePlay.Game),
@@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
return &entities.GamePlay{
GameID: req.GameID,
CustomerID: req.CustomerID,
TokenUsed: req.TokenUsed,
CoinsUsed: req.CoinsUsed,
RandomSeed: req.RandomSeed,
}
}
+2
View File
@@ -189,6 +189,8 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse
SplitType: (*string)(payment.SplitType),
SplitDescription: payment.SplitDescription,
RefundAmount: payment.RefundAmount,
PointsUsed: payment.PointsUsed,
PointValue: payment.PointValue,
RefundReason: payment.RefundReason,
RefundedAt: payment.RefundedAt,
RefundedBy: payment.RefundedBy,
+4 -2
View File
@@ -38,7 +38,8 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
return &contract.SpinGameResponseData{
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
TokensRemaining: data.TokensRemaining,
CoinsRemaining: data.CoinsRemaining,
TokensRemaining: data.CoinsRemaining,
}
}
@@ -53,7 +54,8 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
GameID: resp.GameID,
CustomerID: resp.CustomerID,
PrizeID: resp.PrizeID,
TokenUsed: resp.TokenUsed,
CoinsUsed: resp.CoinsUsed,
TokenUsed: resp.CoinsUsed,
RandomSeed: resp.RandomSeed,
CreatedAt: resp.CreatedAt,
Game: nil, // Optional field - can be populated separately if needed
+19 -8
View File
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct {
TotalAmount float64 `json:"total_amount"`
// Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9,
// K7) and is reported apart; its accounting treatment waits on note N2.
TotalAmount float64 `json:"total_amount"`
// Rupiah paid with EnakPoint, and how many EnakPoint that was.
PointAmount float64 `json:"point_amount"`
PointsUsed int64 `json:"points_used"`
// TotalAmount plus PointAmount: the value of the orders paid.
TotalWithPoints float64 `json:"total_with_points"`
TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"`
@@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
Percentage float64 `json:"percentage"`
// Share of the money received; 0 for EnakPoint, which is not money in.
Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
// False for EnakPoint.
CountsAsCashIn bool `json:"counts_as_cash_in"`
}
// SalesAnalyticsRequest represents the request for sales analytics
@@ -464,12 +475,12 @@ type ProfitLossAnalyticsResponse struct {
}
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
+7 -7
View File
@@ -9,7 +9,7 @@ import (
type CreateGamePlayRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
CoinsUsed int `json:"coins_used" validate:"min=0"`
RandomSeed *string `json:"random_seed,omitempty"`
}
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
TokenUsed int `json:"token_used"`
CoinsUsed int `json:"coins_used"`
RandomSeed *string `json:"random_seed,omitempty"`
CreatedAt time.Time `json:"created_at"`
Game *GameResponse `json:"game,omitempty"`
@@ -33,18 +33,18 @@ type ListGamePlaysQuery struct {
GameID *uuid.UUID `query:"game_id"`
CustomerID *uuid.UUID `query:"customer_id"`
PrizeID *uuid.UUID `query:"prize_id"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
}
// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost.
type PlayGameRequest struct {
GameID uuid.UUID `json:"game_id" validate:"required"`
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
TokenUsed int `json:"token_used" validate:"min=0"`
}
type PlayGameResponse struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
}
+7 -2
View File
@@ -28,8 +28,10 @@ type Payment struct {
}
type CreatePaymentRequest struct {
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
Points *int64
PaymentCode *string
Amount float64 `validate:"required,min=0"`
TransactionID *string `validate:"omitempty"`
SplitNumber int `validate:"omitempty,min=1"`
@@ -60,6 +62,9 @@ type PaymentResponse struct {
SplitType *string
SplitDescription *string
RefundAmount float64
// Set for a payment with EnakPoint.
PointsUsed *int64
PointValue *float64
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
+3 -3
View File
@@ -20,9 +20,9 @@ type SpinGameResponse struct {
// SpinGameResponseData contains the game play result
type SpinGameResponseData struct {
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
TokensRemaining int64 `json:"tokens_remaining"`
GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"`
}
// SpinGamePrizeResponse represents a prize won in the spin game
+19
View File
@@ -138,3 +138,22 @@ type AdminWalletAdjustmentResult struct {
// 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"`
}
+73
View File
@@ -0,0 +1,73 @@
package models
import (
"time"
"github.com/google/uuid"
)
// WalletExchangePreview is GET /customer/wallet/exchange/preview
// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give.
type WalletExchangePreview struct {
// The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint.
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
CoinBalance int64 `json:"coin_balance"`
Coins int64 `json:"coins"`
Points int64 `json:"points"`
// Whether Coins can be exchanged now, and why not when it cannot.
Valid bool `json:"valid"`
Reason string `json:"reason,omitempty"`
}
// WalletMovedLot is part of what an exchange or a transfer delivered, with the
// expiry it carried over from the lot it came from (K9).
type WalletMovedLot struct {
Amount int64 `json:"amount"`
// Nil when it never expires.
ExpiresAt *time.Time `json:"expires_at"`
}
// WalletExchangeResult is POST /customer/wallet/exchange.
type WalletExchangeResult struct {
GroupID uuid.UUID `json:"group_id"`
Coins int64 `json:"coins"`
Points int64 `json:"points"`
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
// The EnakPoint received, split by expiry.
Lots []WalletMovedLot `json:"lots"`
CoinBalance int64 `json:"coin_balance"`
PointBalance int64 `json:"point_balance"`
// True when this was a retry of an exchange already made; nothing moved again.
Replayed bool `json:"replayed"`
}
// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone
// number belongs to, masked, so the sender can check before confirming (F5).
type WalletTransferRecipient struct {
Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
}
// WalletTransfer is what a customer asks to send (F5).
type WalletTransfer struct {
// POINT or COIN.
Currency string
Amount int64
RecipientPhone string
}
// WalletTransferResult is POST /customer/wallet/transfer.
type WalletTransferResult struct {
GroupID uuid.UUID `json:"group_id"`
Currency string `json:"currency"`
Amount int64 `json:"amount"`
Recipient WalletTransferRecipient `json:"recipient"`
// What the recipient received, split by the expiry it carried over.
Lots []WalletMovedLot `json:"lots"`
// The sender's balance in the currency sent.
Balance int64 `json:"balance"`
// True when this was a retry of a transfer already made; nothing moved again.
Replayed bool `json:"replayed"`
}
+46
View File
@@ -0,0 +1,46 @@
package models
import (
"time"
"github.com/google/uuid"
)
// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace
// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and
// where each of them came from, lot by lot, back to the lot that first brought the
// balance into a wallet. It follows balance across transfers, exchanges and refunds.
type AdminWalletTrace struct {
Transaction AdminWalletTraceRow `json:"transaction"`
// One per lot the row took from (a debit) or created (a credit).
Lots []AdminWalletTraceLot `json:"lots"`
}
// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain.
type AdminWalletTraceLot struct {
Amount int64 `json:"amount"`
// The lot itself first, then the lot it was carried over from, and so on. The
// last step is the lot an EARN, ADJUSTMENT or MIGRATION created.
Chain []AdminWalletTraceStep `json:"chain"`
}
// AdminWalletTraceStep is one lot on a chain and the row that created it.
type AdminWalletTraceStep struct {
Lot AdminWalletLot `json:"lot"`
Source AdminWalletTraceRow `json:"source"`
}
// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the
// customer it belongs to.
type AdminWalletTraceRow struct {
ID uuid.UUID `json:"id"`
Customer AdminWalletNamedRef `json:"customer"`
Currency string `json:"currency"`
Type string `json:"type"`
Amount int64 `json:"amount"`
Description string `json:"description"`
ReferenceType string `json:"reference_type"`
ReferenceID uuid.UUID `json:"reference_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+22 -7
View File
@@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
}
var totalAmount float64
// EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own
// method but left out of the money received and of the shares. How it is booked
// waits on note N2.
var cashAmount, pointAmount float64
var pointsUsed int64
var totalOrders int64
var totalPayments int64
for _, data := range analyticsData {
totalAmount += data.TotalAmount
if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) {
pointAmount += data.TotalAmount
pointsUsed += data.PointsUsed
} else {
cashAmount += data.TotalAmount
}
totalOrders += data.OrderCount
totalPayments += data.PaymentCount
}
// The value of an order includes what EnakPoint paid, so the average does too.
var averageOrderValue float64
if totalOrders > 0 {
averageOrderValue = totalAmount / float64(totalOrders)
averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders)
}
// Calculate percentages
var resultData []models.PaymentMethodAnalyticsData
for _, data := range analyticsData {
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
var percentage float64
if totalAmount > 0 {
percentage = (data.TotalAmount / totalAmount) * 100
if cashIn && cashAmount > 0 {
percentage = (data.TotalAmount / cashAmount) * 100
}
resultData = append(resultData, models.PaymentMethodAnalyticsData{
@@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
OrderCount: data.OrderCount,
PaymentCount: data.PaymentCount,
Percentage: percentage,
PointsUsed: data.PointsUsed,
CountsAsCashIn: cashIn,
})
}
summary := models.PaymentMethodSummary{
TotalAmount: totalAmount,
TotalAmount: cashAmount,
PointAmount: pointAmount,
PointsUsed: pointsUsed,
TotalWithPoints: cashAmount + pointAmount,
TotalOrders: totalOrders,
TotalPayments: totalPayments,
AverageOrderValue: averageOrderValue,
+36 -2
View File
@@ -10,6 +10,7 @@ import (
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -24,10 +25,11 @@ type analyticsRepositoryStub struct {
exclusiveSummaryCalls int
exclusiveSummaryFrom []time.Time
exclusiveSummaryTo []time.Time
paymentMethods []*entities.PaymentMethodAnalytics
}
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
return nil, nil
func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
return s.paymentMethods, nil
}
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
@@ -570,3 +572,35 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 2)
}
// EnakPoint is listed as its own method but is not money in (F9, K7).
func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{
{PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2},
{PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1},
{PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000},
}}
p := NewAnalyticsProcessorImpl(repo, nil)
got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{
OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out")
assert.Equal(t, 30000.0, got.Summary.PointAmount)
assert.Equal(t, int64(30000), got.Summary.PointsUsed)
assert.Equal(t, 120000.0, got.Summary.TotalWithPoints)
assert.Equal(t, int64(4), got.Summary.TotalOrders)
assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid")
byType := map[string]models.PaymentMethodAnalyticsData{}
for _, d := range got.Data {
byType[d.PaymentMethodType] = d
}
assert.True(t, byType["cash"].CountsAsCashIn)
assert.False(t, byType["point"].CountsAsCashIn)
assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received")
assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01)
assert.Zero(t, byType["point"].Percentage)
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
}
@@ -0,0 +1,77 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/repository"
)
// ErrInvalidCustomerDevice wraps every rejection of a device registration.
var ErrInvalidCustomerDevice = errors.New("invalid customer device")
type customerPushSender interface {
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
}
// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push
// notifications to a customer's devices.
type CustomerDeviceProcessor struct {
repo repository.CustomerDeviceRepository
// Nil when FCM is not configured; notifications are then skipped.
fcm customerPushSender
now func() time.Time
}
func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor {
return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now}
}
// Register saves the FCM token the app got for this device. The app calls it after
// login and whenever FCM gives it a new token.
func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error {
device.DeviceID = strings.TrimSpace(device.DeviceID)
device.FCMToken = strings.TrimSpace(device.FCMToken)
switch {
case device.DeviceID == "" || len(device.DeviceID) > 255:
return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice)
case device.FCMToken == "" || len(device.FCMToken) > 512:
return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice)
}
if device.Platform != nil {
platform := strings.ToLower(strings.TrimSpace(*device.Platform))
if platform != "android" && platform != "ios" && platform != "web" {
return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice)
}
device.Platform = &platform
}
return p.repo.Register(ctx, device, p.now())
}
// Unregister forgets a device, so it stops getting the customer's notifications.
func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID))
}
// Notify pushes a notification to every device of the customer through FCM. A
// customer without a registered device gets nothing, which is not an error.
func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
if p.fcm == nil {
logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID))
return nil
}
tokens, err := p.repo.ListTokens(ctx, customerID)
if err != nil {
return err
}
if len(tokens) == 0 {
return nil
}
return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data)
}
@@ -0,0 +1,136 @@
package processor
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/repository"
)
// customerDeviceRepoFake keeps devices the way the table does: one row per customer
// and device, and a token belongs to one row only.
type customerDeviceRepoFake struct{ devices []repository.CustomerDevice }
func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error {
kept := f.devices[:0]
for _, existing := range f.devices {
sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID
if !sameRow && existing.FCMToken != d.FCMToken {
kept = append(kept, existing)
}
}
f.devices = append(kept, d)
return nil
}
func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error {
kept := f.devices[:0]
for _, d := range f.devices {
if d.CustomerID != customerID || d.DeviceID != deviceID {
kept = append(kept, d)
}
}
f.devices = kept
return nil
}
func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) {
var tokens []string
for _, d := range f.devices {
if d.CustomerID == customerID {
tokens = append(tokens, d.FCMToken)
}
}
return tokens, nil
}
type fcmFake struct {
tokens [][]string
title string
body string
data map[string]string
err error
}
func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error {
f.tokens = append(f.tokens, tokens)
f.title, f.body, f.data = title, body, data
return f.err
}
func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) {
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
p := NewCustomerDeviceProcessor(repo, fcm)
ctx := context.Background()
budi, anita := uuid.New(), uuid.New()
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")}))
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"}))
// A refreshed token replaces the old one of the same device.
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"}))
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"}))
require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"}))
assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens)
assert.Equal(t, "EnakPoint masuk", fcm.title)
assert.Equal(t, map[string]string{"type": "X"}, fcm.data)
}
func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) {
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
p := NewCustomerDeviceProcessor(repo, fcm)
ctx := context.Background()
budi, anita := uuid.New(), uuid.New()
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"}))
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"}))
// Budi's notifications no longer reach the phone Anita is now logged in on.
require.NoError(t, p.Notify(ctx, budi, "t", "b", nil))
require.NoError(t, p.Notify(ctx, anita, "t", "b", nil))
assert.Equal(t, [][]string{{"shared"}}, fcm.tokens)
}
func TestCustomerDevice_NothingToSend(t *testing.T) {
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
ctx := context.Background()
customer := uuid.New()
// No device registered, or logged out: nothing is sent and nothing fails.
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone"))
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
assert.Empty(t, fcm.tokens)
// FCM not configured.
require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil))
}
func TestCustomerDevice_FCMFailureIsReported(t *testing.T) {
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")}
p := NewCustomerDeviceProcessor(repo, fcm)
ctx := context.Background()
customer := uuid.New()
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
assert.Error(t, p.Notify(ctx, customer, "t", "b", nil))
}
func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) {
p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil)
ctx := context.Background()
for name, d := range map[string]repository.CustomerDevice{
"no device": {FCMToken: "t"},
"no token": {DeviceID: "phone", FCMToken: " "},
"bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")},
} {
assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name)
}
}
+21 -17
View File
@@ -100,24 +100,22 @@ type pinOtpSender interface {
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
}
// pinAlerter tells a customer their PIN was locked. There is no push channel to
// customers yet, so the app sends it by WhatsApp.
type pinAlerter interface {
SendWhatsAppMessage(phoneNumber, message string) error
}
// NotificationTypePinLocked is the data type of the push a customer gets when their
// PIN locks, so the app can offer the PIN reset.
const NotificationTypePinLocked = "PIN_LOCKED"
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
// that moves balance on the customer's request calls VerifyPin first (K8).
type CustomerPinProcessor struct {
repo repository.CustomerPinRepository
otp pinOtpSender
alerter pinAlerter
now func() time.Time
cost int
repo repository.CustomerPinRepository
otp pinOtpSender
notifier customerNotifier
now func() time.Time
cost int
}
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor {
return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost}
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor {
return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost}
}
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
@@ -295,7 +293,7 @@ func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.Cus
// customer; attempts racing it just see the lock.
if attempts == pinMaxAttempts {
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
p.alertLocked(state, *lockedUntil)
p.alertLocked(ctx, state, *lockedUntil)
}
return &PinError{Code: PinErrLocked, Until: lockedUntil}
}
@@ -420,13 +418,19 @@ func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUI
}
}
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
if p.alerter == nil || state.PhoneNumber == nil {
// alertLocked pushes the lock to the customer's app through FCM (F11). It is best
// effort: the lock stands whether or not the push goes out.
func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) {
if p.notifier == nil {
return
}
message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
data := map[string]string{
"type": NotificationTypePinLocked,
"locked_until": until.UTC().Format(time.RFC3339),
}
if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
}
}
@@ -68,10 +68,10 @@ type alerterFake struct {
messages []string
}
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.messages = append(f.messages, message)
f.messages = append(f.messages, body)
return nil
}
@@ -1,10 +1,17 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestCheckNewPin(t *testing.T) {
@@ -14,18 +21,18 @@ func TestCheckNewPin(t *testing.T) {
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
}
for name, c := range map[string][2]string{
"too short": {"12345", "12345"},
"too long": {"1234567", "1234567"},
"not digits": {"12a456", "12a456"},
"confirmation": {"482913", "482914"},
"one digit": {"111111", "111111"},
"zeros": {"000000", "000000"},
"run up": {"123456", "123456"},
"run up from 4": {"456789", "456789"},
"run down": {"654321", "654321"},
"run down from 9": {"987654", "987654"},
"birth date DDMMYY": {"140390", "140390"},
"birth date YYMMDD": {"900314", "900314"},
"too short": {"12345", "12345"},
"too long": {"1234567", "1234567"},
"not digits": {"12a456", "12a456"},
"confirmation": {"482913", "482914"},
"one digit": {"111111", "111111"},
"zeros": {"000000", "000000"},
"run up": {"123456", "123456"},
"run up from 4": {"456789", "456789"},
"run down": {"654321", "654321"},
"run down from 9": {"987654", "987654"},
"birth date DDMMYY": {"140390", "140390"},
"birth date YYMMDD": {"900314", "900314"},
} {
err := checkNewPin(c[0], c[1], &birth)
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
@@ -34,3 +41,67 @@ func TestCheckNewPin(t *testing.T) {
// Without a birth date only the other rules apply.
assert.NoError(t, checkNewPin("140390", "140390", nil))
}
// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure.
type pinRepoFake struct {
repository.CustomerPinRepository
state repository.CustomerPinState
}
func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) {
s := f.state
return &s, nil
}
func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) {
f.state.FailedAttempts, f.state.LockedUntil = 1, nil
} else {
f.state.FailedAttempts++
if f.state.FailedAttempts >= maxAttempts {
f.state.LockedUntil = &lockUntil
}
}
return f.state.FailedAttempts, f.state.LockedUntil, nil
}
func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error {
f.state.FailedAttempts, f.state.LockedUntil = 0, nil
return nil
}
func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error {
return nil
}
func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) {
customer := uuid.New()
hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost)
require.NoError(t, err)
h := string(hash)
repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}}
notifier := &notifierFake{}
p := NewCustomerPinProcessor(repo, nil, notifier)
now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC)
p.now = func() time.Time { return now }
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
}
assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks")
err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
var pinErr *PinError
require.ErrorAs(t, err, &pinErr)
assert.Equal(t, PinErrLocked, pinErr.Code)
// Attempts while locked do not push again.
_ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{})
require.Len(t, notifier.pushes[customer], 1)
push := notifier.pushes[customer][0]
assert.Equal(t, "PIN terkunci", push.title)
assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body)
assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data)
}
+155 -89
View File
@@ -1,6 +1,7 @@
package processor
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
@@ -8,33 +9,55 @@ import (
"context"
"errors"
"fmt"
"math"
"math/rand"
"time"
"github.com/google/uuid"
)
// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game,
// its prizes or their EnakCoin. The message says which.
var ErrGamePlayRefused = errors.New("game cannot be played")
const (
gameCoinCostKey = "coin_cost"
defaultGameCoinCost = 1
)
type gameReader interface {
GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error)
}
type gamePrizeStock interface {
GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error)
DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error
}
type GamePlayProcessor struct {
gamePlayRepo repository.GamePlayRepository
gameRepo *repository.GameRepository
gamePrizeRepo *repository.GamePrizeRepository
customerTokensRepo *repository.CustomerTokensRepository
customerPointsRepo repository.CustomerPointsRepository
gamePlayRepo repository.GamePlayRepository
gameRepo gameReader
gamePrizeRepo gamePrizeStock
wallet *WalletProcessor
spendable spendableReader
tx TxRunner
}
func NewGamePlayProcessor(
gamePlayRepo repository.GamePlayRepository,
gameRepo *repository.GameRepository,
gamePrizeRepo *repository.GamePrizeRepository,
customerTokensRepo *repository.CustomerTokensRepository,
customerPointsRepo repository.CustomerPointsRepository,
gameRepo gameReader,
gamePrizeRepo gamePrizeStock,
wallet *WalletProcessor,
spendable spendableReader,
tx TxRunner,
) *GamePlayProcessor {
return &GamePlayProcessor{
gamePlayRepo: gamePlayRepo,
gameRepo: gameRepo,
gamePrizeRepo: gamePrizeRepo,
customerTokensRepo: customerTokensRepo,
customerPointsRepo: customerPointsRepo,
gamePlayRepo: gamePlayRepo,
gameRepo: gameRepo,
gamePrizeRepo: gamePrizeRepo,
wallet: wallet,
spendable: spendable,
tx: tx,
}
}
@@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
}
offset := (query.Page - 1) * query.Limit
// The column was renamed; the old name still sorts the same way.
if query.SortBy == "token_used" {
query.SortBy = "coins_used"
}
// Get game plays from repository
gamePlays, total, err := p.gamePlayRepo.List(
@@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
}, nil
}
// PlayGame handles the game playing logic
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
// the prize from stock happen in one transaction, so a play that cannot be completed,
// for instance because the prize just ran out, takes nothing and records nothing.
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
// Verify game exists and is active
refuse := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
}
game, err := p.gameRepo.GetByID(ctx, req.GameID)
if err != nil {
return nil, fmt.Errorf("game not found: %w", err)
}
if !game.IsActive {
return nil, errors.New("game is not active")
return nil, refuse("game is not active")
}
// Convert GameType to TokenType
tokenType := entities.TokenType(game.Type)
// Check if customer has enough tokens
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
cost, err := gameCoinCost(game.Metadata)
if err != nil {
return nil, fmt.Errorf("customer tokens not found: %w", err)
return nil, refuse("%s", err.Error())
}
if customerTokens.Balance < int64(req.TokenUsed) {
return nil, errors.New("insufficient tokens")
}
// Deduct tokens
err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
if err != nil {
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
}
// Get available prizes
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
if err != nil {
return nil, fmt.Errorf("failed to get available prizes: %w", err)
}
if len(availablePrizes) == 0 {
return nil, errors.New("no prizes available")
}
// Convert entities to models for prize selection
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
for i, prize := range availablePrizes {
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
}
// Select prize based on weight
selectedPrize := p.selectPrizeByWeight(prizeResponses)
// Generate random seed for audit
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
// Create game play record
gamePlay := &models.CreateGamePlayRequest{
GameID: req.GameID,
CustomerID: req.CustomerID,
TokenUsed: req.TokenUsed,
RandomSeed: &randomSeed,
}
gamePlayEntity := mappers.ToGamePlayEntity(gamePlay)
if selectedPrize != nil {
gamePlayEntity.PrizeID = &selectedPrize.ID
}
err = p.gamePlayRepo.Create(ctx, gamePlayEntity)
if err != nil {
// Rollback token deduction
p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
return nil, fmt.Errorf("failed to create game play: %w", err)
}
// Decrease prize stock if prize was won
if selectedPrize != nil {
err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
var gamePlay *entities.GamePlay
var selectedPrize *models.GamePrizeResponse
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
if err != nil {
// Log error but don't fail the transaction
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
return fmt.Errorf("failed to get available prizes: %w", err)
}
}
if len(availablePrizes) == 0 {
return refuse("no prizes available")
}
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
for i, prize := range availablePrizes {
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
}
selectedPrize = p.selectPrizeByWeight(prizeResponses)
// Get updated token balance
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
// Random seed kept for audit.
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
gamePlay = &entities.GamePlay{
ID: uuid.New(),
GameID: req.GameID,
CustomerID: req.CustomerID,
CoinsUsed: int(cost),
RandomSeed: &randomSeed,
}
if selectedPrize != nil {
gamePlay.PrizeID = &selectedPrize.ID
}
// The EnakCoin go first: the debit locks the wallet, so plays by the same
// customer at the same time queue up instead of spending the same balance.
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: req.CustomerID,
Currency: constants.WalletCurrencyCoin,
Type: constants.WalletTxTypeGameSpend,
Amount: cost,
ReferenceType: constants.WalletRefTypeGamePlay,
ReferenceID: gamePlay.ID,
Description: gameSpendDescription(game.Name, selectedPrize),
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return refuse("not enough EnakCoin: this game costs %d", cost)
}
if err != nil {
return err
}
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
return err
}
if selectedPrize != nil {
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
return refuse("the prize just ran out, please play again")
}
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
if err != nil {
return nil, err
}
return &models.PlayGameResponse{
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
PrizeWon: selectedPrize,
TokensRemaining: updatedTokens.Balance,
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
PrizeWon: selectedPrize,
CoinsRemaining: balances[constants.WalletCurrencyCoin],
}, nil
}
// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a
// whole number of EnakCoin, 1 when it is not set (F8).
func gameCoinCost(metadata entities.Metadata) (int64, error) {
raw, ok := metadata[gameCoinCostKey]
if !ok || raw == nil {
return defaultGameCoinCost, nil
}
var cost int64
switch v := raw.(type) {
case float64:
if v != math.Trunc(v) {
return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v)
}
cost = int64(v)
case int:
cost = int64(v)
case int64:
cost = v
default:
return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw)
}
if cost < 1 {
return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost)
}
return cost, nil
}
// gameSpendDescription is what the customer's history shows for a play, frozen when it
// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb".
func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string {
description := "Main " + gameName
if prize != nil {
description += ": dapat " + prize.Name
}
return truncateRunes(description, walletDescriptionLimit)
}
// selectPrizeByWeight selects a prize based on weight distribution
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
if len(prizes) == 0 {
@@ -0,0 +1,69 @@
package processor
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// Plays against Postgres, so the game repositories really join the transaction: a
// refused play leaves no game_plays row and no GAME_SPEND.
func TestPlayGame_AgainstPostgres(t *testing.T) {
db, _, player, _ := walletMoveDB(t)
gameID, prizeID := uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error)
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
})
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
in := earn(player, 3, nil)
in.Currency = constants.WalletCurrencyCoin
_, err := wallet.Credit(ctx, in)
return err
}))
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
wallet, repository.NewWalletQueryRepository(db), txm)
play := func() (*models.PlayGameResponse, error) {
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
}
res, err := play()
require.NoError(t, err)
assert.Equal(t, int64(1), res.CoinsRemaining)
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
var spend struct {
ReferenceID uuid.UUID
Amount int64
}
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
assert.Equal(t, int64(-2), spend.Amount)
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
_, err = play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
_, err = play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
var plays, spends int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
assert.Equal(t, int64(1), plays)
assert.Equal(t, int64(1), spends)
}
@@ -0,0 +1,245 @@
package processor
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// snapshot saves everything the fake wallet holds and returns what puts it back, so
// a test transaction can roll back like a real one.
func (f *walletRepoFake) snapshot() func() {
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
for id, w := range f.wallets {
c := *w
wallets[id] = &c
}
transactions := make([]*entities.WalletTransaction, len(f.transactions))
for i, t := range f.transactions {
c := *t
transactions[i] = &c
}
lots := make([]*entities.WalletLot, len(f.lots))
for i, l := range f.lots {
c := *l
lots[i] = &c
}
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
return func() {
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
}
}
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
type rollbackTx struct{ saves []func() func() }
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
restores := make([]func(), 0, len(r.saves))
for _, save := range r.saves {
restores = append(restores, save())
}
err := fn(ctx)
if err != nil {
for _, restore := range restores {
restore()
}
}
return err
}
type gameFake struct{ games map[uuid.UUID]*entities.Game }
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
g, ok := f.games[id]
if !ok {
return nil, errors.New("record not found")
}
return g, nil
}
type prizeStockFake struct {
prizes []entities.GamePrize
stockErr error
}
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
var out []entities.GamePrize
for _, p := range f.prizes {
if p.GameID == gameID && p.Stock > 0 {
out = append(out, p)
}
}
return out, nil
}
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
if f.stockErr != nil {
return f.stockErr
}
for i := range f.prizes {
if f.prizes[i].ID == id {
if f.prizes[i].Stock < amount {
return repository.ErrGamePrizeOutOfStock
}
f.prizes[i].Stock -= amount
return nil
}
}
return repository.ErrGamePrizeOutOfStock
}
func (f *prizeStockFake) snapshot() func() {
prizes := append([]entities.GamePrize(nil), f.prizes...)
return func() { f.prizes = prizes }
}
// gamePlayRepoFake records plays; the other methods of the interface are not used.
type gamePlayRepoFake struct {
repository.GamePlayRepository
plays []entities.GamePlay
}
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
f.plays = append(f.plays, *gp)
return nil
}
func (f *gamePlayRepoFake) snapshot() func() {
plays := append([]entities.GamePlay(nil), f.plays...)
return func() { f.plays = plays }
}
type gameTestEnv struct {
*walletMoveEnv
game *entities.Game
prize entities.GamePrize
stock *prizeStockFake
plays *gamePlayRepoFake
player uuid.UUID
p *GamePlayProcessor
}
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
e.plays = &gamePlayRepoFake{}
e.player = e.member("Budi", "081234561234")
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
return e
}
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
}
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
// coin_cost comes back from JSONB as a float64.
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 10, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(7), res.CoinsRemaining)
require.NotNil(t, res.PrizeWon)
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
require.Len(t, e.plays.plays, 1)
play := e.plays.plays[0]
assert.Equal(t, 3, play.CoinsUsed)
assert.Equal(t, e.prize.ID, *play.PrizeID)
assert.Equal(t, 4, e.stock.prizes[0].Stock)
spend := e.repo.transactions[len(e.repo.transactions)-1]
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
assert.Equal(t, int64(-3), spend.Amount)
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
}
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 2, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(1), res.CoinsRemaining)
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
}
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 2, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Empty(t, e.plays.plays)
assert.Equal(t, 5, e.stock.prizes[0].Stock)
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
}
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
rows := len(e.repo.transactions)
// Another play took the last one between reading the prizes and taking it.
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
// Anything else going wrong with the stock cancels it too.
e.stock.stockErr = errors.New("connection reset")
_, err = e.play()
assert.Error(t, err)
assert.Empty(t, e.plays.plays)
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
}
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
for name, metadata := range map[string]entities.Metadata{
"zero": {"coin_cost": float64(0)},
"fraction": {"coin_cost": 1.5},
"not number": {"coin_cost": "2"},
} {
t.Run(name, func(t *testing.T) {
e := newGameTestEnv(t, metadata)
e.earnCoins(t, e.player, 5, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}
t.Run("inactive", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.game.IsActive = false
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
})
t.Run("no prizes left", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.stock.prizes[0].Stock = 0
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}
+157 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
@@ -20,6 +21,9 @@ type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
@@ -111,6 +115,9 @@ type OrderProcessorImpl struct {
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
loyalty OrderLoyalty
pointPayments *PointPaymentProcessor
paymentCodes paymentCodeRedeemer
pins pinVerifier
}
// OrderLoyalty is what the order flow tells and asks the loyalty program
@@ -137,6 +144,58 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
p.loyalty = loyalty
}
type paymentCodeRedeemer interface {
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
}
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
// payments to pointPayments approved by the code the customer shows at the cashier,
// and PayWithPointsInApp approved by the customer's PIN (F9).
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
p.pointPayments = pointPayments
p.paymentCodes = codes
p.pins = pins
}
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
// generic payment path, which would record the payment without taking any balance.
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.paymentCodes == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
}
var cashier *uuid.UUID
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
cashier = &id
}
code := *req.PaymentCode
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Points: *req.Points,
CashierID: cashier,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
}
return nil
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, req.OrderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// onOrderPaid is the single place every path that completes an order's payment goes
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
// after the payment has committed. The hook runs detached from the caller's
@@ -153,10 +212,16 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
// after their writes have committed, with the same guarantees as onOrderPaid: it can
// never block or fail the void or refund.
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
if p.loyalty == nil {
return
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
if p.pointPayments != nil {
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
}
}
if p.loyalty != nil {
p.loyalty.OnOrderRefunded(ctx, orderID)
}
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
}
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
@@ -801,6 +866,18 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
reason = *req.Reason
}
// An order refund is handed back in cash or another method, so it cannot cover what
// was paid with EnakPoint (K7). Checked before anything is written.
if p.pointPayments != nil {
planned, err := p.plannedOrderRefund(ctx, id, req)
if err != nil {
return err
}
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
return err
}
}
// Process refund based on request type
if req.RefundAmount != nil {
// Full or partial refund by amount
@@ -892,10 +969,13 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
return nil, fmt.Errorf("order is already fully paid")
}
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
if method.Type == entities.PaymentMethodTypePoint {
return p.createPointPayment(ctx, req)
}
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
@@ -1280,6 +1360,11 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
// Splitting with EnakPoint would record a payment without taking any balance; an
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
if payment.Type == entities.PaymentMethodTypePoint {
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
}
customer := &entities.Customer{}
if req.CustomerID != uuid.Nil {
@@ -1726,3 +1811,71 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
func stringPtr(s string) *string {
return &s
}
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
// app or a self-order. The session alone is not enough: the customer's PIN approves
// it (K8). An order that is not the customer's own is reported as not found, so the
// endpoint does not reveal other customers' orders.
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.pins == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
if err != nil {
return nil, err
}
if owner == nil || *owner != customerID {
return nil, repository.ErrPointPaymentOrderNotFound
}
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
if err != nil {
return nil, err
}
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: orderID,
PaymentMethodID: methodID,
Points: points,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, orderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
// way it will be, without writing anything.
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
if req.RefundAmount != nil {
return *req.RefundAmount, nil
}
var total float64
for _, itemRefund := range req.OrderItems {
if itemRefund.RefundAmount != nil {
total += *itemRefund.RefundAmount
continue
}
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
if err != nil {
return 0, fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != orderID {
return 0, fmt.Errorf("order item does not belong to this order")
}
quantity := itemRefund.RefundQuantity
if quantity == 0 {
quantity = orderItem.Quantity
}
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
}
return total, nil
}
-9
View File
@@ -18,8 +18,6 @@ type OtpProcessor interface {
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
SendWhatsAppMessage(phoneNumber string, message string) error
ValidateOtpCode(code string) bool
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
InvalidateOtpSession(ctx context.Context, token string) error
@@ -242,10 +240,3 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
return digits
}
func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error {
if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil {
return fmt.Errorf("failed to send WhatsApp message: %w", err)
}
return nil
}
+401
View File
@@ -0,0 +1,401 @@
package processor
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// pointPaymentEnv is an order flow wired as in the app, against Postgres and a
// miniredis for payment codes.
type pointPaymentEnv struct {
t *testing.T
db *gorm.DB
orders *OrderProcessorImpl
payments *PointPaymentProcessor
codes *PaymentCodeProcessor
org uuid.UUID
cashier uuid.UUID
outlet uuid.UUID
point uuid.UUID
cash uuid.UUID
walkIn uuid.UUID
ctx context.Context
}
func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()}
e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org)
e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t")
e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org)
e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org)
var ids []string
require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error)
require.Len(t, ids, 1)
e.point = uuid.MustParse(ids[0])
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error)
e.walkIn = uuid.MustParse(ids[0])
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org)
db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org)
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet)
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet)
db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier)
db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org)
})
txm := repository.NewTxManager(db)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm)
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { client.Close() })
e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"})
e.orders = &OrderProcessorImpl{
orderRepo: repository.NewOrderRepositoryImpl(db),
orderItemRepo: repository.NewOrderItemRepositoryImpl(db),
paymentRepo: repository.NewPaymentRepositoryImpl(db),
paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db),
splitBillProcessor: nil,
txManager: txm,
}
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"})
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
s, err := settings.Outlet(context.Background(), e.outlet)
require.NoError(t, err)
s.Point.Enabled = true
s.PointPayment.AcceptPayment = true
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
require.NoError(t, err)
e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String())
return e
}
func (e *pointPaymentEnv) exec(q string, args ...any) {
e.t.Helper()
require.NoError(e.t, e.db.Exec(q, args...).Error)
}
// customerWith creates a customer holding the given EnakPoint.
func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID {
e.t.Helper()
id := uuid.New()
e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org)
if points > 0 {
require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points,
ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
return err
}))
}
return id
}
func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID {
e.t.Helper()
id := uuid.New()
e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
subtotal, tax_amount, total_amount, remaining_amount, payment_status)
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`,
id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal)
return id
}
func (e *pointPaymentEnv) code(customer uuid.UUID) string {
e.t.Helper()
c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(e.t, err)
return c.Code
}
func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) {
return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code})
}
func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 {
e.t.Helper()
var b int64
require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
return b
}
func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) {
e.t.Helper()
var row struct {
PaymentStatus string
RemainingAmount float64
}
require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error)
return row.PaymentStatus, row.RemainingAmount
}
func TestPointPayment_FullPayment(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 50000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, 50000.0, payment.Amount)
require.NotNil(t, payment.PointsUsed)
assert.Equal(t, int64(50000), *payment.PointsUsed)
assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment")
status, remaining := e.orderState(order)
assert.Equal(t, "completed", status)
assert.Zero(t, remaining)
assert.Equal(t, int64(50000), e.balance(customer))
var ledger struct {
Amount int64
ReferenceType string
ReferenceID string
OutletID string
CreatedByUser string
}
require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id,
created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error)
assert.Equal(t, int64(-50000), ledger.Amount)
assert.Equal(t, "PAYMENT", ledger.ReferenceType)
assert.Equal(t, payment.ID.String(), ledger.ReferenceID)
assert.Equal(t, e.outlet.String(), ledger.OutletID)
assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it")
// Paid entirely with EnakPoint, so nothing earns (Q10).
var earned int64
require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
assert.Zero(t, earned)
}
func TestPointPayment_PartialThenCash(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 67500.0, remaining)
// The rest in cash settles it; earning counts only the part not paid with
// EnakPoint: floor(67.500 / 100) = 675.
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
var earned int64
require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
assert.Equal(t, int64(675), earned)
assert.Equal(t, int64(100000-20000+675), e.balance(customer))
}
func TestPointPayment_PercentCap(t *testing.T) {
e := newPointPaymentEnv(t)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
s, err := settings.Outlet(context.Background(), e.outlet)
require.NoError(t, err)
s.PointPayment.MaxPaymentPercent = 50
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
require.NoError(t, err)
customer := e.customerWith(100000)
order := e.order(customer, 100000)
preview, err := e.payments.Preview(context.Background(), e.org, order)
require.NoError(t, err)
assert.True(t, preview.Eligible)
assert.Equal(t, int64(50000), preview.MaxPoints)
assert.Equal(t, int64(100000), preview.PointBalance)
_, err = e.payPoints(order, 50001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected)
_, err = e.payPoints(order, 30000, e.code(customer))
require.NoError(t, err)
_, err = e.payPoints(order, 20001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap")
_, err = e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, int64(50000), e.balance(customer))
}
func TestPointPayment_Refusals(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
other := e.customerWith(100000)
// A walk-in order cannot be paid with EnakPoint.
walkInOrder := e.order(e.walkIn, 10000)
preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder)
require.NoError(t, err)
assert.False(t, preview.Eligible)
assert.Contains(t, preview.Reason, "walk-in")
_, err = e.payPoints(walkInOrder, 1000, "123456")
assert.ErrorIs(t, err, ErrPointPaymentRejected)
order := e.order(customer, 10000)
_, err = e.payPoints(order, 1000, "000000")
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code")
_, err = e.payPoints(order, 1000, e.code(other))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code")
missing := int64(1000)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing})
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all")
code := e.code(customer)
_, err = e.payPoints(order, 1000, code)
require.NoError(t, err)
_, err = e.payPoints(order, 1000, code)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once")
_, err = e.payPoints(order, 9001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left")
// Splitting with the EnakPoint method would skip the balance, so it is refused.
e.orders.splitBillProcessor = splitFake{}
_, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000})
assert.ErrorIs(t, err, ErrPointPaymentRejected)
assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything")
assert.Equal(t, int64(100000), e.balance(other))
}
// Two payments for the same customer at once, on two orders: the balance is taken
// once, never twice. Authorization is taken as given so only the balance decides.
func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(30000)
orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)}
var wg sync.WaitGroup
results := make([]error, len(orders))
for i, order := range orders {
wg.Add(1)
go func(i int, order uuid.UUID) {
defer wg.Done()
_, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{
OrderID: order, PaymentMethodID: e.point, Points: 20000,
Authorize: func(context.Context, uuid.UUID) error { return nil },
})
}(i, order)
}
wg.Wait()
succeeded := 0
for _, err := range results {
if err == nil {
succeeded++
} else {
assert.ErrorIs(t, err, ErrPointPaymentRejected)
}
}
assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two")
assert.Equal(t, int64(10000), e.balance(customer))
// And the wallet still reconciles.
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
require.NoError(t, err)
for _, d := range found {
assert.NotEqual(t, customer, d.CustomerID, d.Check)
}
}
func TestPointPayment_InApp(t *testing.T) {
e := newPointPaymentEnv(t)
owner := e.customerWith(100000)
stranger := e.customerWith(100000)
order := e.order(owner, 60000)
info := models.CustomerPinRequestInfo{}
// Another customer cannot pay it, and is not told it exists.
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
// The session alone is not enough: a wrong PIN takes nothing.
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, int64(100000), e.balance(owner))
// The owner pays part, then the rest, with the same rules as at the cashier.
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
require.NoError(t, err)
assert.Equal(t, int64(10000), *payment.PointsUsed)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 50000.0, remaining)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
assert.Equal(t, int64(40000), e.balance(owner))
assert.Equal(t, int64(100000), e.balance(stranger))
var createdBy *string
require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error)
assert.Nil(t, createdBy, "no cashier took an in-app payment")
}
// The payment method report counts only money actually received as money in; the
// EnakPoint part is listed apart (F9).
func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(),
&models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)})
require.NoError(t, err)
assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total")
assert.Equal(t, 20000.0, report.Summary.PointAmount)
assert.Equal(t, int64(20000), report.Summary.PointsUsed)
assert.Equal(t, 87500.0, report.Summary.TotalWithPoints)
require.Len(t, report.Data, 2)
for _, d := range report.Data {
assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName)
}
}
@@ -0,0 +1,336 @@
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
}
@@ -0,0 +1,39 @@
package processor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestComputePointPaymentLimits(t *testing.T) {
for name, c := range map[string]struct {
total, paid, paidWithPoints float64
percent, value, balance int64
wantRemaining, wantMax int64
}{
// The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point.
"PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000},
"balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550},
"part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000},
"capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000},
"percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000},
"percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0},
"point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875},
"nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0},
"overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0},
"no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0},
"cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000},
} {
got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance)
assert.Equal(t, c.wantRemaining, got.RemainingCents, name)
assert.Equal(t, c.wantMax, got.MaxPoints, name)
assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name)
}
}
func TestFormatRupiah(t *testing.T) {
for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} {
assert.Equal(t, want, formatRupiah(n))
}
}
+157
View File
@@ -0,0 +1,157 @@
package processor
import (
"context"
"fmt"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the
// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7):
//
// - void: every EnakPoint used on the order;
// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value),
// so a later change of the point value does not change how many come back, and a
// rupiah remainder below one EnakPoint is lost (Q13).
//
// Never more than the payment used, and only what has not come back yet, so it can be
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
// they were taken from (the 7-day extension waits on note N4). It returns how many came
// back in total.
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return 0, err
}
payments, err := p.repo.ListPointPayments(ctx, orderID)
if err != nil {
return 0, err
}
var returned int64
for _, payment := range payments {
if payment.LedgerID == nil || payment.CustomerID == nil {
continue
}
target := pointRefundTarget(order.IsVoid, payment)
if target == 0 {
continue
}
var n int64
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil {
return err
}
allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID)
if err != nil {
return err
}
refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID)
if err != nil {
return err
}
var already int64
for _, amount := range refunded {
already += amount
}
toReturn := target - already
if toReturn <= 0 {
return nil
}
// Fill the lots the payment took from, each up to what it gave.
var lots []WalletLotInput
left := toReturn
for _, a := range allocations {
if left == 0 {
break
}
room := a.Amount - refunded[a.LotID]
if room <= 0 {
continue
}
take := min(room, left)
left -= take
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
}
toReturn -= left
ledgerID := *payment.LedgerID
if _, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: *payment.CustomerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypePaymentRefund,
Amount: toReturn,
ReferenceType: constants.WalletRefTypePayment,
ReferenceID: payment.PaymentID,
ReversesTransactionID: &ledgerID,
OutletID: payment.OutletID,
Description: pointRefundDescription(order),
Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid},
IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target),
},
Lots: lots,
}); err != nil {
return err
}
n = toReturn
return nil
})
if err != nil {
return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err)
}
returned += n
}
return returned, nil
}
// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash
// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded
// through its own payment, and comes back as EnakPoint.
func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error {
refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID)
if err != nil {
return err
}
paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID)
if err != nil {
return err
}
if paidWithPoints == 0 {
return nil
}
if toCents(amount) > toCents(refundable) {
return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint",
ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100))
}
return nil
}
func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 {
if orderVoid {
return payment.PointsUsed
}
if payment.Status != string(entities.PaymentTransactionStatusRefunded) {
return 0
}
valueCents := toCents(payment.PointValue)
if valueCents <= 0 {
return 0
}
return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents)
}
func pointRefundDescription(order *repository.PointPaymentOrder) string {
description := "Pengembalian #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
return truncateRunes(description, walletDescriptionLimit)
}
+151
View File
@@ -0,0 +1,151 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
e.t.Helper()
var n int64
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
return n
}
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
JOIN payments p ON p.id = t.reference_id
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
}
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
e.t.Helper()
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
require.NoError(e.t, err)
for _, d := range found {
for _, c := range customers {
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
}
}
}
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(0)
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
})
return err
}))
// Part of the order paid with EnakPoint, then the order is voided.
order := e.order(customer, 50000)
_, err := e.payPoints(order, 30000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, int64(10000), e.balance(customer))
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
assert.Equal(t, int64(30000), e.returned(order))
assert.Equal(t, int64(40000), e.balance(customer))
var expiry time.Time
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
// Calling it again returns nothing more.
e.orders.onOrderRefunded(e.ctx, order)
assert.Equal(t, int64(30000), e.returned(order))
e.assertReconciled(customer)
}
func TestPointRefund_PartialRefundFloors(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 50000, e.code(customer))
require.NoError(t, err)
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
assert.Equal(t, int64(12345), e.returned(order))
assert.Equal(t, int64(50000+12345), e.balance(customer))
e.assertReconciled(customer)
}
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
e := newPointPaymentEnv(t)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
setValue := func(v int64) {
s, err := settings.Organization(context.Background(), e.org)
require.NoError(t, err)
s.PointValue = v
_, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
require.NoError(t, err)
}
setValue(100)
customer := e.customerWith(1000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 500, e.code(customer))
require.NoError(t, err)
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
// The value changes before the refund; the customer still gets back what they used.
setValue(250)
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
assert.Equal(t, int64(1000), e.balance(customer))
}
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000})
require.NoError(t, err)
amount := 40000.0
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
var refunded float64
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
assert.Zero(t, refunded, "nothing was written")
amount = 30000
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
}
// Giving back the EnakPoint part does not take earning back: that part never earned.
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
pointPayment, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
require.Equal(t, int64(675), earned)
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
assert.Equal(t, int64(20000), e.returned(order))
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
e.assertReconciled(customer)
}
+1 -11
View File
@@ -69,17 +69,7 @@ func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizati
return nil, err
}
for _, lot := range lots {
result.Lots = append(result.Lots, models.AdminWalletLot{
ID: lot.ID,
Currency: lot.Currency,
OriginalAmount: lot.OriginalAmount,
RemainingAmount: lot.RemainingAmount,
ExpiresAt: lot.ExpiresAt,
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
SourceTransactionID: lot.SourceTransactionID,
OriginLotID: lot.OriginLotID,
CreatedAt: lot.CreatedAt,
})
result.Lots = append(result.Lots, adminWalletLot(lot, now))
}
rows, total, err := p.query.ListTransactions(ctx, filter)
@@ -0,0 +1,290 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"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"
)
// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on
// the customer's side: the amount, the limits, the recipient or the balance. The
// message says which.
var ErrWalletMoveRejected = errors.New("wallet move refused")
// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the
// prefix that scopes it to the customer, in wallet_transactions.idempotency_key.
const walletMoveKeyLimit = 50
type organizationSettingsReader interface {
Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error)
}
// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md
// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin.
type WalletExchangeProcessor struct {
customers repository.WalletMoveRepository
settings organizationSettingsReader
spendable spendableReader
pins pinVerifier
wallet *WalletProcessor
tx TxRunner
now func() time.Time
}
func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor {
return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now}
}
// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what
// exchanging coins would give, so the app can show it before asking for the PIN.
func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) {
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
if err != nil {
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
rate := settings.Exchange
preview := &models.WalletExchangePreview{
CoinAmount: rate.CoinAmount,
PointAmount: rate.PointAmount,
CoinBalance: balances[constants.WalletCurrencyCoin],
Coins: coins,
}
if reason := exchangeProblem(customer, rate, coins); reason != "" {
preview.Reason = reason
return preview, nil
}
preview.Points = exchangePoints(coins, rate)
if coins > preview.CoinBalance {
preview.Reason = "not enough EnakCoin"
return preview, nil
}
preview.Valid = true
return preview, nil
}
// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the
// organization's rate, in one transaction. The two ledger rows share a group and
// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry
// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life
// (K9). The PIN approves it (K8).
//
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
// the first exchange, at the rate it was made, without moving anything again.
func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) {
key, err := walletMoveKey(idempotencyKey)
if err != nil {
return nil, err
}
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
if err != nil {
return nil, err
}
// Refuse a malformed request before the PIN is checked, so a typo in the amount
// costs the customer no PIN attempt.
if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" {
return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason)
}
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil {
return nil, err
}
outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key)
inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key)
result := &models.WalletExchangeResult{Coins: coins}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
return err
}
rate := settings.Exchange
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
previous, err := p.wallet.FindTransaction(ctx, outKey)
if err != nil {
return err
}
if previous != nil && previous.GroupID != nil {
// A retry: repeat it with the ids and the rate the first attempt froze, so
// both rows replay even if the rate has changed since.
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
rate = frozenExchangeRate(previous.Metadata, rate)
}
points := exchangePoints(coins, rate)
metadata := entities.Metadata{
"coins": coins,
"points": points,
"coin_amount": rate.CoinAmount,
"point_amount": rate.PointAmount,
}
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID,
CustomerID: customerID,
Currency: constants.WalletCurrencyCoin,
Type: constants.WalletTxTypeExchangeOut,
Amount: coins,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: inID,
GroupID: &groupID,
Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins),
Metadata: metadata,
IdempotencyKey: outKey,
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected)
}
if err != nil {
return err
}
in, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID,
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeExchangeIn,
Amount: points,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: outID,
GroupID: &groupID,
Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins),
Metadata: metadata,
IdempotencyKey: inKey,
},
Lots: exchangeLots(out.Allocations, rate),
})
if err != nil {
return err
}
result.GroupID = groupID
result.Points = points
result.CoinAmount = rate.CoinAmount
result.PointAmount = rate.PointAmount
result.Lots = movedLots(in.Lots)
result.Replayed = out.Replayed
return nil
})
if err != nil {
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
result.CoinBalance = balances[constants.WalletCurrencyCoin]
result.PointBalance = balances[constants.WalletCurrencyPoint]
return result, nil
}
// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as
// the request goes. The balance is checked under the wallet lock.
func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string {
switch {
case !customer.IsActive:
return "the customer is not active"
case coins <= 0:
return "the number of EnakCoin must be positive"
case rate.CoinAmount <= 0 || rate.PointAmount <= 0:
return "exchange is not available"
case coins%rate.CoinAmount != 0:
// Otherwise part of the EnakCoin would be lost to rounding (F4).
return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount)
}
return ""
}
// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a
// multiple of coin_amount.
func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
return coins / rate.CoinAmount * rate.PointAmount
}
// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took,
// so each part keeps the expiry of its lot and points back at it (K9). The share of
// a lot is the difference of floor(coins so far × point_amount / coin_amount) before
// and after it, which adds up exactly because the total is a multiple of
// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none.
//
// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no
// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin
// lot's expiry is the only bound.
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput {
var lots []WalletLotInput
var coinsSoFar int64
for _, a := range allocations {
before := coinsSoFar * rate.PointAmount / rate.CoinAmount
coinsSoFar += a.Amount
points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before
if points == 0 {
continue
}
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
}
return lots
}
// frozenExchangeRate reads the rate an exchange was made at from its ledger row.
func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings {
coinAmount, ok1 := metadataInt(metadata, "coin_amount")
pointAmount, ok2 := metadataInt(metadata, "point_amount")
if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 {
return fallback
}
return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount}
}
// metadataInt reads a whole number from metadata that may have been through JSONB,
// which gives numbers back as float64.
func metadataInt(metadata entities.Metadata, key string) (int64, bool) {
switch v := metadata[key].(type) {
case float64:
return int64(v), true
case int64:
return v, true
case int:
return int64(v), true
}
return 0, false
}
func movedLots(lots []entities.WalletLot) []models.WalletMovedLot {
out := make([]models.WalletMovedLot, 0, len(lots))
for _, lot := range lots {
out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt})
}
return out
}
// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers
// require (F4, F5).
func walletMoveKey(key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected)
}
if len(key) > walletMoveKeyLimit {
return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit)
}
return key, nil
}
@@ -0,0 +1,334 @@
package processor
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test
// also checks the §7.5 invariants when it ends.
type walletMoveEnv struct {
*walletTestEnv
customers *walletMoveRepoFake
settings *models.OrganizationLoyaltySettings
pins *movePinFake
}
func newWalletMoveEnv(t *testing.T) *walletMoveEnv {
e := &walletMoveEnv{
walletTestEnv: newWalletTestEnv(t),
customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}},
settings: &models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
},
pins: &movePinFake{good: "482913"},
}
e.customers.ledger = e.repo
// Ledger rows are stamped from now on, so "today" is the day of e.now.
e.repo.clock = e.now
return e
}
// member adds an active customer of the organization.
func (e *walletMoveEnv) member(name, phone string) uuid.UUID {
id := e.customer()
e.customers.byID[id] = &repository.WalletMoveCustomer{
ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true,
}
return id
}
func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{})
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor {
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier)
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := *e.settings
return &s, nil
}
// SpendableBalances sums the unexpired lots, as the real query does.
func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
out := map[string]int64{}
for _, lot := range e.repo.lots {
if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
out[lot.Currency] += lot.RemainingAmount
}
}
return out, nil
}
// earnCoins gives a customer an EnakCoin lot.
func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult {
t.Helper()
in := earn(customerID, amount, expiresAt)
in.Currency = constants.WalletCurrencyCoin
return e.credit(t, in)
}
func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
t.Helper()
w, err := e.repo.GetWallet(e.ctx, customerID)
require.NoError(t, err)
return w.CoinBalance
}
type walletMoveRepoFake struct {
byID map[uuid.UUID]*repository.WalletMoveCustomer
ledger *walletRepoFake
}
func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) {
c, ok := f.byID[id]
if !ok {
return nil, repository.ErrWalletNotFound
}
copied := *c
return &copied, nil
}
func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) {
for id, c := range f.byID {
if c.PhoneNumber != nil && *c.PhoneNumber == phone {
return f.GetCustomer(ctx, id)
}
}
return nil, repository.ErrWalletNotFound
}
func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
var total int64
for _, tx := range f.ledger.transactions {
if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) {
total -= tx.Amount
}
}
return total, nil
}
// movePinFake accepts one PIN and records the actions it was asked to approve.
type movePinFake struct {
good string
err error
actions []PinAction
}
func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
f.actions = append(f.actions, action)
if f.err != nil {
return f.err
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi Santoso", "081234561234")
e.earnCoins(t, c, 50, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(50), res.Points)
assert.Equal(t, int64(0), res.CoinBalance)
assert.Equal(t, int64(50), res.PointBalance)
assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions)
out, in := e.repo.transactions[1], e.repo.transactions[2]
assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type)
assert.Equal(t, constants.WalletCurrencyCoin, out.Currency)
assert.Equal(t, int64(-50), out.Amount)
assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type)
assert.Equal(t, constants.WalletCurrencyPoint, in.Currency)
assert.Equal(t, int64(50), in.Amount)
// The pair shares a group and each row points at the other (§8.1).
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, res.GroupID, *out.GroupID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description)
assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description)
// Both rows freeze the rate.
for _, row := range []*entities.WalletTransaction{out, in} {
assert.Equal(t, int64(1), row.Metadata["coin_amount"])
assert.Equal(t, int64(1), row.Metadata["point_amount"])
}
}
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 35, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 30)
require.NoError(t, err)
assert.True(t, preview.Valid)
assert.Equal(t, int64(9), preview.Points)
assert.Equal(t, int64(35), preview.CoinBalance)
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(9), res.Points)
assert.Equal(t, int64(5), res.CoinBalance)
assert.Equal(t, int64(9), res.PointBalance)
}
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 50, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 25)
require.NoError(t, err)
assert.False(t, preview.Valid)
assert.Contains(t, preview.Reason, "multiples of 10")
_, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
// Refused before the PIN, so a typo costs no attempt.
assert.Empty(t, e.pins.actions)
assert.Equal(t, int64(50), e.coinBalance(t, c))
_, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
}
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
first := e.earnCoins(t, c, 15, soon)
second := e.earnCoins(t, c, 15, later)
third := e.earnCoins(t, c, 10, nil)
// 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from
// the one that never expires, giving 12 EnakPoint split 4 + 5 + 3.
res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
require.Len(t, res.Lots, 3)
assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0])
assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1])
assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2])
origins := map[uuid.UUID]*time.Time{}
for _, lot := range e.repo.lots {
if lot.Currency == constants.WalletCurrencyPoint {
require.NotNil(t, lot.OriginLotID)
origins[*lot.OriginLotID] = lot.ExpiresAt
}
}
assert.Equal(t, map[uuid.UUID]*time.Time{
first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil,
}, origins)
for _, lot := range e.repo.lots {
if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil {
continue
}
for _, coinLot := range e.repo.lots {
if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil {
require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not")
assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt))
}
}
}
}
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, e.at(time.Hour))
e.earnCoins(t, c, 5, nil)
// Neither lot is worth a whole EnakPoint alone; the one that completes the 10
// carries it.
res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots)
}
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 6)
require.NoError(t, err)
assert.False(t, preview.Valid)
_, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrInvalid, pinErr.Code)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 100, nil)
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
// The rate changes before the retry arrives; the retry still gets the first result.
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2}
again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), again.Points)
assert.Equal(t, int64(60), e.coinBalance(t, c))
assert.Len(t, e.repo.transactions, 3)
// The same key for a different amount is not a retry.
_, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Empty(t, e.pins.actions)
}
+198
View File
@@ -0,0 +1,198 @@
package processor
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// fixedOrganizationSettings serves the same organization settings to every caller.
type fixedOrganizationSettings struct {
s models.OrganizationLoyaltySettings
}
func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := f.s
return &s, nil
}
// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers,
// removed again when the test ends. See internal/repository/wallet_repository_test.go.
func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, a, b = uuid.New(), uuid.New(), uuid.New()
phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10]
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
a, org, phoneA, b, org, phoneB).Error)
customers := []uuid.UUID{a, b}
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ? AND origin_lot_id IS NOT NULL`, customers)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
return db, org, a, b
}
func TestWalletExchange_AgainstPostgres(t *testing.T) {
db, _, a, _ := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3},
}}
p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db),
&movePinFake{good: "482913"}, wallet, txm)
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
in := earn(a, 30, &expiry)
in.Currency = constants.WalletCurrencyCoin
_, err := wallet.Credit(ctx, in)
return err
}))
res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(6), res.Points)
assert.Equal(t, int64(10), res.CoinBalance)
assert.Equal(t, int64(6), res.PointBalance)
require.Len(t, res.Lots, 1)
require.NotNil(t, res.Lots[0].ExpiresAt)
assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry))
// The retry reads the frozen rate back out of JSONB and replays.
again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, int64(6), again.Points)
var rows int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error)
assert.Equal(t, int64(2), rows)
}
// Transfers in both directions at once must not deadlock: both lock the two wallets
// in customer_id order. Every one of them lands, and the totals still reconcile.
func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
db, _, a, b := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
moves := repository.NewWalletMoveRepository(db)
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
}}
p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil {
return err
}
_, err := wallet.Credit(ctx, earn(b, 100, nil))
return err
}))
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
const rounds = 10
errs := make(chan error, 2*rounds)
var wg sync.WaitGroup
for i := 0; i < rounds; i++ {
for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} {
wg.Add(1)
go func(from, to uuid.UUID, i int) {
defer wg.Done()
_, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{})
errs <- err
}(pair[0], pair[1], i)
}
}
wg.Wait()
close(errs)
for err := range errs {
assert.NoError(t, err)
}
var balances []int64
require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error)
assert.Equal(t, []int64{100, 100}, balances)
// B's lots that came from A keep A's expiry to the second.
var mismatched int64
require.NoError(t, db.Raw(`
SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id
WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error)
assert.Zero(t, mismatched)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now()))
assert.Equal(t, int64(rounds), sent)
return err
}))
}
// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1.
func TestWalletTrace_AgainstPostgres(t *testing.T) {
db, org, a, b := walletMoveDB(t)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
}}
transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour)
ord1 := earn(a, 100, &dec)
ord1.Description = "Belanja #ORD-1"
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, ord1); err != nil {
return err
}
_, err := wallet.Credit(ctx, earn(a, 50, &jan))
return err
}))
_, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{})
require.NoError(t, err)
var payment *WalletResult
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
payment, err = wallet.Debit(ctx, pay(b, 30))
return err
}))
trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID)
require.NoError(t, err)
require.Len(t, trace.Lots, 1)
chain := trace.Lots[0].Chain
require.Len(t, chain, 2)
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
_, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID)
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
}
+7
View File
@@ -142,6 +142,13 @@ func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error
return err
}
// FindTransaction returns the row written under an idempotency key, or nil when the
// key is new. Read it under the wallet lock: a flow that writes several rows uses it
// to recognise a retry before checking limits the first attempt already counts in.
func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey string) (*entities.WalletTransaction, error) {
return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey)
}
// Credit adds Amount to the wallet and creates its lots.
func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) {
if err := validateWalletEntry(&in.WalletEntry, true); err != nil {
@@ -0,0 +1,180 @@
package processor
import (
"context"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletTraceMaxDepth bounds how far back a chain is followed. Every step is a
// transfer, exchange or refund of the step before, so real chains are short; the
// bound only guards against a loop in bad data.
const walletTraceMaxDepth = 100
// WalletTraceProcessor traces a ledger row lot by lot for the dashboard
// (docs/prd-point-coin.md F7, §8.1). It only reads.
type WalletTraceProcessor struct {
repo repository.WalletTraceRepository
now func() time.Time
}
func NewWalletTraceProcessor(repo repository.WalletTraceRepository) *WalletTraceProcessor {
return &WalletTraceProcessor{repo: repo, now: time.Now}
}
type walletTraceStart struct {
lotID uuid.UUID
amount int64
}
// Trace follows a row of the organization's ledger: a debit through the lots it took
// from, a credit through the lots it created, and each lot back through origin_lot_id
// to the lot that first brought the balance in. A row of another organization is
// repository.ErrWalletTransactionNotFound.
func (p *WalletTraceProcessor) Trace(ctx context.Context, organizationID, transactionID uuid.UUID) (*models.AdminWalletTrace, error) {
row, err := p.repo.GetTransaction(ctx, organizationID, transactionID)
if err != nil {
return nil, err
}
var starts []walletTraceStart
if row.Amount < 0 {
allocations, err := p.repo.ListAllocations(ctx, row.ID)
if err != nil {
return nil, err
}
for _, a := range allocations {
starts = append(starts, walletTraceStart{lotID: a.LotID, amount: a.Amount})
}
} else {
created, err := p.repo.ListLotsBySource(ctx, row.ID)
if err != nil {
return nil, err
}
for _, lot := range created {
starts = append(starts, walletTraceStart{lotID: lot.ID, amount: lot.OriginalAmount})
}
}
lots, err := p.loadChains(ctx, starts)
if err != nil {
return nil, err
}
sourceIDs := make([]uuid.UUID, 0, len(lots))
customerIDs := []uuid.UUID{row.CustomerID}
for _, lot := range lots {
sourceIDs = append(sourceIDs, lot.SourceTransactionID)
customerIDs = append(customerIDs, lot.CustomerID)
}
sourceRows, err := p.repo.GetTransactions(ctx, uniqueIDs(sourceIDs))
if err != nil {
return nil, err
}
sources := make(map[uuid.UUID]entities.WalletTransaction, len(sourceRows))
for _, s := range sourceRows {
sources[s.ID] = s
}
names, err := p.repo.CustomerNames(ctx, uniqueIDs(customerIDs))
if err != nil {
return nil, err
}
now := p.now()
trace := &models.AdminWalletTrace{
Transaction: walletTraceRow(*row, names),
Lots: make([]models.AdminWalletTraceLot, 0, len(starts)),
}
for _, start := range starts {
item := models.AdminWalletTraceLot{Amount: start.amount, Chain: []models.AdminWalletTraceStep{}}
visited := map[uuid.UUID]bool{}
for id := &start.lotID; id != nil && !visited[*id]; {
lot, ok := lots[*id]
if !ok {
break
}
visited[*id] = true
item.Chain = append(item.Chain, models.AdminWalletTraceStep{
Lot: adminWalletLot(lot, now),
Source: walletTraceRow(sources[lot.SourceTransactionID], names),
})
id = lot.OriginLotID
}
trace.Lots = append(trace.Lots, item)
}
return trace, nil
}
// loadChains loads the starting lots and every lot they were carried over from, one
// generation at a time.
func (p *WalletTraceProcessor) loadChains(ctx context.Context, starts []walletTraceStart) (map[uuid.UUID]entities.WalletLot, error) {
lots := map[uuid.UUID]entities.WalletLot{}
pending := make([]uuid.UUID, 0, len(starts))
for _, s := range starts {
pending = append(pending, s.lotID)
}
for depth := 0; len(pending) > 0 && depth < walletTraceMaxDepth; depth++ {
found, err := p.repo.GetLots(ctx, uniqueIDs(pending))
if err != nil {
return nil, err
}
pending = pending[:0]
for _, lot := range found {
lots[lot.ID] = lot
}
for _, lot := range found {
if lot.OriginLotID != nil {
if _, seen := lots[*lot.OriginLotID]; !seen {
pending = append(pending, *lot.OriginLotID)
}
}
}
}
return lots, nil
}
func walletTraceRow(row entities.WalletTransaction, names map[uuid.UUID]string) models.AdminWalletTraceRow {
return models.AdminWalletTraceRow{
ID: row.ID,
Customer: models.AdminWalletNamedRef{ID: row.CustomerID, Name: names[row.CustomerID]},
Currency: row.Currency,
Type: row.Type,
Amount: row.Amount,
Description: row.Description,
ReferenceType: row.ReferenceType,
ReferenceID: row.ReferenceID,
OutletID: row.OutletID,
CreatedAt: row.CreatedAt,
}
}
func adminWalletLot(lot entities.WalletLot, now time.Time) models.AdminWalletLot {
return models.AdminWalletLot{
ID: lot.ID,
Currency: lot.Currency,
OriginalAmount: lot.OriginalAmount,
RemainingAmount: lot.RemainingAmount,
ExpiresAt: lot.ExpiresAt,
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
SourceTransactionID: lot.SourceTransactionID,
OriginLotID: lot.OriginLotID,
CreatedAt: lot.CreatedAt,
}
}
func uniqueIDs(ids []uuid.UUID) []uuid.UUID {
seen := make(map[uuid.UUID]bool, len(ids))
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}
@@ -0,0 +1,161 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletTraceRepoFake reads the fake wallet the way the trace repository reads the
// tables.
type walletTraceRepoFake struct{ e *walletMoveEnv }
func (f walletTraceRepoFake) GetTransaction(_ context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
for _, tx := range f.e.repo.transactions {
if tx.ID == id && tx.OrganizationID == organizationID {
c := *tx
return &c, nil
}
}
return nil, repository.ErrWalletTransactionNotFound
}
func (f walletTraceRepoFake) GetTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
var out []entities.WalletTransaction
for _, tx := range f.e.repo.transactions {
if containsID(ids, tx.ID) {
out = append(out, *tx)
}
}
return out, nil
}
func (f walletTraceRepoFake) ListAllocations(ctx context.Context, id uuid.UUID) ([]entities.WalletLotAllocation, error) {
return f.e.repo.ListAllocationsByTransaction(ctx, id)
}
func (f walletTraceRepoFake) ListLotsBySource(ctx context.Context, id uuid.UUID) ([]entities.WalletLot, error) {
return f.e.repo.ListLotsBySourceTransaction(ctx, id)
}
func (f walletTraceRepoFake) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
return f.e.repo.GetLotsByIDs(ctx, ids)
}
func (f walletTraceRepoFake) CustomerNames(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
names := map[uuid.UUID]string{}
for _, id := range ids {
if c, ok := f.e.customers.byID[id]; ok {
names[id] = c.Name
}
}
return names, nil
}
func containsID(ids []uuid.UUID, id uuid.UUID) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string) *entities.WalletTransaction {
t.Helper()
for _, tx := range e.repo.transactions {
if tx.CustomerID == customerID && tx.Type == txType {
return tx
}
}
t.Fatalf("no %s row for %s", txType, customerID)
return nil
}
// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B
// pays 30. Tracing B's payment leads to A's order #ORD-1.
func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi Santoso", "081234561234")
ord1 := earn(a, 100, e.at(30*24*time.Hour))
ord1.Description = "Belanja #ORD-1"
ord2 := earn(a, 50, e.at(60*24*time.Hour))
ord2.Description = "Belanja #ORD-2"
e.credit(t, ord1)
e.credit(t, ord2)
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
payment, err := e.p.Debit(e.ctx, pay(b, 30))
require.NoError(t, err)
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID)
require.NoError(t, err)
assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type)
assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name)
require.Len(t, trace.Lots, 1)
assert.Equal(t, int64(30), trace.Lots[0].Amount)
chain := trace.Lots[0].Chain
require.Len(t, chain, 2)
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
assert.Equal(t, b, chain[0].Source.Customer.ID)
assert.Equal(t, constants.WalletTxTypeEarn, chain[1].Source.Type)
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
assert.Equal(t, "Belanja #ORD-1", chain[1].Source.Description)
assert.Equal(t, constants.WalletRefTypeOrder, chain[1].Source.ReferenceType)
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
assert.Nil(t, chain[1].Lot.OriginLotID, "the chain ends at the lot the order created")
assert.Equal(t, *chain[0].Lot.OriginLotID, chain[1].Lot.ID)
}
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, e.at(time.Hour)))
e.credit(t, earn(a, 50, nil))
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
// The sending side took from both of A's lots, which were A's own earnings.
out, err := p.Trace(e.ctx, e.org, findRow(t, e, a, constants.WalletTxTypeTransferOut).ID)
require.NoError(t, err)
require.Len(t, out.Lots, 2)
assert.Equal(t, []int64{100, 20}, []int64{out.Lots[0].Amount, out.Lots[1].Amount})
for _, lot := range out.Lots {
require.Len(t, lot.Chain, 1)
assert.Equal(t, constants.WalletTxTypeEarn, lot.Chain[0].Source.Type)
}
// The receiving side created two lots, each carried over from one of A's.
in, err := p.Trace(e.ctx, e.org, findRow(t, e, b, constants.WalletTxTypeTransferIn).ID)
require.NoError(t, err)
require.Len(t, in.Lots, 2)
for _, lot := range in.Lots {
require.Len(t, lot.Chain, 2)
assert.Equal(t, b, lot.Chain[0].Source.Customer.ID)
assert.Equal(t, a, lot.Chain[1].Source.Customer.ID)
assert.Equal(t, lot.Chain[1].Lot.ExpiresAt, lot.Chain[0].Lot.ExpiresAt)
}
}
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
res := e.credit(t, earn(a, 10, nil))
_, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID)
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
}
@@ -0,0 +1,307 @@
package processor
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrWalletRecipientNotFound means no customer of the sender's organization has the
// phone number. A customer of another organization is reported the same way, so the
// check does not reveal who uses the app elsewhere.
var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number")
// customerNotifier pushes a notification to a customer's app through FCM.
// CustomerDeviceProcessor is one.
type customerNotifier interface {
Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error
}
// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient
// gets, so the app can open the transaction.
const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN"
// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in
// the same organization (docs/prd-point-coin.md F5).
type WalletTransferProcessor struct {
customers repository.WalletMoveRepository
settings organizationSettingsReader
spendable spendableReader
pins pinVerifier
wallet *WalletProcessor
tx TxRunner
notifier customerNotifier
now func() time.Time
}
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor {
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
}
// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number
// of the customer a phone number belongs to, if the sender may send to them.
func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) {
sender, err := p.customers.GetCustomer(ctx, senderID)
if err != nil {
return nil, err
}
recipient, err := p.recipient(ctx, sender, phoneNumber)
if err != nil {
return nil, err
}
return maskedRecipient(recipient), nil
}
// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone,
// approved by the sender's PIN (K8), and tells the recipient.
//
// Both wallets are locked in customer_id order, so two transfers in opposite
// directions cannot deadlock. 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, so sending a balance back and forth cannot extend it.
// The two rows share a group and name each other's customer.
//
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
// the first transfer without moving anything again or counting against the limits.
func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) {
reject := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...))
}
key, err := walletMoveKey(idempotencyKey)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(in.Currency))
if !constants.IsValidWalletCurrency(currency) {
return nil, reject("currency must be POINT or COIN")
}
if in.Amount <= 0 {
return nil, reject("the amount must be positive")
}
sender, err := p.customers.GetCustomer(ctx, senderID)
if err != nil {
return nil, err
}
if !sender.IsActive {
return nil, reject("the customer is not active")
}
settings, err := p.settings.Organization(ctx, sender.OrganizationID)
if err != nil {
return nil, err
}
limits := settings.Transfer
switch {
case !limits.Enabled:
return nil, reject("transfers are turned off")
case in.Amount < limits.MinAmount:
return nil, reject("at least %d can be sent at a time", limits.MinAmount)
case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction:
return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction)
}
recipient, err := p.recipient(ctx, sender, in.RecipientPhone)
if err != nil {
return nil, err
}
// Everything the request alone can get wrong is refused above, before the PIN, so
// it costs no attempt. The PIN also refuses a transfer held after a PIN reset.
if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil {
return nil, err
}
to, from := maskedRecipient(recipient), maskedRecipient(sender)
outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key)
inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key)
result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to}
var receivedID uuid.UUID
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil {
return err
}
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
previous, err := p.wallet.FindTransaction(ctx, outKey)
if err != nil {
return err
}
if previous != nil {
// A retry: it replays below, so it must not count against the daily limit
// it is already part of.
if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil {
return ErrWalletIdempotencyConflict
}
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
} else if limits.DailyLimit != nil {
sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now()))
if err != nil {
return err
}
if sent+in.Amount > *limits.DailyLimit {
return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0))
}
}
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID,
CustomerID: senderID,
Currency: currency,
Type: constants.WalletTxTypeTransferOut,
Amount: in.Amount,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: inID,
GroupID: &groupID,
CounterpartyCustomerID: &recipient.ID,
Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit),
IdempotencyKey: outKey,
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return reject("not enough %s", walletCurrencyName(currency))
}
if err != nil {
return err
}
received, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID,
CustomerID: recipient.ID,
Currency: currency,
Type: constants.WalletTxTypeTransferIn,
Amount: in.Amount,
ReferenceType: constants.WalletRefTypeWalletTx,
ReferenceID: outID,
GroupID: &groupID,
CounterpartyCustomerID: &senderID,
Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit),
IdempotencyKey: inKey,
},
Lots: out.CarryOver(),
})
if err != nil {
return err
}
result.GroupID = groupID
result.Lots = movedLots(received.Lots)
result.Replayed = out.Replayed
receivedID = received.Transaction.ID
return nil
})
if err != nil {
return nil, err
}
if !result.Replayed {
p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID)
}
balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now())
if err != nil {
return nil, err
}
result.Balance = balances[currency]
return result, nil
}
// recipient finds who a phone number belongs to and checks the sender may send to
// them: an active customer of the same organization, not the walk-in customer, and
// not the sender.
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
phoneNumber = strings.TrimSpace(phoneNumber)
if phoneNumber == "" {
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
}
recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber)
if errors.Is(err, repository.ErrWalletNotFound) {
return nil, ErrWalletRecipientNotFound
}
if err != nil {
return nil, err
}
switch {
case recipient.OrganizationID != sender.OrganizationID:
return nil, ErrWalletRecipientNotFound
case recipient.ID == sender.ID:
return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected)
case recipient.IsDefault || !recipient.IsActive:
return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected)
}
return recipient, nil
}
// tellRecipient pushes the transfer to the recipient's app (F5). It is best effort:
// the transfer has already happened, so a failure to send is only logged.
func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) {
if p.notifier == nil {
return
}
name := walletCurrencyName(currency)
title := name + " masuk"
body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber)
data := map[string]string{
"type": NotificationTypeWalletTransferIn,
"transaction_id": transactionID.String(),
"group_id": groupID.String(),
"currency": currency,
"amount": strconv.FormatInt(amount, 10),
}
if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), err)
}
}
func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient {
phone := ""
if c.PhoneNumber != nil {
phone = maskPhoneNumber(*c.PhoneNumber)
}
return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone}
}
// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***",
// and one letter of a word that short, so the sender can recognise the recipient
// without the app revealing their name (F5, §8.1).
func maskName(name string) string {
words := strings.Fields(name)
if len(words) == 0 {
return "***"
}
for i, w := range words {
keep := 2
if utf8.RuneCountInString(w) <= 2 {
keep = 1
}
words[i] = string([]rune(w)[:keep]) + "***"
}
return strings.Join(words, " ")
}
// maskPhoneNumber keeps the first two and the last four digits:
// "081234561234" → "08**-****-1234".
func maskPhoneNumber(phone string) string {
runes := []rune(strings.TrimSpace(phone))
if len(runes) < 8 {
return "****"
}
return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:])
}
func walletCurrencyName(currency string) string {
if currency == constants.WalletCurrencyCoin {
return "EnakCoin"
}
return "EnakPoint"
}
// startOfWalletDay is midnight of t's day in the customer's time zone, where the
// daily transfer limit starts over.
func startOfWalletDay(t time.Time) time.Time {
local := t.In(walletDisplayLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
}
@@ -0,0 +1,243 @@
package processor
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type pushFake struct {
title, body string
data map[string]string
}
// notifierFake records the pushes each customer would get.
type notifierFake struct{ pushes map[uuid.UUID][]pushFake }
func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
if f.pushes == nil {
f.pushes = map[uuid.UUID][]pushFake{}
}
f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data})
return nil
}
func sendPoints(amount int64, phone string) models.WalletTransfer {
return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone}
}
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi Santoso", "081234561234")
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
first := e.credit(t, earn(a, 100, dec))
second := e.credit(t, earn(a, 50, jan))
notifier := &notifierFake{}
// The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next.
res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(30), res.Balance)
assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient)
assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots)
assert.Equal(t, int64(120), e.balance(t, b))
assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions)
var bLots []uuid.UUID
for _, lot := range e.repo.lots {
if lot.CustomerID == b {
require.NotNil(t, lot.OriginLotID)
bLots = append(bLots, *lot.OriginLotID)
for _, origin := range e.repo.lots {
if origin.ID == *lot.OriginLotID {
assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did")
}
}
}
}
assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots)
out, in := e.repo.transactions[2], e.repo.transactions[3]
assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type)
assert.Equal(t, b, *out.CounterpartyCustomerID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description)
assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type)
assert.Equal(t, a, *in.CounterpartyCustomerID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description)
assert.GreaterOrEqual(t, e.repo.locks[a], 1)
assert.GreaterOrEqual(t, e.repo.locks[b], 1)
assert.Equal(t, []pushFake{{
title: "EnakPoint masuk",
body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).",
data: map[string]string{
"type": NotificationTypeWalletTransferIn,
"transaction_id": in.ID.String(),
"group_id": in.GroupID.String(),
"currency": constants.WalletCurrencyPoint,
"amount": "120",
},
}}, notifier.pushes[b])
assert.Empty(t, notifier.pushes[a], "the sender gets no push")
}
func TestWalletTransfer_Coins(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.earnCoins(t, a, 10, nil)
_, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(6), e.coinBalance(t, a))
assert.Equal(t, int64(4), e.coinBalance(t, b))
}
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 100, nil))
walkIn := e.member("Walk-in", "081100000000")
e.customers.byID[walkIn].IsDefault = true
inactive := e.member("Old", "081100000001")
e.customers.byID[inactive].IsActive = false
elsewhere := e.member("Other Org", "081100000002")
e.customers.byID[elsewhere].OrganizationID = uuid.New()
for phone, want := range map[string]error{
"081200005678": ErrWalletMoveRejected, // herself
"081100000000": ErrWalletMoveRejected, // the walk-in customer
"081100000001": ErrWalletMoveRejected, // inactive
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
"081999999999": ErrWalletRecipientNotFound,
"": ErrWalletMoveRejected,
} {
_, err := e.transfers(nil).Recipient(e.ctx, a, phone)
assert.ErrorIs(t, err, want, phone)
_, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, want, phone)
}
assert.Empty(t, e.pins.actions, "refused before the PIN")
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi Santoso", "081234561234")
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ")
require.NoError(t, err)
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got)
}
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 1000, nil))
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
send := func(amount int64, key string) error {
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{})
return err
}
assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected)
assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected)
require.NoError(t, send(300, "k1"))
require.NoError(t, send(200, "k2"))
// The daily limit is used up; a retry of a transfer already made still replays.
assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected)
require.NoError(t, send(200, "k2"))
// It starts over the next day in the customer's time zone.
e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute)
e.repo.clock = e.now
require.NoError(t, send(10, "k4"))
assert.Equal(t, int64(490), e.balance(t, a))
e.settings.Transfer.Enabled = false
assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected)
}
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
until := e.now.Add(time.Hour)
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrTransferBlocked, pinErr.Code)
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
// An expired lot cannot be sent even before the expiry job takes it.
e.credit(t, earn(a, 50, e.at(-time.Hour)))
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(0), e.balance(t, b))
}
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.member("Citra", "081255550000")
e.credit(t, earn(a, 100, nil))
notifier := &notifierFake{}
first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), e.balance(t, b))
assert.Len(t, notifier.pushes[b], 1)
// The same key to someone else is not a retry.
_, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletTransfer_UnknownSender(t *testing.T) {
e := newWalletMoveEnv(t)
_, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestMaskName(t *testing.T) {
assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso"))
assert.Equal(t, "An***", maskName("Anita"))
assert.Equal(t, "A*** Ra***", maskName("Al Rahman"))
assert.Equal(t, "***", maskName(" "))
assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234"))
assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234"))
assert.Equal(t, "****", maskPhoneNumber("12345"))
}
+10 -9
View File
@@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
pm.type as payment_method_type,
COALESCE(SUM(p.amount), 0) as total_amount,
COUNT(DISTINCT p.order_id) as order_count,
COUNT(p.id) as payment_count
COUNT(p.id) as payment_count,
COALESCE(SUM(p.points_used), 0) as points_used
`).
Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id").
Joins("JOIN orders o ON p.order_id = o.id").
@@ -478,7 +479,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
COALESCE(SUM(oi.weight), 0) as weight_sold,
COALESCE(SUM(oi.total_price), 0) as revenue,
CASE
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`)
ELSE 0
END as average_price,
COUNT(DISTINCT oi.order_id) as order_count,
@@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total,
CASE
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`)
ELSE 0
END as fifo_hpp_per_unit,
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -551,9 +552,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -604,9 +605,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
COUNT(DISTINCT c.id) as category_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
`).
Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id").
@@ -0,0 +1,84 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// CustomerDevice is one install of the customer app that can receive push
// notifications.
type CustomerDevice struct {
CustomerID uuid.UUID
DeviceID string
Platform *string
FCMToken string
AppVersion *string
}
// CustomerDeviceRepository keeps the FCM tokens of the customer app.
type CustomerDeviceRepository interface {
// Register saves the device's current token. A token is only ever one customer's:
// registering it takes it away from whoever had it before on that phone, so they
// stop getting this customer's notifications.
Register(ctx context.Context, device CustomerDevice, now time.Time) error
// Unregister forgets a device, for instance on logout. An unknown device is not an error.
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error
ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error)
}
type customerDeviceRepository struct {
db *gorm.DB
}
func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository {
return &customerDeviceRepository{db: db}
}
func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error {
return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`,
d.FCMToken, d.CustomerID, d.DeviceID).Error
if err != nil {
return fmt.Errorf("failed to release FCM token: %w", err)
}
err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (customer_id, device_id) DO UPDATE SET
platform = EXCLUDED.platform,
fcm_token = EXCLUDED.fcm_token,
app_version = EXCLUDED.app_version,
last_active_at = EXCLUDED.last_active_at,
updated_at = NOW()`,
d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error
if err != nil {
return fmt.Errorf("failed to register customer device: %w", err)
}
return nil
})
}
func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
err := DBFromContext(ctx, r.db).WithContext(ctx).
Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error
if err != nil {
return fmt.Errorf("failed to unregister customer device: %w", err)
}
return nil
}
func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) {
var tokens []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("customer_devices").
Where("customer_id = ? AND fcm_token <> ''", customerID).
Order("last_active_at DESC NULLS LAST").
Pluck("fcm_token", &tokens).Error
if err != nil {
return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err)
}
return tokens, nil
}
+7 -1
View File
@@ -92,7 +92,13 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
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.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
COALESCE(o.is_void, false) AS is_void,
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
-- so giving it back must not take earning back.
COALESCE(o.refund_amount, 0) - COALESCE((
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount,
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
+7 -7
View File
@@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository {
}
func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error {
if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil {
return fmt.Errorf("failed to create game play: %w", err)
}
return nil
@@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game
func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) {
var gamePlay entities.GamePlay
err := r.db.WithContext(ctx).
err := DBFromContext(ctx, r.db).WithContext(ctx).
Preload("Game").
Preload("Customer").
Preload("Prize").
@@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
var gamePlays []*entities.GamePlay
var total int64
query := r.db.WithContext(ctx).Model(&entities.GamePlay{})
query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{})
// Apply filters
if search != "" {
@@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
var gamePlays []*entities.GamePlay
query := r.db.WithContext(ctx).
query := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ?", customerID).
Preload("Game").
Preload("Prize").
@@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui
func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
var gamePlays []*entities.GamePlay
query := r.db.WithContext(ctx).
query := DBFromContext(ctx, r.db).WithContext(ctx).
Where("game_id = ?", gameID).
Preload("Customer").
Preload("Prize").
@@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID,
func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
err := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.GamePlay{}).
Where("customer_id = ?", customerID).
Count(&count).Error
@@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u
func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
err := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.GamePlay{}).
Where("game_id = ?", gameID).
Count(&count).Error
+22 -9
View File
@@ -3,12 +3,16 @@ package repository
import (
"apskel-pos-be/internal/entities"
"context"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it.
var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock")
type GamePrizeRepository struct {
db *gorm.DB
}
@@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository {
}
func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error {
return r.db.WithContext(ctx).Create(gamePrize).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error
}
func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) {
var gamePrize entities.GamePrize
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
if err != nil {
return nil, err
}
@@ -32,7 +36,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
var gamePrizes []entities.GamePrize
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
if err != nil {
return nil, err
}
@@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
var gamePrizes []entities.GamePrize
var total int64
query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize")
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize")
if search != "" {
searchTerm := "%" + search + "%"
@@ -76,22 +80,31 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
}
func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error {
return r.db.WithContext(ctx).Save(gamePrize).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error
}
func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
}
// DecreaseStock takes amount from a prize's stock. When less than amount is left it
// changes nothing and returns ErrGamePrizeOutOfStock.
func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error {
return r.db.WithContext(ctx).Model(&entities.GamePrize{}).
res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}).
Where("id = ? AND stock >= ?", id, amount).
Update("stock", gorm.Expr("stock - ?", amount)).Error
Update("stock", gorm.Expr("stock - ?", amount))
if res.Error != nil {
return fmt.Errorf("failed to decrease prize stock: %w", res.Error)
}
if res.RowsAffected == 0 {
return ErrGamePrizeOutOfStock
}
return nil
}
func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
var gamePrizes []entities.GamePrize
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").
Where("game_id = ? AND stock > 0", gameID).
Order("weight DESC").
Find(&gamePrizes).Error
+8 -8
View File
@@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository {
}
func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error {
return r.db.WithContext(ctx).Create(game).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error
}
func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) {
var game entities.Game
err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
if err != nil {
return nil, err
}
@@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
var games []entities.Game
var total int64
query := r.db.WithContext(ctx).Preload("Prizes")
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes")
if search != "" {
searchTerm := "%" + search + "%"
@@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
}
func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error {
return r.db.WithContext(ctx).Save(game).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error
}
func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error
}
func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) {
var games []entities.Game
err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
if err != nil {
return nil, err
}
@@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e
func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) {
var games []entities.Game
err := r.db.WithContext(ctx).
err := DBFromContext(ctx, r.db).WithContext(ctx).
Preload("Prizes").
Where("is_active = ? AND type = ?", true, entities.GameTypeSpin).
Find(&games).Error
@@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam
func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) {
var game entities.Game
err := r.db.WithContext(ctx).
err := DBFromContext(ctx, r.db).WithContext(ctx).
Preload("Prizes").
Where("is_active = ? AND type = ?", true, "SPIN").
First(&game).Error
@@ -0,0 +1,339 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"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
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
// returned for a PAYMENT row.
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
// back: paid minus already refunded.
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, 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])
}
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
type PointPaymentRow struct {
PaymentID uuid.UUID
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
// The PAYMENT ledger row that took the balance; nil if none was written.
LedgerID *uuid.UUID
CustomerID *uuid.UUID
OutletID *uuid.UUID
}
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
// expiry, so a refund can return it to the same expiry.
type PointPaymentAllocation struct {
LotID uuid.UUID
Amount int64
ExpiresAt *time.Time
}
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
var rows []struct {
PaymentID string
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
LedgerID *string
CustomerID *string
OutletID *string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
COALESCE(p.refund_amount, 0) AS refund_amount,
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
ORDER BY p.created_at, p.id`,
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
}
out := make([]PointPaymentRow, 0, len(rows))
for _, row := range rows {
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
p.PaymentID, _ = uuid.Parse(row.PaymentID)
p.LedgerID = parseOptionalUUID(row.LedgerID)
p.CustomerID = parseOptionalUUID(row.CustomerID)
p.OutletID = parseOptionalUUID(row.OutletID)
out = append(out, p)
}
return out, nil
}
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
var rows []struct {
LotID string
Amount int64
ExpiresAt *time.Time
}
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = ?
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
}
out := make([]PointPaymentAllocation, 0, len(rows))
for _, row := range rows {
id, err := uuid.Parse(row.LotID)
if err != nil {
continue
}
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
}
return out, nil
}
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
var rows []struct {
OriginLotID string
Amount int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
}
out := make(map[uuid.UUID]int64, len(rows))
for _, row := range rows {
if id, err := uuid.Parse(row.OriginLotID); err == nil {
out[id] = row.Amount
}
}
return out, nil
}
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
orderID, constants.PaymentMethodTypePoint,
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
}
return total, nil
}
func parseOptionalUUID(s *string) *uuid.UUID {
if s == nil {
return nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil
}
return &id
}
@@ -0,0 +1,82 @@
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
}
@@ -0,0 +1,109 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to
// another organization.
var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found")
// WalletTraceRepository reads what tracing a ledger row lot by lot needs
// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance.
type WalletTraceRepository interface {
// GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to
// the organization.
GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error)
GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error)
// ListAllocations returns what a debit took from each lot, in the order it took.
ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error)
// ListLotsBySource returns the lots a credit created, oldest first.
ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
}
type walletTraceRepository struct {
db *gorm.DB
names *walletAdminRepository
}
func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository {
return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}}
}
func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
var rows []entities.WalletTransaction
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("id = ? AND organization_id = ?", id, organizationID).
Limit(1).
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to get wallet transaction: %w", err)
}
if len(rows) == 0 {
return nil, ErrWalletTransactionNotFound
}
return &rows[0], nil
}
func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
var rows []entities.WalletTransaction
if len(ids) == 0 {
return rows, nil
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("failed to get wallet transactions: %w", err)
}
return rows, nil
}
func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) {
var rows []entities.WalletLotAllocation
// A debit draws in K9 order, so the lots' order is the order it took from them.
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("wallet_lot_allocations a").
Select("a.*").
Joins("JOIN wallet_lots l ON l.id = a.lot_id").
Where("a.transaction_id = ?", transactionID).
Order("l.expires_at NULLS LAST, l.created_at, l.id").
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err)
}
return rows, nil
}
func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("source_transaction_id = ?", transactionID).
Order("created_at, id").
Find(&lots).Error
if err != nil {
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
}
return lots, nil
}
func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
if len(ids) == 0 {
return lots, nil
}
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil {
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
}
return lots, nil
}
func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names.CustomerNames(ctx, ids)
}
+20 -2
View File
@@ -57,12 +57,16 @@ type Router struct {
walletAdminHandler *handler.WalletAdminHandler
loyaltySettingsHandler *handler.LoyaltySettingsHandler
customerPinHandler *handler.CustomerPinHandler
authMiddleware *middleware.AuthMiddleware
pointPaymentHandler *handler.PointPaymentHandler
customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler
customerWalletHandler *handler.CustomerWalletHandler
customerDeviceHandler *handler.CustomerDeviceHandler
authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client
}
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, redisClient *redis.Client) *Router {
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, redisClient *redis.Client) *Router {
return &Router{
config: cfg,
@@ -111,6 +115,10 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService),
customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService),
customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService),
redisClient: redisClient,
}
}
@@ -165,6 +173,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange)
customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange)
customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient)
customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer)
customer.PUT("/devices", r.customerDeviceHandler.Register)
customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister)
customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints)
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
customer.GET("/pin/status", r.customerPinHandler.Status)
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
@@ -294,6 +309,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
orders.GET("", r.orderHandler.ListOrders)
orders.GET("/:id", r.orderHandler.GetOrderByID)
orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview)
orders.POST("", r.orderHandler.CreateOrder)
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
orders.PUT("/:id", r.orderHandler.UpdateOrder)
@@ -641,6 +657,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
}
// Trace one ledger row lot by lot back to where its balance came from (F7, §8.1)
gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction)
campaignRules := gamification.Group("/campaign-rules")
{
+9
View File
@@ -30,12 +30,21 @@ func TestAllRoutesRegister(t *testing.T) {
"GET /api/v1/customer/wallet/transactions",
"GET /api/v1/marketing/customers/:id/wallet",
"POST /api/v1/marketing/customers/:id/wallet/adjust",
"GET /api/v1/marketing/wallet-transactions/:id/trace",
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
"GET /api/v1/marketing/loyalty-settings",
"PUT /api/v1/marketing/loyalty-settings",
"GET /api/v1/marketing/loyalty-settings/history",
"POST /api/v1/customer/wallet/payment-code",
"GET /api/v1/customer/wallet/exchange/preview",
"POST /api/v1/customer/wallet/exchange",
"GET /api/v1/customer/wallet/transfer/recipient",
"POST /api/v1/customer/wallet/transfer",
"PUT /api/v1/customer/devices",
"DELETE /api/v1/customer/devices/:device_id",
"GET /api/v1/orders/:id/point-payment/preview",
"POST /api/v1/customer/orders/:id/pay-with-points",
"GET /api/v1/customer/pin/status",
"POST /api/v1/customer/pin/otp",
"POST /api/v1/customer/pin",
@@ -0,0 +1,58 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// CustomerDeviceService registers the customer app's devices for push notifications.
type CustomerDeviceService interface {
Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response
}
type CustomerDeviceServiceImpl struct {
devices *processor.CustomerDeviceProcessor
}
func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl {
return &CustomerDeviceServiceImpl{devices: devices}
}
func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response {
err := s.devices.Register(ctx, repository.CustomerDevice{
CustomerID: customerID,
DeviceID: req.DeviceID,
FCMToken: req.FCMToken,
Platform: req.Platform,
AppVersion: req.AppVersion,
})
if err != nil {
return customerDeviceErrorResponse(err)
}
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID})
}
func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response {
if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil {
return customerDeviceErrorResponse(err)
}
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID})
}
func customerDeviceErrorResponse(err error) *contract.Response {
code := constants.InternalServerErrorCode
if errors.Is(err, processor.ErrInvalidCustomerDevice) {
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.RequestEntity, err.Error()),
})
}
@@ -0,0 +1,34 @@
package service
import (
"context"
"github.com/google/uuid"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
)
// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the
// app or a self-order (docs/prd-point-coin.md F9).
type CustomerOrderPaymentService interface {
PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerOrderPaymentServiceImpl struct {
orders processor.OrderProcessor
}
func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl {
return &CustomerOrderPaymentServiceImpl{orders: orders}
}
func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response {
payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment))
}
@@ -0,0 +1,91 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// CustomerWalletService moves balance on the customer's own request: exchanging
// EnakCoin into EnakPoint and sending either to another customer
// (docs/prd-point-coin.md F4, F5).
type CustomerWalletService interface {
PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response
Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response
Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerWalletServiceImpl struct {
exchanges *processor.WalletExchangeProcessor
transfers *processor.WalletTransferProcessor
}
func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor, transfers *processor.WalletTransferProcessor) *CustomerWalletServiceImpl {
return &CustomerWalletServiceImpl{exchanges: exchanges, transfers: transfers}
}
func (s *CustomerWalletServiceImpl) TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response {
recipient, err := s.transfers.Recipient(ctx, customerID, phoneNumber)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(recipient)
}
func (s *CustomerWalletServiceImpl) Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response {
result, err := s.transfers.Transfer(ctx, customerID, models.WalletTransfer{
Currency: req.Currency,
Amount: req.Amount,
RecipientPhone: req.RecipientPhone,
}, req.Pin, idempotencyKey, info)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response {
preview, err := s.exchanges.Preview(ctx, customerID, coins)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(preview)
}
func (s *CustomerWalletServiceImpl) Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response {
result, err := s.exchanges.Exchange(ctx, customerID, req.Coins, req.Pin, idempotencyKey, info)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
// walletMoveErrorResponse keeps the PIN codes the apps act on, and tells a refused
// request apart from a server failure.
func walletMoveErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrWalletNotFound),
errors.Is(err, processor.ErrWalletRecipientNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrWalletMoveRejected),
errors.Is(err, processor.ErrWalletIdempotencyConflict),
errors.Is(err, processor.ErrWalletInvalidEntry):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
})
}
+2 -1
View File
@@ -557,7 +557,8 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment method ID is required")
}
if req.Amount <= 0 {
// A payment with EnakPoint gives points instead; its amount is computed from them.
if req.Points == nil && req.Amount <= 0 {
return fmt.Errorf("payment amount must be greater than zero")
}
@@ -42,6 +42,14 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req
return args.Get(0).(*models.OrderResponse), args.Error(1)
}
func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
args := m.Called(ctx, customerID, orderID, points, pin, info)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.PaymentResponse), args.Error(1)
}
func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
+55
View File
@@ -0,0 +1,55 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// PointPaymentService serves what the cashier needs before taking EnakPoint
// (docs/prd-point-coin.md F9).
type PointPaymentService interface {
Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response
}
type PointPaymentServiceImpl struct {
payments *processor.PointPaymentProcessor
}
func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl {
return &PointPaymentServiceImpl{payments: payments}
}
func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response {
preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(preview)
}
// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN
// problems with their own codes, and anything else as a server error.
func PointPaymentErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrPointPaymentOrderNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrPointPaymentRejected):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
})
}
+9 -27
View File
@@ -6,7 +6,6 @@ import (
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
@@ -17,13 +16,11 @@ type SpinGameService interface {
type spinGameService struct {
gamePlayProcessor *processor.GamePlayProcessor
txManager *repository.TxManager
}
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService {
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService {
return &spinGameService{
gamePlayProcessor: gamePlayProcessor,
txManager: txManager,
}
}
@@ -45,28 +42,13 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
return nil, fmt.Errorf("invalid customer ID format: %w", err)
}
// Use transaction to ensure consistency
var playResponse *models.PlayGameResponse
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
// Create play game request
playGameReq := &models.PlayGameRequest{
GameID: spinID,
CustomerID: customerUUID,
TokenUsed: 1, // Default to 1 token per spin
}
// Play the game within transaction
var playErr error
playResponse, playErr = s.gamePlayProcessor.PlayGame(txCtx, playGameReq)
if playErr != nil {
return fmt.Errorf("failed to play spin game: %w", playErr)
}
return nil
// PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin.
playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{
GameID: spinID,
CustomerID: customerUUID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to play spin game: %w", err)
}
// Convert prize to customer-facing format
@@ -88,9 +70,9 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
Status: "SUCCESS",
Message: "Spin game completed successfully.",
Data: &models.SpinGameResponseData{
GamePlay: playResponse.GamePlay,
PrizeWon: customerPrize,
TokensRemaining: playResponse.TokensRemaining,
GamePlay: playResponse.GamePlay,
PrizeWon: customerPrize,
CoinsRemaining: playResponse.CoinsRemaining,
},
}
+15 -2
View File
@@ -19,14 +19,25 @@ import (
type WalletAdminService interface {
GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response
AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response
// TraceTransaction follows a ledger row lot by lot back to where its balance came from.
TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response
}
type WalletAdminServiceImpl struct {
walletAdminProcessor *processor.WalletAdminProcessor
walletTraceProcessor *processor.WalletTraceProcessor
}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor) *WalletAdminServiceImpl {
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl {
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor, walletTraceProcessor: walletTraceProcessor}
}
func (s *WalletAdminServiceImpl) TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response {
trace, err := s.walletTraceProcessor.Trace(ctx, apctx.OrganizationID, transactionID)
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(trace)
}
func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response {
@@ -56,6 +67,8 @@ func walletErrorResponse(err error) *contract.Response {
switch {
case errors.Is(err, repository.ErrWalletNotFound):
code, message = constants.NotFoundErrorCode, "customer not found"
case errors.Is(err, repository.ErrWalletTransactionNotFound):
code, message = constants.NotFoundErrorCode, "wallet transaction not found"
case errors.Is(err, repository.ErrWalletInsufficientBalance):
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
case errors.Is(err, processor.ErrInvalidWalletQuery),
@@ -60,6 +60,8 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount,
Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
})
}
@@ -72,6 +74,9 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
GroupBy: resp.GroupBy,
Summary: contract.PaymentMethodSummary{
TotalAmount: resp.Summary.TotalAmount,
PointAmount: resp.Summary.PointAmount,
PointsUsed: resp.Summary.PointsUsed,
TotalWithPoints: resp.Summary.TotalWithPoints,
TotalOrders: resp.Summary.TotalOrders,
TotalPayments: resp.Summary.TotalPayments,
AverageOrderValue: resp.Summary.AverageOrderValue,
@@ -617,6 +622,8 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount,
Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
})
}
@@ -350,7 +350,7 @@ func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.C
return &models.CreateGamePlayRequest{
GameID: req.GameID,
CustomerID: req.CustomerID,
TokenUsed: req.TokenUsed,
CoinsUsed: req.CoinsUsed,
RandomSeed: req.RandomSeed,
}
}
@@ -359,7 +359,6 @@ func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameReque
return &models.PlayGameRequest{
GameID: req.GameID,
CustomerID: req.CustomerID,
TokenUsed: req.TokenUsed,
}
}
@@ -401,7 +400,8 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR
GameID: model.GameID,
CustomerID: model.CustomerID,
PrizeID: model.PrizeID,
TokenUsed: model.TokenUsed,
CoinsUsed: model.CoinsUsed,
TokenUsed: model.CoinsUsed,
RandomSeed: model.RandomSeed,
CreatedAt: model.CreatedAt,
Game: game,
@@ -433,7 +433,8 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR
return &contract.PlayGameResponse{
GamePlay: gamePlayValue,
PrizeWon: prizeWon,
TokensRemaining: model.TokensRemaining,
CoinsRemaining: model.CoinsRemaining,
TokensRemaining: model.CoinsRemaining,
}
}
@@ -324,6 +324,8 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr
return &models.CreatePaymentRequest{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Points: req.Points,
PaymentCode: req.PaymentCode,
Amount: req.Amount,
TransactionID: req.TransactionID,
SplitNumber: req.SplitNumber,
@@ -365,6 +367,8 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo
SplitType: resp.SplitType,
SplitDescription: resp.SplitDescription,
RefundAmount: resp.RefundAmount,
PointsUsed: resp.PointsUsed,
PointValue: resp.PointValue,
RefundReason: resp.RefundReason,
RefundedAt: resp.RefundedAt,
RefundedBy: resp.RefundedBy,
+2 -6
View File
@@ -436,8 +436,8 @@ func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract.
return err, "VALIDATION_ERROR"
}
if req.TokenUsed < 0 {
return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED"
if req.CoinsUsed < 0 {
return errors.New("coins used cannot be negative"), "INVALID_COINS_USED"
}
return nil, ""
@@ -466,10 +466,6 @@ func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGa
return err, "VALIDATION_ERROR"
}
if req.TokenUsed < 0 {
return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED"
}
return nil, ""
}
@@ -0,0 +1,2 @@
ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_coins_used_non_negative TO chk_game_plays_token_used_non_negative;
ALTER TABLE game_plays RENAME COLUMN coins_used TO token_used;
@@ -0,0 +1,4 @@
-- Every game now costs EnakCoin (docs/prd-point-coin.md F8, K1), so what a play used
-- is a number of EnakCoin.
ALTER TABLE game_plays RENAME COLUMN token_used TO coins_used;
ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_token_used_non_negative TO chk_game_plays_coins_used_non_negative;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS customer_devices;
@@ -0,0 +1,17 @@
-- Devices of the customer app, so customers can get push notifications through FCM
-- (docs/prd-point-coin.md F5: the recipient of a transfer is notified). user_devices
-- only holds staff devices.
CREATE TABLE customer_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
device_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) CHECK (platform IN ('android', 'ios', 'web')),
fcm_token VARCHAR(512) NOT NULL,
app_version VARCHAR(50),
last_active_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_customer_devices_customer_device ON customer_devices(customer_id, device_id);
CREATE INDEX idx_customer_devices_fcm_token ON customer_devices(fcm_token);